Skip to content

Commit 6cb5d12

Browse files
feat: migrate channel routing to SettingsStore with hot-reload
- Add load_from_store/save_to_store for database-backed config - Change AgentDeps.channel_routing to Arc<RwLock<Option<...>>> for hot-reload - Make apply_channel_routing async to support RwLock reads - Load from DB first, fallback to channel-routing.json file - Update all test files for new RwLock type Architecture change requested by ilblackdragon: config now uses the SettingsStore system, enabling web UI configuration and hot-reload without restarts.
1 parent caab172 commit 6cb5d12

8 files changed

Lines changed: 88 additions & 17 deletions

File tree

src/agent/agent_loop.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ pub struct AgentDeps {
148148
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
149149
/// Software builder for self-repair tool rebuilding.
150150
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
151-
/// Per-channel tool routing config (loaded from channel-routing.json).
152-
pub channel_routing: Option<Arc<crate::agent::channel_routing::ChannelRoutingConfig>>,
151+
/// Per-channel tool routing config. Wrapped in RwLock for hot-reload support.
152+
pub channel_routing:
153+
Arc<tokio::sync::RwLock<Option<crate::agent::channel_routing::ChannelRoutingConfig>>>,
153154
}
154155

155156
/// The main agent that coordinates all components.

src/agent/channel_routing.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::path::Path;
99

1010
use serde::{Deserialize, Serialize};
1111

12+
use crate::db::SettingsStore;
1213
use crate::llm::ToolDefinition;
1314

1415
/// Channel-to-tool-group routing configuration.
@@ -71,6 +72,59 @@ impl ChannelRoutingConfig {
7172
}
7273
}
7374

