mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 09:09:19 +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
+16
-2
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user