mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 00:47:51 +00:00
wecom channel adapter
* feat: Add WeCom (WeChat Work) channel adapter - Add wecom.rs channel adapter implementation - Add WeComConfig in config.rs - Register WeCom adapter in channel_bridge.rs WeCom channel supports: - Inbound messages via callback webhook - Outbound messages via WeCom API - Access token caching and auto-refresh Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: handle WeCom callbacks and preserve hand extension tools * fix: render WeCom replies as plain text * fix: resolve clippy warnings in wecom adapter - Remove unused WECOM_API_HOST constant - Fix needless borrow in send_text call - Replace assert_eq!(bool, true) with assert!() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: cargo fmt for wecom-related files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: cargo fmt --all Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: upgrade quinn-proto and add cargo audit ignore list - Upgrade quinn-proto 0.11.13 → 0.11.14 (RUSTSEC-2026-0037 DoS fix) - Add .cargo/audit.toml to ignore unmaintainable transitive deps (tauri GTK3 bindings, time pinned by mac-notification-sys, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4fa2f9474b
commit
77ed954d18
@@ -0,0 +1,34 @@
|
||||
# Ignored advisories — all are transitive dependencies we cannot upgrade directly.
|
||||
#
|
||||
# time 0.3.45: pinned by mac-notification-sys (tauri dependency), awaiting upstream fix
|
||||
# GTK3/glib/pango/etc: tauri uses gtk-rs GTK3 bindings which are unmaintained
|
||||
# paste, proc-macro-error, fxhash: unmaintained transitive deps
|
||||
# lexical-core: unmaintained, pulled by tauri dep chain
|
||||
# serde_cbor: unmaintained, pulled by tao (tauri)
|
||||
# cocoa/cocoa-foundation: unmaintained, pulled by tauri/tao
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
"RUSTSEC-2026-0009", # time DoS — pinned by mac-notification-sys
|
||||
"RUSTSEC-2024-0370", # proc-macro-error unmaintained
|
||||
"RUSTSEC-2024-0411", # gtk-rs GTK3 unmaintained (gdk-pixbuf)
|
||||
"RUSTSEC-2024-0412", # gtk-rs GTK3 unmaintained (gdk)
|
||||
"RUSTSEC-2024-0413", # gtk-rs GTK3 unmaintained (atk)
|
||||
"RUSTSEC-2024-0414", # gtk-rs GTK3 unmaintained (pango)
|
||||
"RUSTSEC-2024-0415", # gtk-rs GTK3 unmaintained (gio)
|
||||
"RUSTSEC-2024-0416", # gtk-rs GTK3 unmaintained (atk-sys)
|
||||
"RUSTSEC-2024-0417", # gtk-rs GTK3 unmaintained (gdk-pixbuf-sys)
|
||||
"RUSTSEC-2024-0418", # gtk-rs GTK3 unmaintained (gdk-sys)
|
||||
"RUSTSEC-2024-0419", # gtk-rs GTK3 unmaintained (gtk3-macros)
|
||||
"RUSTSEC-2024-0420", # gtk-rs GTK3 unmaintained (pango-sys)
|
||||
"RUSTSEC-2024-0429", # gtk-rs GTK3 unmaintained (gtk-sys)
|
||||
"RUSTSEC-2024-0436", # paste unmaintained
|
||||
"RUSTSEC-2025-0057", # fxhash unmaintained
|
||||
"RUSTSEC-2025-0075", # glib unmaintained
|
||||
"RUSTSEC-2025-0080", # cocoa unmaintained
|
||||
"RUSTSEC-2025-0081", # cocoa-foundation unmaintained
|
||||
"RUSTSEC-2025-0098", # lexical-core unmaintained
|
||||
"RUSTSEC-2025-0100", # gio-sys unmaintained
|
||||
"RUSTSEC-2026-0002", # serde_cbor unmaintained
|
||||
"RUSTSEC-2023-0086", # lexopt unmaintained (if present)
|
||||
]
|
||||
Generated
+10
-2
@@ -3871,8 +3871,10 @@ dependencies = [
|
||||
"openfang-types",
|
||||
"regex-lite",
|
||||
"reqwest 0.12.28",
|
||||
"roxmltree",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -4858,9 +4860,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
@@ -5341,6 +5343,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.31.0"
|
||||
|
||||
@@ -102,6 +102,9 @@ walkdir = "2"
|
||||
|
||||
# Security
|
||||
sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
subtle = "2"
|
||||
|
||||
@@ -51,6 +51,7 @@ use openfang_channels::linkedin::LinkedInAdapter;
|
||||
use openfang_channels::mumble::MumbleAdapter;
|
||||
use openfang_channels::ntfy::NtfyAdapter;
|
||||
use openfang_channels::webhook::WebhookAdapter;
|
||||
use openfang_channels::wecom::WeComAdapter;
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_types::agent::AgentId;
|
||||
use std::sync::Arc;
|
||||
@@ -551,7 +552,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
match self.kernel.cron_scheduler.remove_job(j.id) {
|
||||
Ok(_) => {
|
||||
let id_str = j.id.0.to_string();
|
||||
format!("Job [{}] '{}' removed.", safe_truncate_str(&id_str, 8), j.name)
|
||||
format!(
|
||||
"Job [{}] '{}' removed.",
|
||||
safe_truncate_str(&id_str, 8),
|
||||
j.name
|
||||
)
|
||||
}
|
||||
Err(e) => format!("Failed to remove job: {e}"),
|
||||
}
|
||||
@@ -793,13 +798,17 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
// Wave 5
|
||||
"mumble" => channels.mumble.as_ref().map(|c| c.overrides.clone()),
|
||||
"dingtalk" => channels.dingtalk.as_ref().map(|c| c.overrides.clone()),
|
||||
"dingtalk_stream" => channels.dingtalk_stream.as_ref().map(|c| c.overrides.clone()),
|
||||
"dingtalk_stream" => channels
|
||||
.dingtalk_stream
|
||||
.as_ref()
|
||||
.map(|c| c.overrides.clone()),
|
||||
"discourse" => channels.discourse.as_ref().map(|c| c.overrides.clone()),
|
||||
"gitter" => channels.gitter.as_ref().map(|c| c.overrides.clone()),
|
||||
"ntfy" => channels.ntfy.as_ref().map(|c| c.overrides.clone()),
|
||||
"gotify" => channels.gotify.as_ref().map(|c| c.overrides.clone()),
|
||||
"webhook" => channels.webhook.as_ref().map(|c| c.overrides.clone()),
|
||||
"linkedin" => channels.linkedin.as_ref().map(|c| c.overrides.clone()),
|
||||
"wecom" => channels.wecom.as_ref().map(|c| c.overrides.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1030,9 +1039,7 @@ fn read_token(env_var_or_token: &str, adapter_name: &str) -> Option<String> {
|
||||
match std::env::var(env_var_or_token) {
|
||||
Ok(t) if !t.is_empty() => Some(t),
|
||||
Ok(_) => {
|
||||
warn!(
|
||||
"{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping"
|
||||
);
|
||||
warn!("{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -1167,7 +1174,9 @@ pub async fn start_channel_bridge_with_config(
|
||||
// WhatsApp — supports Cloud API mode (access token) or Web/QR mode (gateway URL)
|
||||
if let Some(ref wa_config) = config.whatsapp {
|
||||
let cloud_token = read_token(&wa_config.access_token_env, "WhatsApp");
|
||||
let gateway_url = std::env::var(&wa_config.gateway_url_env).ok().filter(|u| !u.is_empty());
|
||||
let gateway_url = std::env::var(&wa_config.gateway_url_env)
|
||||
.ok()
|
||||
.filter(|u| !u.is_empty());
|
||||
|
||||
if cloud_token.is_some() || gateway_url.is_some() {
|
||||
let token = cloud_token.unwrap_or_default();
|
||||
@@ -1415,8 +1424,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Feishu/Lark
|
||||
if let Some(ref fs_config) = config.feishu {
|
||||
if let Some(secret) = read_token(&fs_config.app_secret_env, "Feishu") {
|
||||
let region =
|
||||
openfang_channels::feishu::FeishuRegion::parse_region(&fs_config.region);
|
||||
let region = openfang_channels::feishu::FeishuRegion::parse_region(&fs_config.region);
|
||||
let encrypt_key = fs_config
|
||||
.encrypt_key_env
|
||||
.as_ref()
|
||||
@@ -1443,6 +1451,21 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// WeCom/WeChat Work
|
||||
if let Some(ref wc_config) = config.wecom {
|
||||
if let Some(secret) = read_token(&wc_config.secret_env, "WeCom") {
|
||||
let adapter = Arc::new(WeComAdapter::with_verification(
|
||||
wc_config.corp_id.clone(),
|
||||
wc_config.agent_id.clone(),
|
||||
secret,
|
||||
wc_config.webhook_port,
|
||||
wc_config.encoding_aes_key.clone(),
|
||||
wc_config.token.clone(),
|
||||
));
|
||||
adapters.push((adapter, wc_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wave 4 ──────────────────────────────────────────────────
|
||||
|
||||
// Nextcloud Talk
|
||||
@@ -1561,9 +1584,12 @@ pub async fn start_channel_bridge_with_config(
|
||||
// DingTalk (stream mode)
|
||||
if let Some(ref ds_config) = config.dingtalk_stream {
|
||||
if let Some(app_key) = read_token(&ds_config.app_key_env, "DingTalk Stream (app_key)") {
|
||||
if let Some(app_secret) = read_token(&ds_config.app_secret_env, "DingTalk Stream (app_secret)") {
|
||||
let robot_code = read_token(&ds_config.robot_code_env, "DingTalk Stream (robot_code)")
|
||||
.unwrap_or_else(|| app_key.clone());
|
||||
if let Some(app_secret) =
|
||||
read_token(&ds_config.app_secret_env, "DingTalk Stream (app_secret)")
|
||||
{
|
||||
let robot_code =
|
||||
read_token(&ds_config.robot_code_env, "DingTalk Stream (robot_code)")
|
||||
.unwrap_or_else(|| app_key.clone());
|
||||
let adapter = Arc::new(DingTalkStreamAdapter::new(app_key, app_secret, robot_code));
|
||||
adapters.push((adapter, ds_config.default_agent.clone()));
|
||||
}
|
||||
|
||||
@@ -221,9 +221,11 @@ fn extract_session_cookie(request: &Request<Body>) -> Option<String> {
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies
|
||||
.split(';')
|
||||
.find_map(|c| c.trim().strip_prefix("openfang_session=").map(|v| v.to_string()))
|
||||
cookies.split(';').find_map(|c| {
|
||||
c.trim()
|
||||
.strip_prefix("openfang_session=")
|
||||
.map(|v| v.to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -202,9 +202,10 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
|
||||
let blocks: Vec<ContentBlock> = parts
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
OaiContentPart::Text { text } => {
|
||||
Some(ContentBlock::Text { text: text.clone(), provider_metadata: None })
|
||||
}
|
||||
OaiContentPart::Text { text } => Some(ContentBlock::Text {
|
||||
text: text.clone(),
|
||||
provider_metadata: None,
|
||||
}),
|
||||
OaiContentPart::ImageUrl { image_url } => {
|
||||
// Parse data URI: data:{media_type};base64,{data}
|
||||
if let Some(rest) = image_url.url.strip_prefix("data:") {
|
||||
|
||||
+454
-175
@@ -73,14 +73,18 @@ pub async fn spawn_agent(
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("Template '{}' not found", safe_name)})),
|
||||
Json(
|
||||
serde_json::json!({"error": format!("Template '{}' not found", safe_name)}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Either 'manifest_toml' or 'template' is required"})),
|
||||
Json(
|
||||
serde_json::json!({"error": "Either 'manifest_toml' or 'template' is required"}),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -176,15 +180,13 @@ pub async fn list_agents(State(state): State<Arc<AppState>>) -> impl IntoRespons
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
// Resolve "default" provider/model to actual kernel defaults
|
||||
let provider = if e.manifest.model.provider.is_empty()
|
||||
|| e.manifest.model.provider == "default"
|
||||
{
|
||||
dm.provider.as_str()
|
||||
} else {
|
||||
e.manifest.model.provider.as_str()
|
||||
};
|
||||
let model = if e.manifest.model.model.is_empty()
|
||||
|| e.manifest.model.model == "default"
|
||||
let provider =
|
||||
if e.manifest.model.provider.is_empty() || e.manifest.model.provider == "default" {
|
||||
dm.provider.as_str()
|
||||
} else {
|
||||
e.manifest.model.provider.as_str()
|
||||
};
|
||||
let model = if e.manifest.model.model.is_empty() || e.manifest.model.model == "default"
|
||||
{
|
||||
dm.model.as_str()
|
||||
} else {
|
||||
@@ -367,7 +369,13 @@ pub async fn send_message(
|
||||
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
|
||||
match state
|
||||
.kernel
|
||||
.send_message_with_handle(agent_id, &req.message, Some(kernel_handle), req.sender_id, req.sender_name)
|
||||
.send_message_with_handle(
|
||||
agent_id,
|
||||
&req.message,
|
||||
Some(kernel_handle),
|
||||
req.sender_id,
|
||||
req.sender_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -473,20 +481,19 @@ pub async fn get_agent_session(
|
||||
// Persist image to upload dir so it can be
|
||||
// served back when loading session history.
|
||||
let file_id = uuid::Uuid::new_v4().to_string();
|
||||
let upload_dir =
|
||||
std::env::temp_dir().join("openfang_uploads");
|
||||
let upload_dir = std::env::temp_dir().join("openfang_uploads");
|
||||
let _ = std::fs::create_dir_all(&upload_dir);
|
||||
if let Ok(bytes) =
|
||||
base64::engine::general_purpose::STANDARD.decode(data)
|
||||
{
|
||||
let _ = std::fs::write(
|
||||
upload_dir.join(&file_id),
|
||||
&bytes,
|
||||
);
|
||||
let _ = std::fs::write(upload_dir.join(&file_id), &bytes);
|
||||
UPLOAD_REGISTRY.insert(
|
||||
file_id.clone(),
|
||||
UploadMeta {
|
||||
filename: format!("image.{}", media_type.rsplit('/').next().unwrap_or("png")),
|
||||
filename: format!(
|
||||
"image.{}",
|
||||
media_type.rsplit('/').next().unwrap_or("png")
|
||||
),
|
||||
content_type: media_type.clone(),
|
||||
},
|
||||
);
|
||||
@@ -510,8 +517,7 @@ pub async fn get_agent_session(
|
||||
"expanded": false,
|
||||
}));
|
||||
// Will be filled after this loop when we know msg_idx
|
||||
tool_use_index
|
||||
.insert(id.clone(), (usize::MAX, tool_idx));
|
||||
tool_use_index.insert(id.clone(), (usize::MAX, tool_idx));
|
||||
}
|
||||
// ToolResult blocks are handled in pass 2
|
||||
openfang_types::message::ContentBlock::ToolResult { .. } => {}
|
||||
@@ -556,9 +562,7 @@ pub async fn get_agent_session(
|
||||
..
|
||||
} = b
|
||||
{
|
||||
if let Some(&(msg_idx, tool_idx)) =
|
||||
tool_use_index.get(tool_use_id)
|
||||
{
|
||||
if let Some(&(msg_idx, tool_idx)) = tool_use_index.get(tool_use_id) {
|
||||
if let Some(msg) = built_messages.get_mut(msg_idx) {
|
||||
if let Some(tools_arr) =
|
||||
msg.get_mut("tools").and_then(|v| v.as_array_mut())
|
||||
@@ -566,8 +570,7 @@ pub async fn get_agent_session(
|
||||
if let Some(tool_obj) = tools_arr.get_mut(tool_idx) {
|
||||
let preview: String =
|
||||
result.chars().take(2000).collect();
|
||||
tool_obj["result"] =
|
||||
serde_json::Value::String(preview);
|
||||
tool_obj["result"] = serde_json::Value::String(preview);
|
||||
tool_obj["is_error"] =
|
||||
serde_json::Value::Bool(*is_error);
|
||||
}
|
||||
@@ -676,10 +679,7 @@ pub async fn restart_agent(
|
||||
drop(entry);
|
||||
|
||||
// Cancel any running task
|
||||
let was_running = state
|
||||
.kernel
|
||||
.stop_agent_run(agent_id)
|
||||
.unwrap_or(false);
|
||||
let was_running = state.kernel.stop_agent_run(agent_id).unwrap_or(false);
|
||||
|
||||
// Reset state to Running (also updates last_active)
|
||||
let _ = state
|
||||
@@ -1078,12 +1078,7 @@ pub async fn delete_workflow(
|
||||
}
|
||||
});
|
||||
|
||||
if state
|
||||
.kernel
|
||||
.workflows
|
||||
.remove_workflow(workflow_id)
|
||||
.await
|
||||
{
|
||||
if state.kernel.workflows.remove_workflow(workflow_id).await {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "removed", "workflow_id": id})),
|
||||
@@ -1411,21 +1406,23 @@ pub async fn send_message_stream(
|
||||
}
|
||||
|
||||
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
|
||||
let (rx, _handle) =
|
||||
match state
|
||||
.kernel
|
||||
.send_message_streaming(agent_id, &req.message, Some(kernel_handle), req.sender_id, req.sender_name)
|
||||
{
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
tracing::warn!("Streaming message failed for agent {id}: {e}");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "Streaming message failed"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let (rx, _handle) = match state.kernel.send_message_streaming(
|
||||
agent_id,
|
||||
&req.message,
|
||||
Some(kernel_handle),
|
||||
req.sender_id,
|
||||
req.sender_name,
|
||||
) {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
tracing::warn!("Streaming message failed for agent {id}: {e}");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "Streaming message failed"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let sse_stream = stream::unfold(rx, |mut rx| async move {
|
||||
match rx.recv().await {
|
||||
@@ -2162,6 +2159,24 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[
|
||||
setup_steps: &["Enter host and username below", "Optionally add a password"],
|
||||
config_template: "[channels.mumble]\nhost = \"\"\nusername = \"openfang\"",
|
||||
},
|
||||
ChannelMeta {
|
||||
name: "wecom", display_name: "WeCom", icon: "WC",
|
||||
description: "WeCom (WeChat Work) adapter",
|
||||
category: "messaging", difficulty: "Easy", setup_time: "~3 min",
|
||||
quick_setup: "Enter your Corp ID, Agent ID, and Secret",
|
||||
setup_type: "form",
|
||||
fields: &[
|
||||
ChannelField { key: "corp_id", label: "Corp ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "wwxxxxx", advanced: false },
|
||||
ChannelField { key: "agent_id", label: "Agent ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "wwxxxxx", advanced: false },
|
||||
ChannelField { key: "secret_env", label: "Secret", field_type: FieldType::Secret, env_var: Some("WECOM_SECRET"), required: true, placeholder: "secret", advanced: false },
|
||||
ChannelField { key: "token", label: "Callback Token", field_type: FieldType::Text, env_var: None, required: false, placeholder: "callback_token", advanced: true },
|
||||
ChannelField { key: "encoding_aes_key", label: "Encoding AES Key", field_type: FieldType::Text, env_var: None, required: false, placeholder: "encoding_aes_key", advanced: true },
|
||||
ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8454", advanced: true },
|
||||
ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true },
|
||||
],
|
||||
setup_steps: &["Create a WeCom application at work.weixin.qq.com", "Get Corp ID, Agent ID, and Secret", "Configure callback URL to your webhook endpoint"],
|
||||
config_template: "[channels.wecom]\ncorp_id = \"\"\nagent_id = \"\"\nsecret_env = \"WECOM_SECRET\"",
|
||||
},
|
||||
];
|
||||
|
||||
/// Check if a channel is configured (has a `[channels.xxx]` section in config).
|
||||
@@ -2208,6 +2223,7 @@ fn is_channel_configured(config: &openfang_types::config::ChannelsConfig, name:
|
||||
"gotify" => config.gotify.is_some(),
|
||||
"webhook" => config.webhook.is_some(),
|
||||
"mumble" => config.mumble.is_some(),
|
||||
"wecom" => config.wecom.is_some(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -2257,9 +2273,7 @@ fn build_field_json(
|
||||
val.clone()
|
||||
};
|
||||
field["value"] = display_val;
|
||||
if !val.is_null()
|
||||
&& val.as_str().map(|s| !s.is_empty()).unwrap_or(true)
|
||||
{
|
||||
if !val.is_null() && val.as_str().map(|s| !s.is_empty()).unwrap_or(true) {
|
||||
field["has_value"] = serde_json::Value::Bool(true);
|
||||
}
|
||||
}
|
||||
@@ -2279,47 +2293,174 @@ fn channel_config_values(
|
||||
name: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
match name {
|
||||
"telegram" => config.telegram.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"discord" => config.discord.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"slack" => config.slack.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"whatsapp" => config.whatsapp.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"signal" => config.signal.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"matrix" => config.matrix.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"email" => config.email.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"teams" => config.teams.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mattermost" => config.mattermost.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"irc" => config.irc.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"google_chat" => config.google_chat.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"twitch" => config.twitch.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"rocketchat" => config.rocketchat.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"zulip" => config.zulip.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"xmpp" => config.xmpp.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"line" => config.line.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"viber" => config.viber.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"messenger" => config.messenger.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"reddit" => config.reddit.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mastodon" => config.mastodon.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"bluesky" => config.bluesky.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"feishu" => config.feishu.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"revolt" => config.revolt.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"nextcloud" => config.nextcloud.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"guilded" => config.guilded.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"keybase" => config.keybase.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"threema" => config.threema.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"nostr" => config.nostr.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"webex" => config.webex.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"pumble" => config.pumble.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"flock" => config.flock.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"twist" => config.twist.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mumble" => config.mumble.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"dingtalk" => config.dingtalk.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"dingtalk_stream" => config.dingtalk_stream.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"discourse" => config.discourse.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"gitter" => config.gitter.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"ntfy" => config.ntfy.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"gotify" => config.gotify.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"webhook" => config.webhook.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"linkedin" => config.linkedin.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"telegram" => config
|
||||
.telegram
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"discord" => config
|
||||
.discord
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"slack" => config
|
||||
.slack
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"whatsapp" => config
|
||||
.whatsapp
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"signal" => config
|
||||
.signal
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"matrix" => config
|
||||
.matrix
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"email" => config
|
||||
.email
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"teams" => config
|
||||
.teams
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mattermost" => config
|
||||
.mattermost
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"irc" => config
|
||||
.irc
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"google_chat" => config
|
||||
.google_chat
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"twitch" => config
|
||||
.twitch
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"rocketchat" => config
|
||||
.rocketchat
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"zulip" => config
|
||||
.zulip
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"xmpp" => config
|
||||
.xmpp
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"line" => config
|
||||
.line
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"viber" => config
|
||||
.viber
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"messenger" => config
|
||||
.messenger
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"reddit" => config
|
||||
.reddit
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mastodon" => config
|
||||
.mastodon
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"bluesky" => config
|
||||
.bluesky
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"feishu" => config
|
||||
.feishu
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"revolt" => config
|
||||
.revolt
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"nextcloud" => config
|
||||
.nextcloud
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"guilded" => config
|
||||
.guilded
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"keybase" => config
|
||||
.keybase
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"threema" => config
|
||||
.threema
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"nostr" => config
|
||||
.nostr
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"webex" => config
|
||||
.webex
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"pumble" => config
|
||||
.pumble
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"flock" => config
|
||||
.flock
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"twist" => config
|
||||
.twist
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mumble" => config
|
||||
.mumble
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"dingtalk" => config
|
||||
.dingtalk
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"dingtalk_stream" => config
|
||||
.dingtalk_stream
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"discourse" => config
|
||||
.discourse
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"gitter" => config
|
||||
.gitter
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"ntfy" => config
|
||||
.ntfy
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"gotify" => config
|
||||
.gotify
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"webhook" => config
|
||||
.webhook
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"linkedin" => config
|
||||
.linkedin
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
"wecom" => config
|
||||
.wecom
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2441,7 +2582,10 @@ pub async fn configure_channel(
|
||||
);
|
||||
} else {
|
||||
// Config field — collect for TOML write with type info
|
||||
config_fields.insert(field_def.key.to_string(), (value.to_string(), field_def.field_type));
|
||||
config_fields.insert(
|
||||
field_def.key.to_string(),
|
||||
(value.to_string(), field_def.field_type),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3129,9 +3273,7 @@ pub async fn health(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let shared_id = openfang_types::agent::AgentId(uuid::Uuid::from_bytes([
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
|
||||
]));
|
||||
memory
|
||||
.structured_get(shared_id, "__health_check__")
|
||||
.is_ok()
|
||||
memory.structured_get(shared_id, "__health_check__").is_ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
@@ -3153,9 +3295,7 @@ pub async fn health_detail(State(state): State<Arc<AppState>>) -> impl IntoRespo
|
||||
let shared_id = openfang_types::agent::AgentId(uuid::Uuid::from_bytes([
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
|
||||
]));
|
||||
memory
|
||||
.structured_get(shared_id, "__health_check__")
|
||||
.is_ok()
|
||||
memory.structured_get(shared_id, "__health_check__").is_ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
@@ -3457,7 +3597,9 @@ pub async fn clawhub_search(
|
||||
"items": items,
|
||||
"next_cursor": null,
|
||||
});
|
||||
state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone()));
|
||||
state
|
||||
.clawhub_cache
|
||||
.insert(cache_key, (Instant::now(), resp.clone()));
|
||||
(StatusCode::OK, Json(resp))
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -3470,9 +3612,7 @@ pub async fn clawhub_search(
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(
|
||||
serde_json::json!({"items": [], "next_cursor": null, "error": msg}),
|
||||
),
|
||||
Json(serde_json::json!({"items": [], "next_cursor": null, "error": msg})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3531,7 +3671,9 @@ pub async fn clawhub_browse(
|
||||
"items": items,
|
||||
"next_cursor": results.next_cursor,
|
||||
});
|
||||
state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone()));
|
||||
state
|
||||
.clawhub_cache
|
||||
.insert(cache_key, (Instant::now(), resp.clone()));
|
||||
(StatusCode::OK, Json(resp))
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -3544,9 +3686,7 @@ pub async fn clawhub_browse(
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(
|
||||
serde_json::json!({"items": [], "next_cursor": null, "error": msg}),
|
||||
),
|
||||
Json(serde_json::json!({"items": [], "next_cursor": null, "error": msg})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3776,7 +3916,10 @@ pub async fn list_hands(State(state): State<Arc<AppState>>) -> impl IntoResponse
|
||||
.check_requirements(&d.id)
|
||||
.unwrap_or_default();
|
||||
let readiness = state.kernel.hand_registry.readiness(&d.id);
|
||||
let requirements_met = readiness.as_ref().map(|r| r.requirements_met).unwrap_or(false);
|
||||
let requirements_met = readiness
|
||||
.as_ref()
|
||||
.map(|r| r.requirements_met)
|
||||
.unwrap_or(false);
|
||||
let active = readiness.as_ref().map(|r| r.active).unwrap_or(false);
|
||||
let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false);
|
||||
serde_json::json!({
|
||||
@@ -3839,7 +3982,10 @@ pub async fn get_hand(
|
||||
.check_requirements(&hand_id)
|
||||
.unwrap_or_default();
|
||||
let readiness = state.kernel.hand_registry.readiness(&hand_id);
|
||||
let requirements_met = readiness.as_ref().map(|r| r.requirements_met).unwrap_or(false);
|
||||
let requirements_met = readiness
|
||||
.as_ref()
|
||||
.map(|r| r.requirements_met)
|
||||
.unwrap_or(false);
|
||||
let active = readiness.as_ref().map(|r| r.active).unwrap_or(false);
|
||||
let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false);
|
||||
let settings_status = state
|
||||
@@ -3916,7 +4062,10 @@ pub async fn check_hand_deps(
|
||||
.check_requirements(&hand_id)
|
||||
.unwrap_or_default();
|
||||
let readiness = state.kernel.hand_registry.readiness(&hand_id);
|
||||
let requirements_met = readiness.as_ref().map(|r| r.requirements_met).unwrap_or(false);
|
||||
let requirements_met = readiness
|
||||
.as_ref()
|
||||
.map(|r| r.requirements_met)
|
||||
.unwrap_or(false);
|
||||
let active = readiness.as_ref().map(|r| r.active).unwrap_or(false);
|
||||
let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false);
|
||||
(
|
||||
@@ -4281,7 +4430,12 @@ pub async fn activate_hand(
|
||||
// If the hand agent has a non-reactive schedule (autonomous hands),
|
||||
// start its background loop so it begins running immediately.
|
||||
if let Some(agent_id) = instance.agent_id {
|
||||
let entry = state.kernel.registry.list().into_iter().find(|e| e.id == agent_id);
|
||||
let entry = state
|
||||
.kernel
|
||||
.registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.find(|e| e.id == agent_id);
|
||||
if let Some(entry) = entry {
|
||||
if !matches!(
|
||||
entry.manifest.schedule,
|
||||
@@ -4437,7 +4591,9 @@ pub async fn update_hand_settings(
|
||||
},
|
||||
None => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("No active instance for hand: {hand_id}. Activate the hand first.")})),
|
||||
Json(
|
||||
serde_json::json!({"error": format!("No active instance for hand: {hand_id}. Activate the hand first.")}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -4574,7 +4730,10 @@ pub async fn hand_instance_browser(
|
||||
content = data["content"].as_str().unwrap_or("").to_string();
|
||||
// Truncate content to avoid huge payloads (UTF-8 safe)
|
||||
if content.len() > 2000 {
|
||||
content = format!("{}... (truncated)", openfang_types::truncate_str(&content, 2000));
|
||||
content = format!(
|
||||
"{}... (truncated)",
|
||||
openfang_types::truncate_str(&content, 2000)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5244,7 +5403,9 @@ pub async fn update_agent_budget(
|
||||
if hourly.is_none() && daily.is_none() && monthly.is_none() && tokens.is_none() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Provide at least one of: max_cost_per_hour_usd, max_cost_per_day_usd, max_cost_per_month_usd, max_llm_tokens_per_hour"})),
|
||||
Json(
|
||||
serde_json::json!({"error": "Provide at least one of: max_cost_per_hour_usd, max_cost_per_day_usd, max_cost_per_month_usd, max_llm_tokens_per_hour"}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5536,7 +5697,10 @@ pub async fn patch_agent(
|
||||
}
|
||||
if let Some(model) = body.get("model").and_then(|v| v.as_str()) {
|
||||
let explicit_provider = body.get("provider").and_then(|v| v.as_str());
|
||||
if let Err(e) = state.kernel.set_agent_model(agent_id, model, explicit_provider) {
|
||||
if let Err(e) = state
|
||||
.kernel
|
||||
.set_agent_model(agent_id, model, explicit_provider)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
@@ -5561,7 +5725,9 @@ pub async fn patch_agent(
|
||||
let _ = state.kernel.memory.save_agent(&entry);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "agent_id": entry.id.to_string(), "name": entry.name})),
|
||||
Json(
|
||||
serde_json::json!({"status": "ok", "agent_id": entry.id.to_string(), "name": entry.name}),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -6079,7 +6245,9 @@ pub async fn add_custom_model(
|
||||
if !catalog.add_custom_model(entry) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({"error": format!("Model '{}' already exists for provider '{}'", id, provider)})),
|
||||
Json(
|
||||
serde_json::json!({"error": format!("Model '{}' already exists for provider '{}'", id, provider)}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6784,7 +6952,10 @@ pub async fn set_model(
|
||||
}
|
||||
};
|
||||
let explicit_provider = body["provider"].as_str();
|
||||
match state.kernel.set_agent_model(agent_id, model, explicit_provider) {
|
||||
match state
|
||||
.kernel
|
||||
.set_agent_model(agent_id, model, explicit_provider)
|
||||
{
|
||||
Ok(()) => {
|
||||
// Return the resolved model+provider so frontend stays in sync.
|
||||
// The model name may have been normalized (provider prefix stripped),
|
||||
@@ -6793,11 +6964,18 @@ pub async fn set_model(
|
||||
.kernel
|
||||
.registry
|
||||
.get(agent_id)
|
||||
.map(|e| (e.manifest.model.model.clone(), e.manifest.model.provider.clone()))
|
||||
.map(|e| {
|
||||
(
|
||||
e.manifest.model.model.clone(),
|
||||
e.manifest.model.provider.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| (model.to_string(), String::new()));
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "model": resolved_model, "provider": resolved_provider})),
|
||||
Json(
|
||||
serde_json::json!({"status": "ok", "model": resolved_model, "provider": resolved_provider}),
|
||||
),
|
||||
)
|
||||
}
|
||||
Err(e) => (
|
||||
@@ -6882,10 +7060,7 @@ pub async fn set_agent_tools(
|
||||
.kernel
|
||||
.set_agent_tool_filters(agent_id, allowlist, blocklist)
|
||||
{
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok"})),
|
||||
),
|
||||
Ok(()) => (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
@@ -7095,10 +7270,7 @@ pub async fn set_provider_key(
|
||||
.map(|p| p.api_key_env.clone())
|
||||
.unwrap_or_else(|| {
|
||||
// Custom provider — derive env var: MY_PROVIDER → MY_PROVIDER_API_KEY
|
||||
format!(
|
||||
"{}_API_KEY",
|
||||
name.to_uppercase().replace('-', "_")
|
||||
)
|
||||
format!("{}_API_KEY", name.to_uppercase().replace('-', "_"))
|
||||
})
|
||||
};
|
||||
|
||||
@@ -7157,7 +7329,11 @@ pub async fn set_provider_key(
|
||||
let switched = if !current_has_key && current_provider != name {
|
||||
// Find a default model for the newly-keyed provider
|
||||
let default_model = {
|
||||
let catalog = state.kernel.model_catalog.read().unwrap_or_else(|e| e.into_inner());
|
||||
let catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
catalog.default_model_for_provider(&name)
|
||||
};
|
||||
if let Some(model_id) = default_model {
|
||||
@@ -7170,7 +7346,8 @@ pub async fn set_provider_key(
|
||||
backup_config(&config_path);
|
||||
if let Ok(existing) = std::fs::read_to_string(&config_path) {
|
||||
let cleaned = remove_toml_section(&existing, "default_model");
|
||||
let _ = std::fs::write(&config_path, format!("{}\n{}", cleaned.trim(), update_toml));
|
||||
let _ =
|
||||
std::fs::write(&config_path, format!("{}\n{}", cleaned.trim(), update_toml));
|
||||
} else {
|
||||
let _ = std::fs::write(&config_path, update_toml);
|
||||
}
|
||||
@@ -7232,9 +7409,10 @@ pub async fn set_provider_key(
|
||||
let mut resp = serde_json::json!({"status": "saved", "provider": name});
|
||||
if switched {
|
||||
resp["switched_default"] = serde_json::json!(true);
|
||||
resp["message"] = serde_json::json!(
|
||||
format!("API key saved and default provider switched to '{}'.", name)
|
||||
);
|
||||
resp["message"] = serde_json::json!(format!(
|
||||
"API key saved and default provider switched to '{}'.",
|
||||
name
|
||||
));
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(resp))
|
||||
@@ -7441,8 +7619,7 @@ pub async fn set_provider_url(
|
||||
}
|
||||
|
||||
// Probe reachability at the new URL
|
||||
let probe =
|
||||
openfang_runtime::provider_health::probe_provider(&name, &base_url).await;
|
||||
let probe = openfang_runtime::provider_health::probe_provider(&name, &base_url).await;
|
||||
|
||||
// Merge discovered models into catalog
|
||||
if !probe.discovered_models.is_empty() {
|
||||
@@ -8336,7 +8513,11 @@ pub async fn run_schedule(
|
||||
);
|
||||
|
||||
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
|
||||
match state.kernel.send_message_with_handle(target_agent, &run_message, Some(kernel_handle), None, None).await {
|
||||
match state
|
||||
.kernel
|
||||
.send_message_with_handle(target_agent, &run_message, Some(kernel_handle), None, None)
|
||||
.await
|
||||
{
|
||||
Ok(result) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
@@ -8490,7 +8671,9 @@ pub async fn patch_agent_config(
|
||||
if name.len() > MAX_NAME_LEN {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({"error": format!("Name exceeds max length ({MAX_NAME_LEN} chars)")})),
|
||||
Json(
|
||||
serde_json::json!({"error": format!("Name exceeds max length ({MAX_NAME_LEN} chars)")}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8498,7 +8681,9 @@ pub async fn patch_agent_config(
|
||||
if desc.len() > MAX_DESC_LEN {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({"error": format!("Description exceeds max length ({MAX_DESC_LEN} chars)")})),
|
||||
Json(
|
||||
serde_json::json!({"error": format!("Description exceeds max length ({MAX_DESC_LEN} chars)")}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8506,7 +8691,9 @@ pub async fn patch_agent_config(
|
||||
if prompt.len() > MAX_PROMPT_LEN {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({"error": format!("System prompt exceeds max length ({MAX_PROMPT_LEN} chars)")})),
|
||||
Json(
|
||||
serde_json::json!({"error": format!("System prompt exceeds max length ({MAX_PROMPT_LEN} chars)")}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9503,12 +9690,18 @@ pub async fn config_reload(State(state): State<Arc<AppState>>) -> impl IntoRespo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/config/schema — Return a simplified JSON description of the config structure.
|
||||
pub async fn config_schema(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
pub async fn config_schema(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
// Build provider/model options from model catalog for dropdowns
|
||||
let catalog = state.kernel.model_catalog.read().unwrap_or_else(|e| e.into_inner());
|
||||
let provider_options: Vec<String> = catalog.list_providers().iter().map(|p| p.id.clone()).collect();
|
||||
let catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let provider_options: Vec<String> = catalog
|
||||
.list_providers()
|
||||
.iter()
|
||||
.map(|p| p.id.clone())
|
||||
.collect();
|
||||
let model_options: Vec<serde_json::Value> = catalog
|
||||
.list_models()
|
||||
.iter()
|
||||
@@ -10362,8 +10555,7 @@ pub async fn copilot_oauth_start() -> impl IntoResponse {
|
||||
CopilotFlowState {
|
||||
device_code: resp.device_code,
|
||||
interval: resp.interval,
|
||||
expires_at: Instant::now()
|
||||
+ std::time::Duration::from_secs(resp.expires_in),
|
||||
expires_at: Instant::now() + std::time::Duration::from_secs(resp.expires_in),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10430,7 +10622,9 @@ pub async fn copilot_oauth_poll(
|
||||
if let Err(e) = write_secret_env(&secrets_path, "GITHUB_TOKEN", &access_token) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")})),
|
||||
Json(
|
||||
serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10634,15 +10828,30 @@ fn audit_to_comms_event(
|
||||
// Format detail: "tokens_in=X, tokens_out=Y" → readable summary
|
||||
let detail = if entry.detail.starts_with("tokens_in=") {
|
||||
let parts: Vec<&str> = entry.detail.split(", ").collect();
|
||||
let in_tok = parts.first().and_then(|p| p.strip_prefix("tokens_in=")).unwrap_or("?");
|
||||
let out_tok = parts.get(1).and_then(|p| p.strip_prefix("tokens_out=")).unwrap_or("?");
|
||||
let in_tok = parts
|
||||
.first()
|
||||
.and_then(|p| p.strip_prefix("tokens_in="))
|
||||
.unwrap_or("?");
|
||||
let out_tok = parts
|
||||
.get(1)
|
||||
.and_then(|p| p.strip_prefix("tokens_out="))
|
||||
.unwrap_or("?");
|
||||
if entry.outcome == "ok" {
|
||||
format!("{} in / {} out tokens", in_tok, out_tok)
|
||||
} else {
|
||||
format!("{} in / {} out — {}", in_tok, out_tok, openfang_types::truncate_str(&entry.outcome, 80))
|
||||
format!(
|
||||
"{} in / {} out — {}",
|
||||
in_tok,
|
||||
out_tok,
|
||||
openfang_types::truncate_str(&entry.outcome, 80)
|
||||
)
|
||||
}
|
||||
} else if entry.outcome != "ok" {
|
||||
format!("{} — {}", openfang_types::truncate_str(&entry.detail, 80), openfang_types::truncate_str(&entry.outcome, 80))
|
||||
format!(
|
||||
"{} — {}",
|
||||
openfang_types::truncate_str(&entry.detail, 80),
|
||||
openfang_types::truncate_str(&entry.outcome, 80)
|
||||
)
|
||||
} else {
|
||||
openfang_types::truncate_str(&entry.detail, 200).to_string()
|
||||
};
|
||||
@@ -10650,12 +10859,18 @@ fn audit_to_comms_event(
|
||||
}
|
||||
"AgentSpawn" => (
|
||||
CommsEventKind::AgentSpawned,
|
||||
format!("Agent spawned: {}", openfang_types::truncate_str(&entry.detail, 100)),
|
||||
format!(
|
||||
"Agent spawned: {}",
|
||||
openfang_types::truncate_str(&entry.detail, 100)
|
||||
),
|
||||
"",
|
||||
),
|
||||
"AgentKill" => (
|
||||
CommsEventKind::AgentTerminated,
|
||||
format!("Agent killed: {}", openfang_types::truncate_str(&entry.detail, 100)),
|
||||
format!(
|
||||
"Agent killed: {}",
|
||||
openfang_types::truncate_str(&entry.detail, 100)
|
||||
),
|
||||
"",
|
||||
),
|
||||
_ => return None,
|
||||
@@ -10667,8 +10882,16 @@ fn audit_to_comms_event(
|
||||
kind,
|
||||
source_id: entry.agent_id.clone(),
|
||||
source_name: resolve_name(&entry.agent_id),
|
||||
target_id: if target_label.is_empty() { String::new() } else { target_label.to_string() },
|
||||
target_name: if target_label.is_empty() { String::new() } else { target_label.to_string() },
|
||||
target_id: if target_label.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
target_label.to_string()
|
||||
},
|
||||
target_name: if target_label.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
target_label.to_string()
|
||||
},
|
||||
detail,
|
||||
})
|
||||
}
|
||||
@@ -10719,9 +10942,7 @@ pub async fn comms_events(
|
||||
/// GET /api/comms/events/stream — SSE stream of inter-agent communication events.
|
||||
///
|
||||
/// Polls the audit log every 500ms for new inter-agent events.
|
||||
pub async fn comms_events_stream(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> axum::response::Response {
|
||||
pub async fn comms_events_stream(State(state): State<Arc<AppState>>) -> axum::response::Response {
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<
|
||||
@@ -10876,15 +11097,17 @@ pub async fn auth_login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<serde_json::Value>,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::Response;
|
||||
use axum::body::Body;
|
||||
use axum::response::Response;
|
||||
|
||||
let auth_cfg = &state.kernel.config.auth;
|
||||
if !auth_cfg.enabled {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::json!({"error": "Auth not enabled"}).to_string()))
|
||||
.body(Body::from(
|
||||
serde_json::json!({"error": "Auth not enabled"}).to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -10914,7 +11137,9 @@ pub async fn auth_login(
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::json!({"error": "Invalid credentials"}).to_string()))
|
||||
.body(Body::from(
|
||||
serde_json::json!({"error": "Invalid credentials"}).to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -10929,9 +11154,8 @@ pub async fn auth_login(
|
||||
let token =
|
||||
crate::session_auth::create_session_token(username, &secret, auth_cfg.session_ttl_hours);
|
||||
let ttl_secs = auth_cfg.session_ttl_hours * 3600;
|
||||
let cookie = format!(
|
||||
"openfang_session={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl_secs}"
|
||||
);
|
||||
let cookie =
|
||||
format!("openfang_session={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl_secs}");
|
||||
|
||||
state.kernel.audit_log.record(
|
||||
"system",
|
||||
@@ -10944,11 +11168,14 @@ pub async fn auth_login(
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "application/json")
|
||||
.header("set-cookie", &cookie)
|
||||
.body(Body::from(serde_json::json!({
|
||||
"status": "ok",
|
||||
"token": token,
|
||||
"username": username,
|
||||
}).to_string()))
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"status": "ok",
|
||||
"token": token,
|
||||
"username": username,
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -10989,9 +11216,11 @@ pub async fn auth_check(
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies
|
||||
.split(';')
|
||||
.find_map(|c| c.trim().strip_prefix("openfang_session=").map(|v| v.to_string()))
|
||||
cookies.split(';').find_map(|c| {
|
||||
c.trim()
|
||||
.strip_prefix("openfang_session=")
|
||||
.map(|v| v.to_string())
|
||||
})
|
||||
})
|
||||
.and_then(|token| crate::session_auth::verify_session_token(&token, &secret));
|
||||
|
||||
@@ -11036,3 +11265,53 @@ fn remove_toml_section(content: &str, section: &str) -> String {
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod channel_config_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_channel_configured_wecom_none() {
|
||||
let config = openfang_types::config::ChannelsConfig::default();
|
||||
assert!(!is_channel_configured(&config, "wecom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_channel_configured_wecom_some() {
|
||||
let mut config = openfang_types::config::ChannelsConfig::default();
|
||||
config.wecom = Some(openfang_types::config::WeComConfig {
|
||||
corp_id: "test_corp".to_string(),
|
||||
agent_id: "test_agent".to_string(),
|
||||
secret_env: "WECOM_SECRET".to_string(),
|
||||
webhook_port: 8454,
|
||||
token: Some("token".to_string()),
|
||||
encoding_aes_key: Some("aes_key".to_string()),
|
||||
default_agent: Some("assistant".to_string()),
|
||||
overrides: openfang_types::config::ChannelOverrides::default(),
|
||||
});
|
||||
assert!(is_channel_configured(&config, "wecom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wecom_in_channel_registry() {
|
||||
let wecom_meta = CHANNEL_REGISTRY.iter().find(|c| c.name == "wecom");
|
||||
assert!(wecom_meta.is_some());
|
||||
let meta = wecom_meta.unwrap();
|
||||
assert_eq!(meta.display_name, "WeCom");
|
||||
assert_eq!(meta.category, "messaging");
|
||||
assert!(
|
||||
meta.fields
|
||||
.iter()
|
||||
.find(|f| f.key == "corp_id")
|
||||
.unwrap()
|
||||
.required
|
||||
);
|
||||
assert!(
|
||||
meta.fields
|
||||
.iter()
|
||||
.find(|f| f.key == "secret_env")
|
||||
.unwrap()
|
||||
.required
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,9 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}",
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
|
||||
axum::routing::get(routes::get_agent)
|
||||
.delete(routes::kill_agent)
|
||||
.patch(routes::patch_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/mode",
|
||||
@@ -309,7 +311,9 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/workflows/{id}",
|
||||
axum::routing::get(routes::get_workflow).put(routes::update_workflow).delete(routes::delete_workflow),
|
||||
axum::routing::get(routes::get_workflow)
|
||||
.put(routes::update_workflow)
|
||||
.delete(routes::delete_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/workflows/{id}/run",
|
||||
@@ -383,8 +387,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/hands/{hand_id}/settings",
|
||||
axum::routing::get(routes::get_hand_settings)
|
||||
.put(routes::update_hand_settings),
|
||||
axum::routing::get(routes::get_hand_settings).put(routes::update_hand_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/instances/{id}/pause",
|
||||
@@ -441,15 +444,8 @@ pub async fn build_router(
|
||||
"/api/comms/events/stream",
|
||||
axum::routing::get(routes::comms_events_stream),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/send",
|
||||
axum::routing::post(routes::comms_send),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/task",
|
||||
axum::routing::post(routes::comms_task),
|
||||
)
|
||||
;
|
||||
.route("/api/comms/send", axum::routing::post(routes::comms_send))
|
||||
.route("/api/comms/task", axum::routing::post(routes::comms_task));
|
||||
|
||||
// Split into a second router chunk to stay within axum's type nesting limit.
|
||||
let app = app
|
||||
@@ -497,8 +493,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/budget/agents/{id}",
|
||||
axum::routing::get(routes::agent_budget_status)
|
||||
.put(routes::update_agent_budget),
|
||||
axum::routing::get(routes::agent_budget_status).put(routes::update_agent_budget),
|
||||
)
|
||||
// Session endpoints
|
||||
.route("/api/sessions", axum::routing::get(routes::list_sessions))
|
||||
@@ -703,18 +698,9 @@ pub async fn build_router(
|
||||
axum::routing::get(crate::openai_compat::list_models),
|
||||
)
|
||||
// Dashboard authentication endpoints
|
||||
.route(
|
||||
"/api/auth/login",
|
||||
axum::routing::post(routes::auth_login),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/logout",
|
||||
axum::routing::post(routes::auth_logout),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/check",
|
||||
axum::routing::get(routes::auth_check),
|
||||
)
|
||||
.route("/api/auth/login", axum::routing::post(routes::auth_login))
|
||||
.route("/api/auth/logout", axum::routing::post(routes::auth_logout))
|
||||
.route("/api/auth/check", axum::routing::get(routes::auth_check))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
auth_state,
|
||||
middleware::auth,
|
||||
@@ -833,8 +819,7 @@ pub async fn run_daemon(
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(1024)?;
|
||||
let listener =
|
||||
tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
|
||||
let listener = tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
|
||||
|
||||
// Run server with graceful shutdown.
|
||||
// SECURITY: `into_make_service_with_connect_info` injects the peer
|
||||
@@ -965,11 +950,8 @@ fn is_daemon_responding(addr: &str) -> bool {
|
||||
.or_else(|| addr.strip_prefix("https://"))
|
||||
.unwrap_or(addr);
|
||||
if let Ok(sock_addr) = addr_only.parse::<std::net::SocketAddr>() {
|
||||
std::net::TcpStream::connect_timeout(
|
||||
&sock_addr,
|
||||
std::time::Duration::from_millis(500),
|
||||
)
|
||||
.is_ok()
|
||||
std::net::TcpStream::connect_timeout(&sock_addr, std::time::Duration::from_millis(500))
|
||||
.is_ok()
|
||||
} else {
|
||||
// Fallback: try connecting to hostname
|
||||
std::net::TcpStream::connect(addr_only)
|
||||
|
||||
@@ -502,10 +502,13 @@ async fn handle_text_message(
|
||||
// Send message to agent with streaming
|
||||
let kernel_handle: Arc<dyn KernelHandle> =
|
||||
state.kernel.clone() as Arc<dyn KernelHandle>;
|
||||
match state
|
||||
.kernel
|
||||
.send_message_streaming(agent_id, &content, Some(kernel_handle), None, None)
|
||||
{
|
||||
match state.kernel.send_message_streaming(
|
||||
agent_id,
|
||||
&content,
|
||||
Some(kernel_handle),
|
||||
None,
|
||||
None,
|
||||
) {
|
||||
Ok((mut rx, handle)) => {
|
||||
// Forward stream events to WebSocket with debouncing.
|
||||
//
|
||||
@@ -712,7 +715,8 @@ async fn handle_text_message(
|
||||
};
|
||||
|
||||
// Estimate context pressure
|
||||
let ctx_pct = (usage.input_tokens as f64 / 200_000.0 * 100.0).min(100.0);
|
||||
let ctx_pct =
|
||||
(usage.input_tokens as f64 / 200_000.0 * 100.0).min(100.0);
|
||||
let pressure = if ctx_pct > 85.0 {
|
||||
"critical"
|
||||
} else if ctx_pct > 70.0 {
|
||||
@@ -1183,7 +1187,10 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
if inner.contains("localhost:11434") || inner.contains("ollama") {
|
||||
"Model not found on Ollama. Run `ollama pull <model>` first. Use /model to see options.".to_string()
|
||||
} else {
|
||||
format!("{}. Use /model to see options.", classified.sanitized_message)
|
||||
format!(
|
||||
"{}. Use /model to see options.",
|
||||
classified.sanitized_message
|
||||
)
|
||||
}
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Format => {
|
||||
|
||||
@@ -24,12 +24,14 @@ zeroize = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
html-escape = { workspace = true }
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
regex-lite = "0.1"
|
||||
roxmltree = "0.20"
|
||||
|
||||
lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
|
||||
@@ -11,10 +11,10 @@ use crate::types::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use openfang_types::message::ContentBlock;
|
||||
use futures::StreamExt;
|
||||
use openfang_types::agent::AgentId;
|
||||
use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat};
|
||||
use openfang_types::message::ContentBlock;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::watch;
|
||||
@@ -407,7 +407,11 @@ async fn send_response(
|
||||
thread_id: Option<&str>,
|
||||
output_format: OutputFormat,
|
||||
) {
|
||||
let formatted = formatter::format_for_channel(&text, output_format);
|
||||
let formatted = if adapter.name() == "wecom" {
|
||||
formatter::format_for_wecom(&text, output_format)
|
||||
} else {
|
||||
formatter::format_for_channel(&text, output_format)
|
||||
};
|
||||
let content = ChannelContent::Text(formatted);
|
||||
|
||||
let result = if let Some(tid) = thread_id {
|
||||
@@ -421,6 +425,15 @@ async fn send_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn default_output_format_for_channel(channel_type: &str) -> OutputFormat {
|
||||
match channel_type {
|
||||
"telegram" => OutputFormat::TelegramHtml,
|
||||
"slack" => OutputFormat::SlackMrkdwn,
|
||||
"wecom" => OutputFormat::PlainText,
|
||||
_ => OutputFormat::Markdown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a lifecycle reaction (best-effort, non-blocking for supported adapters).
|
||||
///
|
||||
/// Silently ignores errors — reactions are non-critical UX polish.
|
||||
@@ -525,17 +538,16 @@ async fn dispatch_message(
|
||||
|
||||
// Fetch per-channel overrides (if configured)
|
||||
let overrides = handle.channel_overrides(ct_str).await;
|
||||
let channel_default_format = match ct_str {
|
||||
"telegram" => OutputFormat::TelegramHtml,
|
||||
"slack" => OutputFormat::SlackMrkdwn,
|
||||
_ => OutputFormat::Markdown,
|
||||
};
|
||||
let channel_default_format = default_output_format_for_channel(ct_str);
|
||||
let output_format = overrides
|
||||
.as_ref()
|
||||
.and_then(|o| o.output_format)
|
||||
.unwrap_or(channel_default_format);
|
||||
let threading_enabled = overrides.as_ref().map(|o| o.threading).unwrap_or(false);
|
||||
let lifecycle_reactions = overrides.as_ref().map(|o| o.lifecycle_reactions).unwrap_or(true);
|
||||
let lifecycle_reactions = overrides
|
||||
.as_ref()
|
||||
.map(|o| o.lifecycle_reactions)
|
||||
.unwrap_or(true);
|
||||
let thread_id = if threading_enabled {
|
||||
message.thread_id.as_deref()
|
||||
} else {
|
||||
@@ -561,7 +573,9 @@ async fn dispatch_message(
|
||||
}
|
||||
GroupPolicy::MentionOnly => {
|
||||
// Only allow messages where the bot was @mentioned or commands.
|
||||
let was_mentioned = message.metadata.get("was_mentioned")
|
||||
let was_mentioned = message
|
||||
.metadata
|
||||
.get("was_mentioned")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_command = matches!(&message.content, ChannelContent::Command { .. });
|
||||
@@ -607,9 +621,16 @@ async fn dispatch_message(
|
||||
}
|
||||
|
||||
// For images: download, base64 encode, and send as multimodal content blocks
|
||||
if let ChannelContent::Image { ref url, ref caption } = message.content {
|
||||
if let ChannelContent::Image {
|
||||
ref url,
|
||||
ref caption,
|
||||
} = message.content
|
||||
{
|
||||
let blocks = download_image_to_blocks(url, caption.as_deref()).await;
|
||||
if blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. })) {
|
||||
if blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::Image { .. }))
|
||||
{
|
||||
// We have actual image data — send as structured blocks for vision
|
||||
dispatch_with_blocks(
|
||||
blocks,
|
||||
@@ -632,17 +653,26 @@ async fn dispatch_message(
|
||||
let text = match &message.content {
|
||||
ChannelContent::Text(t) => t.clone(),
|
||||
ChannelContent::Command { .. } => unreachable!(), // handled above
|
||||
ChannelContent::Image { ref url, ref caption } => {
|
||||
ChannelContent::Image {
|
||||
ref url,
|
||||
ref caption,
|
||||
} => {
|
||||
// Fallback when image download failed
|
||||
match caption {
|
||||
Some(c) => format!("[User sent a photo: {url}]\nCaption: {c}"),
|
||||
None => format!("[User sent a photo: {url}]"),
|
||||
}
|
||||
}
|
||||
ChannelContent::File { ref url, ref filename } => {
|
||||
ChannelContent::File {
|
||||
ref url,
|
||||
ref filename,
|
||||
} => {
|
||||
format!("[User sent a file ({filename}): {url}]")
|
||||
}
|
||||
ChannelContent::Voice { ref url, duration_seconds } => {
|
||||
ChannelContent::Voice {
|
||||
ref url,
|
||||
duration_seconds,
|
||||
} => {
|
||||
format!("[User sent a voice message ({duration_seconds}s): {url}]")
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
@@ -834,7 +864,14 @@ async fn dispatch_message(
|
||||
if let Some(reply) = handle.check_auto_reply(agent_id, &text).await {
|
||||
send_response(adapter, &message.sender, reply, thread_id, output_format).await;
|
||||
handle
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
|
||||
.record_delivery(
|
||||
agent_id,
|
||||
ct_str,
|
||||
&message.sender.platform_id,
|
||||
true,
|
||||
None,
|
||||
thread_id,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
@@ -866,16 +903,20 @@ async fn dispatch_message(
|
||||
}
|
||||
send_response(adapter, &message.sender, response, thread_id, output_format).await;
|
||||
handle
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
|
||||
.record_delivery(
|
||||
agent_id,
|
||||
ct_str,
|
||||
&message.sender.platform_id,
|
||||
true,
|
||||
None,
|
||||
thread_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
// Try re-resolution before reporting error
|
||||
if let Some(new_id) =
|
||||
try_reresolution(&e, &channel_key, handle, router).await
|
||||
{
|
||||
let typing_task2 =
|
||||
spawn_typing_loop(adapter_arc.clone(), message.sender.clone());
|
||||
if let Some(new_id) = try_reresolution(&e, &channel_key, handle, router).await {
|
||||
let typing_task2 = spawn_typing_loop(adapter_arc.clone(), message.sender.clone());
|
||||
let retry = handle.send_message(new_id, &text).await;
|
||||
typing_task2.abort();
|
||||
match retry {
|
||||
@@ -889,14 +930,8 @@ async fn dispatch_message(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
response,
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
send_response(adapter, &message.sender, response, thread_id, output_format)
|
||||
.await;
|
||||
handle
|
||||
.record_delivery(
|
||||
new_id,
|
||||
@@ -1057,7 +1092,9 @@ fn detect_image_magic(bytes: &[u8]) -> Option<String> {
|
||||
if bytes.len() >= 4 && bytes[..4] == [0x47, 0x49, 0x46, 0x38] {
|
||||
return Some("image/gif".to_string());
|
||||
}
|
||||
if bytes.len() >= 12 && bytes[..4] == [0x52, 0x49, 0x46, 0x46] && bytes[8..12] == [0x57, 0x45, 0x42, 0x50]
|
||||
if bytes.len() >= 12
|
||||
&& bytes[..4] == [0x52, 0x49, 0x46, 0x46]
|
||||
&& bytes[8..12] == [0x57, 0x45, 0x42, 0x50]
|
||||
{
|
||||
return Some("image/webp".to_string());
|
||||
}
|
||||
@@ -1126,9 +1163,8 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec<Conte
|
||||
// 1. Trusted Content-Type header (only if image/*)
|
||||
// 2. Magic byte sniffing (most reliable for binary data)
|
||||
// 3. URL extension fallback
|
||||
let media_type = header_type.unwrap_or_else(|| {
|
||||
detect_image_magic(&bytes).unwrap_or_else(|| media_type_from_url(url))
|
||||
});
|
||||
let media_type = header_type
|
||||
.unwrap_or_else(|| detect_image_magic(&bytes).unwrap_or_else(|| media_type_from_url(url)));
|
||||
|
||||
if bytes.len() > MAX_IMAGE_BYTES {
|
||||
warn!(
|
||||
@@ -1136,10 +1172,16 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec<Conte
|
||||
bytes.len()
|
||||
);
|
||||
let desc = match caption {
|
||||
Some(c) => format!("[Image too large for vision ({} KB)]\nCaption: {c}", bytes.len() / 1024),
|
||||
Some(c) => format!(
|
||||
"[Image too large for vision ({} KB)]\nCaption: {c}",
|
||||
bytes.len() / 1024
|
||||
),
|
||||
None => format!("[Image too large for vision ({} KB)]", bytes.len() / 1024),
|
||||
};
|
||||
return vec![ContentBlock::Text { text: desc, provider_metadata: None }];
|
||||
return vec![ContentBlock::Text {
|
||||
text: desc,
|
||||
provider_metadata: None,
|
||||
}];
|
||||
}
|
||||
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
@@ -1258,19 +1300,21 @@ async fn dispatch_with_blocks(
|
||||
}
|
||||
send_response(adapter, &message.sender, response, thread_id, output_format).await;
|
||||
handle
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
|
||||
.record_delivery(
|
||||
agent_id,
|
||||
ct_str,
|
||||
&message.sender.platform_id,
|
||||
true,
|
||||
None,
|
||||
thread_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
// Try re-resolution before reporting error
|
||||
if let Some(new_id) =
|
||||
try_reresolution(&e, &channel_key, handle, router).await
|
||||
{
|
||||
let typing_task2 =
|
||||
spawn_typing_loop(adapter_arc.clone(), message.sender.clone());
|
||||
let retry = handle
|
||||
.send_message_with_blocks(new_id, blocks)
|
||||
.await;
|
||||
if let Some(new_id) = try_reresolution(&e, &channel_key, handle, router).await {
|
||||
let typing_task2 = spawn_typing_loop(adapter_arc.clone(), message.sender.clone());
|
||||
let retry = handle.send_message_with_blocks(new_id, blocks).await;
|
||||
typing_task2.abort();
|
||||
match retry {
|
||||
Ok(response) => {
|
||||
@@ -1283,14 +1327,8 @@ async fn dispatch_with_blocks(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
response,
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
send_response(adapter, &message.sender, response, thread_id, output_format)
|
||||
.await;
|
||||
handle
|
||||
.record_delivery(
|
||||
new_id,
|
||||
@@ -1808,6 +1846,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_output_format_for_channel() {
|
||||
assert_eq!(
|
||||
default_output_format_for_channel("telegram"),
|
||||
OutputFormat::TelegramHtml
|
||||
);
|
||||
assert_eq!(
|
||||
default_output_format_for_channel("slack"),
|
||||
OutputFormat::SlackMrkdwn
|
||||
);
|
||||
assert_eq!(
|
||||
default_output_format_for_channel("wecom"),
|
||||
OutputFormat::PlainText
|
||||
);
|
||||
assert_eq!(
|
||||
default_output_format_for_channel("discord"),
|
||||
OutputFormat::Markdown
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message_with_blocks_default_fallback() {
|
||||
// The default implementation of send_message_with_blocks extracts text
|
||||
@@ -1898,11 +1956,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_media_type_from_url() {
|
||||
assert_eq!(media_type_from_url("https://example.com/photo.png"), "image/png");
|
||||
assert_eq!(media_type_from_url("https://example.com/anim.gif"), "image/gif");
|
||||
assert_eq!(media_type_from_url("https://example.com/img.webp"), "image/webp");
|
||||
assert_eq!(media_type_from_url("https://example.com/photo.jpg"), "image/jpeg");
|
||||
assert_eq!(
|
||||
media_type_from_url("https://example.com/photo.png"),
|
||||
"image/png"
|
||||
);
|
||||
assert_eq!(
|
||||
media_type_from_url("https://example.com/anim.gif"),
|
||||
"image/gif"
|
||||
);
|
||||
assert_eq!(
|
||||
media_type_from_url("https://example.com/img.webp"),
|
||||
"image/webp"
|
||||
);
|
||||
assert_eq!(
|
||||
media_type_from_url("https://example.com/photo.jpg"),
|
||||
"image/jpeg"
|
||||
);
|
||||
// No extension — defaults to JPEG
|
||||
assert_eq!(media_type_from_url("https://api.telegram.org/file/bot123/photos/file_42"), "image/jpeg");
|
||||
assert_eq!(
|
||||
media_type_from_url("https://api.telegram.org/file/bot123/photos/file_42"),
|
||||
"image/jpeg"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,10 @@ impl DingTalkStreamAdapter {
|
||||
user_ids: &[&str],
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let token = self.get_token().await.map_err(|e| -> Box<dyn std::error::Error> { e })?;
|
||||
let token = self
|
||||
.get_token()
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { e })?;
|
||||
|
||||
let (msg_key, _msg_param) = match &content {
|
||||
ChannelContent::Text(t) => (
|
||||
@@ -180,30 +183,28 @@ impl ChannelAdapter for DingTalkStreamAdapter {
|
||||
}
|
||||
|
||||
// 1. Get access token
|
||||
let token = match get_access_token(&client, &app_key, &app_secret, &token_cache)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
warn!("DingTalk Stream: token fetch failed: {e}");
|
||||
attempt += 1;
|
||||
tokio::time::sleep(backoff(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Get WebSocket endpoint
|
||||
let ws_url =
|
||||
match get_ws_endpoint(&client, &app_key, &app_secret, &token).await {
|
||||
Ok(u) => u,
|
||||
let token =
|
||||
match get_access_token(&client, &app_key, &app_secret, &token_cache).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
warn!("DingTalk Stream: endpoint fetch failed: {e}");
|
||||
warn!("DingTalk Stream: token fetch failed: {e}");
|
||||
attempt += 1;
|
||||
tokio::time::sleep(backoff(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Get WebSocket endpoint
|
||||
let ws_url = match get_ws_endpoint(&client, &app_key, &app_secret, &token).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
warn!("DingTalk Stream: endpoint fetch failed: {e}");
|
||||
attempt += 1;
|
||||
tokio::time::sleep(backoff(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"DingTalk Stream: connecting to {}...",
|
||||
&ws_url[..ws_url.len().min(60)]
|
||||
@@ -379,7 +380,11 @@ async fn get_ws_endpoint(
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let sep = if resp.endpoint.contains('?') { "&" } else { "?" };
|
||||
let sep = if resp.endpoint.contains('?') {
|
||||
"&"
|
||||
} else {
|
||||
"?"
|
||||
};
|
||||
Ok(format!("{}{}ticket={}", resp.endpoint, sep, resp.ticket))
|
||||
}
|
||||
|
||||
@@ -481,13 +486,11 @@ where
|
||||
"CALLBACK" | "EVENT" => {
|
||||
let data_str = frame.data.to_string();
|
||||
// Try direct parse, then try unwrapping double-encoded string
|
||||
let cb: Option<CallbackPayload> = serde_json::from_str(&data_str)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
serde_json::from_str::<String>(&data_str)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
});
|
||||
let cb: Option<CallbackPayload> = serde_json::from_str(&data_str).ok().or_else(|| {
|
||||
serde_json::from_str::<String>(&data_str)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
});
|
||||
|
||||
if let Some(cb) = cb {
|
||||
if cb.msg_type == "text" {
|
||||
@@ -499,9 +502,7 @@ where
|
||||
let cmd = parts[0].trim_start_matches('/');
|
||||
let args: Vec<String> = parts
|
||||
.get(1)
|
||||
.map(|a| {
|
||||
a.split_whitespace().map(String::from).collect()
|
||||
})
|
||||
.map(|a| a.split_whitespace().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
ChannelContent::Command {
|
||||
name: cmd.to_string(),
|
||||
|
||||
@@ -318,9 +318,14 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
}
|
||||
|
||||
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
|
||||
if let Some(msg) =
|
||||
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users, ignore_bots)
|
||||
.await
|
||||
if let Some(msg) = parse_discord_message(
|
||||
d,
|
||||
&bot_user_id,
|
||||
&allowed_guilds,
|
||||
&allowed_users,
|
||||
ignore_bots,
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
"Discord {event_name} from {}: {:?}",
|
||||
@@ -517,8 +522,8 @@ async fn parse_discord_message(
|
||||
.map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str())))
|
||||
.unwrap_or(false);
|
||||
// Also check content for <@bot_id> or <@!bot_id> patterns
|
||||
let mentioned_in_content =
|
||||
content_text.contains(&format!("<@{bid}>")) || content_text.contains(&format!("<@!{bid}>"));
|
||||
let mentioned_in_content = content_text.contains(&format!("<@{bid}>"))
|
||||
|| content_text.contains(&format!("<@!{bid}>"));
|
||||
mentioned_in_array || mentioned_in_content
|
||||
} else {
|
||||
false
|
||||
@@ -566,7 +571,9 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
assert_eq!(msg.sender.display_name, "alice");
|
||||
assert_eq!(msg.sender.platform_id, "ch1");
|
||||
@@ -674,7 +681,8 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
|
||||
let msg =
|
||||
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed guilds
|
||||
@@ -697,7 +705,9 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -741,7 +751,9 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "alice#1234");
|
||||
}
|
||||
|
||||
@@ -763,7 +775,9 @@ mod tests {
|
||||
});
|
||||
|
||||
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
assert!(
|
||||
matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content")
|
||||
@@ -786,7 +800,14 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()], true).await;
|
||||
let msg = parse_discord_message(
|
||||
&d,
|
||||
&bot_id,
|
||||
&[],
|
||||
&["user111".into(), "user222".into()],
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed users
|
||||
@@ -817,9 +838,14 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg.is_group);
|
||||
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
|
||||
assert_eq!(
|
||||
msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
|
||||
// Message without mention in group
|
||||
let d2 = serde_json::json!({
|
||||
@@ -835,7 +861,9 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg2.is_group);
|
||||
assert!(!msg2.metadata.contains_key("was_mentioned"));
|
||||
}
|
||||
@@ -855,13 +883,21 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!msg.is_group);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_adapter_creation() {
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], true, 37376);
|
||||
let adapter = DiscordAdapter::new(
|
||||
"test-token".to_string(),
|
||||
vec!["123".to_string(), "456".to_string()],
|
||||
vec![],
|
||||
true,
|
||||
37376,
|
||||
);
|
||||
assert_eq!(adapter.name(), "discord");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Discord);
|
||||
}
|
||||
|
||||
@@ -139,8 +139,7 @@ impl EmailAdapter {
|
||||
async fn build_smtp_transport(
|
||||
&self,
|
||||
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error>> {
|
||||
let creds =
|
||||
Credentials::new(self.username.clone(), self.password.as_str().to_string());
|
||||
let creds = Credentials::new(self.username.clone(), self.password.as_str().to_string());
|
||||
|
||||
let transport = if self.smtp_port == 465 {
|
||||
// Implicit TLS (port 465)
|
||||
@@ -215,8 +214,8 @@ fn fetch_unseen_emails(
|
||||
.build()
|
||||
.map_err(|e| format!("TLS connector error: {e}"))?;
|
||||
|
||||
let client = imap::connect((host, port), host, &tls)
|
||||
.map_err(|e| format!("IMAP connect failed: {e}"))?;
|
||||
let client =
|
||||
imap::connect((host, port), host, &tls).map_err(|e| format!("IMAP connect failed: {e}"))?;
|
||||
|
||||
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
|
||||
// that reject LOGIN and only support AUTH=PLAIN (SASL).
|
||||
@@ -390,8 +389,7 @@ impl ChannelAdapter for EmailAdapter {
|
||||
}
|
||||
|
||||
// Extract target agent from subject brackets (stored in metadata for router)
|
||||
let _target_agent =
|
||||
EmailAdapter::extract_agent_from_subject(&subject);
|
||||
let _target_agent = EmailAdapter::extract_agent_from_subject(&subject);
|
||||
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
|
||||
|
||||
// Build the message body: prepend subject context
|
||||
|
||||
@@ -432,11 +432,7 @@ fn extract_text_from_post(content: &serde_json::Value) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Check whether the bot should respond to a group message.
|
||||
fn should_respond_in_group(
|
||||
text: &str,
|
||||
mentions: &serde_json::Value,
|
||||
bot_names: &[String],
|
||||
) -> bool {
|
||||
fn should_respond_in_group(text: &str, mentions: &serde_json::Value, bot_names: &[String]) -> bool {
|
||||
if let Some(arr) = mentions.as_array() {
|
||||
if !arr.is_empty() {
|
||||
return true;
|
||||
@@ -522,7 +518,11 @@ fn parse_event(
|
||||
|
||||
let text = match msg_type {
|
||||
"text" => {
|
||||
let t = content_json["text"].as_str().unwrap_or("").trim().to_string();
|
||||
let t = content_json["text"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -677,8 +677,7 @@ impl ChannelAdapter for FeishuAdapter {
|
||||
let mut event_data = body.0.clone();
|
||||
|
||||
// Step 1: Decrypt if encrypted
|
||||
if let Some(encrypted) =
|
||||
body.0.get("encrypt").and_then(|v| v.as_str())
|
||||
if let Some(encrypted) = body.0.get("encrypt").and_then(|v| v.as_str())
|
||||
{
|
||||
if let Some(ref key) = *ek {
|
||||
match decrypt_event(encrypted, key) {
|
||||
@@ -703,8 +702,7 @@ impl ChannelAdapter for FeishuAdapter {
|
||||
== Some("url_verification")
|
||||
{
|
||||
if let Some(ref expected_token) = *vt {
|
||||
let token =
|
||||
event_data["token"].as_str().unwrap_or("");
|
||||
let token = event_data["token"].as_str().unwrap_or("");
|
||||
if token != expected_token {
|
||||
warn!("{region_label}: invalid verification token");
|
||||
return (
|
||||
@@ -754,16 +752,13 @@ impl ChannelAdapter for FeishuAdapter {
|
||||
if let Some(msg) =
|
||||
parse_event(&event_data, &bot_names, &channel_name)
|
||||
{
|
||||
if !message_dedup
|
||||
.check_and_insert(&msg.platform_message_id)
|
||||
{
|
||||
if !message_dedup.check_and_insert(&msg.platform_message_id) {
|
||||
let _ = tx.send(msg).await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// V1 legacy event format
|
||||
let event_type =
|
||||
event_data["event"]["type"].as_str().unwrap_or("");
|
||||
let event_type = event_data["event"]["type"].as_str().unwrap_or("");
|
||||
if event_type == "message" {
|
||||
let event = &event_data["event"];
|
||||
let text = event["text"].as_str().unwrap_or("");
|
||||
@@ -778,10 +773,8 @@ impl ChannelAdapter for FeishuAdapter {
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let is_group = event["chat_type"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
== "group";
|
||||
let is_group =
|
||||
event["chat_type"].as_str().unwrap_or("") == "group";
|
||||
|
||||
if !message_dedup.check_and_insert(&msg_id) {
|
||||
let content = if text.starts_with('/') {
|
||||
@@ -936,7 +929,10 @@ mod tests {
|
||||
assert_eq!(FeishuRegion::parse_region("cn"), FeishuRegion::Cn);
|
||||
assert_eq!(FeishuRegion::parse_region("intl"), FeishuRegion::Intl);
|
||||
assert_eq!(FeishuRegion::parse_region("lark"), FeishuRegion::Intl);
|
||||
assert_eq!(FeishuRegion::parse_region("international"), FeishuRegion::Intl);
|
||||
assert_eq!(
|
||||
FeishuRegion::parse_region("international"),
|
||||
FeishuRegion::Intl
|
||||
);
|
||||
assert_eq!(FeishuRegion::parse_region("anything"), FeishuRegion::Cn);
|
||||
}
|
||||
|
||||
@@ -1076,10 +1072,7 @@ mod tests {
|
||||
strip_mention_placeholders("@_user_1 hello world"),
|
||||
"hello world"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_mention_placeholders("@_user_1 @_user_2 hi"),
|
||||
"hi"
|
||||
);
|
||||
assert_eq!(strip_mention_placeholders("@_user_1 @_user_2 hi"), "hi");
|
||||
assert_eq!(strip_mention_placeholders("no mentions"), "no mentions");
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,15 @@ pub fn format_for_channel(text: &str, format: OutputFormat) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a message for WeCom, using a stronger plain-text conversion to avoid
|
||||
/// leaking Markdown syntax into enterprise chat replies.
|
||||
pub fn format_for_wecom(text: &str, format: OutputFormat) -> String {
|
||||
match format {
|
||||
OutputFormat::PlainText => markdown_to_wecom_plain(text),
|
||||
_ => format_for_channel(text, format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Markdown to Telegram HTML subset.
|
||||
///
|
||||
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`, `<blockquote>`.
|
||||
@@ -68,14 +77,14 @@ fn markdown_to_telegram_html(text: &str) -> String {
|
||||
if current.is_empty() || !current.starts_with('>') {
|
||||
break;
|
||||
}
|
||||
let content = current
|
||||
.strip_prefix('>')
|
||||
.unwrap_or(current)
|
||||
.trim_start();
|
||||
let content = current.strip_prefix('>').unwrap_or(current).trim_start();
|
||||
quote_lines.push(render_inline_markdown(content));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push(format!("<blockquote>{}</blockquote>", quote_lines.join("\n")));
|
||||
blocks.push(format!(
|
||||
"<blockquote>{}</blockquote>",
|
||||
quote_lines.join("\n")
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -107,7 +116,11 @@ fn markdown_to_telegram_html(text: &str) -> String {
|
||||
while i < lines.len() {
|
||||
let current = lines[i].trim();
|
||||
if let Some(next_item) = ordered_list_item(current) {
|
||||
items.push(format!("{}. {}", counter, render_inline_markdown(next_item.trim())));
|
||||
items.push(format!(
|
||||
"{}. {}",
|
||||
counter,
|
||||
render_inline_markdown(next_item.trim())
|
||||
));
|
||||
counter += 1;
|
||||
i += 1;
|
||||
} else if current.is_empty() {
|
||||
@@ -313,6 +326,192 @@ fn markdown_to_slack_mrkdwn(text: &str) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
fn strip_atx_heading(line: &str) -> String {
|
||||
let trimmed = line.trim_start();
|
||||
let heading_level = trimmed.chars().take_while(|c| *c == '#').count();
|
||||
if !(1..=6).contains(&heading_level) {
|
||||
return line.to_string();
|
||||
}
|
||||
|
||||
if trimmed.chars().nth(heading_level) != Some(' ') {
|
||||
return line.to_string();
|
||||
}
|
||||
|
||||
trimmed[heading_level..]
|
||||
.trim()
|
||||
.trim_end_matches('#')
|
||||
.trim_end()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn strip_blockquote_prefix(line: &str) -> String {
|
||||
let mut trimmed = line.trim_start();
|
||||
while let Some(rest) = trimmed.strip_prefix('>') {
|
||||
trimmed = rest.trim_start();
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn strip_task_list_prefix(line: &str) -> String {
|
||||
let trimmed = line.trim_start();
|
||||
for prefix in [
|
||||
"- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] ",
|
||||
] {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix) {
|
||||
return rest.to_string();
|
||||
}
|
||||
}
|
||||
line.to_string()
|
||||
}
|
||||
|
||||
fn is_fenced_code_marker(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
let mut chars = trimmed.chars();
|
||||
let Some(marker) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if marker != '`' && marker != '~' {
|
||||
return false;
|
||||
}
|
||||
chars.all(|c| c == marker || c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn is_setext_heading_underline(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.contains(['=', '-'])
|
||||
}
|
||||
|
||||
fn is_table_divider(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, '|' | ':' | '-' | ' '))
|
||||
}
|
||||
|
||||
fn strip_inline_markdown(mut text: String) -> String {
|
||||
while let Some(start) = text.find("![") {
|
||||
if let Some(mid) = text[start..].find("](") {
|
||||
let mid = start + mid;
|
||||
if let Some(end) = text[mid + 2..].find(')') {
|
||||
let end = mid + 2 + end;
|
||||
let alt = &text[start + 2..mid];
|
||||
let url = &text[mid + 2..end];
|
||||
let replacement = if alt.is_empty() {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{alt} ({url})")
|
||||
};
|
||||
text = format!("{}{}{}", &text[..start], replacement, &text[end + 1..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
while let Some(start) = text.find('[') {
|
||||
if let Some(mid) = text[start..].find("](") {
|
||||
let mid = start + mid;
|
||||
if let Some(end) = text[mid + 2..].find(')') {
|
||||
let end = mid + 2 + end;
|
||||
let label = &text[start + 1..mid];
|
||||
let url = &text[mid + 2..end];
|
||||
text = format!("{}{} ({}){}", &text[..start], label, url, &text[end + 1..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
while let Some(start) = text.find('<') {
|
||||
if let Some(end) = text[start + 1..].find('>') {
|
||||
let end = start + 1 + end;
|
||||
let inner = &text[start + 1..end];
|
||||
if inner.starts_with("http://")
|
||||
|| inner.starts_with("https://")
|
||||
|| inner.starts_with("mailto:")
|
||||
{
|
||||
text = format!("{}{}{}", &text[..start], inner, &text[end + 1..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
text = text.replace("**", "");
|
||||
text = text.replace("__", "");
|
||||
text = text.replace("~~", "");
|
||||
text = text.replace('`', "");
|
||||
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
for (i, &ch) in chars.iter().enumerate() {
|
||||
if ch == '*'
|
||||
&& (i == 0 || chars[i - 1] != '*')
|
||||
&& (i + 1 >= chars.len() || chars[i + 1] != '*')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Strip common Markdown blocks for WeCom plain-text replies.
|
||||
fn markdown_to_wecom_plain(text: &str) -> String {
|
||||
let mut result_lines = Vec::new();
|
||||
let mut in_fenced_code = false;
|
||||
|
||||
for raw_line in text.replace("\r\n", "\n").lines() {
|
||||
let trimmed = raw_line.trim();
|
||||
|
||||
if is_fenced_code_marker(trimmed) {
|
||||
in_fenced_code = !in_fenced_code;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_fenced_code {
|
||||
result_lines.push(raw_line.trim_end().to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_setext_heading_underline(trimmed) || is_table_divider(trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut line = strip_atx_heading(raw_line);
|
||||
line = strip_blockquote_prefix(&line);
|
||||
line = strip_task_list_prefix(&line);
|
||||
|
||||
let trimmed_line = line.trim();
|
||||
if trimmed_line.starts_with('|') && trimmed_line.ends_with('|') && trimmed_line.len() > 2 {
|
||||
line = trimmed_line
|
||||
.trim_matches('|')
|
||||
.split('|')
|
||||
.map(|cell| cell.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
line = strip_inline_markdown(line);
|
||||
result_lines.push(line.trim().to_string());
|
||||
}
|
||||
|
||||
let mut collapsed = Vec::new();
|
||||
for line in result_lines {
|
||||
if line.is_empty()
|
||||
&& collapsed
|
||||
.last()
|
||||
.is_some_and(|prev: &String| prev.is_empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
collapsed.push(line);
|
||||
}
|
||||
|
||||
collapsed.join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
/// Strip all Markdown formatting, producing plain text.
|
||||
fn markdown_to_plain(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
@@ -451,4 +650,26 @@ mod tests {
|
||||
let result = markdown_to_plain("[click](https://example.com)");
|
||||
assert_eq!(result, "click (https://example.com)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wecom_plain_text_strips_common_markdown_blocks() {
|
||||
let result = markdown_to_wecom_plain(
|
||||
"# Title\n\
|
||||
\n\
|
||||
> quoted text\n\
|
||||
\n\
|
||||
- [x] done item\n\
|
||||
- [ ] todo item\n\
|
||||
\n\
|
||||
```rust\n\
|
||||
let value = 1;\n\
|
||||
```\n\
|
||||
\n\
|
||||
[docs](https://example.com)\n",
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
"Title\n\nquoted text\n\ndone item\ntodo item\n\nlet value = 1;\n\ndocs (https://example.com)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,3 +51,4 @@ pub mod linkedin;
|
||||
pub mod mumble;
|
||||
pub mod ntfy;
|
||||
pub mod webhook;
|
||||
pub mod wecom;
|
||||
|
||||
@@ -160,9 +160,7 @@ async fn get_room_member_count(
|
||||
access_token: &str,
|
||||
room_id: &str,
|
||||
) -> Option<usize> {
|
||||
let url = format!(
|
||||
"{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members"
|
||||
);
|
||||
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
@@ -296,10 +294,13 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
if !allowed_rooms.is_empty()
|
||||
&& !allowed_rooms.iter().any(|r| r == room_id)
|
||||
{
|
||||
debug!("Matrix: ignoring invite to {room_id} (not in allowed_rooms)");
|
||||
debug!(
|
||||
"Matrix: ignoring invite to {room_id} (not in allowed_rooms)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accept_invite(&client, &homeserver, access_token.as_str(), room_id).await;
|
||||
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,10 +373,7 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
metadata.insert(
|
||||
"is_dm".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
metadata.insert("is_dm".to_string(), serde_json::json!(true));
|
||||
}
|
||||
|
||||
let channel_msg = ChannelMessage {
|
||||
|
||||
@@ -165,7 +165,10 @@ impl ChannelAdapter for NostrAdapter {
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let pubkey = self.derive_pubkey();
|
||||
info!("Nostr adapter starting (pubkey: {}...)", openfang_types::truncate_str(&pubkey, 16));
|
||||
info!(
|
||||
"Nostr adapter starting (pubkey: {}...)",
|
||||
openfang_types::truncate_str(&pubkey, 16)
|
||||
);
|
||||
|
||||
if self.relays.is_empty() {
|
||||
return Err("Nostr: no relay URLs configured".into());
|
||||
|
||||
@@ -78,8 +78,7 @@ impl AgentRouter {
|
||||
agent_name: String,
|
||||
) {
|
||||
self.channel_defaults.insert(channel_key.clone(), agent_id);
|
||||
self.channel_default_names
|
||||
.insert(channel_key, agent_name);
|
||||
self.channel_default_names.insert(channel_key, agent_name);
|
||||
}
|
||||
|
||||
/// Retrieve the stored agent name for a channel default (if any).
|
||||
|
||||
@@ -290,15 +290,14 @@ impl ChannelAdapter for SlackAdapter {
|
||||
|
||||
// Extract the event
|
||||
let event = &payload["payload"]["event"];
|
||||
if let Some(msg) =
|
||||
parse_slack_event(
|
||||
event,
|
||||
&bot_user_id,
|
||||
&allowed_channels,
|
||||
&active_threads,
|
||||
auto_thread_reply,
|
||||
)
|
||||
.await
|
||||
if let Some(msg) = parse_slack_event(
|
||||
event,
|
||||
&bot_user_id,
|
||||
&allowed_channels,
|
||||
&active_threads,
|
||||
auto_thread_reply,
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
"Slack message from {}: {:?}",
|
||||
@@ -369,12 +368,8 @@ impl ChannelAdapter for SlackAdapter {
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(
|
||||
channel_id,
|
||||
"(Unsupported content type)",
|
||||
Some(thread_id),
|
||||
)
|
||||
.await?;
|
||||
self.api_send_message(channel_id, "(Unsupported content type)", Some(thread_id))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -572,7 +567,9 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Slack);
|
||||
assert_eq!(msg.sender.platform_id, "C789");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello agent!"));
|
||||
@@ -621,12 +618,25 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed channels
|
||||
let msg =
|
||||
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()], &Arc::new(DashMap::new()), true).await;
|
||||
let msg = parse_slack_event(
|
||||
&event,
|
||||
&bot_id,
|
||||
&["C111".to_string(), "C222".to_string()],
|
||||
&Arc::new(DashMap::new()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed channels
|
||||
let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()], &Arc::new(DashMap::new()), true).await;
|
||||
let msg = parse_slack_event(
|
||||
&event,
|
||||
&bot_id,
|
||||
&["C789".to_string()],
|
||||
&Arc::new(DashMap::new()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -658,7 +668,9 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -683,7 +695,9 @@ mod tests {
|
||||
"ts": "1700000001.000200"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Slack);
|
||||
assert_eq!(msg.sender.platform_id, "C789");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message text"));
|
||||
|
||||
@@ -147,11 +147,7 @@ impl TelegramAdapter {
|
||||
caption: Option<&str>,
|
||||
thread_id: Option<i64>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"{}/bot{}/sendPhoto",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let url = format!("{}/bot{}/sendPhoto", self.api_base_url, self.token.as_str());
|
||||
let mut body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"photo": photo_url,
|
||||
@@ -245,11 +241,7 @@ impl TelegramAdapter {
|
||||
voice_url: &str,
|
||||
thread_id: Option<i64>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"{}/bot{}/sendVoice",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let url = format!("{}/bot{}/sendVoice", self.api_base_url, self.token.as_str());
|
||||
let mut body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"voice": voice_url,
|
||||
@@ -391,8 +383,7 @@ impl TelegramAdapter {
|
||||
self.api_send_voice(chat_id, &url, thread_id).await?;
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
self.api_send_location(chat_id, lat, lon, thread_id)
|
||||
.await?;
|
||||
self.api_send_location(chat_id, lat, lon, thread_id).await?;
|
||||
}
|
||||
ChannelContent::Command { name, args } => {
|
||||
let text = format!("/{name} {}", args.join(" "));
|
||||
@@ -568,7 +559,16 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
|
||||
// Parse the message
|
||||
let bot_uname = bot_username.read().await.clone();
|
||||
let msg = match parse_telegram_update(update, &allowed_users, token.as_str(), &client, &api_base_url, bot_uname.as_deref()).await {
|
||||
let msg = match parse_telegram_update(
|
||||
update,
|
||||
&allowed_users,
|
||||
token.as_str(),
|
||||
&client,
|
||||
&api_base_url,
|
||||
bot_uname.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(m) => m,
|
||||
None => continue, // filtered out or unparseable
|
||||
};
|
||||
@@ -665,9 +665,7 @@ async fn telegram_get_file_url(
|
||||
return None;
|
||||
}
|
||||
let file_path = body["result"]["file_path"].as_str()?;
|
||||
Some(format!(
|
||||
"{api_base_url}/file/bot{token}/{file_path}"
|
||||
))
|
||||
Some(format!("{api_base_url}/file/bot{token}/{file_path}"))
|
||||
}
|
||||
|
||||
async fn parse_telegram_update(
|
||||
@@ -679,7 +677,10 @@ async fn parse_telegram_update(
|
||||
bot_username: Option<&str>,
|
||||
) -> Option<ChannelMessage> {
|
||||
let update_id = update["update_id"].as_i64().unwrap_or(0);
|
||||
let message = match update.get("message").or_else(|| update.get("edited_message")) {
|
||||
let message = match update
|
||||
.get("message")
|
||||
.or_else(|| update.get("edited_message"))
|
||||
{
|
||||
Some(m) => m,
|
||||
None => {
|
||||
debug!("Telegram: dropping update {update_id} — no message or edited_message field");
|
||||
@@ -713,9 +714,7 @@ async fn parse_telegram_update(
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let title = sender_chat["title"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown Channel");
|
||||
let title = sender_chat["title"].as_str().unwrap_or("Unknown Channel");
|
||||
(uid, title.to_string())
|
||||
} else {
|
||||
debug!("Telegram: dropping update {update_id} — no from or sender_chat field");
|
||||
@@ -782,7 +781,10 @@ async fn parse_telegram_update(
|
||||
Some(url) => ChannelContent::Image { url, caption },
|
||||
None => ChannelContent::Text(format!(
|
||||
"[Photo received{}]",
|
||||
caption.as_deref().map(|c| format!(": {c}")).unwrap_or_default()
|
||||
caption
|
||||
.as_deref()
|
||||
.map(|c| format!(": {c}"))
|
||||
.unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
} else if message.get("document").is_some() {
|
||||
@@ -834,7 +836,10 @@ async fn parse_telegram_update(
|
||||
// so the agent sees the reply context without breaking command parsing.
|
||||
let mut new_args = vec![format!("{prefix}{}", args.join(" "))];
|
||||
new_args.retain(|a| !a.trim().is_empty());
|
||||
ChannelContent::Command { name, args: new_args }
|
||||
ChannelContent::Command {
|
||||
name,
|
||||
args: new_args,
|
||||
}
|
||||
}
|
||||
other => other, // Image/File/Voice/Location — no text to prepend
|
||||
}
|
||||
@@ -936,7 +941,17 @@ pub fn calculate_backoff(current: Duration) -> Duration {
|
||||
/// Everything else (e.g. `<name>`, `<thinking>`) gets escaped to `<...>`.
|
||||
fn sanitize_telegram_html(text: &str) -> String {
|
||||
const ALLOWED: &[&str] = &[
|
||||
"b", "i", "u", "s", "em", "strong", "a", "code", "pre", "blockquote", "tg-spoiler",
|
||||
"b",
|
||||
"i",
|
||||
"u",
|
||||
"s",
|
||||
"em",
|
||||
"strong",
|
||||
"a",
|
||||
"code",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"tg-spoiler",
|
||||
"tg-emoji",
|
||||
];
|
||||
|
||||
@@ -1015,7 +1030,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Telegram);
|
||||
assert_eq!(msg.sender.display_name, "Alice Smith");
|
||||
assert_eq!(msg.sender.platform_id, "111222333");
|
||||
@@ -1047,7 +1064,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -1079,17 +1098,34 @@ mod tests {
|
||||
let client = test_client();
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await;
|
||||
let msg =
|
||||
parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Non-matching allowed_users = filter out
|
||||
let blocked: Vec<String> = vec!["111".to_string(), "222".to_string()];
|
||||
let msg = parse_telegram_update(&update, &blocked, "fake:token", &client, DEFAULT_API_URL, None).await;
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&blocked,
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// Matching allowed_users = allow
|
||||
let allowed: Vec<String> = vec!["999".to_string()];
|
||||
let msg = parse_telegram_update(&update, &allowed, "fake:token", &client, DEFAULT_API_URL, None).await;
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&allowed,
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -1115,7 +1151,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Telegram);
|
||||
assert_eq!(msg.sender.display_name, "Alice Smith");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message!"));
|
||||
@@ -1151,7 +1189,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agents");
|
||||
@@ -1175,7 +1215,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(msg.content, ChannelContent::Location { .. }));
|
||||
}
|
||||
|
||||
@@ -1199,7 +1241,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
// With a fake token, getFile will fail, so we get a text fallback
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
@@ -1234,7 +1278,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.contains("Document received"));
|
||||
@@ -1265,13 +1311,17 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.contains("Voice message"));
|
||||
assert!(t.contains("15s"));
|
||||
}
|
||||
ChannelContent::Voice { duration_seconds, .. } => {
|
||||
ChannelContent::Voice {
|
||||
duration_seconds, ..
|
||||
} => {
|
||||
assert_eq!(*duration_seconds, 15);
|
||||
}
|
||||
other => panic!("Expected Text or Voice for voice message, got {other:?}"),
|
||||
@@ -1294,7 +1344,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.thread_id, Some("42".to_string()));
|
||||
assert!(msg.is_group);
|
||||
}
|
||||
@@ -1314,7 +1366,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.thread_id, None);
|
||||
assert!(!msg.is_group);
|
||||
}
|
||||
@@ -1336,7 +1390,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.thread_id, Some("99".to_string()));
|
||||
}
|
||||
|
||||
@@ -1359,10 +1415,14 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "My Channel");
|
||||
assert_eq!(msg.sender.platform_id, "-1001234567890");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Forwarded from channel"));
|
||||
assert!(
|
||||
matches!(msg.content, ChannelContent::Text(ref t) if t == "Forwarded from channel")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1379,7 +1439,8 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await;
|
||||
let msg =
|
||||
parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -1403,9 +1464,21 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg.is_group);
|
||||
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
|
||||
assert_eq!(
|
||||
msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1423,7 +1496,16 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg.is_group);
|
||||
assert!(!msg.metadata.contains_key("was_mentioned"));
|
||||
}
|
||||
@@ -1448,7 +1530,16 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg.is_group);
|
||||
assert!(!msg.metadata.contains_key("was_mentioned"));
|
||||
}
|
||||
@@ -1476,9 +1567,21 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg.is_group);
|
||||
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
|
||||
assert_eq!(
|
||||
msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1501,8 +1604,20 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap();
|
||||
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1525,7 +1640,16 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, Some("testbot")).await.unwrap();
|
||||
let msg = parse_telegram_update(
|
||||
&update,
|
||||
&[],
|
||||
"fake:token",
|
||||
&client,
|
||||
DEFAULT_API_URL,
|
||||
Some("testbot"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!msg.is_group);
|
||||
// In private chats, mention detection is skipped — no metadata set
|
||||
assert!(!msg.metadata.contains_key("was_mentioned"));
|
||||
@@ -1576,7 +1700,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.starts_with("[Replying to Bob: We should use Rust]\n\n"));
|
||||
@@ -1586,7 +1712,9 @@ mod tests {
|
||||
}
|
||||
// reply_to_message_id should be stored in metadata
|
||||
assert_eq!(
|
||||
msg.metadata.get("reply_to_message_id").and_then(|v| v.as_i64()),
|
||||
msg.metadata
|
||||
.get("reply_to_message_id")
|
||||
.and_then(|v| v.as_i64()),
|
||||
Some(99)
|
||||
);
|
||||
}
|
||||
@@ -1614,7 +1742,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.starts_with("[Replying to Carol: Sunset view]\n\n"));
|
||||
@@ -1623,7 +1753,9 @@ mod tests {
|
||||
other => panic!("Expected Text, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
msg.metadata.get("reply_to_message_id").and_then(|v| v.as_i64()),
|
||||
msg.metadata
|
||||
.get("reply_to_message_id")
|
||||
.and_then(|v| v.as_i64()),
|
||||
Some(98)
|
||||
);
|
||||
}
|
||||
@@ -1651,7 +1783,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert_eq!(t, "What was that?");
|
||||
@@ -1659,7 +1793,9 @@ mod tests {
|
||||
other => panic!("Expected Text, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
msg.metadata.get("reply_to_message_id").and_then(|v| v.as_i64()),
|
||||
msg.metadata
|
||||
.get("reply_to_message_id")
|
||||
.and_then(|v| v.as_i64()),
|
||||
Some(97)
|
||||
);
|
||||
}
|
||||
@@ -1685,7 +1821,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.starts_with("[Replying to Unknown: Anonymous message]\n\n"));
|
||||
@@ -1710,7 +1848,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None).await.unwrap();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert_eq!(t, "Just a normal message");
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
//! WeCom (WeChat Work) channel adapter.
|
||||
//!
|
||||
//! Uses the WeCom Work API for sending messages and a webhook HTTP server for
|
||||
//! receiving inbound events. Authentication is performed via an access token
|
||||
//! obtained from `https://qyapi.weixin.qq.com/cgi-bin/gettoken`.
|
||||
//! The token is cached and refreshed automatically.
|
||||
|
||||
use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::Utc;
|
||||
use futures::Stream;
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// WeCom token endpoint.
|
||||
const WECOM_TOKEN_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/gettoken";
|
||||
|
||||
/// WeCom send message endpoint.
|
||||
const WECOM_SEND_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/message/send";
|
||||
|
||||
/// Maximum WeCom message text length (characters).
|
||||
const MAX_MESSAGE_LEN: usize = 2048;
|
||||
|
||||
/// Token refresh buffer — refresh 5 minutes before actual expiry.
|
||||
const TOKEN_REFRESH_BUFFER_SECS: u64 = 300;
|
||||
|
||||
fn decrypt_aes_cbc(key: &[u8], encrypted_base64: &str) -> Result<Vec<u8>, String> {
|
||||
use base64::Engine;
|
||||
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
|
||||
|
||||
// Decode base64
|
||||
let mut encrypted = base64::engine::general_purpose::STANDARD
|
||||
.decode(encrypted_base64)
|
||||
.map_err(|e| format!("base64 decode error: {}", e))?;
|
||||
|
||||
// IV is first 16 bytes of key
|
||||
type Aes256CbcDecrypt = cbc::Decryptor<aes::Aes256>;
|
||||
let iv = &key[..16];
|
||||
let cipher = Aes256CbcDecrypt::new(key.into(), iv.into());
|
||||
|
||||
let decrypted = cipher
|
||||
.decrypt_padded_mut::<aes::cipher::block_padding::NoPadding>(&mut encrypted)
|
||||
.map_err(|e| format!("decrypt error: {}", e))?;
|
||||
|
||||
let decrypted = decrypted.to_vec();
|
||||
let pad = decrypted
|
||||
.last()
|
||||
.copied()
|
||||
.ok_or_else(|| "decrypted payload is empty".to_string())? as usize;
|
||||
|
||||
if pad == 0 || pad > 32 || decrypted.len() < pad {
|
||||
return Err(format!("invalid WeCom PKCS7 padding length: {pad}"));
|
||||
}
|
||||
if !decrypted[decrypted.len() - pad..]
|
||||
.iter()
|
||||
.all(|byte| *byte as usize == pad)
|
||||
{
|
||||
return Err("invalid WeCom PKCS7 padding bytes".to_string());
|
||||
}
|
||||
|
||||
Ok(decrypted[..decrypted.len() - pad].to_vec())
|
||||
}
|
||||
|
||||
fn is_valid_wecom_signature(
|
||||
token: &str,
|
||||
timestamp: &str,
|
||||
nonce: &str,
|
||||
encrypted_payload: &str,
|
||||
msg_signature: &str,
|
||||
) -> bool {
|
||||
let mut parts = [token, timestamp, nonce, encrypted_payload];
|
||||
parts.sort_unstable();
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(parts.concat().as_bytes());
|
||||
hex::encode(hasher.finalize()) == msg_signature
|
||||
}
|
||||
|
||||
fn decode_wecom_payload(encoding_aes_key: &str, encrypted_payload: &str) -> Result<String, String> {
|
||||
use base64::{
|
||||
alphabet,
|
||||
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
|
||||
Engine,
|
||||
};
|
||||
|
||||
let aes_key_engine = GeneralPurpose::new(
|
||||
&alphabet::STANDARD,
|
||||
GeneralPurposeConfig::new()
|
||||
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
|
||||
.with_decode_allow_trailing_bits(true),
|
||||
);
|
||||
|
||||
let aes_key = aes_key_engine
|
||||
.decode(encoding_aes_key)
|
||||
.map_err(|e| format!("aes key decode error: {e}"))?;
|
||||
let decrypted = decrypt_aes_cbc(&aes_key, encrypted_payload)?;
|
||||
|
||||
if decrypted.len() < 20 {
|
||||
return Err("decrypted payload too short".to_string());
|
||||
}
|
||||
|
||||
let msg_len =
|
||||
u32::from_be_bytes([decrypted[16], decrypted[17], decrypted[18], decrypted[19]]) as usize;
|
||||
if decrypted.len() < 20 + msg_len {
|
||||
return Err("decrypted payload shorter than declared echostr".to_string());
|
||||
}
|
||||
|
||||
String::from_utf8(decrypted[20..20 + msg_len].to_vec())
|
||||
.map_err(|e| format!("echostr is not valid utf-8: {e}"))
|
||||
}
|
||||
|
||||
fn parse_wecom_xml_fields(xml: &str) -> Result<HashMap<String, String>, String> {
|
||||
let doc = roxmltree::Document::parse(xml).map_err(|e| format!("invalid xml: {e}"))?;
|
||||
let root = doc.root_element();
|
||||
if root.tag_name().name() != "xml" {
|
||||
return Err("root element is not <xml>".to_string());
|
||||
}
|
||||
|
||||
let mut fields = HashMap::new();
|
||||
for child in root.children().filter(|node| node.is_element()) {
|
||||
let value = child
|
||||
.children()
|
||||
.filter_map(|node| node.text())
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string();
|
||||
fields.insert(child.tag_name().name().to_string(), value);
|
||||
}
|
||||
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
fn decode_wecom_post_body(
|
||||
body: &str,
|
||||
params: &HashMap<String, String>,
|
||||
token: Option<&str>,
|
||||
encoding_aes_key: Option<&str>,
|
||||
) -> Result<HashMap<String, String>, String> {
|
||||
let parsed = parse_wecom_xml_fields(body)?;
|
||||
|
||||
let Some(encrypted_payload) = parsed.get("Encrypt") else {
|
||||
return Ok(parsed);
|
||||
};
|
||||
|
||||
let token = token.ok_or_else(|| "missing WeCom callback token".to_string())?;
|
||||
let timestamp = params
|
||||
.get("timestamp")
|
||||
.ok_or_else(|| "missing timestamp".to_string())?;
|
||||
let nonce = params
|
||||
.get("nonce")
|
||||
.ok_or_else(|| "missing nonce".to_string())?;
|
||||
let msg_signature = params
|
||||
.get("msg_signature")
|
||||
.ok_or_else(|| "missing msg_signature".to_string())?;
|
||||
|
||||
if !is_valid_wecom_signature(token, timestamp, nonce, encrypted_payload, msg_signature) {
|
||||
return Err("invalid WeCom callback signature".to_string());
|
||||
}
|
||||
|
||||
let aes_key = encoding_aes_key
|
||||
.filter(|key| !key.is_empty())
|
||||
.ok_or_else(|| "missing WeCom encoding_aes_key".to_string())?;
|
||||
let decrypted_xml = decode_wecom_payload(aes_key, encrypted_payload)?;
|
||||
parse_wecom_xml_fields(&decrypted_xml)
|
||||
}
|
||||
|
||||
fn wecom_success_response() -> axum::response::Response {
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; charset=utf-8",
|
||||
)],
|
||||
"success",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// WeCom adapter.
|
||||
pub struct WeComAdapter {
|
||||
/// WeCom corp ID.
|
||||
corp_id: String,
|
||||
/// WeCom application agent ID.
|
||||
agent_id: String,
|
||||
/// WeCom application secret, zeroized on drop.
|
||||
secret: Zeroizing<String>,
|
||||
/// Encoding AES key for callback verification (optional).
|
||||
encoding_aes_key: Option<String>,
|
||||
/// Token for callback verification (optional).
|
||||
token: Option<String>,
|
||||
/// Port on which the inbound webhook HTTP server listens.
|
||||
webhook_port: u16,
|
||||
/// HTTP client for API calls.
|
||||
client: reqwest::Client,
|
||||
/// Shutdown signal.
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Cached access token and its expiry instant.
|
||||
cached_token: Arc<RwLock<Option<(String, Instant)>>>,
|
||||
}
|
||||
|
||||
impl WeComAdapter {
|
||||
/// Create a new WeCom adapter.
|
||||
pub fn new(corp_id: String, agent_id: String, secret: String, webhook_port: u16) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
corp_id,
|
||||
agent_id,
|
||||
secret: Zeroizing::new(secret),
|
||||
encoding_aes_key: None,
|
||||
token: None,
|
||||
webhook_port,
|
||||
client: reqwest::Client::new(),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
cached_token: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new WeCom adapter with callback verification.
|
||||
pub fn with_verification(
|
||||
corp_id: String,
|
||||
agent_id: String,
|
||||
secret: String,
|
||||
webhook_port: u16,
|
||||
encoding_aes_key: Option<String>,
|
||||
token: Option<String>,
|
||||
) -> Self {
|
||||
let mut adapter = Self::new(corp_id, agent_id, secret, webhook_port);
|
||||
adapter.encoding_aes_key = encoding_aes_key;
|
||||
adapter.token = token;
|
||||
adapter
|
||||
}
|
||||
|
||||
/// Obtain a valid access token, refreshing if expired or missing.
|
||||
async fn get_token(&self) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut cached = self.cached_token.write().await;
|
||||
|
||||
// Check if we have a valid cached token
|
||||
if let Some((token, expiry)) = cached.as_ref() {
|
||||
let now = Instant::now();
|
||||
let buffer = Duration::from_secs(TOKEN_REFRESH_BUFFER_SECS);
|
||||
if now + buffer < *expiry {
|
||||
return Ok(token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch new token
|
||||
let url = format!(
|
||||
"{}?corpid={}&corpsecret={}",
|
||||
WECOM_TOKEN_URL,
|
||||
self.corp_id,
|
||||
self.secret.as_str()
|
||||
);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let json: serde_json::Value = response.json().await?;
|
||||
|
||||
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
|
||||
if errcode != 0 {
|
||||
return Err(format!(
|
||||
"WeCom API error: {} - {}",
|
||||
errcode,
|
||||
json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
let token = json["access_token"]
|
||||
.as_str()
|
||||
.ok_or("Missing access_token in response")?
|
||||
.to_string();
|
||||
|
||||
let expires_in = json["expires_in"].as_i64().unwrap_or(7200) as u64;
|
||||
|
||||
let expiry = Instant::now() + Duration::from_secs(expires_in);
|
||||
*cached = Some((token.clone(), expiry));
|
||||
|
||||
info!("WeCom access token refreshed, expires in {}s", expires_in);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Send a text message to a user.
|
||||
async fn send_text(
|
||||
&self,
|
||||
user_id: &str,
|
||||
content: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let token = self.get_token().await?;
|
||||
|
||||
let url = format!("{}?access_token={}", WECOM_SEND_URL, token);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"touser": user_id,
|
||||
"msgtype": "text",
|
||||
"agentid": self.agent_id,
|
||||
"text": {
|
||||
"content": content
|
||||
}
|
||||
});
|
||||
|
||||
let response = self.client.post(&url).json(&payload).send().await?;
|
||||
|
||||
let json: serde_json::Value = response.json().await?;
|
||||
|
||||
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
|
||||
if errcode != 0 {
|
||||
return Err(format!(
|
||||
"WeCom send error: {} - {}",
|
||||
errcode,
|
||||
json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate credentials by getting the token.
|
||||
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let _token = self.get_token().await?;
|
||||
// Token obtained successfully means credentials are valid
|
||||
Ok(format!("corp_id={}", self.corp_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for WeComAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"wecom"
|
||||
}
|
||||
|
||||
fn channel_type(&self) -> ChannelType {
|
||||
ChannelType::Custom("wecom".to_string())
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
// Validate credentials
|
||||
let _ = self.validate().await?;
|
||||
info!("WeCom adapter initialized");
|
||||
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let port = self.webhook_port;
|
||||
let token = self.token.clone();
|
||||
let encoding_aes_key = self.encoding_aes_key.clone();
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let token = Arc::new(token);
|
||||
let encoding_aes_key = Arc::new(encoding_aes_key);
|
||||
let tx = Arc::new(tx);
|
||||
|
||||
let app = axum::Router::new().route(
|
||||
"/wecom/webhook",
|
||||
axum::routing::get({
|
||||
let encoding_aes_key = Arc::clone(&encoding_aes_key);
|
||||
let token = Arc::clone(&token);
|
||||
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>| {
|
||||
let encoding_aes_key = Arc::clone(&encoding_aes_key);
|
||||
let token = Arc::clone(&token);
|
||||
async move {
|
||||
// Handle callback verification (URL validation GET request)
|
||||
// WeChat Work sends GET with msg_signature, timestamp, nonce, echostr
|
||||
if let (Some(echostr_encoded), Some(msg_sig), Some(timestamp), Some(nonce)) = (
|
||||
params.get("echostr"),
|
||||
params.get("msg_signature"),
|
||||
params.get("timestamp"),
|
||||
params.get("nonce"),
|
||||
) {
|
||||
let Some(token_str) = token.as_deref() else {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"missing WeCom callback token",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
if !is_valid_wecom_signature(
|
||||
token_str,
|
||||
timestamp,
|
||||
nonce,
|
||||
echostr_encoded,
|
||||
msg_sig,
|
||||
) {
|
||||
return (
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"invalid WeCom callback signature",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let body = match encoding_aes_key.as_deref() {
|
||||
Some(aes_key) if !aes_key.is_empty() => {
|
||||
match decode_wecom_payload(aes_key, echostr_encoded) {
|
||||
Ok(echostr_plain) => echostr_plain,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to decrypt WeCom echostr");
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"invalid WeCom echostr",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => echostr_encoded.clone(),
|
||||
};
|
||||
|
||||
return (
|
||||
axum::http::StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
||||
body,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"missing WeCom verification parameters",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}).post({
|
||||
let token = Arc::clone(&token);
|
||||
let encoding_aes_key = Arc::clone(&encoding_aes_key);
|
||||
let tx = Arc::clone(&tx);
|
||||
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>, body: String| {
|
||||
let token = Arc::clone(&token);
|
||||
let encoding_aes_key = Arc::clone(&encoding_aes_key);
|
||||
let tx = Arc::clone(&tx);
|
||||
async move {
|
||||
let fields = match decode_wecom_post_body(
|
||||
&body,
|
||||
¶ms,
|
||||
token.as_deref(),
|
||||
encoding_aes_key.as_deref(),
|
||||
) {
|
||||
Ok(fields) => fields,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to parse WeCom callback body");
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
||||
"invalid WeCom callback body",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let msg_type = fields.get("MsgType").map(String::as_str).unwrap_or("");
|
||||
let user_id = fields
|
||||
.get("FromUserName")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let event = fields.get("Event").map(String::as_str).unwrap_or("");
|
||||
|
||||
info!(
|
||||
msg_type = msg_type,
|
||||
event = event,
|
||||
from_user = %user_id,
|
||||
"Received WeCom callback"
|
||||
);
|
||||
|
||||
if msg_type == "event" {
|
||||
if (event == "subscribe" || event == "enter_agent")
|
||||
&& !user_id.is_empty()
|
||||
{
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Custom("wecom".to_string()),
|
||||
platform_message_id: String::new(),
|
||||
sender: ChannelUser {
|
||||
platform_id: user_id.clone(),
|
||||
display_name: user_id.clone(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content: ChannelContent::Text(String::new()),
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: false,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
};
|
||||
let _ = tx.send(msg).await;
|
||||
}
|
||||
|
||||
return wecom_success_response();
|
||||
}
|
||||
|
||||
if msg_type == "text" {
|
||||
let content = fields.get("Content").cloned().unwrap_or_default();
|
||||
let msg_id = fields.get("MsgId").cloned().unwrap_or_default();
|
||||
|
||||
if !user_id.is_empty() && !content.is_empty() {
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Custom("wecom".to_string()),
|
||||
platform_message_id: msg_id,
|
||||
sender: ChannelUser {
|
||||
platform_id: user_id.clone(),
|
||||
display_name: user_id.clone(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content: ChannelContent::Text(content),
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: false,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
};
|
||||
let _ = tx.send(msg).await;
|
||||
}
|
||||
}
|
||||
|
||||
wecom_success_response()
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
|
||||
info!("WeCom webhook server listening on http://0.0.0.0:{}", port);
|
||||
|
||||
let server = axum::serve(listener, app);
|
||||
|
||||
tokio::select! {
|
||||
result = server => {
|
||||
if let Err(e) = result {
|
||||
warn!("WeCom webhook server error: {}", e);
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("WeCom adapter shutting down");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let user_id = &user.platform_id;
|
||||
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
// Split long messages
|
||||
for chunk in split_message(&text, MAX_MESSAGE_LEN) {
|
||||
self.send_text(user_id, chunk).await?;
|
||||
}
|
||||
}
|
||||
ChannelContent::Command { name: _, args: _ } => {
|
||||
// WeCom doesn't support commands natively
|
||||
warn!("WeCom: commands not supported");
|
||||
}
|
||||
_ => {
|
||||
warn!("WeCom: unsupported content type");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_adapter_name() {
|
||||
let adapter = WeComAdapter::new(
|
||||
"corp_id".to_string(),
|
||||
"agent_id".to_string(),
|
||||
"secret".to_string(),
|
||||
8080,
|
||||
);
|
||||
assert_eq!(adapter.name(), "wecom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapter_channel_type() {
|
||||
let adapter = WeComAdapter::new(
|
||||
"corp_id".to_string(),
|
||||
"agent_id".to_string(),
|
||||
"secret".to_string(),
|
||||
8080,
|
||||
);
|
||||
assert_eq!(
|
||||
adapter.channel_type(),
|
||||
ChannelType::Custom("wecom".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapter_with_verification() {
|
||||
let adapter = WeComAdapter::with_verification(
|
||||
"corp_id".to_string(),
|
||||
"agent_id".to_string(),
|
||||
"secret".to_string(),
|
||||
8080,
|
||||
Some("encoding_aes_key".to_string()),
|
||||
Some("token".to_string()),
|
||||
);
|
||||
assert_eq!(adapter.name(), "wecom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_message_length() {
|
||||
// MAX_MESSAGE_LEN should be 2048 for WeCom
|
||||
assert_eq!(MAX_MESSAGE_LEN, 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_refresh_buffer() {
|
||||
// Token refresh buffer should be 5 minutes
|
||||
assert_eq!(TOKEN_REFRESH_BUFFER_SECS, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wecom_signature_validation() {
|
||||
assert!(is_valid_wecom_signature(
|
||||
"token",
|
||||
"1710000000",
|
||||
"nonce",
|
||||
"echostr",
|
||||
"bf56bf867459f80e3ceb854596f39f02a5ac5e13",
|
||||
));
|
||||
assert!(!is_valid_wecom_signature(
|
||||
"token",
|
||||
"1710000000",
|
||||
"nonce",
|
||||
"echostr",
|
||||
"bad-signature",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_wecom_payload() {
|
||||
let plain = decode_wecom_payload(
|
||||
"ShlNaJ0PrdXQAuCDVqMki7c2JLNnY6mebvQodTv9qoV",
|
||||
"/gKbXNFpvlyYNTCneTag1rGm1P4Q5fExE3OPzdYlEyUVDgi55PHVIbo+mHMXWatdW8H8RTQJCly0HBNrWry2Uw==",
|
||||
)
|
||||
.expect("echostr should decrypt");
|
||||
|
||||
assert_eq!(plain, "openfang-wecom-check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wecom_xml_fields() {
|
||||
let fields = parse_wecom_xml_fields(
|
||||
r#"<xml>
|
||||
<ToUserName><![CDATA[wwcorp]]></ToUserName>
|
||||
<FromUserName><![CDATA[user123]]></FromUserName>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[hello]]></Content>
|
||||
<MsgId>123456</MsgId>
|
||||
</xml>"#,
|
||||
)
|
||||
.expect("xml should parse");
|
||||
|
||||
assert_eq!(
|
||||
fields.get("FromUserName").map(String::as_str),
|
||||
Some("user123")
|
||||
);
|
||||
assert_eq!(fields.get("MsgType").map(String::as_str), Some("text"));
|
||||
assert_eq!(fields.get("Content").map(String::as_str), Some("hello"));
|
||||
assert_eq!(fields.get("MsgId").map(String::as_str), Some("123456"));
|
||||
}
|
||||
}
|
||||
@@ -222,11 +222,9 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
if let Some(ref gw) = self.gateway_url {
|
||||
let text = match &content {
|
||||
ChannelContent::Text(t) => t.clone(),
|
||||
ChannelContent::Image { caption, .. } => {
|
||||
caption
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(Image — not supported in Web mode)".to_string())
|
||||
}
|
||||
ChannelContent::Image { caption, .. } => caption
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(Image — not supported in Web mode)".to_string()),
|
||||
ChannelContent::File { filename, .. } => {
|
||||
format!("(File: {filename} — not supported in Web mode)")
|
||||
}
|
||||
|
||||
@@ -7,34 +7,112 @@
|
||||
/// Returns all bundled agent templates as `(name, toml_content)` pairs.
|
||||
pub fn bundled_agents() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("analyst", include_str!("../../../agents/analyst/agent.toml")),
|
||||
("architect", include_str!("../../../agents/architect/agent.toml")),
|
||||
("assistant", include_str!("../../../agents/assistant/agent.toml")),
|
||||
(
|
||||
"analyst",
|
||||
include_str!("../../../agents/analyst/agent.toml"),
|
||||
),
|
||||
(
|
||||
"architect",
|
||||
include_str!("../../../agents/architect/agent.toml"),
|
||||
),
|
||||
(
|
||||
"assistant",
|
||||
include_str!("../../../agents/assistant/agent.toml"),
|
||||
),
|
||||
("coder", include_str!("../../../agents/coder/agent.toml")),
|
||||
("code-reviewer", include_str!("../../../agents/code-reviewer/agent.toml")),
|
||||
("customer-support", include_str!("../../../agents/customer-support/agent.toml")),
|
||||
("data-scientist", include_str!("../../../agents/data-scientist/agent.toml")),
|
||||
("debugger", include_str!("../../../agents/debugger/agent.toml")),
|
||||
("devops-lead", include_str!("../../../agents/devops-lead/agent.toml")),
|
||||
("doc-writer", include_str!("../../../agents/doc-writer/agent.toml")),
|
||||
("email-assistant", include_str!("../../../agents/email-assistant/agent.toml")),
|
||||
("health-tracker", include_str!("../../../agents/health-tracker/agent.toml")),
|
||||
("hello-world", include_str!("../../../agents/hello-world/agent.toml")),
|
||||
("home-automation", include_str!("../../../agents/home-automation/agent.toml")),
|
||||
("legal-assistant", include_str!("../../../agents/legal-assistant/agent.toml")),
|
||||
("meeting-assistant", include_str!("../../../agents/meeting-assistant/agent.toml")),
|
||||
(
|
||||
"code-reviewer",
|
||||
include_str!("../../../agents/code-reviewer/agent.toml"),
|
||||
),
|
||||
(
|
||||
"customer-support",
|
||||
include_str!("../../../agents/customer-support/agent.toml"),
|
||||
),
|
||||
(
|
||||
"data-scientist",
|
||||
include_str!("../../../agents/data-scientist/agent.toml"),
|
||||
),
|
||||
(
|
||||
"debugger",
|
||||
include_str!("../../../agents/debugger/agent.toml"),
|
||||
),
|
||||
(
|
||||
"devops-lead",
|
||||
include_str!("../../../agents/devops-lead/agent.toml"),
|
||||
),
|
||||
(
|
||||
"doc-writer",
|
||||
include_str!("../../../agents/doc-writer/agent.toml"),
|
||||
),
|
||||
(
|
||||
"email-assistant",
|
||||
include_str!("../../../agents/email-assistant/agent.toml"),
|
||||
),
|
||||
(
|
||||
"health-tracker",
|
||||
include_str!("../../../agents/health-tracker/agent.toml"),
|
||||
),
|
||||
(
|
||||
"hello-world",
|
||||
include_str!("../../../agents/hello-world/agent.toml"),
|
||||
),
|
||||
(
|
||||
"home-automation",
|
||||
include_str!("../../../agents/home-automation/agent.toml"),
|
||||
),
|
||||
(
|
||||
"legal-assistant",
|
||||
include_str!("../../../agents/legal-assistant/agent.toml"),
|
||||
),
|
||||
(
|
||||
"meeting-assistant",
|
||||
include_str!("../../../agents/meeting-assistant/agent.toml"),
|
||||
),
|
||||
("ops", include_str!("../../../agents/ops/agent.toml")),
|
||||
("orchestrator", include_str!("../../../agents/orchestrator/agent.toml")),
|
||||
("personal-finance", include_str!("../../../agents/personal-finance/agent.toml")),
|
||||
("planner", include_str!("../../../agents/planner/agent.toml")),
|
||||
("recruiter", include_str!("../../../agents/recruiter/agent.toml")),
|
||||
("researcher", include_str!("../../../agents/researcher/agent.toml")),
|
||||
("sales-assistant", include_str!("../../../agents/sales-assistant/agent.toml")),
|
||||
("security-auditor", include_str!("../../../agents/security-auditor/agent.toml")),
|
||||
("social-media", include_str!("../../../agents/social-media/agent.toml")),
|
||||
("test-engineer", include_str!("../../../agents/test-engineer/agent.toml")),
|
||||
("translator", include_str!("../../../agents/translator/agent.toml")),
|
||||
("travel-planner", include_str!("../../../agents/travel-planner/agent.toml")),
|
||||
(
|
||||
"orchestrator",
|
||||
include_str!("../../../agents/orchestrator/agent.toml"),
|
||||
),
|
||||
(
|
||||
"personal-finance",
|
||||
include_str!("../../../agents/personal-finance/agent.toml"),
|
||||
),
|
||||
(
|
||||
"planner",
|
||||
include_str!("../../../agents/planner/agent.toml"),
|
||||
),
|
||||
(
|
||||
"recruiter",
|
||||
include_str!("../../../agents/recruiter/agent.toml"),
|
||||
),
|
||||
(
|
||||
"researcher",
|
||||
include_str!("../../../agents/researcher/agent.toml"),
|
||||
),
|
||||
(
|
||||
"sales-assistant",
|
||||
include_str!("../../../agents/sales-assistant/agent.toml"),
|
||||
),
|
||||
(
|
||||
"security-auditor",
|
||||
include_str!("../../../agents/security-auditor/agent.toml"),
|
||||
),
|
||||
(
|
||||
"social-media",
|
||||
include_str!("../../../agents/social-media/agent.toml"),
|
||||
),
|
||||
(
|
||||
"test-engineer",
|
||||
include_str!("../../../agents/test-engineer/agent.toml"),
|
||||
),
|
||||
(
|
||||
"translator",
|
||||
include_str!("../../../agents/translator/agent.toml"),
|
||||
),
|
||||
(
|
||||
"travel-planner",
|
||||
include_str!("../../../agents/travel-planner/agent.toml"),
|
||||
),
|
||||
("tutor", include_str!("../../../agents/tutor/agent.toml")),
|
||||
("writer", include_str!("../../../agents/writer/agent.toml")),
|
||||
]
|
||||
|
||||
@@ -940,7 +940,9 @@ fn main() {
|
||||
WorkflowCommands::List => cmd_workflow_list(),
|
||||
WorkflowCommands::Create { file } => cmd_workflow_create(file),
|
||||
WorkflowCommands::Get { workflow_id } => cmd_workflow_get(&workflow_id),
|
||||
WorkflowCommands::Update { workflow_id, file } => cmd_workflow_update(&workflow_id, file),
|
||||
WorkflowCommands::Update { workflow_id, file } => {
|
||||
cmd_workflow_update(&workflow_id, file)
|
||||
}
|
||||
WorkflowCommands::Delete { workflow_id } => cmd_workflow_delete(&workflow_id),
|
||||
WorkflowCommands::Run { workflow_id, input } => cmd_workflow_run(&workflow_id, &input),
|
||||
},
|
||||
@@ -1070,7 +1072,10 @@ fn main() {
|
||||
SystemCommands::Version { json } => cmd_system_version(json),
|
||||
},
|
||||
Some(Commands::Reset { confirm }) => cmd_reset(confirm),
|
||||
Some(Commands::Uninstall { confirm, keep_config }) => cmd_uninstall(confirm, keep_config),
|
||||
Some(Commands::Uninstall {
|
||||
confirm,
|
||||
keep_config,
|
||||
}) => cmd_uninstall(confirm, keep_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1127,8 +1132,8 @@ pub(crate) fn find_daemon() -> Option<String> {
|
||||
/// includes a `Authorization: Bearer <key>` header on every request.
|
||||
/// When api_key is empty or missing, no auth header is sent.
|
||||
pub(crate) fn daemon_client() -> reqwest::blocking::Client {
|
||||
let mut builder = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120));
|
||||
let mut builder =
|
||||
reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(120));
|
||||
|
||||
if let Some(key) = read_api_key() {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
@@ -2248,7 +2253,9 @@ decay_rate = 0.05
|
||||
if !json {
|
||||
ui::check_ok(&format!("Port {api_listen} is available"));
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen}));
|
||||
checks.push(
|
||||
serde_json::json!({"check": "port", "status": "ok", "address": api_listen}),
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
if !json {
|
||||
@@ -3179,7 +3186,11 @@ fn cmd_workflow_run(workflow_id: &str, input: &str) {
|
||||
fn cmd_workflow_get(workflow_id: &str) {
|
||||
let base = require_daemon("workflow get");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(client.get(format!("{base}/api/workflows/{workflow_id}")).send());
|
||||
let body = daemon_json(
|
||||
client
|
||||
.get(format!("{base}/api/workflows/{workflow_id}"))
|
||||
.send(),
|
||||
);
|
||||
|
||||
if body.get("error").is_some() {
|
||||
eprintln!(
|
||||
@@ -4144,7 +4155,10 @@ fn cmd_hand_install(path: &str) {
|
||||
body["name"].as_str().unwrap_or("?"),
|
||||
body["id"].as_str().unwrap_or("?"),
|
||||
);
|
||||
println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?"));
|
||||
println!(
|
||||
"Use `openfang hand activate {}` to start it.",
|
||||
body["id"].as_str().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
|
||||
fn cmd_hand_list() {
|
||||
@@ -4169,10 +4183,7 @@ fn cmd_hand_list() {
|
||||
println!("No hands available.");
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
"{:<14} {:<20} {:<10} DESCRIPTION",
|
||||
"ID", "NAME", "CATEGORY"
|
||||
);
|
||||
println!("{:<14} {:<20} {:<10} DESCRIPTION", "ID", "NAME", "CATEGORY");
|
||||
println!("{}", "-".repeat(72));
|
||||
for h in arr {
|
||||
println!(
|
||||
@@ -4180,7 +4191,12 @@ fn cmd_hand_list() {
|
||||
h["id"].as_str().unwrap_or("?"),
|
||||
h["name"].as_str().unwrap_or("?"),
|
||||
h["category"].as_str().unwrap_or("?"),
|
||||
h["description"].as_str().unwrap_or("").chars().take(40).collect::<String>(),
|
||||
h["description"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(40)
|
||||
.collect::<String>(),
|
||||
);
|
||||
}
|
||||
println!("\nUse `openfang hand activate <id>` to activate a hand.");
|
||||
@@ -4202,10 +4218,7 @@ fn cmd_hand_active() {
|
||||
println!("No active hands.");
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
"{:<38} {:<14} {:<10} AGENT",
|
||||
"INSTANCE", "HAND", "STATUS"
|
||||
);
|
||||
println!("{:<38} {:<14} {:<10} AGENT", "INSTANCE", "HAND", "STATUS");
|
||||
println!("{}", "-".repeat(72));
|
||||
for i in &arr {
|
||||
println!(
|
||||
@@ -4293,10 +4306,7 @@ fn cmd_hand_info(id: &str) {
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(client.get(format!("{base}/api/hands/{id}")).send());
|
||||
if body.get("error").is_some() {
|
||||
eprintln!(
|
||||
"Hand not found: {}",
|
||||
body["error"].as_str().unwrap_or(id)
|
||||
);
|
||||
eprintln!("Hand not found: {}", body["error"].as_str().unwrap_or(id));
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!(
|
||||
@@ -5655,7 +5665,15 @@ fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<
|
||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||
.take(64)
|
||||
.collect();
|
||||
format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt })
|
||||
format!(
|
||||
"{}-{}",
|
||||
agent,
|
||||
if short_prompt.is_empty() {
|
||||
"job"
|
||||
} else {
|
||||
&short_prompt
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
let body = daemon_json(
|
||||
@@ -6409,10 +6427,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
||||
} else {
|
||||
match std::fs::remove_dir_all(&openfang_dir) {
|
||||
Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())),
|
||||
Err(e) => ui::error(&format!(
|
||||
"Failed to remove {}: {e}",
|
||||
openfang_dir.display()
|
||||
)),
|
||||
Err(e) => ui::error(&format!("Failed to remove {}: {e}", openfang_dir.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6421,10 +6436,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
||||
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
|
||||
match std::fs::remove_file(&cargo_bin) {
|
||||
Ok(()) => ui::success(&format!("Removed {}", cargo_bin.display())),
|
||||
Err(e) => ui::error(&format!(
|
||||
"Failed to remove {}: {e}",
|
||||
cargo_bin.display()
|
||||
)),
|
||||
Err(e) => ui::error(&format!("Failed to remove {}: {e}", cargo_bin.display())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6664,7 +6676,10 @@ fn remove_self_binary(exe_path: &std::path::Path) {
|
||||
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.spawn();
|
||||
|
||||
ui::success(&format!("Removed {} (deferred cleanup)", exe_path.display()));
|
||||
ui::success(&format!(
|
||||
"Removed {} (deferred cleanup)",
|
||||
exe_path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -329,7 +329,11 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
|
||||
_ => {
|
||||
// Unknown method — always respond with error
|
||||
Some(jsonrpc_error(rid, -32601, &format!("Method not found: {method}")))
|
||||
Some(jsonrpc_error(
|
||||
rid,
|
||||
-32601,
|
||||
&format!("Method not found: {method}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,10 +394,7 @@ impl StandaloneChat {
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"].as_str().unwrap_or("").to_string(),
|
||||
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
|
||||
})
|
||||
.collect()
|
||||
@@ -459,16 +456,13 @@ impl StandaloneChat {
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let provider = body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Switched to {model_id}"));
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
@@ -506,16 +500,12 @@ impl StandaloneChat {
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Switched to {model_id}"));
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Switch failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,8 +516,7 @@ impl App {
|
||||
}
|
||||
AppEvent::CommsEventsLoaded(events) => {
|
||||
self.comms.events = events;
|
||||
if !self.comms.events.is_empty()
|
||||
&& self.comms.event_list_state.selected().is_none()
|
||||
if !self.comms.events.is_empty() && self.comms.event_list_state.selected().is_none()
|
||||
{
|
||||
self.comms.event_list_state.select(Some(0));
|
||||
}
|
||||
@@ -1869,14 +1868,8 @@ impl App {
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"]
|
||||
.as_str()
|
||||
.unwrap_or("Balanced")
|
||||
.to_string(),
|
||||
provider: m["provider"].as_str().unwrap_or("").to_string(),
|
||||
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
@@ -1935,8 +1928,7 @@ impl App {
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let provider = body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
@@ -1988,10 +1980,8 @@ impl App {
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
self.chat
|
||||
.push_message(chat::Role::System, format!("Switch failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1524,6 +1524,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +341,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,11 @@ const CHANNEL_DEFS: &[ChannelDef] = &[
|
||||
name: "dingtalk_stream",
|
||||
display_name: "DingTalk Stream",
|
||||
category: "Enterprise",
|
||||
env_vars: &["DINGTALK_APP_KEY", "DINGTALK_APP_SECRET", "DINGTALK_ROBOT_CODE"],
|
||||
env_vars: &[
|
||||
"DINGTALK_APP_KEY",
|
||||
"DINGTALK_APP_SECRET",
|
||||
"DINGTALK_ROBOT_CODE",
|
||||
],
|
||||
description: "DingTalk Stream Mode (WebSocket long-connection)",
|
||||
},
|
||||
ChannelDef {
|
||||
|
||||
@@ -483,8 +483,7 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
return; // Too small to show picker
|
||||
}
|
||||
let popup_w = area.width.clamp(30, 54);
|
||||
let popup_h = (filtered.len() as u16 + 4)
|
||||
.clamp(5, area.height.saturating_sub(2));
|
||||
let popup_h = (filtered.len() as u16 + 4).clamp(5, area.height.saturating_sub(2));
|
||||
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
||||
@@ -548,7 +547,12 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let max_name = (chunks[1].width as usize).saturating_sub(14);
|
||||
for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) {
|
||||
for (i, entry) in filtered
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(scroll_start)
|
||||
.take(visible_h)
|
||||
{
|
||||
let selected = i == state.model_picker_idx;
|
||||
let indicator = if selected { "\u{25b6} " } else { " " };
|
||||
|
||||
@@ -882,6 +886,9 @@ fn truncate_line(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max_len.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max_len.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,11 +158,7 @@ impl CommsState {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 {
|
||||
self.events.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
};
|
||||
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
@@ -339,12 +335,12 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) {
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Percentage(35), // topology
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(4), // event list
|
||||
Constraint::Length(1), // hints
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(4), // event list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
@@ -441,10 +437,7 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
|
||||
if state.nodes.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No agents running.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
Paragraph::new(Span::styled(" No agents running.", theme::dim_style())),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
@@ -491,7 +484,10 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(branch, theme::dim_style()),
|
||||
Span::styled(format!("[{}]", child.state), state_color(&child.state)),
|
||||
Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)),
|
||||
Span::styled(
|
||||
format!(" {} ", child.name),
|
||||
Style::default().fg(theme::TEXT),
|
||||
),
|
||||
Span::styled(format!("({})", child.model), theme::dim_style()),
|
||||
]));
|
||||
}
|
||||
@@ -679,7 +675,10 @@ fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
rows[3],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))),
|
||||
Paragraph::new(Span::styled(
|
||||
"Assign to (agent ID, optional):",
|
||||
field_style(2),
|
||||
)),
|
||||
rows[4],
|
||||
);
|
||||
f.render_widget(
|
||||
|
||||
@@ -273,6 +273,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,7 +950,9 @@ fn handle_migration_key(
|
||||
let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
PathBuf::from(h)
|
||||
} else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang")
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".openfang")
|
||||
};
|
||||
let tx = migrate_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
|
||||
@@ -405,6 +405,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,10 +317,7 @@ fn draw_agent_select(f: &mut Frame, area: Rect, state: &mut MemoryState) {
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let id_short = if a.id.len() > 12 {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&a.id, 12)
|
||||
)
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&a.id, 12))
|
||||
} else {
|
||||
a.id.clone()
|
||||
};
|
||||
@@ -408,10 +405,7 @@ fn draw_kv_browser(f: &mut Frame, area: Rect, state: &mut MemoryState) {
|
||||
.iter()
|
||||
.map(|kv| {
|
||||
let val_display = if kv.value.len() > 40 {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&kv.value, 39)
|
||||
)
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&kv.value, 39))
|
||||
} else {
|
||||
kv.value.clone()
|
||||
};
|
||||
@@ -555,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
pub mod agents;
|
||||
pub mod audit;
|
||||
pub mod channels;
|
||||
pub mod comms;
|
||||
pub mod chat;
|
||||
pub mod comms;
|
||||
pub mod dashboard;
|
||||
pub mod extensions;
|
||||
pub mod hands;
|
||||
|
||||
@@ -149,10 +149,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut PeersState) {
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let id_short = if p.node_id.len() > 12 {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&p.node_id, 12)
|
||||
)
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&p.node_id, 12))
|
||||
} else {
|
||||
p.node_id.clone()
|
||||
};
|
||||
@@ -211,6 +208,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,10 +251,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut SessionsState) {
|
||||
.map(|&idx| {
|
||||
let s = &state.sessions[idx];
|
||||
let id_short = if s.id.len() > 12 {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&s.id, 12)
|
||||
)
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&s.id, 12))
|
||||
} else {
|
||||
s.id.clone()
|
||||
};
|
||||
@@ -311,6 +308,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,7 +604,10 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -612,7 +612,10 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -399,6 +399,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +439,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,6 +697,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,7 @@ pub fn default_client_ids() -> HashMap<&'static str, &'static str> {
|
||||
}
|
||||
|
||||
/// Resolve OAuth client IDs with config overrides applied on top of defaults.
|
||||
pub fn resolve_client_ids(
|
||||
config: &openfang_types::config::OAuthConfig,
|
||||
) -> HashMap<String, String> {
|
||||
pub fn resolve_client_ids(config: &openfang_types::config::OAuthConfig) -> HashMap<String, String> {
|
||||
let defaults = default_client_ids();
|
||||
let mut resolved: HashMap<String, String> = defaults
|
||||
.into_iter()
|
||||
|
||||
@@ -241,7 +241,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_einstein_hands_have_schedules() {
|
||||
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"];
|
||||
let einstein_ids = [
|
||||
"lead",
|
||||
"collector",
|
||||
"predictor",
|
||||
"researcher",
|
||||
"twitter",
|
||||
"trader",
|
||||
];
|
||||
for (id, toml_content, skill_content) in bundled_hands() {
|
||||
if einstein_ids.contains(&id) {
|
||||
let def = parse_bundled(id, toml_content, skill_content).unwrap();
|
||||
@@ -266,7 +273,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_einstein_hands_have_memory() {
|
||||
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"];
|
||||
let einstein_ids = [
|
||||
"lead",
|
||||
"collector",
|
||||
"predictor",
|
||||
"researcher",
|
||||
"twitter",
|
||||
"trader",
|
||||
];
|
||||
for (id, toml_content, skill_content) in bundled_hands() {
|
||||
if einstein_ids.contains(&id) {
|
||||
let def = parse_bundled(id, toml_content, skill_content).unwrap();
|
||||
@@ -286,7 +300,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_einstein_hands_have_knowledge_graph() {
|
||||
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"];
|
||||
let einstein_ids = [
|
||||
"lead",
|
||||
"collector",
|
||||
"predictor",
|
||||
"researcher",
|
||||
"twitter",
|
||||
"trader",
|
||||
];
|
||||
for (id, toml_content, skill_content) in bundled_hands() {
|
||||
if einstein_ids.contains(&id) {
|
||||
let def = parse_bundled(id, toml_content, skill_content).unwrap();
|
||||
|
||||
@@ -242,7 +242,8 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
|
||||
}
|
||||
|
||||
if field_changed(&old.provider_api_keys, &new.provider_api_keys) {
|
||||
plan.noop_changes.push("provider_api_keys changed (takes effect on next driver init)".to_string());
|
||||
plan.noop_changes
|
||||
.push("provider_api_keys changed (takes effect on next driver init)".to_string());
|
||||
}
|
||||
|
||||
// ----- No-op fields -----
|
||||
@@ -415,7 +416,10 @@ mod tests {
|
||||
let mut b = default_cfg();
|
||||
b.default_model.model = "gpt-4".to_string();
|
||||
let plan = build_reload_plan(&a, &b);
|
||||
assert!(!plan.restart_required, "default_model should be hot-reloadable");
|
||||
assert!(
|
||||
!plan.restart_required,
|
||||
"default_model should be hot-reloadable"
|
||||
);
|
||||
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
|
||||
}
|
||||
|
||||
|
||||
@@ -234,9 +234,12 @@ impl CronScheduler {
|
||||
if !entry.value().job.enabled {
|
||||
// Re-enable jobs that were auto-disabled due to the stale
|
||||
// agent ID causing repeated failures.
|
||||
if entry.value().last_status.as_deref().is_some_and(|s| {
|
||||
s.contains("not found") || s.contains("No such agent")
|
||||
}) {
|
||||
if entry
|
||||
.value()
|
||||
.last_status
|
||||
.as_deref()
|
||||
.is_some_and(|s| s.contains("not found") || s.contains("No such agent"))
|
||||
{
|
||||
entry.value_mut().job.enabled = true;
|
||||
entry.value_mut().job.next_run =
|
||||
Some(compute_next_run(&entry.value().job.schedule));
|
||||
@@ -348,8 +351,7 @@ impl CronScheduler {
|
||||
);
|
||||
meta.job.enabled = false;
|
||||
} else {
|
||||
meta.job.next_run =
|
||||
Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
|
||||
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1094,7 +1096,10 @@ mod tests {
|
||||
sched.reassign_agent_jobs(old_agent, new_agent);
|
||||
|
||||
let meta = sched.get_meta(id).unwrap();
|
||||
assert!(meta.job.enabled, "Job should be re-enabled after reassignment");
|
||||
assert!(
|
||||
meta.job.enabled,
|
||||
"Job should be re-enabled after reassignment"
|
||||
);
|
||||
assert_eq!(meta.consecutive_errors, 0);
|
||||
assert_eq!(meta.job.agent_id, new_agent);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ use openfang_runtime::sandbox::{SandboxConfig, WasmSandbox};
|
||||
use openfang_runtime::tool_runner::builtin_tool_definitions;
|
||||
use openfang_types::agent::*;
|
||||
use openfang_types::capability::Capability;
|
||||
use openfang_types::config::KernelConfig;
|
||||
use openfang_types::config::{KernelConfig, OutputFormat};
|
||||
use openfang_types::error::OpenFangError;
|
||||
use openfang_types::event::*;
|
||||
use openfang_types::memory::Memory;
|
||||
@@ -150,9 +150,11 @@ pub struct OpenFangKernel {
|
||||
/// WhatsApp Web gateway child process PID (for shutdown cleanup).
|
||||
pub whatsapp_gateway_pid: Arc<std::sync::Mutex<Option<u32>>>,
|
||||
/// Channel adapters registered at bridge startup (for proactive `channel_send` tool).
|
||||
pub channel_adapters: dashmap::DashMap<String, Arc<dyn openfang_channels::types::ChannelAdapter>>,
|
||||
pub channel_adapters:
|
||||
dashmap::DashMap<String, Arc<dyn openfang_channels::types::ChannelAdapter>>,
|
||||
/// Hot-reloadable default model override (set via config hot-reload, read at agent spawn).
|
||||
pub default_model_override: std::sync::RwLock<Option<openfang_types::config::DefaultModelConfig>>,
|
||||
pub default_model_override:
|
||||
std::sync::RwLock<Option<openfang_types::config::DefaultModelConfig>>,
|
||||
/// Per-agent message locks — serializes LLM calls for the same agent to prevent
|
||||
/// session corruption when multiple messages arrive concurrently (e.g. rapid voice
|
||||
/// messages via Telegram). Different agents can still run in parallel.
|
||||
@@ -583,10 +585,7 @@ impl OpenFangKernel {
|
||||
None
|
||||
};
|
||||
let dotenv_path = config.home_dir.join(".env");
|
||||
openfang_extensions::credentials::CredentialResolver::new(
|
||||
vault,
|
||||
Some(&dotenv_path),
|
||||
)
|
||||
openfang_extensions::credentials::CredentialResolver::new(vault, Some(&dotenv_path))
|
||||
};
|
||||
|
||||
// Create LLM driver.
|
||||
@@ -605,11 +604,12 @@ impl OpenFangKernel {
|
||||
let driver_config = DriverConfig {
|
||||
provider: config.default_model.provider.clone(),
|
||||
api_key: default_api_key,
|
||||
base_url: config
|
||||
.default_model
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| config.provider_urls.get(&config.default_model.provider).cloned()),
|
||||
base_url: config.default_model.base_url.clone().or_else(|| {
|
||||
config
|
||||
.provider_urls
|
||||
.get(&config.default_model.provider)
|
||||
.cloned()
|
||||
}),
|
||||
skip_permissions: true,
|
||||
};
|
||||
// Primary driver failure is non-fatal: the dashboard should remain accessible
|
||||
@@ -629,7 +629,9 @@ impl OpenFangKernel {
|
||||
if let Some((provider, model, env_var)) = drivers::detect_available_provider() {
|
||||
let auto_config = DriverConfig {
|
||||
provider: provider.to_string(),
|
||||
api_key: credential_resolver.resolve(env_var).map(|z: zeroize::Zeroizing<String>| z.to_string()),
|
||||
api_key: credential_resolver
|
||||
.resolve(env_var)
|
||||
.map(|z: zeroize::Zeroizing<String>| z.to_string()),
|
||||
base_url: config.provider_urls.get(provider).cloned(),
|
||||
skip_permissions: true,
|
||||
};
|
||||
@@ -668,7 +670,9 @@ impl OpenFangKernel {
|
||||
} else {
|
||||
config.resolve_api_key_env(&fb.provider)
|
||||
};
|
||||
credential_resolver.resolve(&env_var).map(|z: zeroize::Zeroizing<String>| z.to_string())
|
||||
credential_resolver
|
||||
.resolve(&env_var)
|
||||
.map(|z: zeroize::Zeroizing<String>| z.to_string())
|
||||
};
|
||||
let fb_config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
@@ -701,9 +705,7 @@ impl OpenFangKernel {
|
||||
|
||||
// Use the chain, or create a stub driver if everything failed
|
||||
let driver: Arc<dyn LlmDriver> = if driver_chain.len() > 1 {
|
||||
Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::with_models(
|
||||
model_chain,
|
||||
))
|
||||
Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::with_models(model_chain))
|
||||
} else if let Some(single) = driver_chain.into_iter().next() {
|
||||
single
|
||||
} else {
|
||||
@@ -861,7 +863,10 @@ impl OpenFangKernel {
|
||||
configured_model.as_str()
|
||||
};
|
||||
let api_key_env = config.memory.embedding_api_key_env.as_deref().unwrap_or("");
|
||||
let custom_url = config.provider_urls.get(provider.as_str()).map(|s| s.as_str());
|
||||
let custom_url = config
|
||||
.provider_urls
|
||||
.get(provider.as_str())
|
||||
.map(|s| s.as_str());
|
||||
match create_embedding_driver(provider, model, api_key_env, custom_url) {
|
||||
Ok(d) => {
|
||||
info!(provider = %provider, model = %model, "Embedding driver configured from memory config");
|
||||
@@ -1071,11 +1076,16 @@ impl OpenFangKernel {
|
||||
Ok(disk_manifest) => {
|
||||
// Compare key fields to detect changes
|
||||
let changed = disk_manifest.name != entry.manifest.name
|
||||
|| disk_manifest.description != entry.manifest.description
|
||||
|| disk_manifest.model.system_prompt != entry.manifest.model.system_prompt
|
||||
|| disk_manifest.model.provider != entry.manifest.model.provider
|
||||
|| disk_manifest.model.model != entry.manifest.model.model
|
||||
|| disk_manifest.capabilities.tools != entry.manifest.capabilities.tools;
|
||||
|| disk_manifest.description
|
||||
!= entry.manifest.description
|
||||
|| disk_manifest.model.system_prompt
|
||||
!= entry.manifest.model.system_prompt
|
||||
|| disk_manifest.model.provider
|
||||
!= entry.manifest.model.provider
|
||||
|| disk_manifest.model.model
|
||||
!= entry.manifest.model.model
|
||||
|| disk_manifest.capabilities.tools
|
||||
!= entry.manifest.capabilities.tools;
|
||||
if changed {
|
||||
info!(
|
||||
agent = %name,
|
||||
@@ -1156,10 +1166,15 @@ impl OpenFangKernel {
|
||||
restored_entry.manifest.model.model = dm.model.clone();
|
||||
}
|
||||
if !dm.api_key_env.is_empty() {
|
||||
restored_entry.manifest.model.api_key_env = Some(dm.api_key_env.clone());
|
||||
restored_entry.manifest.model.api_key_env =
|
||||
Some(dm.api_key_env.clone());
|
||||
}
|
||||
if dm.base_url.is_some() {
|
||||
restored_entry.manifest.model.base_url.clone_from(&dm.base_url);
|
||||
restored_entry
|
||||
.manifest
|
||||
.model
|
||||
.base_url
|
||||
.clone_from(&dm.base_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1300,9 +1315,10 @@ impl OpenFangKernel {
|
||||
apply_budget_defaults(&self.config.budget, &mut manifest.resources);
|
||||
|
||||
// Create workspace directory for the agent (name-based, so SOUL.md survives recreation)
|
||||
let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| {
|
||||
self.config.effective_workspaces_dir().join(&name)
|
||||
});
|
||||
let workspace_dir = manifest
|
||||
.workspace
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.config.effective_workspaces_dir().join(&name));
|
||||
ensure_workspace(&workspace_dir)?;
|
||||
if manifest.generate_identity_files {
|
||||
generate_identity_files(&workspace_dir, &manifest);
|
||||
@@ -1441,8 +1457,15 @@ impl OpenFangKernel {
|
||||
.get()
|
||||
.and_then(|w| w.upgrade())
|
||||
.map(|arc| arc as Arc<dyn KernelHandle>);
|
||||
self.send_message_with_handle_and_blocks(agent_id, message, handle, Some(blocks), None, None)
|
||||
.await
|
||||
self.send_message_with_handle_and_blocks(
|
||||
agent_id,
|
||||
message,
|
||||
handle,
|
||||
Some(blocks),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a message with an optional kernel handle for inter-agent tools.
|
||||
@@ -1454,8 +1477,15 @@ impl OpenFangKernel {
|
||||
sender_id: Option<String>,
|
||||
sender_name: Option<String>,
|
||||
) -> KernelResult<AgentLoopResult> {
|
||||
self.send_message_with_handle_and_blocks(agent_id, message, kernel_handle, None, sender_id, sender_name)
|
||||
.await
|
||||
self.send_message_with_handle_and_blocks(
|
||||
agent_id,
|
||||
message,
|
||||
kernel_handle,
|
||||
None,
|
||||
sender_id,
|
||||
sender_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a message with optional content blocks and an optional kernel handle.
|
||||
@@ -1504,8 +1534,16 @@ impl OpenFangKernel {
|
||||
self.execute_python_agent(&entry, agent_id, message).await
|
||||
} else {
|
||||
// Default: LLM agent loop (builtin:chat or any unrecognized module)
|
||||
self.execute_llm_agent(&entry, agent_id, message, kernel_handle, content_blocks, sender_id, sender_name)
|
||||
.await
|
||||
self.execute_llm_agent(
|
||||
&entry,
|
||||
agent_id,
|
||||
message,
|
||||
kernel_handle,
|
||||
content_blocks,
|
||||
sender_id,
|
||||
sender_name,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
match result {
|
||||
@@ -1797,7 +1835,11 @@ impl OpenFangKernel {
|
||||
None
|
||||
},
|
||||
peer_agents,
|
||||
current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()),
|
||||
current_date: Some(
|
||||
chrono::Local::now()
|
||||
.format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)")
|
||||
.to_string(),
|
||||
),
|
||||
sender_id,
|
||||
sender_name,
|
||||
};
|
||||
@@ -2337,7 +2379,11 @@ impl OpenFangKernel {
|
||||
None
|
||||
},
|
||||
peer_agents,
|
||||
current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()),
|
||||
current_date: Some(
|
||||
chrono::Local::now()
|
||||
.format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)")
|
||||
.to_string(),
|
||||
),
|
||||
sender_id,
|
||||
sender_name,
|
||||
};
|
||||
@@ -2791,22 +2837,17 @@ impl OpenFangKernel {
|
||||
.get(agent_id)
|
||||
.map(|e| e.manifest.model.base_url.is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_custom_url {
|
||||
// Keep the current provider — don't let auto-detection override
|
||||
// a deliberately configured custom endpoint.
|
||||
None
|
||||
} else {
|
||||
// No custom base_url: safe to auto-detect from catalog / model name
|
||||
let resolved_provider = self
|
||||
.model_catalog
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|catalog| {
|
||||
catalog
|
||||
.find_model(model)
|
||||
.map(|entry| entry.provider.clone())
|
||||
});
|
||||
let resolved_provider = self.model_catalog.read().ok().and_then(|catalog| {
|
||||
catalog
|
||||
.find_model(model)
|
||||
.map(|entry| entry.provider.clone())
|
||||
});
|
||||
resolved_provider.or_else(|| infer_provider_from_model(model))
|
||||
}
|
||||
};
|
||||
@@ -3235,10 +3276,6 @@ impl OpenFangKernel {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
// Redundant safety: tool_allowlist mirrors capabilities.tools above.
|
||||
// available_tools() already filters by capabilities.tools, but this
|
||||
// provides defense-in-depth.
|
||||
tool_allowlist: def.tools.clone(),
|
||||
tool_blocklist: Vec::new(),
|
||||
// Custom profile avoids ToolProfile-based expansion overriding the
|
||||
// explicit tool list.
|
||||
@@ -3289,7 +3326,11 @@ impl OpenFangKernel {
|
||||
// If an agent with this hand's name already exists, remove it first.
|
||||
// Save triggers before kill so they can be restored under the new ID
|
||||
// (issue #519 — triggers were lost on agent restart).
|
||||
let existing = self.registry.list().into_iter().find(|e| e.name == def.agent.name);
|
||||
let existing = self
|
||||
.registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.find(|e| e.name == def.agent.name);
|
||||
let old_agent_id = existing.as_ref().map(|e| e.id);
|
||||
let saved_triggers = old_agent_id
|
||||
.map(|id| self.triggers.take_agent_triggers(id))
|
||||
@@ -3735,7 +3776,9 @@ impl OpenFangKernel {
|
||||
"Reassigned cron jobs after restart"
|
||||
);
|
||||
if let Err(e) = self.cron_scheduler.persist() {
|
||||
warn!("Failed to persist cron jobs after hand restore: {e}");
|
||||
warn!(
|
||||
"Failed to persist cron jobs after hand restore: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reassign triggers (#519). Currently a no-op on
|
||||
@@ -3761,14 +3804,17 @@ impl OpenFangKernel {
|
||||
}
|
||||
|
||||
let agents = self.registry.list();
|
||||
let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> =
|
||||
Vec::new();
|
||||
let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> = Vec::new();
|
||||
|
||||
for entry in &agents {
|
||||
if matches!(entry.manifest.schedule, ScheduleMode::Reactive) {
|
||||
continue;
|
||||
}
|
||||
bg_agents.push((entry.id, entry.name.clone(), entry.manifest.schedule.clone()));
|
||||
bg_agents.push((
|
||||
entry.id,
|
||||
entry.name.clone(),
|
||||
entry.manifest.schedule.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
if !bg_agents.is_empty() {
|
||||
@@ -3993,10 +4039,18 @@ impl OpenFangKernel {
|
||||
let timeout_s = timeout_secs.unwrap_or(120);
|
||||
let timeout = std::time::Duration::from_secs(timeout_s);
|
||||
let delivery = job.delivery.clone();
|
||||
let kh: std::sync::Arc<dyn openfang_runtime::kernel_handle::KernelHandle> = kernel.clone();
|
||||
let kh: std::sync::Arc<
|
||||
dyn openfang_runtime::kernel_handle::KernelHandle,
|
||||
> = kernel.clone();
|
||||
match tokio::time::timeout(
|
||||
timeout,
|
||||
kernel.send_message_with_handle(agent_id, message, Some(kh), None, None),
|
||||
kernel.send_message_with_handle(
|
||||
agent_id,
|
||||
message,
|
||||
Some(kh),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -4049,10 +4103,13 @@ impl OpenFangKernel {
|
||||
Ok(uuid) => crate::workflow::WorkflowId(uuid),
|
||||
Err(_) => {
|
||||
let all_wfs = kernel.workflows.list_workflows().await;
|
||||
if let Some(wf) = all_wfs.iter().find(|w| w.name == *workflow_id) {
|
||||
if let Some(wf) =
|
||||
all_wfs.iter().find(|w| w.name == *workflow_id)
|
||||
{
|
||||
wf.id
|
||||
} else {
|
||||
let err_msg = format!("workflow not found: {workflow_id}");
|
||||
let err_msg =
|
||||
format!("workflow not found: {workflow_id}");
|
||||
tracing::warn!(job = %job_name, %err_msg);
|
||||
kernel.cron_scheduler.record_failure(job_id, &err_msg);
|
||||
continue;
|
||||
@@ -4068,10 +4125,7 @@ impl OpenFangKernel {
|
||||
{
|
||||
Ok(Ok((_run_id, output))) => {
|
||||
match cron_deliver_response(
|
||||
&kernel,
|
||||
agent_id,
|
||||
&output,
|
||||
&delivery,
|
||||
&kernel, agent_id, &output, &delivery,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -4312,10 +4366,9 @@ impl OpenFangKernel {
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
if !recovery_tracker.can_attempt(
|
||||
status.agent_id,
|
||||
config.recovery_cooldown_secs,
|
||||
) {
|
||||
if !recovery_tracker
|
||||
.can_attempt(status.agent_id, config.recovery_cooldown_secs)
|
||||
{
|
||||
debug!(
|
||||
agent = %status.name,
|
||||
"Recovery cooldown active, skipping"
|
||||
@@ -4627,8 +4680,10 @@ impl OpenFangKernel {
|
||||
// If fallback models are configured, wrap in FallbackDriver
|
||||
if !manifest.fallback_models.is_empty() {
|
||||
// Primary driver uses the agent's own model name (already set in request)
|
||||
let mut chain: Vec<(std::sync::Arc<dyn openfang_runtime::llm_driver::LlmDriver>, String)> =
|
||||
vec![(primary.clone(), String::new())];
|
||||
let mut chain: Vec<(
|
||||
std::sync::Arc<dyn openfang_runtime::llm_driver::LlmDriver>,
|
||||
String,
|
||||
)> = vec![(primary.clone(), String::new())];
|
||||
for fb in &manifest.fallback_models {
|
||||
let fb_api_key = if let Some(env) = &fb.api_key_env {
|
||||
std::env::var(env).ok()
|
||||
@@ -5009,8 +5064,8 @@ impl OpenFangKernel {
|
||||
// Check if the agent has unrestricted tool access:
|
||||
// - capabilities.tools is empty (not specified → all tools)
|
||||
// - capabilities.tools contains "*" (explicit wildcard)
|
||||
let tools_unrestricted = declared_tools.is_empty()
|
||||
|| declared_tools.iter().any(|t| t == "*");
|
||||
let tools_unrestricted =
|
||||
declared_tools.is_empty() || declared_tools.iter().any(|t| t == "*");
|
||||
|
||||
// Step 1: Filter builtin tools.
|
||||
// Priority: declared tools > ToolProfile > all builtins.
|
||||
@@ -5057,9 +5112,7 @@ impl OpenFangKernel {
|
||||
};
|
||||
for skill_tool in skill_tools {
|
||||
// If agent declares specific tools, only include matching skill tools
|
||||
if !tools_unrestricted
|
||||
&& !declared_tools.iter().any(|d| d == &skill_tool.name)
|
||||
{
|
||||
if !tools_unrestricted && !declared_tools.iter().any(|d| d == &skill_tool.name) {
|
||||
continue;
|
||||
}
|
||||
all_tools.push(ToolDefinition {
|
||||
@@ -5102,7 +5155,12 @@ impl OpenFangKernel {
|
||||
// These are separate from capabilities.tools and act as additional filters.
|
||||
let (tool_allowlist, tool_blocklist) = entry
|
||||
.as_ref()
|
||||
.map(|e| (e.manifest.tool_allowlist.clone(), e.manifest.tool_blocklist.clone()))
|
||||
.map(|e| {
|
||||
(
|
||||
e.manifest.tool_allowlist.clone(),
|
||||
e.manifest.tool_blocklist.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if !tool_allowlist.is_empty() {
|
||||
@@ -5244,7 +5302,8 @@ impl OpenFangKernel {
|
||||
tool_names.join(", ")
|
||||
));
|
||||
}
|
||||
summary.push_str("MCP tools are prefixed with mcp_{server}_ and work like regular tools.\n");
|
||||
summary
|
||||
.push_str("MCP tools are prefixed with mcp_{server}_ and work like regular tools.\n");
|
||||
// Add filesystem-specific guidance when a filesystem MCP server is connected
|
||||
let has_filesystem = servers.keys().any(|s| s.contains("filesystem"));
|
||||
if has_filesystem {
|
||||
@@ -5450,8 +5509,8 @@ fn infer_provider_from_model(model: &str) -> Option<String> {
|
||||
"minimax" | "gemini" | "anthropic" | "openai" | "groq" | "deepseek" | "mistral"
|
||||
| "cohere" | "xai" | "ollama" | "together" | "fireworks" | "perplexity"
|
||||
| "cerebras" | "sambanova" | "replicate" | "huggingface" | "ai21" | "codex"
|
||||
| "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "zai" | "moonshot"
|
||||
| "openrouter" | "volcengine" | "doubao" | "dashscope" => {
|
||||
| "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "zai"
|
||||
| "moonshot" | "openrouter" | "volcengine" | "doubao" | "dashscope" => {
|
||||
return Some(prefix.to_string());
|
||||
}
|
||||
// "kimi" is a brand alias for moonshot
|
||||
@@ -5468,16 +5527,26 @@ fn infer_provider_from_model(model: &str) -> Option<String> {
|
||||
Some("gemini".to_string())
|
||||
} else if lower.starts_with("claude") {
|
||||
Some("anthropic".to_string())
|
||||
} else if lower.starts_with("gpt") || lower.starts_with("o1") || lower.starts_with("o3") || lower.starts_with("o4") {
|
||||
} else if lower.starts_with("gpt")
|
||||
|| lower.starts_with("o1")
|
||||
|| lower.starts_with("o3")
|
||||
|| lower.starts_with("o4")
|
||||
{
|
||||
Some("openai".to_string())
|
||||
} else if lower.starts_with("llama") || lower.starts_with("mixtral") || lower.starts_with("qwen") {
|
||||
} else if lower.starts_with("llama")
|
||||
|| lower.starts_with("mixtral")
|
||||
|| lower.starts_with("qwen")
|
||||
{
|
||||
// These could be on multiple providers; don't infer
|
||||
None
|
||||
} else if lower.starts_with("grok") {
|
||||
Some("xai".to_string())
|
||||
} else if lower.starts_with("deepseek") {
|
||||
Some("deepseek".to_string())
|
||||
} else if lower.starts_with("mistral") || lower.starts_with("codestral") || lower.starts_with("pixtral") {
|
||||
} else if lower.starts_with("mistral")
|
||||
|| lower.starts_with("codestral")
|
||||
|| lower.starts_with("pixtral")
|
||||
{
|
||||
Some("mistral".to_string())
|
||||
} else if lower.starts_with("command") || lower.starts_with("embed-") {
|
||||
Some("cohere".to_string())
|
||||
@@ -5581,15 +5650,10 @@ async fn cron_deliver_response(
|
||||
"response": response,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let resp = client
|
||||
.post(url)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, "Cron webhook delivery failed");
|
||||
format!("webhook delivery failed: {e}")
|
||||
})?;
|
||||
let resp = client.post(url).json(&payload).send().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "Cron webhook delivery failed");
|
||||
format!("webhook delivery failed: {e}")
|
||||
})?;
|
||||
tracing::debug!(status = %resp.status(), "Cron webhook delivered");
|
||||
Ok(())
|
||||
}
|
||||
@@ -6041,8 +6105,20 @@ impl KernelHandle for OpenFangKernel {
|
||||
|
||||
async fn get_channel_default_recipient(&self, channel: &str) -> Option<String> {
|
||||
match channel {
|
||||
"telegram" => self.config.channels.telegram.as_ref()?.default_chat_id.clone(),
|
||||
"discord" => self.config.channels.discord.as_ref()?.default_channel_id.clone(),
|
||||
"telegram" => self
|
||||
.config
|
||||
.channels
|
||||
.telegram
|
||||
.as_ref()?
|
||||
.default_chat_id
|
||||
.clone(),
|
||||
"discord" => self
|
||||
.config
|
||||
.channels
|
||||
.discord
|
||||
.as_ref()?
|
||||
.default_channel_id
|
||||
.clone(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -6076,7 +6152,20 @@ impl KernelHandle for OpenFangKernel {
|
||||
openfang_user: None,
|
||||
};
|
||||
|
||||
let content = openfang_channels::types::ChannelContent::Text(message.to_string());
|
||||
let formatted = if channel == "wecom" {
|
||||
let output_format = self
|
||||
.config
|
||||
.channels
|
||||
.wecom
|
||||
.as_ref()
|
||||
.and_then(|c| c.overrides.output_format)
|
||||
.unwrap_or(OutputFormat::PlainText);
|
||||
openfang_channels::formatter::format_for_wecom(message, output_format)
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
|
||||
let content = openfang_channels::types::ChannelContent::Text(formatted);
|
||||
|
||||
if let Some(tid) = thread_id {
|
||||
adapter
|
||||
@@ -6135,7 +6224,9 @@ impl KernelHandle for OpenFangKernel {
|
||||
filename: filename.unwrap_or("file").to_string(),
|
||||
},
|
||||
_ => {
|
||||
return Err(format!("Unsupported media type: '{media_type}'. Use 'image' or 'file'."));
|
||||
return Err(format!(
|
||||
"Unsupported media type: '{media_type}'. Use 'image' or 'file'."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6151,7 +6242,10 @@ impl KernelHandle for OpenFangKernel {
|
||||
.map_err(|e| format!("Channel media send failed: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(format!("{} sent to {} via {}", media_type, recipient, channel))
|
||||
Ok(format!(
|
||||
"{} sent to {} via {}",
|
||||
media_type, recipient, channel
|
||||
))
|
||||
}
|
||||
|
||||
async fn send_channel_file_data(
|
||||
@@ -6203,7 +6297,10 @@ impl KernelHandle for OpenFangKernel {
|
||||
.map_err(|e| format!("Channel file send failed: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(format!("File '{}' sent to {} via {}", filename, recipient, channel))
|
||||
Ok(format!(
|
||||
"File '{}' sent to {} via {}",
|
||||
filename, recipient, channel
|
||||
))
|
||||
}
|
||||
|
||||
async fn spawn_agent_checked(
|
||||
@@ -6518,4 +6615,38 @@ mod tests {
|
||||
.iter()
|
||||
.any(|c| matches!(c, Capability::ToolInvoke(name) if name == "shell_exec")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_activation_does_not_seed_runtime_tool_filters() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home_dir = tmp.path().join("openfang-kernel-hand-test");
|
||||
std::fs::create_dir_all(&home_dir).unwrap();
|
||||
|
||||
let config = KernelConfig {
|
||||
home_dir: home_dir.clone(),
|
||||
data_dir: home_dir.join("data"),
|
||||
..KernelConfig::default()
|
||||
};
|
||||
|
||||
let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot");
|
||||
let instance = kernel
|
||||
.activate_hand("browser", HashMap::new())
|
||||
.expect("browser hand should activate");
|
||||
let agent_id = instance.agent_id.expect("browser hand agent id");
|
||||
let entry = kernel
|
||||
.registry
|
||||
.get(agent_id)
|
||||
.expect("browser hand agent entry");
|
||||
|
||||
assert!(
|
||||
entry.manifest.tool_allowlist.is_empty(),
|
||||
"hand activation should leave the runtime tool allowlist empty so skill/MCP tools remain visible"
|
||||
);
|
||||
assert!(
|
||||
entry.manifest.tool_blocklist.is_empty(),
|
||||
"hand activation should not set a runtime blocklist by default"
|
||||
);
|
||||
|
||||
kernel.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,8 +88,7 @@ impl AgentScheduler {
|
||||
// Reset the window if an hour has passed
|
||||
tracker.reset_if_expired();
|
||||
|
||||
if quota.max_llm_tokens_per_hour > 0
|
||||
&& tracker.total_tokens > quota.max_llm_tokens_per_hour
|
||||
if quota.max_llm_tokens_per_hour > 0 && tracker.total_tokens > quota.max_llm_tokens_per_hour
|
||||
{
|
||||
return Err(OpenFangError::QuotaExceeded(format!(
|
||||
"Token limit exceeded: {} / {}",
|
||||
|
||||
@@ -215,11 +215,7 @@ impl TriggerEngine {
|
||||
/// on each trigger and moves the index entry.
|
||||
///
|
||||
/// Returns the number of triggers reassigned.
|
||||
pub fn reassign_agent_triggers(
|
||||
&self,
|
||||
old_agent_id: AgentId,
|
||||
new_agent_id: AgentId,
|
||||
) -> usize {
|
||||
pub fn reassign_agent_triggers(&self, old_agent_id: AgentId, new_agent_id: AgentId) -> usize {
|
||||
let trigger_ids = self
|
||||
.agent_triggers
|
||||
.remove(&old_agent_id)
|
||||
@@ -702,7 +698,10 @@ mod tests {
|
||||
matches!(&t.pattern, TriggerPattern::ContentMatch { substring } if substring == "deploy")
|
||||
&& t.max_fires == 5
|
||||
});
|
||||
assert!(has_content_match, "ContentMatch trigger with max_fires=5 should be preserved");
|
||||
assert!(
|
||||
has_content_match,
|
||||
"ContentMatch trigger with max_fires=5 should be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -727,6 +726,9 @@ mod tests {
|
||||
engine.restore_triggers(new_agent, taken);
|
||||
let restored = engine.list_agent_triggers(new_agent);
|
||||
assert_eq!(restored.len(), 1);
|
||||
assert!(!restored[0].enabled, "Disabled state should survive take/restore");
|
||||
assert!(
|
||||
!restored[0].enabled,
|
||||
"Disabled state should survive take/restore"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,8 @@ use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Gateway source files embedded at compile time.
|
||||
const GATEWAY_INDEX_JS: &str =
|
||||
include_str!("../../../packages/whatsapp-gateway/index.js");
|
||||
const GATEWAY_PACKAGE_JSON: &str =
|
||||
include_str!("../../../packages/whatsapp-gateway/package.json");
|
||||
const GATEWAY_INDEX_JS: &str = include_str!("../../../packages/whatsapp-gateway/index.js");
|
||||
const GATEWAY_PACKAGE_JSON: &str = include_str!("../../../packages/whatsapp-gateway/package.json");
|
||||
|
||||
/// Default port for the WhatsApp Web gateway.
|
||||
const DEFAULT_GATEWAY_PORT: u16 = 3009;
|
||||
@@ -69,8 +67,8 @@ async fn ensure_gateway_installed() -> Result<PathBuf, String> {
|
||||
let package_path = dir.join("package.json");
|
||||
|
||||
// Write files only if content changed (avoids unnecessary npm install)
|
||||
let index_changed =
|
||||
write_if_changed(&index_path, GATEWAY_INDEX_JS).map_err(|e| format!("Write index.js: {e}"))?;
|
||||
let index_changed = write_if_changed(&index_path, GATEWAY_INDEX_JS)
|
||||
.map_err(|e| format!("Write index.js: {e}"))?;
|
||||
let package_changed = write_if_changed(&package_path, GATEWAY_PACKAGE_JSON)
|
||||
.map_err(|e| format!("Write package.json: {e}"))?;
|
||||
|
||||
@@ -164,7 +162,10 @@ pub async fn start_whatsapp_gateway(kernel: &Arc<super::kernel::OpenFangKernel>)
|
||||
.to_string();
|
||||
|
||||
// Auto-set the env var so the rest of the system finds the gateway
|
||||
std::env::set_var("WHATSAPP_WEB_GATEWAY_URL", format!("http://127.0.0.1:{port}"));
|
||||
std::env::set_var(
|
||||
"WHATSAPP_WEB_GATEWAY_URL",
|
||||
format!("http://127.0.0.1:{port}"),
|
||||
);
|
||||
info!("WHATSAPP_WEB_GATEWAY_URL set to http://127.0.0.1:{port}");
|
||||
|
||||
// Spawn with crash monitoring
|
||||
@@ -247,9 +248,7 @@ pub async fn start_whatsapp_gateway(kernel: &Arc<super::kernel::OpenFangKernel>)
|
||||
|
||||
restarts += 1;
|
||||
if restarts >= MAX_RESTARTS {
|
||||
warn!(
|
||||
"WhatsApp gateway exceeded max restarts ({MAX_RESTARTS}), giving up"
|
||||
);
|
||||
warn!("WhatsApp gateway exceeded max restarts ({MAX_RESTARTS}), giving up");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -560,7 +560,9 @@ impl SessionStore {
|
||||
ContentBlock::Text { text, .. } => {
|
||||
text_parts.push(text.clone());
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input, .. } => {
|
||||
ContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
tool_parts.push(serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
|
||||
@@ -99,13 +99,12 @@ impl StructuredStore {
|
||||
let mut pairs = Vec::new();
|
||||
for row in rows {
|
||||
let (key, blob) = row.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&blob)
|
||||
.unwrap_or_else(|_| {
|
||||
// Fallback: try as UTF-8 string
|
||||
String::from_utf8(blob)
|
||||
.map(serde_json::Value::String)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
});
|
||||
let value: serde_json::Value = serde_json::from_slice(&blob).unwrap_or_else(|_| {
|
||||
// Fallback: try as UTF-8 string
|
||||
String::from_utf8(blob)
|
||||
.map(serde_json::Value::String)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
});
|
||||
pairs.push((key, value));
|
||||
}
|
||||
Ok(pairs)
|
||||
@@ -192,7 +191,14 @@ impl StructuredStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str))
|
||||
Ok((
|
||||
name,
|
||||
manifest_blob,
|
||||
state_str,
|
||||
created_str,
|
||||
session_id_str,
|
||||
identity_str,
|
||||
))
|
||||
});
|
||||
|
||||
match result {
|
||||
@@ -307,13 +313,14 @@ impl StructuredStore {
|
||||
let mut repair_queue: Vec<(String, Vec<u8>, String)> = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
let (id_str, name, manifest_blob, state_str, created_str, session_id_str, identity_str) = match row {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("Skipping agent row with read error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (id_str, name, manifest_blob, state_str, created_str, session_id_str, identity_str) =
|
||||
match row {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("Skipping agent row with read error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Deduplicate: skip agents with names we've already seen
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
@@ -125,17 +125,21 @@ struct OpenClawAgentTools {
|
||||
|
||||
/// Extract a profile name from a Value (string or {name: "..."} object).
|
||||
fn extract_profile(val: &serde_json::Value) -> Option<String> {
|
||||
val.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| val.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||
val.as_str().map(|s| s.to_string()).or_else(|| {
|
||||
val.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract a list of strings from a Value (array of strings, single string, or object keys).
|
||||
fn extract_string_list(val: &serde_json::Value) -> Vec<String> {
|
||||
match val {
|
||||
serde_json::Value::Array(arr) => {
|
||||
arr.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect()
|
||||
}
|
||||
serde_json::Value::Array(arr) => arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
serde_json::Value::String(s) => vec![s.clone()],
|
||||
serde_json::Value::Object(map) => map.keys().cloned().collect(),
|
||||
_ => vec![],
|
||||
@@ -517,8 +521,7 @@ fn build_channel_table(
|
||||
let allow_list = allow_from.map(extract_string_list).unwrap_or_default();
|
||||
|
||||
// Add overrides sub-table if any policy is set
|
||||
let has_overrides =
|
||||
dm_policy.is_some() || group_policy.is_some() || !allow_list.is_empty();
|
||||
let has_overrides = dm_policy.is_some() || group_policy.is_some() || !allow_list.is_empty();
|
||||
|
||||
if has_overrides {
|
||||
let mut overrides = toml::map::Map::new();
|
||||
|
||||
@@ -583,8 +583,7 @@ mod tests {
|
||||
assert_eq!(wrapper.state(), &A2aTaskStatus::Completed);
|
||||
|
||||
// Test with a message payload
|
||||
let json_with_msg =
|
||||
r#"{"state":"working","message":{"text":"Processing..."}}"#;
|
||||
let json_with_msg = r#"{"state":"working","message":{"text":"Processing..."}}"#;
|
||||
let wrapper2: A2aTaskStatusWrapper = serde_json::from_str(json_with_msg).unwrap();
|
||||
assert_eq!(wrapper2, A2aTaskStatus::Working);
|
||||
|
||||
|
||||
@@ -56,15 +56,9 @@ const TOOL_ERROR_GUIDANCE: &str =
|
||||
"[System: One or more tool calls failed. Failed tools did not produce usable data. Do NOT invent missing results, cite nonexistent search results, or pretend failed tools succeeded. If your next steps depend on a failed tool, either retry with a materially different approach or explain the failure to the user and stop. Do not write files, store memory, or take downstream actions based on failed tool outputs.]";
|
||||
|
||||
fn append_tool_error_guidance(tool_result_blocks: &mut Vec<ContentBlock>) {
|
||||
let has_tool_error = tool_result_blocks.iter().any(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::ToolResult {
|
||||
is_error: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
let has_tool_error = tool_result_blocks
|
||||
.iter()
|
||||
.any(|block| matches!(block, ContentBlock::ToolResult { is_error: true, .. }));
|
||||
if has_tool_error {
|
||||
tool_result_blocks.push(ContentBlock::Text {
|
||||
text: TOOL_ERROR_GUIDANCE.to_string(),
|
||||
@@ -428,8 +422,8 @@ pub async fn run_agent_loop(
|
||||
// try once more before accepting the empty result.
|
||||
// Triggers on first call OR when input_tokens=0 (silently failed request).
|
||||
if text.trim().is_empty() && response.tool_calls.is_empty() {
|
||||
let is_silent_failure = response.usage.input_tokens == 0
|
||||
&& response.usage.output_tokens == 0;
|
||||
let is_silent_failure =
|
||||
response.usage.input_tokens == 0 && response.usage.output_tokens == 0;
|
||||
if iteration == 0 || is_silent_failure {
|
||||
warn!(
|
||||
agent = %manifest.name,
|
||||
@@ -744,10 +738,13 @@ pub async fn run_agent_loop(
|
||||
append_tool_error_guidance(&mut tool_result_blocks);
|
||||
|
||||
// Detect approval denials and inject guidance to prevent infinite retry loops
|
||||
let denial_count = tool_result_blocks.iter().filter(|b| {
|
||||
matches!(b, ContentBlock::ToolResult { content, is_error: true, .. }
|
||||
let denial_count = tool_result_blocks
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
matches!(b, ContentBlock::ToolResult { content, is_error: true, .. }
|
||||
if content.contains("requires human approval and was denied"))
|
||||
}).count();
|
||||
})
|
||||
.count();
|
||||
if denial_count > 0 {
|
||||
tool_result_blocks.push(ContentBlock::Text {
|
||||
text: format!(
|
||||
@@ -764,9 +761,10 @@ pub async fn run_agent_loop(
|
||||
}
|
||||
|
||||
// Detect tool errors and inject guidance to prevent fabrication
|
||||
let error_count = tool_result_blocks.iter().filter(|b| {
|
||||
matches!(b, ContentBlock::ToolResult { is_error: true, .. })
|
||||
}).count();
|
||||
let error_count = tool_result_blocks
|
||||
.iter()
|
||||
.filter(|b| matches!(b, ContentBlock::ToolResult { is_error: true, .. }))
|
||||
.count();
|
||||
let non_denial_errors = error_count.saturating_sub(denial_count);
|
||||
if non_denial_errors > 0 {
|
||||
tool_result_blocks.push(ContentBlock::Text {
|
||||
@@ -1411,8 +1409,8 @@ pub async fn run_agent_loop_streaming(
|
||||
// try once more before accepting the empty result.
|
||||
// Triggers on first call OR when input_tokens=0 (silently failed request).
|
||||
if text.trim().is_empty() && response.tool_calls.is_empty() {
|
||||
let is_silent_failure = response.usage.input_tokens == 0
|
||||
&& response.usage.output_tokens == 0;
|
||||
let is_silent_failure =
|
||||
response.usage.input_tokens == 0 && response.usage.output_tokens == 0;
|
||||
if iteration == 0 || is_silent_failure {
|
||||
warn!(
|
||||
agent = %manifest.name,
|
||||
@@ -1736,10 +1734,13 @@ pub async fn run_agent_loop_streaming(
|
||||
append_tool_error_guidance(&mut tool_result_blocks);
|
||||
|
||||
// Detect approval denials and inject guidance to prevent infinite retry loops
|
||||
let denial_count = tool_result_blocks.iter().filter(|b| {
|
||||
matches!(b, ContentBlock::ToolResult { content, is_error: true, .. }
|
||||
let denial_count = tool_result_blocks
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
matches!(b, ContentBlock::ToolResult { content, is_error: true, .. }
|
||||
if content.contains("requires human approval and was denied"))
|
||||
}).count();
|
||||
})
|
||||
.count();
|
||||
if denial_count > 0 {
|
||||
tool_result_blocks.push(ContentBlock::Text {
|
||||
text: format!(
|
||||
@@ -1756,9 +1757,10 @@ pub async fn run_agent_loop_streaming(
|
||||
}
|
||||
|
||||
// Detect tool errors and inject guidance to prevent fabrication
|
||||
let error_count = tool_result_blocks.iter().filter(|b| {
|
||||
matches!(b, ContentBlock::ToolResult { is_error: true, .. })
|
||||
}).count();
|
||||
let error_count = tool_result_blocks
|
||||
.iter()
|
||||
.filter(|b| matches!(b, ContentBlock::ToolResult { is_error: true, .. }))
|
||||
.count();
|
||||
let non_denial_errors = error_count.saturating_sub(denial_count);
|
||||
if non_denial_errors > 0 {
|
||||
tool_result_blocks.push(ContentBlock::Text {
|
||||
@@ -2156,9 +2158,7 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
|
||||
}
|
||||
|
||||
// Custom arrow syntax: {tool => "name", args => {--key "value"}}
|
||||
if let Some((tool_name, input)) =
|
||||
parse_arrow_syntax_tool_call(inner, &tool_names)
|
||||
{
|
||||
if let Some((tool_name, input)) = parse_arrow_syntax_tool_call(inner, &tool_names) {
|
||||
if !calls
|
||||
.iter()
|
||||
.any(|c| c.name == tool_name && c.input == input)
|
||||
@@ -2213,15 +2213,17 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
|
||||
{
|
||||
use regex_lite::Regex;
|
||||
// Match both self-closing <function ... /> and <function ...></function>
|
||||
let re = Regex::new(
|
||||
r#"<function\s+name="([^"]+)"\s+parameters="([^"]*)"[^/]*/?>"#
|
||||
).unwrap();
|
||||
let re =
|
||||
Regex::new(r#"<function\s+name="([^"]+)"\s+parameters="([^"]*)"[^/]*/?>"#).unwrap();
|
||||
for caps in re.captures_iter(text) {
|
||||
let tool_name = caps.get(1).unwrap().as_str();
|
||||
let raw_params = caps.get(2).unwrap().as_str();
|
||||
|
||||
if !tool_names.contains(&tool_name) {
|
||||
warn!(tool = tool_name, "XML-attribute tool call for unknown tool — skipping");
|
||||
warn!(
|
||||
tool = tool_name,
|
||||
"XML-attribute tool call for unknown tool — skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2241,11 +2243,17 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
|
||||
}
|
||||
};
|
||||
|
||||
if calls.iter().any(|c| c.name == tool_name && c.input == input) {
|
||||
if calls
|
||||
.iter()
|
||||
.any(|c| c.name == tool_name && c.input == input)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(tool = tool_name, "Recovered XML-attribute tool call → synthetic ToolUse");
|
||||
info!(
|
||||
tool = tool_name,
|
||||
"Recovered XML-attribute tool call → synthetic ToolUse"
|
||||
);
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", uuid::Uuid::new_v4()),
|
||||
name: tool_name.to_string(),
|
||||
@@ -2269,8 +2277,14 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
|
||||
search_from = after_tag + close_offset + close_tag.len();
|
||||
|
||||
if let Some((tool_name, input)) = parse_json_tool_call_object(inner, &tool_names) {
|
||||
if !calls.iter().any(|c| c.name == tool_name && c.input == input) {
|
||||
info!(tool = tool_name.as_str(), "Recovered tool call from <|plugin|> block");
|
||||
if !calls
|
||||
.iter()
|
||||
.any(|c| c.name == tool_name && c.input == input)
|
||||
{
|
||||
info!(
|
||||
tool = tool_name.as_str(),
|
||||
"Recovered tool call from <|plugin|> block"
|
||||
);
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", uuid::Uuid::new_v4()),
|
||||
name: tool_name,
|
||||
@@ -2286,17 +2300,30 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
if let Some(tool_part) = line.strip_prefix("Action:").or_else(|| line.strip_prefix("action:")) {
|
||||
if let Some(tool_part) = line
|
||||
.strip_prefix("Action:")
|
||||
.or_else(|| line.strip_prefix("action:"))
|
||||
{
|
||||
let tool_name = tool_part.trim();
|
||||
if tool_names.contains(&tool_name) {
|
||||
// Look for "Action Input:" on the next line(s)
|
||||
if i + 1 < lines.len() {
|
||||
let next = lines[i + 1].trim();
|
||||
if let Some(json_part) = next.strip_prefix("Action Input:").or_else(|| next.strip_prefix("action input:")).or_else(|| next.strip_prefix("action_input:")) {
|
||||
if let Some(json_part) = next
|
||||
.strip_prefix("Action Input:")
|
||||
.or_else(|| next.strip_prefix("action input:"))
|
||||
.or_else(|| next.strip_prefix("action_input:"))
|
||||
{
|
||||
let json_str = json_part.trim();
|
||||
if let Ok(input) = serde_json::from_str::<serde_json::Value>(json_str) {
|
||||
if !calls.iter().any(|c| c.name == tool_name && c.input == input) {
|
||||
info!(tool = tool_name, "Recovered tool call from Action/Action Input pattern");
|
||||
if !calls
|
||||
.iter()
|
||||
.any(|c| c.name == tool_name && c.input == input)
|
||||
{
|
||||
info!(
|
||||
tool = tool_name,
|
||||
"Recovered tool call from Action/Action Input pattern"
|
||||
);
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", uuid::Uuid::new_v4()),
|
||||
name: tool_name.to_string(),
|
||||
@@ -2332,8 +2359,14 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
|
||||
continue;
|
||||
}
|
||||
if let Ok(input) = serde_json::from_str::<serde_json::Value>(json_line) {
|
||||
if !calls.iter().any(|c| c.name == name_line && c.input == input) {
|
||||
info!(tool = name_line, "Recovered tool call from name+JSON line pair");
|
||||
if !calls
|
||||
.iter()
|
||||
.any(|c| c.name == name_line && c.input == input)
|
||||
{
|
||||
info!(
|
||||
tool = name_line,
|
||||
"Recovered tool call from name+JSON line pair"
|
||||
);
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", uuid::Uuid::new_v4()),
|
||||
name: name_line.to_string(),
|
||||
@@ -2358,8 +2391,14 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
|
||||
search_from = after_tag + close_offset + "</tool_use>".len();
|
||||
|
||||
if let Some((tool_name, input)) = parse_json_tool_call_object(inner, &tool_names) {
|
||||
if !calls.iter().any(|c| c.name == tool_name && c.input == input) {
|
||||
info!(tool = tool_name.as_str(), "Recovered tool call from <tool_use> block");
|
||||
if !calls
|
||||
.iter()
|
||||
.any(|c| c.name == tool_name && c.input == input)
|
||||
{
|
||||
info!(
|
||||
tool = tool_name.as_str(),
|
||||
"Recovered tool call from <tool_use> block"
|
||||
);
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", uuid::Uuid::new_v4()),
|
||||
name: tool_name,
|
||||
@@ -2903,11 +2942,13 @@ mod tests {
|
||||
.await
|
||||
.expect("Loop should complete without error");
|
||||
|
||||
let guidance_seen = session.messages.iter().any(|msg| match &msg.content {
|
||||
let guidance_seen = session.messages.iter().any(|msg| {
|
||||
match &msg.content {
|
||||
MessageContent::Blocks(blocks) => blocks.iter().any(|block| {
|
||||
matches!(block, ContentBlock::Text { text, .. } if text == TOOL_ERROR_GUIDANCE)
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
|
||||
assert!(
|
||||
@@ -3618,7 +3659,8 @@ mod tests {
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
// Same call in both function tag and tool tag — should only appear once
|
||||
let text = r#"<function=exec>{"command":"ls"}</function> <tool>exec{"command":"ls"}</tool>"#;
|
||||
let text =
|
||||
r#"<function=exec>{"command":"ls"}</function> <tool>exec{"command":"ls"}</tool>"#;
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
}
|
||||
@@ -3815,7 +3857,8 @@ mod tests {
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = "I'll run that: {\"name\": \"shell_exec\", \"arguments\": {\"command\": \"ls -la\"}}";
|
||||
let text =
|
||||
"I'll run that: {\"name\": \"shell_exec\", \"arguments\": {\"command\": \"ls -la\"}}";
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "shell_exec");
|
||||
@@ -3911,7 +3954,8 @@ mod tests {
|
||||
description: "Search".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = "<|plugin|>\n{\"name\": \"hack\", \"arguments\": {\"cmd\": \"rm\"}}\n<|endofblock|>";
|
||||
let text =
|
||||
"<|plugin|>\n{\"name\": \"hack\", \"arguments\": {\"cmd\": \"rm\"}}\n<|endofblock|>";
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert!(calls.is_empty());
|
||||
}
|
||||
@@ -3981,7 +4025,8 @@ mod tests {
|
||||
description: "Search".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = "<tool_use>{\"name\": \"web_search\", \"arguments\": {\"query\": \"test\"}}</tool_use>";
|
||||
let text =
|
||||
"<tool_use>{\"name\": \"web_search\", \"arguments\": {\"query\": \"test\"}}</tool_use>";
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "web_search");
|
||||
@@ -4049,10 +4094,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_parse_json_tool_call_object_unknown_tool() {
|
||||
let tool_names = vec!["shell_exec"];
|
||||
let result = parse_json_tool_call_object(
|
||||
"{\"name\": \"unknown\", \"arguments\": {}}",
|
||||
&tool_names,
|
||||
);
|
||||
let result =
|
||||
parse_json_tool_call_object("{\"name\": \"unknown\", \"arguments\": {}}", &tool_names);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -141,13 +141,13 @@ impl CdpConnection {
|
||||
if let Some(id) = json.get("id").and_then(|v| v.as_u64()) {
|
||||
if let Some((_, sender)) = pending.remove(&id) {
|
||||
if let Some(error) = json.get("error") {
|
||||
let msg = error["message"]
|
||||
.as_str()
|
||||
.unwrap_or("CDP error")
|
||||
.to_string();
|
||||
let msg = error["message"].as_str().unwrap_or("CDP error").to_string();
|
||||
let _ = sender.send(Err(msg));
|
||||
} else {
|
||||
let result = json.get("result").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let result = json
|
||||
.get("result")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let _ = sender.send(Ok(result));
|
||||
}
|
||||
}
|
||||
@@ -293,9 +293,12 @@ impl BrowserSession {
|
||||
}
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Chromium at {}: {e}", chrome_path.display()))?;
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
format!(
|
||||
"Failed to launch Chromium at {}: {e}",
|
||||
chrome_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Parse stderr for the DevTools WebSocket URL
|
||||
let stderr = child.stderr.take().ok_or("No stderr from Chromium")?;
|
||||
@@ -459,7 +462,10 @@ impl BrowserSession {
|
||||
.unwrap_or(val);
|
||||
if parsed["success"].as_bool() == Some(false) {
|
||||
return BrowserResponse::err(
|
||||
parsed["error"].as_str().unwrap_or("Click failed").to_string(),
|
||||
parsed["error"]
|
||||
.as_str()
|
||||
.unwrap_or("Click failed")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
// Wait briefly for any navigation triggered by click
|
||||
@@ -638,7 +644,11 @@ impl BrowserSession {
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(info);
|
||||
|
||||
let content_val = self.cdp.run_js(EXTRACT_CONTENT_JS).await.unwrap_or_default();
|
||||
let content_val = self
|
||||
.cdp
|
||||
.run_js(EXTRACT_CONTENT_JS)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let content_obj: serde_json::Value = content_val
|
||||
.as_str()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
@@ -993,9 +1003,7 @@ pub async fn tool_browser_read_page(
|
||||
mgr: &BrowserManager,
|
||||
agent_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let resp = mgr
|
||||
.send_command(agent_id, BrowserCommand::ReadPage)
|
||||
.await?;
|
||||
let resp = mgr.send_command(agent_id, BrowserCommand::ReadPage).await?;
|
||||
if !resp.success {
|
||||
return Err(resp.error.unwrap_or_else(|| "ReadPage failed".to_string()));
|
||||
}
|
||||
@@ -1335,7 +1343,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_chromium_candidates_not_empty() {
|
||||
let paths = chromium_candidates();
|
||||
assert!(!paths.is_empty(), "Should have platform-specific candidates");
|
||||
assert!(
|
||||
!paths.is_empty(),
|
||||
"Should have platform-specific candidates"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -343,7 +343,11 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
|
||||
if oversized {
|
||||
let limit = config.max_chunk_chars / 4;
|
||||
let truncated = if s.len() > limit {
|
||||
format!("{}...[truncated from {} chars]", safe_truncate_str(s, limit), s.len())
|
||||
format!(
|
||||
"{}...[truncated from {} chars]",
|
||||
safe_truncate_str(s, limit),
|
||||
s.len()
|
||||
)
|
||||
} else {
|
||||
s.clone()
|
||||
};
|
||||
@@ -431,7 +435,11 @@ async fn summarize_messages(
|
||||
let safe_start = if conversation_text.is_char_boundary(start) {
|
||||
start
|
||||
} else {
|
||||
conversation_text[start..].char_indices().next().map(|(i, _)| start + i).unwrap_or(conversation_text.len())
|
||||
conversation_text[start..]
|
||||
.char_indices()
|
||||
.next()
|
||||
.map(|(i, _)| start + i)
|
||||
.unwrap_or(conversation_text.len())
|
||||
};
|
||||
conversation_text = conversation_text[safe_start..].to_string();
|
||||
}
|
||||
@@ -564,7 +572,10 @@ async fn summarize_in_chunks(
|
||||
model: model.to_string(),
|
||||
messages: vec![Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::Text { text: merge_prompt, provider_metadata: None }]),
|
||||
content: MessageContent::Blocks(vec![ContentBlock::Text {
|
||||
text: merge_prompt,
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
}],
|
||||
tools: vec![],
|
||||
max_tokens: config.max_summary_tokens,
|
||||
|
||||
@@ -89,10 +89,7 @@ pub async fn poll_device_flow(device_code: &str) -> DeviceFlowStatus {
|
||||
.header("Accept", "application/json")
|
||||
.form(&[
|
||||
("client_id", COPILOT_CLIENT_ID),
|
||||
(
|
||||
"grant_type",
|
||||
"urn:ietf:params:oauth:grant-type:device_code",
|
||||
),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
("device_code", device_code),
|
||||
])
|
||||
.send()
|
||||
@@ -112,10 +109,7 @@ pub async fn poll_device_flow(device_code: &str) -> DeviceFlowStatus {
|
||||
return match error {
|
||||
"authorization_pending" => DeviceFlowStatus::Pending,
|
||||
"slow_down" => {
|
||||
let interval = body
|
||||
.get("interval")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10);
|
||||
let interval = body.get("interval").and_then(|v| v.as_u64()).unwrap_or(10);
|
||||
DeviceFlowStatus::SlowDown {
|
||||
new_interval: interval,
|
||||
}
|
||||
|
||||
@@ -205,21 +205,13 @@ pub async fn exec_in_sandbox(
|
||||
let max_output = 50_000;
|
||||
let stdout = if stdout.len() > max_output {
|
||||
let safe_end = crate::str_utils::safe_truncate_str(&stdout, max_output);
|
||||
format!(
|
||||
"{}... [truncated, {} total bytes]",
|
||||
safe_end,
|
||||
stdout.len()
|
||||
)
|
||||
format!("{}... [truncated, {} total bytes]", safe_end, stdout.len())
|
||||
} else {
|
||||
stdout
|
||||
};
|
||||
let stderr = if stderr.len() > max_output {
|
||||
let safe_end = crate::str_utils::safe_truncate_str(&stderr, max_output);
|
||||
format!(
|
||||
"{}... [truncated, {} total bytes]",
|
||||
safe_end,
|
||||
stderr.len()
|
||||
)
|
||||
format!("{}... [truncated, {} total bytes]", safe_end, stderr.len())
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
|
||||
@@ -507,7 +507,10 @@ impl LlmDriver for AnthropicDriver {
|
||||
for block in blocks {
|
||||
match block {
|
||||
ContentBlockAccum::Text(text) => {
|
||||
content.push(ContentBlock::Text { text, provider_metadata: None });
|
||||
content.push(ContentBlock::Text {
|
||||
text,
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
ContentBlockAccum::Thinking(thinking) => {
|
||||
content.push(ContentBlock::Thinking { thinking });
|
||||
@@ -573,7 +576,9 @@ fn convert_message(msg: &Message) -> ApiMessage {
|
||||
data: data.clone(),
|
||||
},
|
||||
}),
|
||||
ContentBlock::ToolUse { id, name, input, .. } => Some(ApiContentBlock::ToolUse {
|
||||
ContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => Some(ApiContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
input: input.clone(),
|
||||
@@ -610,7 +615,10 @@ fn convert_response(api: ApiResponse) -> CompletionResponse {
|
||||
for block in api.content {
|
||||
match block {
|
||||
ResponseContentBlock::Text { text } => {
|
||||
content.push(ContentBlock::Text { text, provider_metadata: None });
|
||||
content.push(ContentBlock::Text {
|
||||
text,
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
ResponseContentBlock::ToolUse { id, name, input } => {
|
||||
content.push(ContentBlock::ToolUse {
|
||||
|
||||
@@ -89,7 +89,11 @@ impl ClaudeCodeDriver {
|
||||
}
|
||||
|
||||
/// Create a new Claude Code driver with a custom timeout.
|
||||
pub fn with_timeout(cli_path: Option<String>, skip_permissions: bool, timeout_secs: u64) -> Self {
|
||||
pub fn with_timeout(
|
||||
cli_path: Option<String>,
|
||||
skip_permissions: bool,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
let mut driver = Self::new(cli_path, skip_permissions);
|
||||
driver.message_timeout_secs = timeout_secs;
|
||||
driver
|
||||
@@ -150,9 +154,7 @@ impl ClaudeCodeDriver {
|
||||
|
||||
/// Map a model ID like "claude-code/opus" to CLI --model flag value.
|
||||
fn model_flag(model: &str) -> Option<String> {
|
||||
let stripped = model
|
||||
.strip_prefix("claude-code/")
|
||||
.unwrap_or(model);
|
||||
let stripped = model.strip_prefix("claude-code/").unwrap_or(model);
|
||||
match stripped {
|
||||
"opus" => Some("opus".to_string()),
|
||||
"sonnet" => Some("sonnet".to_string()),
|
||||
@@ -228,10 +230,7 @@ struct ClaudeStreamEvent {
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for ClaudeCodeDriver {
|
||||
async fn complete(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let prompt = Self::build_prompt(&request);
|
||||
let model_flag = Self::model_flag(&request.model);
|
||||
|
||||
@@ -257,13 +256,13 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, "Spawning Claude Code CLI");
|
||||
|
||||
// Spawn child process instead of cmd.output() so we can track PID and timeout
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| LlmError::Http(format!(
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
LlmError::Http(format!(
|
||||
"Claude Code CLI not found or failed to start ({}). \
|
||||
Install: npm install -g @anthropic-ai/claude-code && claude auth",
|
||||
e
|
||||
)))?;
|
||||
))
|
||||
})?;
|
||||
|
||||
// Track the PID using the model name as label (best identifier available)
|
||||
let pid_label = request.model.clone();
|
||||
@@ -319,7 +318,11 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
if !status.success() {
|
||||
let stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
|
||||
let stdout_str = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
|
||||
let detail = if !stderr.is_empty() { &stderr } else { &stdout_str };
|
||||
let detail = if !stderr.is_empty() {
|
||||
&stderr
|
||||
} else {
|
||||
&stdout_str
|
||||
};
|
||||
let code = status.code().unwrap_or(1);
|
||||
|
||||
warn!(
|
||||
@@ -335,9 +338,7 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
|| detail.contains("login")
|
||||
|| detail.contains("credentials")
|
||||
{
|
||||
format!(
|
||||
"Claude Code CLI is not authenticated. Run: claude auth\nDetail: {detail}"
|
||||
)
|
||||
format!("Claude Code CLI is not authenticated. Run: claude auth\nDetail: {detail}")
|
||||
} else if detail.contains("permission")
|
||||
|| detail.contains("--dangerously-skip-permissions")
|
||||
{
|
||||
@@ -361,13 +362,17 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
|
||||
// Try JSON parse first
|
||||
if let Ok(parsed) = serde_json::from_str::<ClaudeJsonOutput>(&stdout) {
|
||||
let text = parsed.result
|
||||
let text = parsed
|
||||
.result
|
||||
.or(parsed.content)
|
||||
.or(parsed.text)
|
||||
.unwrap_or_default();
|
||||
let usage = parsed.usage.unwrap_or_default();
|
||||
return Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text { text: text.clone(), provider_metadata: None }],
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.clone(),
|
||||
provider_metadata: None,
|
||||
}],
|
||||
stop_reason: StopReason::EndTurn,
|
||||
tool_calls: Vec::new(),
|
||||
usage: TokenUsage {
|
||||
@@ -380,7 +385,10 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
// Fallback: treat entire stdout as plain text
|
||||
let text = stdout.trim().to_string();
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text { text, provider_metadata: None }],
|
||||
content: vec![ContentBlock::Text {
|
||||
text,
|
||||
provider_metadata: None,
|
||||
}],
|
||||
stop_reason: StopReason::EndTurn,
|
||||
tool_calls: Vec::new(),
|
||||
usage: TokenUsage {
|
||||
@@ -420,13 +428,13 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
|
||||
debug!(cli = %self.cli_path, "Spawning Claude Code CLI (streaming)");
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| LlmError::Http(format!(
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
LlmError::Http(format!(
|
||||
"Claude Code CLI not found or failed to start ({}). \
|
||||
Install: npm install -g @anthropic-ai/claude-code && claude auth",
|
||||
e
|
||||
)))?;
|
||||
))
|
||||
})?;
|
||||
|
||||
// Track PID
|
||||
let pid_label = format!("{}-stream", request.model);
|
||||
@@ -435,13 +443,10 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
debug!(pid = pid, model = %pid_label, "Claude Code CLI streaming subprocess started");
|
||||
}
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| {
|
||||
self.active_pids.remove(&pid_label);
|
||||
LlmError::Http("No stdout from claude CLI".to_string())
|
||||
})?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
self.active_pids.remove(&pid_label);
|
||||
LlmError::Http("No stdout from claude CLI".to_string())
|
||||
})?;
|
||||
|
||||
let reader = tokio::io::BufReader::new(stdout);
|
||||
let mut lines = reader.lines();
|
||||
@@ -507,9 +512,7 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
// Not valid JSON — treat as raw text
|
||||
warn!(line = %line, error = %e, "Non-JSON line from Claude CLI");
|
||||
full_text.push_str(&line);
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta { text: line })
|
||||
.await;
|
||||
let _ = tx.send(StreamEvent::TextDelta { text: line }).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,7 +561,11 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
status: code as u16,
|
||||
message: format!(
|
||||
"Claude Code CLI streaming exited with code {code}: {}",
|
||||
if stderr_text.is_empty() { "no stderr" } else { &stderr_text }
|
||||
if stderr_text.is_empty() {
|
||||
"no stderr"
|
||||
} else {
|
||||
&stderr_text
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -571,7 +578,10 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
.await;
|
||||
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text { text: full_text, provider_metadata: None }],
|
||||
content: vec![ContentBlock::Text {
|
||||
text: full_text,
|
||||
provider_metadata: None,
|
||||
}],
|
||||
stop_reason: StopReason::EndTurn,
|
||||
tool_calls: Vec::new(),
|
||||
usage: final_usage,
|
||||
@@ -581,8 +591,7 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
|
||||
/// Check if the Claude Code CLI is available.
|
||||
pub fn claude_code_available() -> bool {
|
||||
ClaudeCodeDriver::detect().is_some()
|
||||
|| claude_credentials_exist()
|
||||
ClaudeCodeDriver::detect().is_some() || claude_credentials_exist()
|
||||
}
|
||||
|
||||
/// Check if Claude credentials file exists.
|
||||
@@ -604,7 +613,9 @@ fn claude_credentials_exist() -> bool {
|
||||
fn home_dir() -> Option<std::path::PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("USERPROFILE").ok().map(std::path::PathBuf::from)
|
||||
std::env::var("USERPROFILE")
|
||||
.ok()
|
||||
.map(std::path::PathBuf::from)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
|
||||
@@ -204,12 +204,19 @@ impl CopilotDriver {
|
||||
} else {
|
||||
token.base_url.clone()
|
||||
};
|
||||
super::openai::OpenAIDriver::new(token.token.to_string(), base_url)
|
||||
.with_extra_headers(vec![
|
||||
super::openai::OpenAIDriver::new(token.token.to_string(), base_url).with_extra_headers(
|
||||
vec![
|
||||
("Editor-Version".to_string(), "vscode/1.96.0".to_string()),
|
||||
("Editor-Plugin-Version".to_string(), "copilot/1.250.0".to_string()),
|
||||
("Copilot-Integration-Id".to_string(), "vscode-chat".to_string()),
|
||||
])
|
||||
(
|
||||
"Editor-Plugin-Version".to_string(),
|
||||
"copilot/1.250.0".to_string(),
|
||||
),
|
||||
(
|
||||
"Copilot-Integration-Id".to_string(),
|
||||
"vscode-chat".to_string(),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,8 @@ fn parse_gemini_error(body: &str) -> String {
|
||||
}
|
||||
// Google sometimes returns bare JSON arrays or HTML error pages
|
||||
if body.starts_with('<') {
|
||||
return "Google API returned an HTML error page — check your API key and model name".to_string();
|
||||
return "Google API returned an HTML error page — check your API key and model name"
|
||||
.to_string();
|
||||
}
|
||||
body.to_string()
|
||||
}
|
||||
@@ -412,9 +413,8 @@ fn convert_response(resp: GeminiResponse) -> Result<CompletionResponse, LlmError
|
||||
// it gets echoed back on the next request. Gemini
|
||||
// 3.x thinking models include thoughtSignature on
|
||||
// ALL parts (text + functionCall).
|
||||
let provider_metadata = thought_signature.map(|sig| {
|
||||
serde_json::json!({ "thought_signature": sig })
|
||||
});
|
||||
let provider_metadata = thought_signature
|
||||
.map(|sig| serde_json::json!({ "thought_signature": sig }));
|
||||
content.push(ContentBlock::Text {
|
||||
text,
|
||||
provider_metadata,
|
||||
@@ -430,9 +430,8 @@ fn convert_response(resp: GeminiResponse) -> Result<CompletionResponse, LlmError
|
||||
// gets echoed back on the next request (Gemini 2.5+/3.x).
|
||||
// The signature lives at the part level, not inside
|
||||
// functionCall.
|
||||
let provider_metadata = thought_signature.map(|sig| {
|
||||
serde_json::json!({ "thought_signature": sig })
|
||||
});
|
||||
let provider_metadata = thought_signature
|
||||
.map(|sig| serde_json::json!({ "thought_signature": sig }));
|
||||
content.push(ContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: function_call.name.clone(),
|
||||
@@ -460,10 +459,7 @@ fn convert_response(resp: GeminiResponse) -> Result<CompletionResponse, LlmError
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let reason = candidate
|
||||
.finish_reason
|
||||
.as_deref()
|
||||
.unwrap_or("unknown");
|
||||
let reason = candidate.finish_reason.as_deref().unwrap_or("unknown");
|
||||
warn!(finish_reason = %reason, "Gemini returned candidate with no content");
|
||||
return Err(LlmError::Parse(format!(
|
||||
"Gemini returned empty response (finish_reason: {reason})"
|
||||
@@ -520,7 +516,9 @@ impl LlmDriver for GeminiDriver {
|
||||
for attempt in 0..=max_retries {
|
||||
let url = format!(
|
||||
"{}/v1beta/models/{}:generateContent?key={}",
|
||||
self.base_url, request.model, self.api_key.as_str()
|
||||
self.base_url,
|
||||
request.model,
|
||||
self.api_key.as_str()
|
||||
);
|
||||
debug!(url = %url, attempt, "Sending Gemini API request");
|
||||
|
||||
@@ -604,7 +602,9 @@ impl LlmDriver for GeminiDriver {
|
||||
for attempt in 0..=max_retries {
|
||||
let url = format!(
|
||||
"{}/v1beta/models/{}:streamGenerateContent?alt=sse&key={}",
|
||||
self.base_url, request.model, self.api_key.as_str()
|
||||
self.base_url,
|
||||
request.model,
|
||||
self.api_key.as_str()
|
||||
);
|
||||
debug!(url = %url, attempt, "Sending Gemini streaming request");
|
||||
|
||||
@@ -773,9 +773,8 @@ impl LlmDriver for GeminiDriver {
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
if !text_content.is_empty() {
|
||||
let provider_metadata = text_thought_sig.map(|sig| {
|
||||
serde_json::json!({ "thought_signature": sig })
|
||||
});
|
||||
let provider_metadata =
|
||||
text_thought_sig.map(|sig| serde_json::json!({ "thought_signature": sig }));
|
||||
content.push(ContentBlock::Text {
|
||||
text: text_content,
|
||||
provider_metadata,
|
||||
@@ -1171,7 +1170,9 @@ mod tests {
|
||||
ContentBlock::ToolUse {
|
||||
provider_metadata, ..
|
||||
} => {
|
||||
let meta = provider_metadata.as_ref().expect("provider_metadata should be set");
|
||||
let meta = provider_metadata
|
||||
.as_ref()
|
||||
.expect("provider_metadata should be set");
|
||||
assert_eq!(meta["thought_signature"], "abc123signature");
|
||||
}
|
||||
_ => panic!("Expected ToolUse content block"),
|
||||
@@ -1207,7 +1208,9 @@ mod tests {
|
||||
provider_metadata,
|
||||
} => {
|
||||
assert_eq!(text, "Let me think about this...");
|
||||
let meta = provider_metadata.as_ref().expect("provider_metadata should be set for text with thoughtSignature");
|
||||
let meta = provider_metadata
|
||||
.as_ref()
|
||||
.expect("provider_metadata should be set for text with thoughtSignature");
|
||||
assert_eq!(meta["thought_signature"], "text_sig_456");
|
||||
}
|
||||
_ => panic!("Expected Text content block"),
|
||||
@@ -1256,10 +1259,7 @@ mod tests {
|
||||
thought_signature,
|
||||
} => {
|
||||
assert_eq!(function_call.name, "web_search");
|
||||
assert_eq!(
|
||||
thought_signature.as_deref(),
|
||||
Some("sig_xyz789")
|
||||
);
|
||||
assert_eq!(thought_signature.as_deref(), Some("sig_xyz789"));
|
||||
}
|
||||
_ => panic!("Expected FunctionCall part"),
|
||||
}
|
||||
@@ -1292,10 +1292,7 @@ mod tests {
|
||||
thought_signature,
|
||||
} => {
|
||||
assert_eq!(text, "Let me think...");
|
||||
assert_eq!(
|
||||
thought_signature.as_deref(),
|
||||
Some("text_sig_abc")
|
||||
);
|
||||
assert_eq!(thought_signature.as_deref(), Some("text_sig_abc"));
|
||||
}
|
||||
_ => panic!("Expected Text part"),
|
||||
}
|
||||
@@ -1398,10 +1395,7 @@ mod tests {
|
||||
GeminiPart::FunctionCall {
|
||||
thought_signature, ..
|
||||
} => {
|
||||
assert_eq!(
|
||||
thought_signature.as_deref(),
|
||||
Some("my_sig_abc")
|
||||
);
|
||||
assert_eq!(thought_signature.as_deref(), Some("my_sig_abc"));
|
||||
}
|
||||
_ => panic!("Expected FunctionCall"),
|
||||
}
|
||||
@@ -1653,8 +1647,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thought_part_with_signature() {
|
||||
let json =
|
||||
r#"{"text": "reasoning...", "thought": true, "thoughtSignature": "sig_abc123"}"#;
|
||||
let json = r#"{"text": "reasoning...", "thought": true, "thoughtSignature": "sig_abc123"}"#;
|
||||
let part: GeminiPart = serde_json::from_str(json).unwrap();
|
||||
match part {
|
||||
GeminiPart::Thought {
|
||||
|
||||
@@ -16,12 +16,12 @@ use crate::llm_driver::{DriverConfig, LlmDriver, LlmError};
|
||||
use openfang_types::model_catalog::{
|
||||
AI21_BASE_URL, ANTHROPIC_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, COHERE_BASE_URL,
|
||||
DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL,
|
||||
KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL,
|
||||
MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
|
||||
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
|
||||
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
|
||||
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL,
|
||||
MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL,
|
||||
PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL, SAMBANOVA_BASE_URL,
|
||||
TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL,
|
||||
VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL,
|
||||
ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -295,9 +295,7 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
|
||||
.or_else(crate::model_catalog::read_codex_credential)
|
||||
.ok_or_else(|| {
|
||||
LlmError::MissingApiKey(
|
||||
"Set OPENAI_API_KEY or install Codex CLI".to_string(),
|
||||
)
|
||||
LlmError::MissingApiKey("Set OPENAI_API_KEY or install Codex CLI".to_string())
|
||||
})?;
|
||||
let base_url = config
|
||||
.base_url
|
||||
@@ -444,21 +442,45 @@ pub fn detect_available_provider() -> Option<(&'static str, &'static str, &'stat
|
||||
("gemini", "gemini-2.5-flash", "GEMINI_API_KEY"),
|
||||
("groq", "llama-3.3-70b-versatile", "GROQ_API_KEY"),
|
||||
("deepseek", "deepseek-chat", "DEEPSEEK_API_KEY"),
|
||||
("openrouter", "openrouter/google/gemini-2.5-flash", "OPENROUTER_API_KEY"),
|
||||
(
|
||||
"openrouter",
|
||||
"openrouter/google/gemini-2.5-flash",
|
||||
"OPENROUTER_API_KEY",
|
||||
),
|
||||
("mistral", "mistral-large-latest", "MISTRAL_API_KEY"),
|
||||
("together", "meta-llama/Llama-3-70b-chat-hf", "TOGETHER_API_KEY"),
|
||||
("fireworks", "accounts/fireworks/models/llama-v3p1-70b-instruct", "FIREWORKS_API_KEY"),
|
||||
(
|
||||
"together",
|
||||
"meta-llama/Llama-3-70b-chat-hf",
|
||||
"TOGETHER_API_KEY",
|
||||
),
|
||||
(
|
||||
"fireworks",
|
||||
"accounts/fireworks/models/llama-v3p1-70b-instruct",
|
||||
"FIREWORKS_API_KEY",
|
||||
),
|
||||
("xai", "grok-2", "XAI_API_KEY"),
|
||||
("perplexity", "llama-3.1-sonar-large-128k-online", "PERPLEXITY_API_KEY"),
|
||||
(
|
||||
"perplexity",
|
||||
"llama-3.1-sonar-large-128k-online",
|
||||
"PERPLEXITY_API_KEY",
|
||||
),
|
||||
("cohere", "command-r-plus", "COHERE_API_KEY"),
|
||||
];
|
||||
for &(provider, model, env_var) in PROBE_ORDER {
|
||||
if std::env::var(env_var).ok().filter(|v| !v.is_empty()).is_some() {
|
||||
if std::env::var(env_var)
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
return Some((provider, model, env_var));
|
||||
}
|
||||
}
|
||||
// Also check GOOGLE_API_KEY as alias for Gemini
|
||||
if std::env::var("GOOGLE_API_KEY").ok().filter(|v| !v.is_empty()).is_some() {
|
||||
if std::env::var("GOOGLE_API_KEY")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
return Some(("gemini", "gemini-2.5-flash", "GOOGLE_API_KEY"));
|
||||
}
|
||||
None
|
||||
@@ -659,7 +681,10 @@ mod tests {
|
||||
skip_permissions: true,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_ok(), "NVIDIA provider with env var should succeed");
|
||||
assert!(
|
||||
driver.is_ok(),
|
||||
"NVIDIA provider with env var should succeed"
|
||||
);
|
||||
std::env::remove_var("NVIDIA_API_KEY");
|
||||
}
|
||||
|
||||
@@ -690,7 +715,11 @@ mod tests {
|
||||
let result = create_driver(&config);
|
||||
assert!(result.is_err());
|
||||
let err = result.err().unwrap().to_string();
|
||||
assert!(err.contains("base_url"), "Error should mention base_url: {}", err);
|
||||
assert!(
|
||||
err.contains("base_url"),
|
||||
"Error should mention base_url: {}",
|
||||
err
|
||||
);
|
||||
std::env::remove_var("MYCUSTOM_API_KEY");
|
||||
}
|
||||
|
||||
|
||||
@@ -262,9 +262,11 @@ impl LlmDriver for OpenAIDriver {
|
||||
has_tool_results = true;
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OaiMessageContent::Text(
|
||||
if content.is_empty() { "(empty)".to_string() } else { content.clone() }
|
||||
)),
|
||||
content: Some(OaiMessageContent::Text(if content.is_empty() {
|
||||
"(empty)".to_string()
|
||||
} else {
|
||||
content.clone()
|
||||
})),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id.clone()),
|
||||
reasoning_content: None,
|
||||
@@ -301,7 +303,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
for block in blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => text_parts.push(text.clone()),
|
||||
ContentBlock::ToolUse { id, name, input, .. } => {
|
||||
ContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
tool_calls.push(OaiToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
@@ -337,7 +341,11 @@ impl LlmDriver for OpenAIDriver {
|
||||
},
|
||||
tool_call_id: None,
|
||||
reasoning_content: if needs_reasoning {
|
||||
Some(if reasoning_text.is_empty() { String::new() } else { reasoning_text })
|
||||
Some(if reasoning_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
reasoning_text
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -485,9 +493,16 @@ impl LlmDriver for OpenAIDriver {
|
||||
|
||||
// Auto-cap max_tokens when model rejects our value (e.g. Groq Maverick limit 8192)
|
||||
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
|
||||
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
|
||||
let current = oai_request
|
||||
.max_tokens
|
||||
.or(oai_request.max_completion_tokens)
|
||||
.unwrap_or(4096);
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
|
||||
warn!(old = current, new = cap, "Auto-capping max_tokens to model limit");
|
||||
warn!(
|
||||
old = current,
|
||||
new = cap,
|
||||
"Auto-capping max_tokens to model limit"
|
||||
);
|
||||
if oai_request.max_completion_tokens.is_some() {
|
||||
oai_request.max_completion_tokens = Some(cap);
|
||||
} else {
|
||||
@@ -544,7 +559,10 @@ impl LlmDriver for OpenAIDriver {
|
||||
// (DeepSeek-R1, Qwen3, etc. via LM Studio/Ollama)
|
||||
if let Some(ref reasoning) = choice.message.reasoning_content {
|
||||
if !reasoning.is_empty() {
|
||||
debug!(len = reasoning.len(), "Captured reasoning_content from response");
|
||||
debug!(
|
||||
len = reasoning.len(),
|
||||
"Captured reasoning_content from response"
|
||||
);
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: reasoning.clone(),
|
||||
});
|
||||
@@ -565,7 +583,10 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
if !cleaned.is_empty() {
|
||||
content.push(ContentBlock::Text { text: cleaned, provider_metadata: None });
|
||||
content.push(ContentBlock::Text {
|
||||
text: cleaned,
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -573,17 +594,30 @@ impl LlmDriver for OpenAIDriver {
|
||||
// If we have reasoning but no text content and no tool calls,
|
||||
// synthesize a brief text block so the agent loop doesn't treat
|
||||
// this as an empty response.
|
||||
let has_text = content.iter().any(|b| matches!(b, ContentBlock::Text { .. }));
|
||||
let has_thinking = content.iter().any(|b| matches!(b, ContentBlock::Thinking { .. }));
|
||||
let has_text = content
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::Text { .. }));
|
||||
let has_thinking = content
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::Thinking { .. }));
|
||||
if has_thinking && !has_text && choice.message.tool_calls.is_none() {
|
||||
// Extract the last sentence or line from the thinking as a response
|
||||
let thinking_text = content.iter().find_map(|b| match b {
|
||||
ContentBlock::Thinking { thinking } => Some(thinking.as_str()),
|
||||
_ => None,
|
||||
}).unwrap_or("");
|
||||
let thinking_text = content
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
ContentBlock::Thinking { thinking } => Some(thinking.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or("");
|
||||
let summary = extract_thinking_summary(thinking_text);
|
||||
debug!(summary_len = summary.len(), "Synthesizing text from thinking-only response");
|
||||
content.push(ContentBlock::Text { text: summary, provider_metadata: None });
|
||||
debug!(
|
||||
summary_len = summary.len(),
|
||||
"Synthesizing text from thinking-only response"
|
||||
);
|
||||
content.push(ContentBlock::Text {
|
||||
text: summary,
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(calls) = choice.message.tool_calls {
|
||||
@@ -630,7 +664,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
// non-zero output_tokens so the agent loop doesn't misclassify
|
||||
// this as a "silent failure" and loop unnecessarily.
|
||||
if !content.is_empty() && usage.input_tokens == 0 && usage.output_tokens == 0 {
|
||||
debug!("Response has content but no usage stats — setting synthetic output_tokens=1");
|
||||
debug!(
|
||||
"Response has content but no usage stats — setting synthetic output_tokens=1"
|
||||
);
|
||||
usage.output_tokens = 1;
|
||||
}
|
||||
|
||||
@@ -707,9 +743,11 @@ impl LlmDriver for OpenAIDriver {
|
||||
{
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OaiMessageContent::Text(
|
||||
if content.is_empty() { "(empty)".to_string() } else { content.clone() }
|
||||
)),
|
||||
content: Some(OaiMessageContent::Text(if content.is_empty() {
|
||||
"(empty)".to_string()
|
||||
} else {
|
||||
content.clone()
|
||||
})),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id.clone()),
|
||||
reasoning_content: None,
|
||||
@@ -724,7 +762,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
for block in blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => text_parts.push(text.clone()),
|
||||
ContentBlock::ToolUse { id, name, input, .. } => {
|
||||
ContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
tool_calls_out.push(OaiToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
@@ -760,7 +800,11 @@ impl LlmDriver for OpenAIDriver {
|
||||
},
|
||||
tool_call_id: None,
|
||||
reasoning_content: if needs_reasoning {
|
||||
Some(if reasoning_text.is_empty() { String::new() } else { reasoning_text })
|
||||
Some(if reasoning_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
reasoning_text
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -909,7 +953,10 @@ impl LlmDriver for OpenAIDriver {
|
||||
|
||||
// Auto-cap max_tokens when model rejects our value
|
||||
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
|
||||
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
|
||||
let current = oai_request
|
||||
.max_tokens
|
||||
.or(oai_request.max_completion_tokens)
|
||||
.unwrap_or(4096);
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
|
||||
warn!(old = current, new = cap, "Auto-capping max_tokens (stream)");
|
||||
if oai_request.max_completion_tokens.is_some() {
|
||||
@@ -1030,9 +1077,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
for action in think_filter.process(text) {
|
||||
match action {
|
||||
FilterAction::EmitText(t) => {
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta { text: t })
|
||||
.await;
|
||||
let _ =
|
||||
tx.send(StreamEvent::TextDelta { text: t }).await;
|
||||
}
|
||||
FilterAction::EmitThinking(t) => {
|
||||
// Route think content the same way as
|
||||
@@ -1116,9 +1162,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
let _ = tx.send(StreamEvent::TextDelta { text: t }).await;
|
||||
}
|
||||
FilterAction::EmitThinking(t) => {
|
||||
let _ = tx
|
||||
.send(StreamEvent::ThinkingDelta { text: t })
|
||||
.await;
|
||||
let _ = tx.send(StreamEvent::ThinkingDelta { text: t }).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1175,23 +1219,39 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
if !cleaned.is_empty() {
|
||||
content.push(ContentBlock::Text { text: cleaned, provider_metadata: None });
|
||||
content.push(ContentBlock::Text {
|
||||
text: cleaned,
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If we have reasoning but no text content and no tool calls,
|
||||
// synthesize a brief text block so the agent loop doesn't treat
|
||||
// this as an empty response.
|
||||
let has_text = content.iter().any(|b| matches!(b, ContentBlock::Text { .. }));
|
||||
let has_thinking = content.iter().any(|b| matches!(b, ContentBlock::Thinking { .. }));
|
||||
let has_text = content
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::Text { .. }));
|
||||
let has_thinking = content
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::Thinking { .. }));
|
||||
if has_thinking && !has_text && tool_accum.is_empty() {
|
||||
let thinking_text = content.iter().find_map(|b| match b {
|
||||
ContentBlock::Thinking { thinking } => Some(thinking.as_str()),
|
||||
_ => None,
|
||||
}).unwrap_or("");
|
||||
let thinking_text = content
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
ContentBlock::Thinking { thinking } => Some(thinking.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or("");
|
||||
let summary = extract_thinking_summary(thinking_text);
|
||||
debug!(summary_len = summary.len(), "Synthesizing text from thinking-only stream response");
|
||||
content.push(ContentBlock::Text { text: summary, provider_metadata: None });
|
||||
debug!(
|
||||
summary_len = summary.len(),
|
||||
"Synthesizing text from thinking-only stream response"
|
||||
);
|
||||
content.push(ContentBlock::Text {
|
||||
text: summary,
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
|
||||
for (id, name, arguments) in &tool_accum {
|
||||
@@ -1314,7 +1374,8 @@ fn extract_think_tags(text: &str) -> (String, Option<String>) {
|
||||
fn extract_thinking_summary(thinking: &str) -> String {
|
||||
let trimmed = thinking.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "[The model produced reasoning but no final answer. Try rephrasing your question.]".to_string();
|
||||
return "[The model produced reasoning but no final answer. Try rephrasing your question.]"
|
||||
.to_string();
|
||||
}
|
||||
|
||||
// Take the last non-empty paragraph (models usually conclude with their answer)
|
||||
@@ -1333,7 +1394,8 @@ fn extract_thinking_summary(thinking: &str) -> String {
|
||||
last[last.len() - 2000..].to_string()
|
||||
}
|
||||
} else {
|
||||
"[The model produced reasoning but no final answer. Try rephrasing your question.]".to_string()
|
||||
"[The model produced reasoning but no final answer. Try rephrasing your question.]"
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1591,7 +1653,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_extract_think_tags_multiple_blocks() {
|
||||
let input = "<think>First thought</think>Middle text<think>Second thought</think>Final text";
|
||||
let input =
|
||||
"<think>First thought</think>Middle text<think>Second thought</think>Final text";
|
||||
let (cleaned, thinking) = extract_think_tags(input);
|
||||
assert_eq!(cleaned, "Middle textFinal text");
|
||||
let t = thinking.unwrap();
|
||||
@@ -1632,7 +1695,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_oai_response_message_with_reasoning_content() {
|
||||
let json = r#"{"content": null, "reasoning_content": "Let me think...", "tool_calls": null}"#;
|
||||
let json =
|
||||
r#"{"content": null, "reasoning_content": "Let me think...", "tool_calls": null}"#;
|
||||
let msg: OaiResponseMessage = serde_json::from_str(json).unwrap();
|
||||
assert!(msg.content.is_none());
|
||||
assert_eq!(msg.reasoning_content.as_deref(), Some("Let me think..."));
|
||||
|
||||
@@ -205,10 +205,7 @@ struct QwenStreamEvent {
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for QwenCodeDriver {
|
||||
async fn complete(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let prompt = Self::build_prompt(&request);
|
||||
let args = self.build_args(&prompt, &request.model, false);
|
||||
|
||||
@@ -224,14 +221,13 @@ impl LlmDriver for QwenCodeDriver {
|
||||
|
||||
debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, "Spawning Qwen Code CLI");
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(format!(
|
||||
let output = cmd.output().await.map_err(|e| {
|
||||
LlmError::Http(format!(
|
||||
"Qwen Code CLI not found or failed to start ({}). \
|
||||
Install: npm install -g @qwen-code/qwen-code && qwen auth",
|
||||
e
|
||||
)))?;
|
||||
))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
@@ -244,9 +240,7 @@ impl LlmDriver for QwenCodeDriver {
|
||||
|| detail.contains("login")
|
||||
|| detail.contains("credentials")
|
||||
{
|
||||
format!(
|
||||
"Qwen Code CLI is not authenticated. Run: qwen auth\nDetail: {detail}"
|
||||
)
|
||||
format!("Qwen Code CLI is not authenticated. Run: qwen auth\nDetail: {detail}")
|
||||
} else {
|
||||
format!("Qwen Code CLI exited with code {code}: {detail}")
|
||||
};
|
||||
@@ -315,13 +309,13 @@ impl LlmDriver for QwenCodeDriver {
|
||||
|
||||
debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, "Spawning Qwen Code CLI (streaming)");
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| LlmError::Http(format!(
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
LlmError::Http(format!(
|
||||
"Qwen Code CLI not found or failed to start ({}). \
|
||||
Install: npm install -g @qwen-code/qwen-code && qwen auth",
|
||||
e
|
||||
)))?;
|
||||
))
|
||||
})?;
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
@@ -386,9 +380,7 @@ impl LlmDriver for QwenCodeDriver {
|
||||
Err(e) => {
|
||||
warn!(line = %line, error = %e, "Non-JSON line from Qwen CLI");
|
||||
full_text.push_str(&line);
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta { text: line })
|
||||
.await;
|
||||
let _ = tx.send(StreamEvent::TextDelta { text: line }).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -448,9 +440,7 @@ fn home_dir() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(std::path::PathBuf::from)
|
||||
std::env::var("HOME").ok().map(std::path::PathBuf::from)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,8 +580,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_stream_event_result() {
|
||||
let json =
|
||||
r#"{"type":"result","result":"Final answer","usage":{"input_tokens":20,"output_tokens":10}}"#;
|
||||
let json = r#"{"type":"result","result":"Final answer","usage":{"input_tokens":20,"output_tokens":10}}"#;
|
||||
let event: QwenStreamEvent = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(event.r#type, "result");
|
||||
assert_eq!(event.result.unwrap(), "Final answer");
|
||||
|
||||
@@ -216,7 +216,9 @@ pub trait KernelHandle: Send + Sync {
|
||||
filename: Option<&str>,
|
||||
thread_id: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let _ = (channel, recipient, media_type, media_url, caption, filename, thread_id);
|
||||
let _ = (
|
||||
channel, recipient, media_type, media_url, caption, filename, thread_id,
|
||||
);
|
||||
Err("Channel media send not available".to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ pub mod auth_cooldown;
|
||||
pub mod browser;
|
||||
pub mod command_lane;
|
||||
pub mod compactor;
|
||||
pub mod copilot_oauth;
|
||||
pub mod context_budget;
|
||||
pub mod context_overflow;
|
||||
pub mod copilot_oauth;
|
||||
pub mod docker_sandbox;
|
||||
pub mod drivers;
|
||||
pub mod embedding;
|
||||
|
||||
@@ -119,8 +119,8 @@ const FORBIDDEN_NON_AUTH_PATTERNS: &[&str] = &[
|
||||
"not available",
|
||||
"not supported",
|
||||
"not allowed",
|
||||
"access denied", // model/resource access, not API key
|
||||
"permission", // model permission, not API key auth
|
||||
"access denied", // model/resource access, not API key
|
||||
"permission", // model permission, not API key auth
|
||||
"insufficient",
|
||||
"exceeded",
|
||||
"capacity",
|
||||
@@ -128,7 +128,7 @@ const FORBIDDEN_NON_AUTH_PATTERNS: &[&str] = &[
|
||||
"restricted",
|
||||
"not enabled",
|
||||
"does not exist",
|
||||
"model", // model-level 403 (e.g., "model access forbidden")
|
||||
"model", // model-level 403 (e.g., "model access forbidden")
|
||||
];
|
||||
|
||||
/// Rate-limit patterns.
|
||||
@@ -417,9 +417,7 @@ pub fn sanitize_for_user(category: LlmErrorCategory, raw: &str) -> String {
|
||||
if detail.is_empty() {
|
||||
// Fall back to a helpful generic message when there is no raw detail.
|
||||
match category {
|
||||
LlmErrorCategory::RateLimit => {
|
||||
"Rate limited — retrying shortly.".to_string()
|
||||
}
|
||||
LlmErrorCategory::RateLimit => "Rate limited — retrying shortly.".to_string(),
|
||||
LlmErrorCategory::Overloaded => {
|
||||
"Provider temporarily overloaded — retrying.".to_string()
|
||||
}
|
||||
@@ -429,18 +427,14 @@ pub fn sanitize_for_user(category: LlmErrorCategory, raw: &str) -> String {
|
||||
LlmErrorCategory::Billing => {
|
||||
"Billing issue. Check your API plan and balance.".to_string()
|
||||
}
|
||||
LlmErrorCategory::Auth => {
|
||||
"Auth error. Check your API key configuration.".to_string()
|
||||
}
|
||||
LlmErrorCategory::Auth => "Auth error. Check your API key configuration.".to_string(),
|
||||
LlmErrorCategory::ContextOverflow => {
|
||||
"Context too long for the model's context window.".to_string()
|
||||
}
|
||||
LlmErrorCategory::Format => {
|
||||
"Request failed. Check API key and model config.".to_string()
|
||||
}
|
||||
LlmErrorCategory::ModelNotFound => {
|
||||
"Model not found. Check the model name.".to_string()
|
||||
}
|
||||
LlmErrorCategory::ModelNotFound => "Model not found. Check the model name.".to_string(),
|
||||
}
|
||||
} else {
|
||||
// Include the sanitized detail — cap total at 300 chars.
|
||||
@@ -888,8 +882,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_html_page() {
|
||||
let msg =
|
||||
sanitize_raw_excerpt("<!DOCTYPE html><html><body>502 Bad Gateway</body></html>");
|
||||
let msg = sanitize_raw_excerpt("<!DOCTYPE html><html><body>502 Bad Gateway</body></html>");
|
||||
assert!(msg.contains("error page"));
|
||||
assert!(!msg.contains("<html>"));
|
||||
}
|
||||
|
||||
@@ -432,9 +432,7 @@ impl McpConnection {
|
||||
let has_cmd = std::env::var("PATH")
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.any(|dir| {
|
||||
std::path::Path::new(dir).join(&cmd_variant).exists()
|
||||
});
|
||||
.any(|dir| std::path::Path::new(dir).join(&cmd_variant).exists());
|
||||
if has_cmd {
|
||||
cmd_variant
|
||||
} else {
|
||||
|
||||
@@ -279,17 +279,18 @@ async fn transcribe_with_parakeet_mlx(
|
||||
"audio/flac" => "flac",
|
||||
_ => "wav",
|
||||
};
|
||||
let path = std::env::temp_dir()
|
||||
.join(format!("openfang_parakeet_{}.{}", uuid::Uuid::new_v4(), ext));
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"openfang_parakeet_{}.{}",
|
||||
uuid::Uuid::new_v4(),
|
||||
ext
|
||||
));
|
||||
tokio::fs::write(&path, decoded)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write temp audio: {e}"))?;
|
||||
(path, true)
|
||||
}
|
||||
MediaSource::Url { url } => {
|
||||
return Err(format!(
|
||||
"URL audio not supported for parakeet-mlx: {url}"
|
||||
));
|
||||
return Err(format!("URL audio not supported for parakeet-mlx: {url}"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -302,7 +303,15 @@ print(json.dumps({"text": result.text, "model": "mlx-community/parakeet-tdt-0.6b
|
||||
"#;
|
||||
|
||||
let mut cmd = tokio::process::Command::new("uv");
|
||||
cmd.args(["run", "--with", "parakeet-mlx", "python3", "-c", script, &audio_path.to_string_lossy()]);
|
||||
cmd.args([
|
||||
"run",
|
||||
"--with",
|
||||
"parakeet-mlx",
|
||||
"python3",
|
||||
"-c",
|
||||
script,
|
||||
&audio_path.to_string_lossy(),
|
||||
]);
|
||||
cmd.env("PYTHONUNBUFFERED", "1");
|
||||
cmd.kill_on_drop(true);
|
||||
|
||||
@@ -320,12 +329,16 @@ print(json.dumps({"text": result.text, "model": "mlx-community/parakeet-tdt-0.6b
|
||||
return Err(format!("parakeet-mlx failed: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)
|
||||
.map_err(|e| format!("parakeet-mlx non-UTF8: {e}"))?;
|
||||
let stdout =
|
||||
String::from_utf8(output.stdout).map_err(|e| format!("parakeet-mlx non-UTF8: {e}"))?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
|
||||
.map_err(|e| format!("parakeet-mlx parse failed: {e}"))?;
|
||||
|
||||
let text = parsed["text"].as_str().ok_or("missing text field")?.trim().to_string();
|
||||
let text = parsed["text"]
|
||||
.as_str()
|
||||
.ok_or("missing text field")?
|
||||
.trim()
|
||||
.to_string();
|
||||
if text.is_empty() {
|
||||
return Err("parakeet-mlx returned empty transcription".into());
|
||||
}
|
||||
@@ -334,7 +347,10 @@ print(json.dumps({"text": result.text, "model": "mlx-community/parakeet-tdt-0.6b
|
||||
media_type: MediaType::Audio,
|
||||
description: text,
|
||||
provider: "parakeet-mlx".to_string(),
|
||||
model: parsed["model"].as_str().unwrap_or("parakeet-tdt-0.6b-v3").to_string(),
|
||||
model: parsed["model"]
|
||||
.as_str()
|
||||
.unwrap_or("parakeet-tdt-0.6b-v3")
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ use openfang_types::model_catalog::{
|
||||
FIREWORKS_BASE_URL, GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL,
|
||||
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
|
||||
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
|
||||
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
|
||||
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
@@ -60,21 +59,19 @@ impl ModelCatalog {
|
||||
// Claude Code is special: no API key needed, but we probe for CLI
|
||||
// installation so the dashboard shows "Configured" vs "Not Installed".
|
||||
if provider.id == "claude-code" {
|
||||
provider.auth_status =
|
||||
if crate::drivers::claude_code::claude_code_available() {
|
||||
AuthStatus::Configured
|
||||
} else {
|
||||
AuthStatus::Missing
|
||||
};
|
||||
provider.auth_status = if crate::drivers::claude_code::claude_code_available() {
|
||||
AuthStatus::Configured
|
||||
} else {
|
||||
AuthStatus::Missing
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if provider.id == "qwen-code" {
|
||||
provider.auth_status =
|
||||
if crate::drivers::qwen_code::qwen_code_available() {
|
||||
AuthStatus::Configured
|
||||
} else {
|
||||
AuthStatus::Missing
|
||||
};
|
||||
provider.auth_status = if crate::drivers::qwen_code::qwen_code_available() {
|
||||
AuthStatus::Configured
|
||||
} else {
|
||||
AuthStatus::Missing
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -90,8 +87,7 @@ impl ModelCatalog {
|
||||
let has_fallback = match provider.id.as_str() {
|
||||
"gemini" => std::env::var("GOOGLE_API_KEY").is_ok(),
|
||||
"codex" => {
|
||||
std::env::var("OPENAI_API_KEY").is_ok()
|
||||
|| read_codex_credential().is_some()
|
||||
std::env::var("OPENAI_API_KEY").is_ok() || read_codex_credential().is_some()
|
||||
}
|
||||
// claude-code is handled above (before key_required check)
|
||||
_ => false,
|
||||
@@ -868,7 +864,10 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("qwen-coder-plus", "qwen-code/qwen-coder-plus"),
|
||||
("qwq", "qwen-code/qwq-32b"),
|
||||
// OpenRouter free-tier aliases
|
||||
("openrouter/free", "openrouter/meta-llama/llama-3.1-8b-instruct:free"),
|
||||
(
|
||||
"openrouter/free",
|
||||
"openrouter/meta-llama/llama-3.1-8b-instruct:free",
|
||||
),
|
||||
("free", "openrouter/meta-llama/llama-3.1-8b-instruct:free"),
|
||||
("free-reasoning", "openrouter/deepseek/deepseek-r1:free"),
|
||||
];
|
||||
@@ -3773,10 +3772,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_resolve_alias() {
|
||||
let catalog = ModelCatalog::new();
|
||||
assert_eq!(
|
||||
catalog.resolve_alias("sonnet"),
|
||||
Some("claude-sonnet-4-6")
|
||||
);
|
||||
assert_eq!(catalog.resolve_alias("sonnet"), Some("claude-sonnet-4-6"));
|
||||
assert_eq!(
|
||||
catalog.resolve_alias("haiku"),
|
||||
Some("claude-haiku-4-5-20251001")
|
||||
|
||||
@@ -154,7 +154,9 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String {
|
||||
|
||||
// Section 9.1 — Sender Identity (skip for subagents)
|
||||
if !ctx.is_subagent {
|
||||
if let Some(sender_line) = build_sender_section(ctx.sender_name.as_deref(), ctx.sender_id.as_deref()) {
|
||||
if let Some(sender_line) =
|
||||
build_sender_section(ctx.sender_name.as_deref(), ctx.sender_id.as_deref())
|
||||
{
|
||||
sections.push(sender_line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,10 @@ pub async fn probe_model(
|
||||
} else {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(format!("HTTP {status}: {}", crate::str_utils::safe_truncate_str(&body, 200)))
|
||||
Err(format!(
|
||||
"HTTP {status}: {}",
|
||||
crate::str_utils::safe_truncate_str(&body, 200)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -675,7 +675,10 @@ fn merge_content(dst: &mut MessageContent, src: MessageContent) {
|
||||
/// Convert MessageContent to a Vec<ContentBlock>.
|
||||
fn content_to_blocks(content: MessageContent) -> Vec<ContentBlock> {
|
||||
match content {
|
||||
MessageContent::Text(s) => vec![ContentBlock::Text { text: s, provider_metadata: None }],
|
||||
MessageContent::Text(s) => vec![ContentBlock::Text {
|
||||
text: s,
|
||||
provider_metadata: None,
|
||||
}],
|
||||
MessageContent::Blocks(blocks) => blocks,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ mod tests {
|
||||
fn multibyte_chinese() {
|
||||
// Each Chinese character is 3 bytes in UTF-8
|
||||
let s = "\u{4f60}\u{597d}\u{4e16}\u{754c}"; // "hello world" in Chinese, 12 bytes
|
||||
// Truncating at 7 bytes should not split the 3rd char (bytes 6..9)
|
||||
// Truncating at 7 bytes should not split the 3rd char (bytes 6..9)
|
||||
let t = safe_truncate_str(s, 7);
|
||||
assert_eq!(t, "\u{4f60}\u{597d}"); // 6 bytes, 2 chars
|
||||
assert!(t.len() <= 7);
|
||||
|
||||
@@ -202,10 +202,7 @@ mod tests {
|
||||
|
||||
// Delta 3: closing tag + visible text
|
||||
let a3 = filter.process("</think>The answer is 42.");
|
||||
assert_eq!(
|
||||
a3,
|
||||
vec![FilterAction::EmitText("The answer is 42.".into())]
|
||||
);
|
||||
assert_eq!(a3, vec![FilterAction::EmitText("The answer is 42.".into())]);
|
||||
assert!(!filter.is_inside_think());
|
||||
}
|
||||
|
||||
@@ -221,10 +218,7 @@ mod tests {
|
||||
// Delta 2: completes the tag
|
||||
let a2 = filter.process("nk>deep thought");
|
||||
// The tag is complete. "deep thought" is thinking.
|
||||
assert_eq!(
|
||||
a2,
|
||||
vec![FilterAction::EmitThinking("deep thought".into())]
|
||||
);
|
||||
assert_eq!(a2, vec![FilterAction::EmitThinking("deep thought".into())]);
|
||||
assert!(filter.is_inside_think());
|
||||
}
|
||||
|
||||
@@ -234,10 +228,7 @@ mod tests {
|
||||
|
||||
// Enter think block
|
||||
let a1 = filter.process("<think>thinking here</thi");
|
||||
assert_eq!(
|
||||
a1,
|
||||
vec![FilterAction::EmitThinking("thinking here".into())]
|
||||
);
|
||||
assert_eq!(a1, vec![FilterAction::EmitThinking("thinking here".into())]);
|
||||
assert!(filter.is_inside_think());
|
||||
|
||||
// Complete the closing tag
|
||||
@@ -272,8 +263,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_multiple_think_blocks() {
|
||||
let mut filter = StreamingThinkFilter::new();
|
||||
let actions =
|
||||
filter.process("<think>first</think>middle<think>second</think>end");
|
||||
let actions = filter.process("<think>first</think>middle<think>second</think>end");
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![
|
||||
@@ -315,10 +305,7 @@ mod tests {
|
||||
fn test_flush_inside_think_with_pending() {
|
||||
let mut filter = StreamingThinkFilter::new();
|
||||
let a1 = filter.process("<think>thinking</thi");
|
||||
assert_eq!(
|
||||
a1,
|
||||
vec![FilterAction::EmitThinking("thinking".into())]
|
||||
);
|
||||
assert_eq!(a1, vec![FilterAction::EmitThinking("thinking".into())]);
|
||||
assert!(filter.is_inside_think());
|
||||
|
||||
// Stream ends with partial close tag buffered
|
||||
@@ -330,10 +317,7 @@ mod tests {
|
||||
fn test_empty_think_block() {
|
||||
let mut filter = StreamingThinkFilter::new();
|
||||
let actions = filter.process("<think></think>The answer.");
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![FilterAction::EmitText("The answer.".into())]
|
||||
);
|
||||
assert_eq!(actions, vec![FilterAction::EmitText("The answer.".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -380,10 +364,7 @@ mod tests {
|
||||
assert!(filter.is_inside_think());
|
||||
|
||||
let a9 = filter.process("deep thought");
|
||||
assert_eq!(
|
||||
a9,
|
||||
vec![FilterAction::EmitThinking("deep thought".into())]
|
||||
);
|
||||
assert_eq!(a9, vec![FilterAction::EmitThinking("deep thought".into())]);
|
||||
|
||||
let a10 = filter.process("</think>done");
|
||||
assert_eq!(a10, vec![FilterAction::EmitText("done".into())]);
|
||||
|
||||
@@ -28,20 +28,11 @@ fn check_taint_shell_exec(command: &str) -> Option<String> {
|
||||
// Layer 1: Block shell metacharacters that enable command injection.
|
||||
// Uses the same validator as subprocess_sandbox and docker_sandbox.
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
|
||||
return Some(format!(
|
||||
"Shell metacharacter injection blocked: {reason}"
|
||||
));
|
||||
return Some(format!("Shell metacharacter injection blocked: {reason}"));
|
||||
}
|
||||
|
||||
// Layer 2: Heuristic patterns for injected external URLs / base64 payloads
|
||||
let suspicious_patterns = [
|
||||
"curl ",
|
||||
"wget ",
|
||||
"| sh",
|
||||
"| bash",
|
||||
"base64 -d",
|
||||
"eval ",
|
||||
];
|
||||
let suspicious_patterns = ["curl ", "wget ", "| sh", "| bash", "base64 -d", "eval "];
|
||||
for pattern in &suspicious_patterns {
|
||||
if command.contains(pattern) {
|
||||
let mut labels = HashSet::new();
|
||||
@@ -202,7 +193,9 @@ pub async fn execute_tool(
|
||||
let headers = input.get("headers").and_then(|v| v.as_object());
|
||||
let body = input["body"].as_str();
|
||||
if let Some(ctx) = web_ctx {
|
||||
ctx.fetch.fetch_with_options(url, method, headers, body).await
|
||||
ctx.fetch
|
||||
.fetch_with_options(url, method, headers, body)
|
||||
.await
|
||||
} else {
|
||||
tool_web_fetch_legacy(input).await
|
||||
}
|
||||
@@ -223,7 +216,8 @@ pub async fn execute_tool(
|
||||
|
||||
// SECURITY: Always check for shell metacharacters, even in Full mode.
|
||||
// These enable command injection regardless of exec policy.
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command)
|
||||
{
|
||||
return ToolResult {
|
||||
tool_use_id: tool_use_id.to_string(),
|
||||
content: format!(
|
||||
@@ -365,8 +359,7 @@ pub async fn execute_tool(
|
||||
crate::browser::tool_browser_navigate(input, mgr, aid).await
|
||||
}
|
||||
None => Err(
|
||||
"Browser tools not available. Ensure Chrome/Chromium is installed."
|
||||
.to_string(),
|
||||
"Browser tools not available. Ensure Chrome/Chromium is installed.".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -375,63 +368,81 @@ pub async fn execute_tool(
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_click(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
None => {
|
||||
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
|
||||
}
|
||||
},
|
||||
"browser_type" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_type(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
None => {
|
||||
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
|
||||
}
|
||||
},
|
||||
"browser_screenshot" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_screenshot(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
None => {
|
||||
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
|
||||
}
|
||||
},
|
||||
"browser_read_page" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_read_page(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
None => {
|
||||
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
|
||||
}
|
||||
},
|
||||
"browser_close" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_close(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
None => {
|
||||
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
|
||||
}
|
||||
},
|
||||
"browser_scroll" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_scroll(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
None => {
|
||||
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
|
||||
}
|
||||
},
|
||||
"browser_wait" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_wait(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
None => {
|
||||
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
|
||||
}
|
||||
},
|
||||
"browser_run_js" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_run_js(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
None => {
|
||||
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
|
||||
}
|
||||
},
|
||||
"browser_back" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_back(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
None => {
|
||||
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
|
||||
}
|
||||
},
|
||||
|
||||
// Canvas / A2UI tool
|
||||
@@ -442,9 +453,12 @@ pub async fn execute_tool(
|
||||
if mcp::is_mcp_tool(other) {
|
||||
if let Some(mcp_conns) = mcp_connections {
|
||||
let mut conns = mcp_conns.lock().await;
|
||||
let known_names: Vec<String> = conns.iter().map(|c| c.name().to_string()).collect();
|
||||
let known_names: Vec<String> =
|
||||
conns.iter().map(|c| c.name().to_string()).collect();
|
||||
let known_refs: Vec<&str> = known_names.iter().map(|s| s.as_str()).collect();
|
||||
if let Some(server_name) = mcp::extract_mcp_server_from_known(other, &known_refs) {
|
||||
if let Some(server_name) =
|
||||
mcp::extract_mcp_server_from_known(other, &known_refs)
|
||||
{
|
||||
if let Some(conn) = conns.iter_mut().find(|c| c.name() == server_name) {
|
||||
debug!(
|
||||
tool = other,
|
||||
@@ -2198,10 +2212,12 @@ async fn tool_channel_send(
|
||||
let default_id = kh.get_channel_default_recipient(&channel).await;
|
||||
match default_id {
|
||||
Some(id) => id,
|
||||
None => return Err(format!(
|
||||
None => {
|
||||
return Err(format!(
|
||||
"Missing 'recipient' parameter. Set default_chat_id in [channels.{channel}] config \
|
||||
or pass recipient explicitly."
|
||||
)),
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
recipient_input
|
||||
@@ -2226,7 +2242,9 @@ async fn tool_channel_send(
|
||||
let caption = input["message"].as_str().filter(|s| !s.is_empty());
|
||||
let filename = input["filename"].as_str();
|
||||
return kh
|
||||
.send_channel_media(&channel, recipient, "file", url, caption, filename, thread_id)
|
||||
.send_channel_media(
|
||||
&channel, recipient, "file", url, caption, filename, thread_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -2281,9 +2299,7 @@ async fn tool_channel_send(
|
||||
};
|
||||
|
||||
return kh
|
||||
.send_channel_file_data(
|
||||
&channel, recipient, data, &filename, mime_type, thread_id,
|
||||
)
|
||||
.send_channel_file_data(&channel, recipient, data, &filename, mime_type, thread_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -3220,7 +3236,10 @@ async fn tool_canvas_present(
|
||||
let _ = tokio::fs::create_dir_all(&output_dir).await;
|
||||
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let filename = format!("canvas_{timestamp}_{}.html", crate::str_utils::safe_truncate_str(&canvas_id, 8));
|
||||
let filename = format!(
|
||||
"canvas_{timestamp}_{}.html",
|
||||
crate::str_utils::safe_truncate_str(&canvas_id, 8)
|
||||
);
|
||||
let filepath = output_dir.join(&filename);
|
||||
|
||||
// Write the full HTML document
|
||||
@@ -3366,7 +3385,11 @@ mod tests {
|
||||
None, // process_manager
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_error, "Expected error but got: {}", result.content);
|
||||
assert!(
|
||||
result.is_error,
|
||||
"Expected error but got: {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3580,10 +3603,17 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
// Should fail for file-not-found, NOT for permission denied
|
||||
assert!(result.is_error, "Expected error but got: {}", result.content);
|
||||
assert!(
|
||||
result.content.contains("Failed to read") || result.content.contains("not found") || result.content.contains("No such file"),
|
||||
"Unexpected error: {}", result.content
|
||||
result.is_error,
|
||||
"Expected error but got: {}",
|
||||
result.content
|
||||
);
|
||||
assert!(
|
||||
result.content.contains("Failed to read")
|
||||
|| result.content.contains("not found")
|
||||
|| result.content.contains("No such file"),
|
||||
"Unexpected error: {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -424,7 +424,10 @@ mod tests {
|
||||
let html = "<body>İstanbul ẞtraße <B>bold</B> text</body>";
|
||||
let md = html_to_markdown(html);
|
||||
assert!(md.contains("**bold**"), "Expected bold, got: {md}");
|
||||
assert!(md.contains("İstanbul"), "Expected unicode preserved, got: {md}");
|
||||
assert!(
|
||||
md.contains("İstanbul"),
|
||||
"Expected unicode preserved, got: {md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -72,7 +72,10 @@ impl WebFetchEngine {
|
||||
"DELETE" => self.client.delete(url),
|
||||
_ => self.client.get(url),
|
||||
};
|
||||
req = req.header("User-Agent", format!("Mozilla/5.0 (compatible; {})", crate::USER_AGENT));
|
||||
req = req.header(
|
||||
"User-Agent",
|
||||
format!("Mozilla/5.0 (compatible; {})", crate::USER_AGENT),
|
||||
);
|
||||
|
||||
// Add custom headers
|
||||
if let Some(hdrs) = headers {
|
||||
@@ -191,9 +194,7 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
|
||||
let host = extract_host(url);
|
||||
// For IPv6 bracket notation like [::1]:80, extract [::1] as hostname
|
||||
let hostname = if host.starts_with('[') {
|
||||
host.find(']')
|
||||
.map(|i| &host[..=i])
|
||||
.unwrap_or(&host)
|
||||
host.find(']').map(|i| &host[..=i]).unwrap_or(&host)
|
||||
} else {
|
||||
host.split(':').next().unwrap_or(&host)
|
||||
};
|
||||
@@ -290,8 +291,8 @@ mod tests {
|
||||
// (Chinese, Japanese, emoji — common on international finance sites).
|
||||
// Old code: &s[..max] panics when max lands inside a multi-byte char.
|
||||
let content = "\u{4f60}\u{597d}\u{4e16}\u{754c}!"; // "你好世界!" = 13 bytes
|
||||
// Truncate at byte 7 — lands inside the 3rd Chinese char (bytes 6..9).
|
||||
// safe_truncate_str walks back to byte 6, returning "你好".
|
||||
// Truncate at byte 7 — lands inside the 3rd Chinese char (bytes 6..9).
|
||||
// safe_truncate_str walks back to byte 6, returning "你好".
|
||||
let truncated = safe_truncate_str(content, 7);
|
||||
assert_eq!(truncated, "\u{4f60}\u{597d}");
|
||||
assert!(truncated.len() <= 7);
|
||||
@@ -300,7 +301,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_truncate_emoji_no_panic() {
|
||||
let content = "\u{1f4b0}\u{1f4c8}\u{1f4b9}"; // 💰📈💹 = 12 bytes
|
||||
// Truncate at byte 5 — lands inside the 2nd emoji (bytes 4..8).
|
||||
// Truncate at byte 5 — lands inside the 2nd emoji (bytes 4..8).
|
||||
let truncated = safe_truncate_str(content, 5);
|
||||
assert_eq!(truncated, "\u{1f4b0}"); // 4 bytes
|
||||
}
|
||||
|
||||
@@ -337,8 +337,7 @@ impl ClawHubClient {
|
||||
retry_after_secs = ra,
|
||||
"ClawHub sent Retry-After, sleeping {capped}ms"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(capped))
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(capped)).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,9 +359,7 @@ impl ClawHubClient {
|
||||
}
|
||||
|
||||
// Non-retryable HTTP error (4xx other than 429).
|
||||
return Err(SkillError::Network(format!(
|
||||
"{context} returned {status}"
|
||||
)));
|
||||
return Err(SkillError::Network(format!("{context} returned {status}")));
|
||||
}
|
||||
Err(e) => {
|
||||
// Network / timeout error — retryable.
|
||||
|
||||
@@ -329,11 +329,7 @@ async fn execute_shell(
|
||||
)
|
||||
})?;
|
||||
|
||||
debug!(
|
||||
"Executing Shell skill: {} {}",
|
||||
shell,
|
||||
script_path.display()
|
||||
);
|
||||
debug!("Executing Shell skill: {} {}", shell, script_path.display());
|
||||
|
||||
// Use -s to read from stdin, -c to execute command
|
||||
let mut cmd = tokio::process::Command::new(&shell);
|
||||
|
||||
@@ -275,7 +275,7 @@ impl Default for ResourceQuota {
|
||||
max_llm_tokens_per_hour: 0, // unlimited by default
|
||||
max_network_bytes_per_hour: 100 * 1024 * 1024, // 100 MB
|
||||
max_cost_per_hour_usd: 0.0, // unlimited by default
|
||||
max_cost_per_day_usd: 0.0, // unlimited
|
||||
max_cost_per_day_usd: 0.0, // unlimited
|
||||
max_cost_per_month_usd: 0.0, // unlimited
|
||||
}
|
||||
}
|
||||
@@ -1269,10 +1269,7 @@ model = "llama-3.3-70b-versatile"
|
||||
system_prompt = "You are a helpful assistant."
|
||||
"#;
|
||||
let manifest: AgentManifest = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(
|
||||
manifest.model.system_prompt,
|
||||
"You are a helpful assistant."
|
||||
);
|
||||
assert_eq!(manifest.model.system_prompt, "You are a helpful assistant.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -562,8 +562,7 @@ mod tests {
|
||||
#[test]
|
||||
fn policy_require_approval_bool_true() {
|
||||
// require_approval = true → ["shell_exec"]
|
||||
let policy: ApprovalPolicy =
|
||||
serde_json::from_str(r#"{"require_approval": true}"#).unwrap();
|
||||
let policy: ApprovalPolicy = serde_json::from_str(r#"{"require_approval": true}"#).unwrap();
|
||||
assert_eq!(policy.require_approval, vec!["shell_exec"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1645,6 +1645,8 @@ pub struct ChannelsConfig {
|
||||
pub webhook: Option<WebhookConfig>,
|
||||
/// LinkedIn messaging configuration (None = disabled).
|
||||
pub linkedin: Option<LinkedInConfig>,
|
||||
/// WeCom/WeChat Work configuration (None = disabled).
|
||||
pub wecom: Option<WeComConfig>,
|
||||
}
|
||||
|
||||
/// Telegram channel adapter configuration.
|
||||
@@ -2418,6 +2420,44 @@ impl Default for FeishuConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// WeCom/WeChat Work channel adapter configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WeComConfig {
|
||||
/// WeCom corp ID.
|
||||
pub corp_id: String,
|
||||
/// WeCom application agent ID.
|
||||
pub agent_id: String,
|
||||
/// Env var name holding the application secret.
|
||||
pub secret_env: String,
|
||||
/// Port for the incoming webhook.
|
||||
pub webhook_port: u16,
|
||||
/// Callback verification token (optional, for URL verification).
|
||||
pub token: Option<String>,
|
||||
/// Encoding AES key for callback (optional, for encrypted mode).
|
||||
pub encoding_aes_key: Option<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
/// Per-channel behavior overrides.
|
||||
#[serde(default)]
|
||||
pub overrides: ChannelOverrides,
|
||||
}
|
||||
|
||||
impl Default for WeComConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
corp_id: String::new(),
|
||||
agent_id: String::new(),
|
||||
secret_env: "WECOM_SECRET".to_string(),
|
||||
webhook_port: 8454,
|
||||
token: None,
|
||||
encoding_aes_key: None,
|
||||
default_agent: None,
|
||||
overrides: ChannelOverrides::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Revolt (Discord-like) channel adapter configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -3908,10 +3948,7 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
// Auth profiles take precedence over convention (but not explicit mapping)
|
||||
assert_eq!(
|
||||
config.resolve_api_key_env("nvidia"),
|
||||
"NVIDIA_PRIMARY_KEY"
|
||||
);
|
||||
assert_eq!(config.resolve_api_key_env("nvidia"), "NVIDIA_PRIMARY_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -327,8 +327,12 @@ mod tests {
|
||||
match msg.content {
|
||||
MessageContent::Blocks(ref b) => {
|
||||
assert_eq!(b.len(), 2);
|
||||
assert!(matches!(&b[0], ContentBlock::Text { text, .. } if text == "What is in this image?"));
|
||||
assert!(matches!(&b[1], ContentBlock::Image { media_type, .. } if media_type == "image/jpeg"));
|
||||
assert!(
|
||||
matches!(&b[0], ContentBlock::Text { text, .. } if text == "What is in this image?")
|
||||
);
|
||||
assert!(
|
||||
matches!(&b[1], ContentBlock::Image { media_type, .. } if media_type == "image/jpeg")
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected blocks content"),
|
||||
}
|
||||
|
||||
@@ -332,9 +332,7 @@ impl CronJob {
|
||||
}
|
||||
// Workflows can run longer than agent turns (max 3600s = 1h)
|
||||
if *t > 3600 {
|
||||
return Err(format!(
|
||||
"timeout_secs too large ({t}, max 3600)"
|
||||
));
|
||||
return Err(format!("timeout_secs too large ({t}, max 3600)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,8 +187,15 @@ where
|
||||
return Err(de::Error::unknown_variant(
|
||||
other,
|
||||
&[
|
||||
"deny", "none", "disabled", "allowlist", "restricted", "full",
|
||||
"allow", "all", "unrestricted",
|
||||
"deny",
|
||||
"none",
|
||||
"disabled",
|
||||
"allowlist",
|
||||
"restricted",
|
||||
"full",
|
||||
"allow",
|
||||
"all",
|
||||
"unrestricted",
|
||||
],
|
||||
));
|
||||
}
|
||||
@@ -203,9 +210,8 @@ where
|
||||
where
|
||||
A: MapAccess<'de>,
|
||||
{
|
||||
let policy = crate::config::ExecPolicy::deserialize(
|
||||
de::value::MapAccessDeserializer::new(map),
|
||||
)?;
|
||||
let policy =
|
||||
crate::config::ExecPolicy::deserialize(de::value::MapAccessDeserializer::new(map))?;
|
||||
Ok(Some(policy))
|
||||
}
|
||||
|
||||
|
||||
@@ -115,8 +115,7 @@ fn normalize_schema_recursive(schema: &serde_json::Value) -> serde_json::Value {
|
||||
if let Some(arr) = value.as_array() {
|
||||
let types: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
|
||||
let has_null = types.contains(&"null");
|
||||
let non_null: Vec<&&str> =
|
||||
types.iter().filter(|&&t| t != "null").collect();
|
||||
let non_null: Vec<&&str> = types.iter().filter(|&&t| t != "null").collect();
|
||||
if has_null && non_null.len() == 1 {
|
||||
// ["string", "null"] → type: "string", nullable: true
|
||||
result.insert(
|
||||
@@ -139,10 +138,7 @@ fn normalize_schema_recursive(schema: &serde_json::Value) -> serde_json::Value {
|
||||
serde_json::Value::String(non_null[0].to_string()),
|
||||
);
|
||||
if has_null {
|
||||
result.insert(
|
||||
"nullable".to_string(),
|
||||
serde_json::Value::Bool(true),
|
||||
);
|
||||
result.insert("nullable".to_string(), serde_json::Value::Bool(true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -191,10 +187,7 @@ fn resolve_refs(obj: &serde_json::Map<String, serde_json::Value>) -> serde_json:
|
||||
result.remove("$defs");
|
||||
|
||||
// Recursively replace $ref in the schema
|
||||
fn inline_refs(
|
||||
val: &mut serde_json::Value,
|
||||
defs: &serde_json::Map<String, serde_json::Value>,
|
||||
) {
|
||||
fn inline_refs(val: &mut serde_json::Value, defs: &serde_json::Map<String, serde_json::Value>) {
|
||||
match val {
|
||||
serde_json::Value::Object(map) => {
|
||||
// If this object is a $ref, replace it with the definition
|
||||
|
||||
@@ -51,11 +51,15 @@ impl NonceTracker {
|
||||
let now = Instant::now();
|
||||
|
||||
// Garbage-collect expired nonces (older than window)
|
||||
self.seen.retain(|_, ts| now.duration_since(*ts) < self.window);
|
||||
self.seen
|
||||
.retain(|_, ts| now.duration_since(*ts) < self.window);
|
||||
|
||||
// Check for replay
|
||||
if self.seen.contains_key(nonce) {
|
||||
return Err(format!("Nonce replay detected: {}", openfang_types::truncate_str(nonce, 16)));
|
||||
return Err(format!(
|
||||
"Nonce replay detected: {}",
|
||||
openfang_types::truncate_str(nonce, 16)
|
||||
));
|
||||
}
|
||||
|
||||
// Record the nonce
|
||||
@@ -287,11 +291,7 @@ impl PeerNode {
|
||||
}
|
||||
|
||||
// SECURITY: Derive per-session key for authenticated messages
|
||||
let key = derive_session_key(
|
||||
&self.config.shared_secret,
|
||||
&our_nonce,
|
||||
ack_nonce,
|
||||
);
|
||||
let key = derive_session_key(&self.config.shared_secret, &our_nonce, ack_nonce);
|
||||
|
||||
info!(
|
||||
"OFP: handshake complete with {} ({}) — {} agents",
|
||||
@@ -333,8 +333,15 @@ impl PeerNode {
|
||||
// Spawn a task to handle ongoing communication with per-message HMAC
|
||||
let registry = self.registry.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
connection_loop(&mut reader, &mut writer, &peer_node_id, ®istry, &*handle, Some(&sess_key)).await
|
||||
if let Err(e) = connection_loop(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
&peer_node_id,
|
||||
®istry,
|
||||
&*handle,
|
||||
Some(&sess_key),
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!("OFP: connection to {} ended: {}", peer_node_id, e);
|
||||
}
|
||||
@@ -573,8 +580,8 @@ impl PeerNode {
|
||||
// SECURITY: Derive per-session key (server side: their nonce first, our nonce second)
|
||||
let session_key = derive_session_key(
|
||||
&node.config.shared_secret,
|
||||
nonce, // client's nonce
|
||||
&ack_nonce, // our nonce
|
||||
nonce, // client's nonce
|
||||
&ack_nonce, // our nonce
|
||||
);
|
||||
|
||||
info!(
|
||||
@@ -622,8 +629,15 @@ impl PeerNode {
|
||||
};
|
||||
|
||||
// Enter the message dispatch loop with per-message HMAC
|
||||
if let Err(e) =
|
||||
connection_loop(&mut reader, &mut writer, &peer_node_id, registry, handle, Some(&session_key)).await
|
||||
if let Err(e) = connection_loop(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
&peer_node_id,
|
||||
registry,
|
||||
handle,
|
||||
Some(&session_key),
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!("OFP: connection with {} ended: {}", peer_node_id, e);
|
||||
}
|
||||
@@ -940,8 +954,7 @@ pub async fn broadcast_notification(
|
||||
// SECURITY: Derive a per-message key from shared secret + fresh nonce
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
let session_key = hmac_sign(shared_secret, nonce.as_bytes());
|
||||
if let Err(e) = write_message_authenticated(&mut writer, &msg, &session_key).await
|
||||
{
|
||||
if let Err(e) = write_message_authenticated(&mut writer, &msg, &session_key).await {
|
||||
errors.push((peer.node_id.clone(), e));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user