mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 17:19:24 +00:00
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:
co-authored by
Claude Opus 4.6
parent
a320f265b3
commit
ea57447649
+32
-9
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user