Files
optimclaw/src/channels/manager.rs
T
ea57447649 feat: hot-activate WASM channels, channel-first prompts, unified artifact resolution (#297)
* refactor: unify WASM artifact resolution into registry/artifacts.rs

Consolidate duplicated WASM find/build/install logic from 5+ files into
a single src/registry/artifacts.rs module. This fixes two bugs:
- registry/installer.rs now respects CARGO_TARGET_DIR (was hardcoded)
- channels/wasm/bundled.rs now searches all WASM triples (was wasip2 only)

Also includes: extension manager hot-activation for WASM channels,
extension guidance in LLM prompts, channel manager hot-add support,
webhook router channel lookup, and minor cleanups.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: send approval prompts as messages on WASM channels (Telegram, Slack)

WASM channels mapped ApprovalNeeded status to a typing indicator,
so users on Telegram never saw tool approval prompts — the agent
got stuck in AwaitingApproval and all subsequent messages failed
with "Waiting for approval".

- Intercept ApprovalNeeded in WasmChannel::handle_status_update and
  send the prompt as an actual message via call_on_respond, showing
  tool name, description, parameters, and yes/no/always instructions
- Guard against empty LLM responses after clean_response() strips
  reasoning_content think-tags (defense-in-depth for reasoning models)
- Add reasoning_content fallback to NearAiChatProvider::complete()
  for consistency with complete_with_tools()
- Add debug logging when empty responses are suppressed
- Improve error logging for channel respond() failures
- Register WASM channel webhook routes before credential checks so
  platforms don't deactivate webhook URLs with 404s

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR #297 review comments

- ChannelManager::add: use async write().await instead of try_write()
- resolve_target_dir: resolve relative CARGO_TARGET_DIR against crate_dir
- install_wasm_files: log warning on capabilities copy failure
- refresh_active_channel: load capabilities file for webhook secret name
- activate_wasm_channel: validate name against path traversal
- Fix cargo fmt formatting in nearai_chat.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: wire up channel runtime for hot-activation and address PR review round 2

- Wire up set_channel_runtime() in main.rs so hot-activation actually works
  (with_channel_runtime was never called — hot-activation was dead code)
- Change ExtensionManager channel runtime fields to RwLock<Option<...>>
  interior mutability so set_channel_runtime(&self) works after Arc wrapping
- Fix artifact tests to use resolve_target_dir() instead of hardcoding
  "target/" (breaks when CARGO_TARGET_DIR is set)
- Fix bundled.rs build hint: cargo component build (not cargo build --target)
- Fix wasm_artifact_path doc: binary_name should not include .wasm extension

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use char-aware truncation to prevent UTF-8 panic in approval prompt

&s[..77] panics on multi-byte UTF-8 (CJK, emoji). Use s.chars().take(77)
for safe truncation at character boundaries.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-22 08:09:56 +00:00

227 lines
7.7 KiB
Rust

//! Channel manager for coordinating multiple input channels.
use std::collections::HashMap;
use std::sync::Arc;
use futures::stream;
use tokio::sync::{RwLock, mpsc};
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
/// Manages multiple input channels and merges their message streams.
///
/// Includes an injection channel so background tasks (e.g., job monitors) can
/// push messages into the agent loop without being a full `Channel` impl.
pub struct ChannelManager {
channels: Arc<RwLock<HashMap<String, Box<dyn Channel>>>>,
inject_tx: mpsc::Sender<IncomingMessage>,
/// Taken once in `start_all()` and merged into the stream.
inject_rx: tokio::sync::Mutex<Option<mpsc::Receiver<IncomingMessage>>>,
}
impl ChannelManager {
/// Create a new channel manager.
pub fn new() -> Self {
let (inject_tx, inject_rx) = mpsc::channel(64);
Self {
channels: Arc::new(RwLock::new(HashMap::new())),
inject_tx,
inject_rx: tokio::sync::Mutex::new(Some(inject_rx)),
}
}
/// Get a clone of the injection sender.
///
/// Background tasks (like job monitors) use this to push messages into the
/// agent loop without being a full `Channel` implementation.
pub fn inject_sender(&self) -> mpsc::Sender<IncomingMessage> {
self.inject_tx.clone()
}
/// Add a channel to the manager.
pub async fn add(&self, channel: Box<dyn Channel>) {
let name = channel.name().to_string();
self.channels.write().await.insert(name.clone(), channel);
tracing::debug!("Added channel: {}", name);
}
/// Hot-add a channel to a running agent.
///
/// Starts the channel, registers it in the channels map for `respond()`/`broadcast()`,
/// and spawns a task that forwards its stream messages through `inject_tx` into
/// the agent loop.
pub async fn hot_add(&self, channel: Box<dyn Channel>) -> Result<(), ChannelError> {
let name = channel.name().to_string();
let stream = channel.start().await?;
// Register for respond/broadcast/send_status
self.channels.write().await.insert(name.clone(), channel);
// Forward stream messages through inject_tx
let tx = self.inject_tx.clone();
tokio::spawn(async move {
use futures::StreamExt;
let mut stream = stream;
while let Some(msg) = stream.next().await {
if tx.send(msg).await.is_err() {
tracing::warn!(channel = %name, "Inject channel closed, stopping hot-added channel");
break;
}
}
tracing::info!(channel = %name, "Hot-added channel stream ended");
});
Ok(())
}
/// Start all channels and return a merged stream of messages.
///
/// Also merges the injection channel so background tasks can push messages
/// into the same stream.
pub async fn start_all(&self) -> Result<MessageStream, ChannelError> {
let channels = self.channels.read().await;
let mut streams: Vec<MessageStream> = Vec::new();
for (name, channel) in channels.iter() {
match channel.start().await {
Ok(stream) => {
tracing::info!("Started channel: {}", name);
streams.push(stream);
}
Err(e) => {
tracing::error!("Failed to start channel {}: {}", name, e);
// Continue with other channels, don't fail completely
}
}
}
if streams.is_empty() {
return Err(ChannelError::StartupFailed {
name: "all".to_string(),
reason: "No channels started successfully".to_string(),
});
}
// Take the injection receiver (can only be taken once)
if let Some(inject_rx) = self.inject_rx.lock().await.take() {
let inject_stream = tokio_stream::wrappers::ReceiverStream::new(inject_rx);
streams.push(Box::pin(inject_stream));
tracing::debug!("Injection channel merged into message stream");
}
// Merge all streams into one
let merged = stream::select_all(streams);
Ok(Box::pin(merged))
}
/// Send a response to a specific channel.
pub async fn respond(
&self,
msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let channels = self.channels.read().await;
if let Some(channel) = channels.get(&msg.channel) {
channel.respond(msg, response).await
} else {
Err(ChannelError::SendFailed {
name: msg.channel.clone(),
reason: "Channel not found".to_string(),
})
}
}
/// Send a status update to a specific channel.
///
/// The metadata contains channel-specific routing info (e.g., Telegram chat_id)
/// needed to deliver the status to the correct destination.
pub async fn send_status(
&self,
channel_name: &str,
status: StatusUpdate,
metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
let channels = self.channels.read().await;
if let Some(channel) = channels.get(channel_name) {
channel.send_status(status, metadata).await
} else {
// Silently ignore if channel not found (status is best-effort)
Ok(())
}
}
/// Broadcast a message to a specific user on a specific channel.
///
/// Used for proactive notifications like heartbeat alerts.
pub async fn broadcast(
&self,
channel_name: &str,
user_id: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let channels = self.channels.read().await;
if let Some(channel) = channels.get(channel_name) {
channel.broadcast(user_id, response).await
} else {
Err(ChannelError::SendFailed {
name: channel_name.to_string(),
reason: "Channel not found".to_string(),
})
}
}
/// Broadcast a message to all channels.
///
/// Sends to the specified user on every registered channel.
pub async fn broadcast_all(
&self,
user_id: &str,
response: OutgoingResponse,
) -> Vec<(String, Result<(), ChannelError>)> {
let channels = self.channels.read().await;
let mut results = Vec::new();
for (name, channel) in channels.iter() {
let result = channel.broadcast(user_id, response.clone()).await;
results.push((name.clone(), result));
}
results
}
/// Check health of all channels.
pub async fn health_check_all(&self) -> HashMap<String, Result<(), ChannelError>> {
let channels = self.channels.read().await;
let mut results = HashMap::new();
for (name, channel) in channels.iter() {
results.insert(name.clone(), channel.health_check().await);
}
results
}
/// Shutdown all channels.
pub async fn shutdown_all(&self) -> Result<(), ChannelError> {
let channels = self.channels.read().await;
for (name, channel) in channels.iter() {
if let Err(e) = channel.shutdown().await {
tracing::error!("Error shutting down channel {}: {}", name, e);
}
}
Ok(())
}
/// Get list of channel names.
pub async fn channel_names(&self) -> Vec<String> {
self.channels.read().await.keys().cloned().collect()
}
}
impl Default for ChannelManager {
fn default() -> Self {
Self::new()
}
}