Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 184 additions & 28 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ html-escape = "0.2"
# Lightweight regex
regex-lite = "0.1"

# MCP SDK (official Rust implementation)
rmcp = { version = "1.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "reqwest"] }

# Socket options (SO_REUSEADDR)
socket2 = "0.5"

Expand Down
12 changes: 11 additions & 1 deletion crates/openfang-api/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,11 @@ pub async fn send_message(
// (not as a separate session message which the LLM may not process).
let content_blocks = if !req.attachments.is_empty() {
let image_blocks = resolve_attachments(&req.attachments);
if image_blocks.is_empty() { None } else { Some(image_blocks) }
if image_blocks.is_empty() {
None
} else {
Some(image_blocks)
}
} else {
None
};
Expand Down Expand Up @@ -4825,6 +4829,12 @@ pub async fn list_mcp_servers(State(state): State<Arc<AppState>>) -> impl IntoRe
"url": url,
})
}
openfang_types::config::McpTransportEntry::Http { url } => {
serde_json::json!({
"type": "http",
"url": url,
})
}
};
serde_json::json!({
"name": s.name,
Expand Down
9 changes: 6 additions & 3 deletions crates/openfang-channels/src/whatsapp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ impl ChannelAdapter for WhatsAppAdapter {
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
let resp = self.client
let resp = self
.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
Expand All @@ -284,7 +285,8 @@ impl ChannelAdapter for WhatsAppAdapter {
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
let resp = self.client
let resp = self
.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
Expand All @@ -310,7 +312,8 @@ impl ChannelAdapter for WhatsAppAdapter {
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
let resp = self.client
let resp = self
.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
Expand Down
3 changes: 2 additions & 1 deletion crates/openfang-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2580,7 +2580,8 @@ decay_rate = 0.05
checks.push(serde_json::json!({"check": "mcp_server_config", "status": "warn", "name": server.name}));
}
}
openfang_types::config::McpTransportEntry::Sse { url } => {
openfang_types::config::McpTransportEntry::Sse { url }
| openfang_types::config::McpTransportEntry::Http { url } => {
if url.is_empty() {
if !json {
ui::check_warn(&format!(
Expand Down
2,990 changes: 2,990 additions & 0 deletions crates/openfang-desktop/gen/schemas/macOS-schema.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions crates/openfang-extensions/src/bundled.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ mod tests {
crate::McpTransportTemplate::Sse { .. } => {
panic!("{} unexpectedly uses SSE transport", id);
}
crate::McpTransportTemplate::Http { .. } => {
panic!("{} unexpectedly uses HTTP transport", id);
}
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions crates/openfang-extensions/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ pub enum McpTransportTemplate {
Sse {
url: String,
},
Http {
url: String,
},
}

/// An environment variable required by an integration.
Expand Down
4 changes: 4 additions & 0 deletions crates/openfang-extensions/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ impl IntegrationRegistry {
crate::McpTransportTemplate::Sse { url } => {
McpTransportEntry::Sse { url: url.clone() }
}
crate::McpTransportTemplate::Http { url } => {
McpTransportEntry::Http { url: url.clone() }
}
};
let env: Vec<String> = template
.required_env
Expand All @@ -202,6 +205,7 @@ impl IntegrationRegistry {
transport,
timeout_secs: 30,
env,
headers: Vec::new(),
})
})
.collect()
Expand Down
40 changes: 30 additions & 10 deletions crates/openfang-kernel/src/kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2027,19 +2027,24 @@ impl OpenFangKernel {
// Persist usage to database (same as non-streaming path)
let model = &manifest.model.model;
let cost = MeteringEngine::estimate_cost_with_catalog(
&kernel_clone.model_catalog.read().unwrap_or_else(|e| e.into_inner()),
&kernel_clone
.model_catalog
.read()
.unwrap_or_else(|e| e.into_inner()),
model,
result.total_usage.input_tokens,
result.total_usage.output_tokens,
);
let _ = kernel_clone.metering.record(&openfang_memory::usage::UsageRecord {
agent_id,
model: model.clone(),
input_tokens: result.total_usage.input_tokens,
output_tokens: result.total_usage.output_tokens,
cost_usd: cost,
tool_calls: result.iterations.saturating_sub(1),
});
let _ = kernel_clone
.metering
.record(&openfang_memory::usage::UsageRecord {
agent_id,
model: model.clone(),
input_tokens: result.total_usage.input_tokens,
output_tokens: result.total_usage.output_tokens,
cost_usd: cost,
tool_calls: result.iterations.saturating_sub(1),
});

let _ = kernel_clone
.registry
Expand Down Expand Up @@ -4785,6 +4790,7 @@ impl OpenFangKernel {
args: args.clone(),
},
McpTransportEntry::Sse { url } => McpTransport::Sse { url: url.clone() },
McpTransportEntry::Http { url } => McpTransport::Http { url: url.clone() },
};

// Resolve env vars from vault/dotenv before passing to MCP subprocess.
Expand All @@ -4803,6 +4809,7 @@ impl OpenFangKernel {
transport,
timeout_secs: server_config.timeout_secs,
env: server_config.env.clone(),
headers: server_config.headers.clone(),
};

match McpConnection::connect(mcp_config).await {
Expand Down Expand Up @@ -4904,13 +4911,15 @@ impl OpenFangKernel {
args: args.clone(),
},
McpTransportEntry::Sse { url } => McpTransport::Sse { url: url.clone() },
McpTransportEntry::Http { url } => McpTransport::Http { url: url.clone() },
};

let mcp_config = McpServerConfig {
name: server_config.name.clone(),
transport,
timeout_secs: server_config.timeout_secs,
env: server_config.env.clone(),
headers: server_config.headers.clone(),
};

self.extension_health.register(&server_config.name);
Expand Down Expand Up @@ -5022,13 +5031,15 @@ impl OpenFangKernel {
args: args.clone(),
},
McpTransportEntry::Sse { url } => McpTransport::Sse { url: url.clone() },
McpTransportEntry::Http { url } => McpTransport::Http { url: url.clone() },
};

let mcp_config = McpServerConfig {
name: server_config.name.clone(),
transport,
timeout_secs: server_config.timeout_secs,
env: server_config.env.clone(),
headers: server_config.headers.clone(),
};

match McpConnection::connect(mcp_config).await {
Expand Down Expand Up @@ -5099,7 +5110,16 @@ impl OpenFangKernel {
/// If `capabilities.tools` is empty (or contains `"*"`), all tools are
/// available (backwards compatible).
fn available_tools(&self, agent_id: AgentId) -> Vec<ToolDefinition> {
let all_builtins = builtin_tool_definitions();
let all_builtins = if self.config.browser.enabled {
builtin_tool_definitions()
} else {
// When built-in browser is disabled (replaced by an external
// browser MCP server such as CamoFox), filter out browser_* tools.
builtin_tool_definitions()
.into_iter()
.filter(|t| !t.name.starts_with("browser_"))
.collect()
};

// Look up agent entry for profile, skill/MCP allowlists, and declared tools
let entry = self.registry.get(agent_id);
Expand Down
12 changes: 2 additions & 10 deletions crates/openfang-memory/src/knowledge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,7 @@ impl KnowledgeStore {
let mut idx = 1;

if let Some(ref source) = pattern.source {
sql.push_str(&format!(
" AND (s.id = ?{} OR s.name = ?{})",
idx,
idx + 1
));
sql.push_str(&format!(" AND (s.id = ?{} OR s.name = ?{})", idx, idx + 1));
params.push(Box::new(source.clone()));
params.push(Box::new(source.clone()));
idx += 2;
Expand All @@ -117,11 +113,7 @@ impl KnowledgeStore {
idx += 1;
}
if let Some(ref target) = pattern.target {
sql.push_str(&format!(
" AND (t.id = ?{} OR t.name = ?{})",
idx,
idx + 1
));
sql.push_str(&format!(" AND (t.id = ?{} OR t.name = ?{})", idx, idx + 1));
params.push(Box::new(target.clone()));
params.push(Box::new(target.clone()));
idx += 2;
Expand Down
2 changes: 2 additions & 0 deletions crates/openfang-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ zeroize = { workspace = true }
dashmap = { workspace = true }
regex-lite = { workspace = true }
rusqlite = { workspace = true }
rmcp = { workspace = true }
http = "1"
tokio-tungstenite = "0.24"
shlex = "1"

Expand Down
34 changes: 27 additions & 7 deletions crates/openfang-runtime/src/agent_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,15 @@ fn phantom_action_detected(text: &str) -> bool {
let lower = text.to_lowercase();
let action_verbs = ["sent ", "posted ", "emailed ", "delivered ", "forwarded "];
let channel_refs = [
"telegram", "whatsapp", "slack", "discord", "email", "channel",
"message sent", "successfully sent", "has been sent",
"telegram",
"whatsapp",
"slack",
"discord",
"email",
"channel",
"message sent",
"successfully sent",
"has been sent",
];
let has_action = action_verbs.iter().any(|v| lower.contains(v));
let has_channel = channel_refs.iter().any(|c| lower.contains(c));
Expand Down Expand Up @@ -272,7 +279,9 @@ pub async fn run_agent_loop(
// The LLM already received them via llm_messages above.
for msg in session.messages.iter_mut() {
if let MessageContent::Blocks(blocks) = &mut msg.content {
let had_images = blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. }));
let had_images = blocks
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. }));
if had_images {
blocks.retain(|b| !matches!(b, ContentBlock::Image { .. }));
if blocks.is_empty() {
Expand Down Expand Up @@ -454,7 +463,10 @@ pub async fn run_agent_loop(
// One-shot retry: if the LLM returns empty text with no tool use,
// 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() && !response.has_any_content() {
if text.trim().is_empty()
&& response.tool_calls.is_empty()
&& !response.has_any_content()
{
let is_silent_failure =
response.usage.input_tokens == 0 && response.usage.output_tokens == 0;
if iteration == 0 || is_silent_failure {
Expand Down Expand Up @@ -499,7 +511,10 @@ pub async fn run_agent_loop(
// channel action (send, post, email, etc.) but never actually
// called the corresponding tool, re-prompt once to force real
// tool usage instead of hallucinated completion.
let text = if !any_tools_executed && iteration == 0 && phantom_action_detected(&text) {
let text = if !any_tools_executed
&& iteration == 0
&& phantom_action_detected(&text)
{
warn!(agent = %manifest.name, "Phantom action detected — re-prompting for real tool use");
messages.push(Message::assistant(text));
messages.push(Message::user(
Expand Down Expand Up @@ -1275,7 +1290,9 @@ pub async fn run_agent_loop_streaming(
// The LLM already received them via llm_messages above.
for msg in session.messages.iter_mut() {
if let MessageContent::Blocks(blocks) = &mut msg.content {
let had_images = blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. }));
let had_images = blocks
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. }));
if had_images {
blocks.retain(|b| !matches!(b, ContentBlock::Image { .. }));
if blocks.is_empty() {
Expand Down Expand Up @@ -1475,7 +1492,10 @@ pub async fn run_agent_loop_streaming(
// One-shot retry: if the LLM returns empty text with no tool use,
// 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() && !response.has_any_content() {
if text.trim().is_empty()
&& response.tool_calls.is_empty()
&& !response.has_any_content()
{
let is_silent_failure =
response.usage.input_tokens == 0 && response.usage.output_tokens == 0;
if iteration == 0 || is_silent_failure {
Expand Down
5 changes: 2 additions & 3 deletions crates/openfang-runtime/src/drivers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,9 +471,8 @@ impl LlmDriver for AnthropicDriver {
input_json,
}) = blocks.get(block_idx)
{
let input: serde_json::Value =
serde_json::from_str(input_json)
.unwrap_or_else(|_| serde_json::json!({}));
let input: serde_json::Value = serde_json::from_str(input_json)
.unwrap_or_else(|_| serde_json::json!({}));
let _ = tx
.send(StreamEvent::ToolUseEnd {
id: id.clone(),
Expand Down
12 changes: 3 additions & 9 deletions crates/openfang-runtime/src/drivers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -791,9 +791,7 @@ mod tests {
let config = DriverConfig {
provider: "azure".to_string(),
api_key: Some("test-azure-key".to_string()),
base_url: Some(
"https://myresource.openai.azure.com/openai/deployments".to_string(),
),
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
skip_permissions: true,
};
let driver = create_driver(&config);
Expand All @@ -805,9 +803,7 @@ mod tests {
let config = DriverConfig {
provider: "azure".to_string(),
api_key: None,
base_url: Some(
"https://myresource.openai.azure.com/openai/deployments".to_string(),
),
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
skip_permissions: true,
};
let result = create_driver(&config);
Expand Down Expand Up @@ -843,9 +839,7 @@ mod tests {
let config = DriverConfig {
provider: "azure-openai".to_string(),
api_key: Some("test-azure-key".to_string()),
base_url: Some(
"https://myresource.openai.azure.com/openai/deployments".to_string(),
),
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
skip_permissions: true,
};
let driver = create_driver(&config);
Expand Down
3 changes: 1 addition & 2 deletions crates/openfang-runtime/src/drivers/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,7 @@ impl OpenAIDriver {
if self.azure_mode {
builder = builder.header("api-key", self.api_key.as_str());
} else {
builder =
builder.header("authorization", format!("Bearer {}", self.api_key.as_str()));
builder = builder.header("authorization", format!("Bearer {}", self.api_key.as_str()));
}
builder
}
Expand Down
Loading