75+
/// Load from database-backed SettingsStore. Falls back to file if not in DB.
76+
///
77+
/// This provides hot-reload support — the settings system handles cache
78+
/// invalidation and the web UI can modify the config without restarts.
79+
pub async fn load_from_store(
80+
store: &(dyn SettingsStore + Send + Sync),
81+
user_id: &str,
82+
file_fallback: &Path,
83+
) -> Option<Self> {
84+
match store.get_setting(user_id, "channel_routing").await {
85+
Ok(Some(value)) => match serde_json::from_value::<Self>(value) {
86+
Ok(config) => {
87+
if let Err(e) = config.validate() {
88+
tracing::error!("Channel routing config from DB invalid: {}", e);
89+
return None;
90+
}
91+
tracing::info!(
92+
groups = ?config.groups.keys().collect::<Vec<_>>(),
93+
channels = config.channels.len(),
94+
"Loaded channel routing config from database"
95+
);
96+
return Some(config);
97+
}
98+
Err(e) => {
99+
tracing::warn!("Failed to parse channel routing from DB: {}", e);
100+
}
101+
},
102+
Ok(None) => {
103+
tracing::debug!("No channel routing config in database, trying file fallback");
104+
}
105+
Err(e) => {
106+
tracing::warn!("Failed to read channel routing from DB: {}", e);
107+
}
108+
}
109+
// Fall back to file-based config
110+
Self::load(file_fallback)
111+
}
112+
113+
/// Save current config to the database SettingsStore.
114+
pub async fn save_to_store(
115+
&self,
116+
store: &(dyn SettingsStore + Send + Sync),
117+
user_id: &str,
118+
) -> Result<(), crate::error::DatabaseError> {
119+
let value = serde_json::to_value(self).map_err(|e| {
120+
crate::error::DatabaseError::Serialization(format!(
121+
"Failed to serialize channel routing: {}",
122+
e
123+
))
124+
})?;
125+
store.set_setting(user_id, "channel_routing", &value).await
126+
}
127+
74128
/// Validate that group references are consistent.
75129
fn validate(&self) -> Result<(), String> {
76130
// default_group must exist in groups

src/agent/dispatcher.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,13 @@ pub(super) enum AgenticLoopResult {
3535

3636
impl Agent {
3737
/// Apply per-channel tool filtering if routing config is loaded.
38-
fn apply_channel_routing(
38+
async fn apply_channel_routing(
3939
&self,
4040
channel: &str,
4141
tools: Vec<crate::llm::ToolDefinition>,
4242
) -> Vec<crate::llm::ToolDefinition> {
43-
if let Some(ref routing) = self.deps.channel_routing {
43+
let guard = self.deps.channel_routing.read().await;
44+
if let Some(ref routing) = *guard {
4445
let before = tools.len();
4546
let filtered = routing.filter_tool_defs(channel, tools);
4647
if filtered.len() < before {
@@ -178,7 +179,9 @@ impl Agent {
178179
// Build system prompts once for this turn. Two variants: with tools
179180
// (normal iterations) and without (force_text final iteration).
180181
let initial_tool_defs = self.tools().tool_definitions().await;
181-
let initial_tool_defs = self.apply_channel_routing(&message.channel, initial_tool_defs);
182+
let initial_tool_defs = self
183+
.apply_channel_routing(&message.channel, initial_tool_defs)
184+
.await;
182185
let initial_tool_defs = if !active_skills.is_empty() {
183186
crate::skills::attenuate_tools(&initial_tool_defs, &active_skills).tools
184187
} else {
@@ -313,7 +316,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
313316
let tool_defs = self.agent.tools().tool_definitions().await;
314317
let tool_defs = self
315318
.agent
316-
.apply_channel_routing(&self.message.channel, tool_defs);
319+
.apply_channel_routing(&self.message.channel, tool_defs)
320+
.await;
317321

318322
// Apply trust-based tool attenuation if skills are active.
319323
let tool_defs = if !self.active_skills.is_empty() {
@@ -1226,7 +1230,7 @@ mod tests {
12261230
transcription: None,
12271231
document_extraction: None,
12281232
builder: None,
1229-
channel_routing: None,
1233+
channel_routing: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
12301234
};
12311235

12321236
Agent::new(
@@ -2068,7 +2072,7 @@ mod tests {
20682072
transcription: None,
20692073
document_extraction: None,
20702074
builder: None,
2071-
channel_routing: None,
2075+
channel_routing: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
20722076
};
20732077

20742078
Agent::new(
@@ -2188,7 +2192,7 @@ mod tests {
21882192
transcription: None,
21892193
document_extraction: None,
21902194
builder: None,
2191-
channel_routing: None,
2195+
channel_routing: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
21922196
};
21932197

21942198
Agent::new(

src/main.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,21 @@ async fn async_main() -> anyhow::Result<()> {
718718
// Clone context_manager for the reaper before it's moved into Agent::new()
719719
let reaper_context_manager = Arc::clone(&components.context_manager);
720720

721+
// Load channel routing config before components.db is moved
722+
let channel_routing_config = {
723+
let base_dir = ironclaw::bootstrap::ironclaw_base_dir();
724+
if let Some(ref db) = components.db {
725+
ironclaw::agent::channel_routing::ChannelRoutingConfig::load_from_store(
726+
db.as_ref(),
727+
"default",
728+
&base_dir,
729+
)
730+
.await
731+
} else {
732+
ironclaw::agent::channel_routing::ChannelRoutingConfig::load(&base_dir)
733+
}
734+
};
735+
721736
// Capture db reference for SIGHUP handler before it's moved into AgentDeps (Unix only)
722737
#[cfg(unix)]
723738
let sighup_settings_store: Option<Arc<dyn ironclaw::db::SettingsStore>> = components
@@ -749,10 +764,7 @@ async fn async_main() -> anyhow::Result<()> {
749764
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
750765
)),
751766
builder: components.builder,
752-
channel_routing: ironclaw::agent::channel_routing::ChannelRoutingConfig::load(
753-
&ironclaw::bootstrap::ironclaw_base_dir(),
754-
)
755-
.map(Arc::new),
767+
channel_routing: Arc::new(tokio::sync::RwLock::new(channel_routing_config)),
756768
};
757769

758770
let mut agent = Agent::new(

src/testing/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ impl TestHarnessBuilder {
493493
transcription: None,
494494
document_extraction: None,
495495
builder: None,
496-
channel_routing: None,
496+
channel_routing: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
497497
};
498498

499499
TestHarness {

tests/e2e_telegram_message_routing.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ mod tests {
199199
transcription: None,
200200
document_extraction: None,
201201
builder: None,
202-
channel_routing: None,
202+
channel_routing: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
203203
};
204204

205205
let gateway = Arc::new(TestChannel::new());

tests/support/gateway_workflow_harness.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ impl GatewayWorkflowHarness {
258258
transcription: None,
259259
document_extraction: None,
260260
builder: None,
261-
channel_routing: None,
261+
channel_routing: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
262262
},
263263
channels,
264264
None,

tests/support/test_rig.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,7 +643,7 @@ impl TestRigBuilder {
643643
transcription: None,
644644
document_extraction: None,
645645
builder: None,
646-
channel_routing: None,
646+
channel_routing: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
647647
};
648648

649649
// 7. Create TestChannel and ChannelManager.

0 commit comments

Comments
 (0)