Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
129625e
feat(agent): add native Fusion dual-lane routing
w0wl0lxd Jul 27, 2026
6b497c1
docs(fusion): sync plan with Linear and PR #180 status
w0wl0lxd Jul 27, 2026
5221ab0
docs(changelog): add fragment for Fusion dual-lane routing
w0wl0lxd Jul 27, 2026
cb1924c
feat(fusion): surface lead/sidekick costs in print and session meta
w0wl0lxd Jul 27, 2026
b40baf5
feat(fusion): merge main, resolve agent.rs conflict, and add config docs
w0wl0lxd Jul 28, 2026
64fa92d
docs(fusion): add agent.fusion config documentation to gen-docs
w0wl0lxd Jul 28, 2026
ba4853d
Merge remote-tracking branch 'origin/main' into feat/n00n-fusion
w0wl0lxd Jul 28, 2026
3c2843c
fix(fusion): resolve clippy and merge-from-main issues
w0wl0lxd Jul 28, 2026
5822485
Merge branch 'main' into feat/n00n-fusion
w0wl0lxd Jul 28, 2026
e51674a
fix(fusion): add fusion_delegate to docgen sections and update token …
w0wl0lxd Jul 28, 2026
5eeb683
fix(fusion): add fusion flag to Windows agent stub and regenerate too…
w0wl0lxd Jul 28, 2026
a191d7c
fix(fusion): address PR #180 review findings for lane switches and te…
w0wl0lxd Jul 29, 2026
a62c297
merge(main): resolve agent CLI conflicts with Fusion and background s…
w0wl0lxd Jul 29, 2026
2042794
fix(ci): clippy single_char_add_str and fusion_delegate schema
w0wl0lxd Jul 29, 2026
6ad22a7
merge(main): sync PR #180 after #170
w0wl0lxd Jul 29, 2026
c6e7c70
fix(token-profile): refresh cold_start baseline for fusion+explore
w0wl0lxd Jul 29, 2026
b56d108
fix(agent): prevent provider/model desync on fusion lane switch
w0wl0lxd Jul 29, 2026
840b898
merge(main): sync PR #180 after cursor-native
w0wl0lxd Jul 29, 2026
1ffeb4e
fix(ci): regen fusion docs/baseline and raise tool-def byte budget
w0wl0lxd Jul 29, 2026
5d60bba
fix(token-profile): align cold_start baseline with fusion schema
w0wl0lxd Jul 29, 2026
d344969
merge(main): sync PR #180 after skill-system-v2
w0wl0lxd Jul 29, 2026
19441da
fix(ci): refresh baseline and raise tool-def budget after skill merge
w0wl0lxd Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/180.added.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 5 additions & 9 deletions n00n-agent/src/agent/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenUsage, AgentError> {
) -> Result<(TokenUsage, String), AgentError> {
run_precompact_hooks(trigger, session_id, cwd, transcript_path).await?;

let compact_start = std::time::Instant::now();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -203,6 +198,7 @@ pub async fn compact(
usage,
num_turns: 1,
stop_reason: None,
fusion: None,
})?;

Ok(())
Expand Down
183 changes: 179 additions & 4 deletions n00n-agent/src/agent/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -196,12 +207,31 @@ pub struct Agent<'h> {
tool_filter: ToolFilter,
active_tools: ActiveTools,
supports_tool_examples: bool,
lead_model: Option<Arc<Model>>,
lead_provider: Option<Arc<dyn Provider>>,
fusion_state: Option<FusionState>,
}

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(&params.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),
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Expand All @@ -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),
})
}

Expand Down Expand Up @@ -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");
}
}
}

Comment thread
w0wl0lxd marked this conversation as resolved.
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);
Expand All @@ -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,
Expand All @@ -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
Expand Down
Loading
Loading