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]>
This commit is contained in:
Illia Polosukhin
2026-02-22 08:09:56 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a320f265b3
commit ea57447649
29 changed files with 1411 additions and 369 deletions
+32 -9
View File
@@ -40,16 +40,39 @@ impl ChannelManager {
}
/// Add a channel to the manager.
pub fn add(&mut self, channel: Box<dyn Channel>) {
pub async fn add(&self, channel: Box<dyn Channel>) {
let name = channel.name().to_string();
// We need to get the inner HashMap to insert
// Since we're in a sync context during setup, we'll use try_write
if let Ok(mut channels) = self.channels.try_write() {
channels.insert(name.clone(), channel);
tracing::debug!("Added channel: {}", name);
} else {
tracing::error!("Failed to add channel: {} (lock contention)", name);
}
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.
+13 -9
View File
@@ -65,24 +65,28 @@ fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> {
return Ok((flat_wasm, caps_path));
}
// Fall back to build tree layout (dev builds)
let build_wasm = channel_dir
.join("target/wasm32-wasip2/release")
.join(format!("{}.wasm", crate_name));
if build_wasm.exists() && caps_path.exists() {
// Fall back to build tree layout (dev builds) — search across all WASM triples
if let Some(build_wasm) =
crate::registry::artifacts::find_wasm_artifact(&channel_dir, crate_name, "release")
&& caps_path.exists()
{
return Ok((build_wasm, caps_path));
}
// Provide a helpful error with the paths we checked
let expected_build = crate::registry::artifacts::resolve_target_dir(&channel_dir)
.join("wasm32-wasip2/release")
.join(format!("{}.wasm", crate_name));
Err(format!(
"Channel '{}' WASM not found. Checked:\n \
- {} (flat/packaged)\n \
- {} (build tree)\n \
- {} (build tree, and other triples)\n \
Build it first:\n \
cd {} && cargo build --target wasm32-wasip2 --release",
cd {} && cargo component build --release",
name,
flat_wasm.display(),
build_wasm.display(),
expected_build.display(),
channel_dir.display()
))
}
+15
View File
@@ -110,6 +110,21 @@ impl WasmChannelRouter {
.unwrap_or_else(|| "X-Webhook-Secret".to_string())
}
/// Update the webhook secret for an already-registered channel.
///
/// This is used when credentials are saved after a channel was registered
/// without a secret (e.g., loaded at startup before the user configured it).
pub async fn update_secret(&self, channel_name: &str, secret: String) {
self.secrets
.write()
.await
.insert(channel_name.to_string(), secret);
tracing::info!(
channel = %channel_name,
"Updated webhook secret for channel"
);
}
/// Unregister a channel and its endpoints.
pub async fn unregister(&self, channel_name: &str) {
self.channels.write().await.remove(channel_name);
+72 -1
View File
@@ -780,7 +780,12 @@ impl WasmChannel {
/// Execute the on_start callback.
///
/// Returns the channel configuration for HTTP endpoint registration.
async fn call_on_start(&self) -> Result<ChannelConfig, WasmChannelError> {
/// Call the WASM module's `on_start` callback.
///
/// Typically called once during `start()`, but can be called again after
/// credentials are refreshed to re-trigger webhook registration and
/// other one-time setup that depends on credentials.
pub async fn call_on_start(&self) -> Result<ChannelConfig, WasmChannelError> {
// If no WASM bytes, return default config (for testing)
if self.prepared.component().is_none() {
tracing::info!(
@@ -1437,6 +1442,72 @@ impl WasmChannel {
StatusUpdate::StreamChunk(_) => {
// No-op, too noisy
}
StatusUpdate::ApprovalNeeded {
tool_name,
description,
parameters,
..
} => {
// WASM channels (Telegram, Slack, etc.) cannot render
// interactive approval overlays. Send the approval prompt
// as an actual message so the user can reply yes/no.
self.cancel_typing_task().await;
let params_preview = parameters
.as_object()
.map(|obj| {
obj.iter()
.map(|(k, v)| {
let val = match v {
serde_json::Value::String(s) => {
if s.chars().count() > 80 {
let truncated: String = s.chars().take(77).collect();
format!("\"{}...\"", truncated)
} else {
format!("\"{}\"", s)
}
}
other => {
let s = other.to_string();
if s.chars().count() > 80 {
let truncated: String = s.chars().take(77).collect();
format!("{}...", truncated)
} else {
s
}
}
};
format!(" {}: {}", k, val)
})
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
let prompt = format!(
"Approval needed: {tool_name}\n\
{description}\n\
\n\
Parameters:\n\
{params_preview}\n\
\n\
Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
);
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
if let Err(e) = self
.call_on_respond(uuid::Uuid::new_v4(), &prompt, None, &metadata_json)
.await
{
tracing::warn!(
channel = %self.name,
error = %e,
"Failed to send approval prompt via on_respond, falling back to on_status"
);
// Fall back to status update (typing indicator)
let _ = self.call_on_status(&status, metadata).await;
}
}
_ => {
// Done, Interrupted, Status, ToolStarted, ToolCompleted: cancel and fire once
self.cancel_typing_task().await;
+1 -1
View File
@@ -20,7 +20,7 @@ pub async fn extensions_list_handler(
))?;
let installed = ext_mgr
.list(None)
.list(None, false)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+3 -3
View File
@@ -1704,7 +1704,7 @@ async fn extensions_list_handler(
))?;
let installed = ext_mgr
.list(None)
.list(None, false)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -1955,7 +1955,7 @@ async fn extensions_registry_handler(
let installed: std::collections::HashSet<(String, String)> =
if let Some(ext_mgr) = state.extension_manager.as_ref() {
ext_mgr
.list(None)
.list(None, false)
.await
.unwrap_or_default()
.into_iter()
@@ -1998,7 +1998,7 @@ async fn extensions_setup_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let kind = ext_mgr
.list(None)
.list(None, false)
.await
.ok()
.and_then(|list| list.into_iter().find(|e| e.name == name))
+9 -14
View File
@@ -1455,18 +1455,11 @@ function renderExtensionCard(ext) {
actions.className = 'ext-actions';
if (!ext.active) {
if (ext.kind === 'wasm_channel') {
const restartLabel = document.createElement('span');
restartLabel.className = 'ext-restart-label';
restartLabel.textContent = 'Restart to activate';
actions.appendChild(restartLabel);
} else {
const activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = 'Activate';
activateBtn.addEventListener('click', () => activateExtension(ext.name));
actions.appendChild(activateBtn);
}
const activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = 'Activate';
activateBtn.addEventListener('click', () => activateExtension(ext.name));
actions.appendChild(activateBtn);
} else {
const activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
@@ -1490,8 +1483,10 @@ function renderExtensionCard(ext) {
card.appendChild(actions);
// For active WASM channels, check for pending pairing requests
if (ext.active && ext.kind === 'wasm_channel') {
// For WASM channels, check for pending pairing requests.
// Show even when inactive — pairing requests can arrive via webhooks
// before the channel is fully activated.
if (ext.kind === 'wasm_channel') {
const pairingSection = document.createElement('div');
pairingSection.className = 'ext-pairing';
card.appendChild(pairingSection);
-6
View File
@@ -1936,12 +1936,6 @@ body {
font-weight: 500;
}
.ext-restart-label {
font-size: 12px;
color: var(--text-secondary);
font-style: italic;
}
.btn-ext {
padding: 4px 10px;
border-radius: var(--radius);