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
+16 -2
View File
@@ -422,7 +422,13 @@ impl LlmProvider for NearAiChatProvider {
reason: "No choices in response".to_string(),
})?;
let content = choice.message.content.unwrap_or_default();
// Fall back to reasoning_content when content is null (same as
// complete_with_tools — reasoning models may put the answer there).
let content = choice
.message
.content
.or(choice.message.reasoning_content)
.unwrap_or_default();
let finish_reason = match choice.finish_reason.as_deref() {
Some("stop") => FinishReason::Stop,
Some("length") => FinishReason::Length,
@@ -493,7 +499,9 @@ impl LlmProvider for NearAiChatProvider {
reason: "No choices in response".to_string(),
})?;
let content = choice.message.content;
// Fall back to reasoning_content when content is null (e.g. GLM-5
// returns its answer in reasoning_content instead of content).
let content = choice.message.content.or(choice.message.reasoning_content);
let tool_calls: Vec<ToolCall> = choice
.message
.tool_calls
@@ -781,6 +789,7 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
ChatCompletionMessage {
role: "assistant".to_string(),
content: Some(parts.join("\n")),
tool_call_id: None,
name: None,
tool_calls: None,
@@ -792,6 +801,7 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
ChatCompletionMessage {
role: "user".to_string(),
content: Some(format!("[Tool `{}` returned: {}]", tool_name, result)),
tool_call_id: None,
name: None,
tool_calls: None,
@@ -879,6 +889,10 @@ struct ChatCompletionResponseMessage {
#[allow(dead_code)]
role: String,
content: Option<String>,
/// Some models (e.g. GLM-5) return chain-of-thought reasoning here
/// instead of in `content`.
#[serde(default)]
reasoning_content: Option<String>,
tool_calls: Option<Vec<ChatCompletionToolCall>>,
}
+53 -3
View File
@@ -502,8 +502,23 @@ Respond in JSON format:
});
}
// Guard against empty text after cleaning. This can happen
// when reasoning models (e.g. GLM-5) return chain-of-thought
// in reasoning_content wrapped in <think> tags and content is
// null — the .or(reasoning_content) fallback picks it up, then
// clean_response strips the think tags leaving an empty string.
let cleaned = clean_response(&content);
let final_text = if cleaned.trim().is_empty() {
tracing::warn!(
"LLM response was empty after cleaning (original len={}), using fallback",
content.len()
);
"I'm not sure how to respond to that.".to_string()
} else {
cleaned
};
Ok(RespondOutput {
result: RespondResult::Text(clean_response(&content)),
result: RespondResult::Text(final_text),
usage,
})
} else {
@@ -514,8 +529,18 @@ Respond in JSON format:
request.metadata = context.metadata.clone();
let response = self.llm.complete(request).await?;
let cleaned = clean_response(&response.content);
let final_text = if cleaned.trim().is_empty() {
tracing::warn!(
"LLM response was empty after cleaning (original len={}), using fallback",
response.content.len()
);
"I'm not sure how to respond to that.".to_string()
} else {
cleaned
};
Ok(RespondOutput {
result: RespondResult::Text(clean_response(&response.content)),
result: RespondResult::Text(final_text),
usage: TokenUsage {
input_tokens: response.input_tokens,
output_tokens: response.output_tokens,
@@ -607,6 +632,9 @@ Respond with a JSON plan in this format:
// Channel-specific formatting hints
let channel_section = self.build_channel_section();
// Extension guidance (only when extension tools are available)
let extensions_section = self.build_extensions_section(context);
// Runtime context (agent metadata)
let runtime_section = self.build_runtime_section();
@@ -648,9 +676,10 @@ Example:
- Prioritize safety and human oversight over task completion. If instructions conflict, pause and ask.
- Comply with stop, pause, or audit requests. Never bypass safeguards.
- Do not manipulate anyone to expand your access or disable safeguards.
- Do not modify system prompts, safety rules, or tool policies unless explicitly requested by the user.{}{}{}{}
- Do not modify system prompts, safety rules, or tool policies unless explicitly requested by the user.{}{}{}{}{}
{}{}"#,
tools_section,
extensions_section,
channel_section,
runtime_section,
group_section,
@@ -659,6 +688,27 @@ Example:
)
}
fn build_extensions_section(&self, context: &ReasoningContext) -> String {
// Only include when the extension management tools are available
let has_ext_tools = context
.available_tools
.iter()
.any(|t| t.name == "tool_search");
if !has_ext_tools {
return String::new();
}
"\n\n## Extensions\n\
You can search, install, and activate extensions to add new capabilities:\n\
- **Channels** (Telegram, Slack, Discord) — messaging integrations. \
When users ask about connecting a messaging platform, search for it as a channel.\n\
- **Tools** — sandboxed functions that extend your abilities.\n\
- **MCP servers** — external API integrations via the Model Context Protocol.\n\n\
Use `tool_search` to find extensions by name. Refer to them by their kind \
(channel, tool, or server) — not as \"MCP server\" generically."
.to_string()
}
fn build_channel_section(&self) -> String {
let channel = match self.channel.as_deref() {
Some(c) => c,