mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86b50070e8 | ||
|
|
c6aab08faa | ||
|
|
62ec09d0ae | ||
|
|
f6f9cf7e9f | ||
|
|
56aeb499c9 | ||
|
|
b4e6a693f5 | ||
|
|
48d5418c91 | ||
|
|
cc93ef4571 | ||
|
|
3e069798f9 | ||
|
|
ad10aa5e80 | ||
|
|
385aee8e56 | ||
|
|
a00327abe9 | ||
|
|
487555a5e5 | ||
|
|
9d51426cb4 | ||
|
|
6fab720843 | ||
|
|
4667f497ef | ||
|
|
eba9198827 | ||
|
|
f2413949bc | ||
|
|
9e230f423e | ||
|
|
8138b7e0e8 | ||
|
|
cfae867908 | ||
|
|
6857e3cf06 | ||
|
|
772cbdbe38 | ||
|
|
b2e2b1a038 | ||
|
|
d237ecf161 | ||
|
|
4a3d570155 | ||
|
|
ebcdc17c13 | ||
|
|
45e06b9bad | ||
|
|
c6b46ccbe1 | ||
|
|
9fc0fe71bf | ||
|
|
06df0795c8 | ||
|
|
eafeb6a012 | ||
|
|
05431509be | ||
|
|
9d3136e512 | ||
|
|
60566f22fb | ||
|
|
50440e4047 | ||
|
|
f45268aedc | ||
|
|
cc54e14114 | ||
|
|
1037ef768d | ||
|
|
c3dcf02e3c | ||
|
|
b157e3c7e6 |
@@ -0,0 +1,62 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What happened?
|
||||
placeholder: Describe the bug clearly and concisely.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this?
|
||||
placeholder: |
|
||||
1. Run `openfang start`
|
||||
2. Open dashboard at http://localhost:4200
|
||||
3. Click ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpenFang Version
|
||||
description: Output of `openfang -V`
|
||||
placeholder: "0.3.23"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- Linux (x86_64)
|
||||
- Linux (aarch64/ARM64)
|
||||
- macOS (Apple Silicon)
|
||||
- macOS (Intel)
|
||||
- Windows
|
||||
- Android (Termux)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Screenshots
|
||||
description: Paste relevant logs or attach screenshots.
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What feature would you like?
|
||||
placeholder: Describe the feature and why it would be useful.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you tried any workarounds?
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, screenshots, or references.
|
||||
@@ -0,0 +1,17 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "ci"
|
||||
@@ -0,0 +1,19 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? Link related issues with "Fixes #123". -->
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- Brief list of what changed. -->
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes
|
||||
- [ ] `cargo test --workspace` passes
|
||||
- [ ] Live integration tested (if applicable)
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] No new unsafe code
|
||||
- [ ] No secrets or API keys in diff
|
||||
- [ ] User input validated at boundaries
|
||||
@@ -181,6 +181,11 @@ jobs:
|
||||
- name: Build CLI
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: cargo build --release --target ${{ matrix.target }} --bin openfang
|
||||
- name: Ad-hoc codesign CLI binary (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
xattr -cr target/${{ matrix.target }}/release/openfang || true
|
||||
codesign --force --sign - target/${{ matrix.target }}/release/openfang
|
||||
- name: Package (Unix)
|
||||
if: matrix.archive == 'tar.gz'
|
||||
run: |
|
||||
|
||||
Generated
+306
-325
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.10"
|
||||
version = "0.3.42"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -49,6 +49,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
|
||||
# IDs
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
@@ -119,9 +120,15 @@ colored = "3"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
|
||||
# HTML entity decoding
|
||||
html-escape = "0.2"
|
||||
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
# Socket options (SO_REUSEADDR)
|
||||
socket2 = "0.5"
|
||||
|
||||
# Zip archive extraction
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
|
||||
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
|
||||
<img src="https://img.shields.io/badge/version-0.3.30-green?style=flat-square" alt="v0.3.30" />
|
||||
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
@@ -27,9 +27,9 @@
|
||||
|
||||
---
|
||||
|
||||
> **v0.1.0 — First Release (February 2026)**
|
||||
> **v0.3.30 — Security Hardening Release (March 2026)**
|
||||
>
|
||||
> OpenFang is feature-complete but this is the first public release. You may encounter instability, rough edges, or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
> OpenFang is feature-complete but still pre-1.0. You may encounter rough edges or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
|
||||
---
|
||||
|
||||
@@ -371,7 +371,7 @@ cargo fmt --all -- --check
|
||||
|
||||
## Stability Notice
|
||||
|
||||
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
OpenFang v0.3.30 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
|
||||
- **Breaking changes** may occur between minor versions until v1.0
|
||||
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
|
||||
@@ -382,6 +382,12 @@ We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
To report a security vulnerability, email **jaber@rightnowai.co**. We take all reports seriously and will respond within 48 hours.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — use it however you want.
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| 0.1.x | :white_check_mark: |
|
||||
| 0.3.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -14,7 +14,7 @@ If you discover a security vulnerability in OpenFang, please report it responsib
|
||||
|
||||
### How to Report
|
||||
|
||||
1. Email: **security@openfang.ai**
|
||||
1. Email: **jaber@rightnowai.co**
|
||||
2. Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
|
||||
@@ -33,9 +33,10 @@ governor = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -73,6 +73,33 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
async fn send_message_with_blocks(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
blocks: Vec<openfang_types::message::ContentBlock>,
|
||||
) -> Result<String, String> {
|
||||
// Extract text for the message parameter (used for memory recall / logging)
|
||||
let text: String = blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
openfang_types::message::ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let text = if text.is_empty() {
|
||||
"[Image]".to_string()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let result = self
|
||||
.kernel
|
||||
.send_message_with_blocks(agent_id, &text, blocks)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
async fn find_agent_by_name(&self, name: &str) -> Result<Option<AgentId>, String> {
|
||||
Ok(self.kernel.registry.find_by_name(name).map(|e| e.id))
|
||||
}
|
||||
@@ -648,7 +675,16 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
self.kernel
|
||||
.set_agent_model(agent_id, model)
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(format!("Model switched to: {model}"))
|
||||
// Read back resolved model+provider from registry
|
||||
let entry = self
|
||||
.kernel
|
||||
.registry
|
||||
.get(agent_id)
|
||||
.ok_or_else(|| "Agent not found after model switch".to_string())?;
|
||||
Ok(format!(
|
||||
"Model switched to: {} (provider: {})",
|
||||
entry.manifest.model.model, entry.manifest.model.provider
|
||||
))
|
||||
}
|
||||
|
||||
async fn stop_run(&self, agent_id: AgentId) -> Result<String, String> {
|
||||
@@ -1030,6 +1066,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
token,
|
||||
tg_config.allowed_users.clone(),
|
||||
poll_interval,
|
||||
tg_config.api_url.clone(),
|
||||
));
|
||||
adapters.push((adapter, tg_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1041,6 +1078,8 @@ pub async fn start_channel_bridge_with_config(
|
||||
let adapter = Arc::new(DiscordAdapter::new(
|
||||
token,
|
||||
dc_config.allowed_guilds.clone(),
|
||||
dc_config.allowed_users.clone(),
|
||||
dc_config.ignore_bots,
|
||||
dc_config.intents,
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
@@ -1620,6 +1659,35 @@ pub async fn reload_channels_from_disk(
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
// Re-read secrets.env so new API tokens are available in std::env
|
||||
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
|
||||
if secrets_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&secrets_path) {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(eq_pos) = trimmed.find('=') {
|
||||
let key = trimmed[..eq_pos].trim();
|
||||
let mut value = trimmed[eq_pos + 1..].trim().to_string();
|
||||
if !key.is_empty() {
|
||||
// Strip matching quotes
|
||||
if ((value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\'')))
|
||||
&& value.len() >= 2
|
||||
{
|
||||
value = value[1..value.len() - 1].to_string();
|
||||
}
|
||||
// Always overwrite — the file is the source of truth after dashboard edits
|
||||
std::env::set_var(key, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("Reloaded secrets.env for channel hot-reload");
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read config from disk
|
||||
let config_path = state.kernel.config.home_dir.join("config.toml");
|
||||
let fresh_config = openfang_kernel::config::load_config(Some(&config_path));
|
||||
|
||||
@@ -45,39 +45,17 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
|
||||
|
||||
/// Bearer token authentication middleware.
|
||||
///
|
||||
/// When `api_key` is non-empty, all requests must include
|
||||
/// `Authorization: Bearer <api_key>`. If the key is empty, auth is bypassed.
|
||||
/// When `api_key` is non-empty (after trimming), requests to non-public
|
||||
/// endpoints must include `Authorization: Bearer <api_key>`.
|
||||
/// If the key is empty or whitespace-only, auth is disabled entirely
|
||||
/// (public/local development mode).
|
||||
pub async fn auth(
|
||||
axum::extract::State(api_key): axum::extract::State<String>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
// If no API key configured, restrict to loopback addresses only.
|
||||
if api_key.is_empty() {
|
||||
let is_loopback = request
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_loopback {
|
||||
tracing::warn!(
|
||||
"Rejected non-localhost request: no API key configured. \
|
||||
Set api_key in config.toml for remote access."
|
||||
);
|
||||
return Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"error": "No API key configured. Remote access denied. Configure api_key in ~/.openfang/config.toml"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
return next.run(request).await;
|
||||
}
|
||||
// SECURITY: Capture method early for method-aware public endpoint checks.
|
||||
let method = request.method().clone();
|
||||
|
||||
// Shutdown is loopback-only (CLI on same machine) — skip token auth
|
||||
let path = request.uri().path();
|
||||
@@ -86,52 +64,67 @@ pub async fn auth(
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(true); // default true for unix sockets / tests
|
||||
.unwrap_or(false); // SECURITY: default-deny — unknown origin is NOT loopback
|
||||
if is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these)
|
||||
if path == "/"
|
||||
// Public endpoints that don't require auth (dashboard needs these).
|
||||
// SECURITY: /api/agents is GET-only (listing). POST (spawn) requires auth.
|
||||
// SECURITY: Public endpoints are GET-only unless explicitly noted.
|
||||
// POST/PUT/DELETE to any endpoint ALWAYS requires auth to prevent
|
||||
// unauthenticated writes (cron job creation, skill install, etc.).
|
||||
let is_get = method == axum::http::Method::GET;
|
||||
let is_public = path == "/"
|
||||
|| path == "/logo.png"
|
||||
|| path == "/favicon.ico"
|
||||
|| path == "/.well-known/agent.json"
|
||||
|| path.starts_with("/a2a/")
|
||||
|| (path == "/.well-known/agent.json" && is_get)
|
||||
|| (path.starts_with("/a2a/") && is_get)
|
||||
|| path == "/api/health"
|
||||
|| path == "/api/health/detail"
|
||||
|| path == "/api/status"
|
||||
|| path == "/api/version"
|
||||
|| path == "/api/agents"
|
||||
|| path == "/api/profiles"
|
||||
|| path == "/api/config"
|
||||
|| path.starts_with("/api/uploads/")
|
||||
|| (path == "/api/agents" && is_get)
|
||||
|| (path == "/api/profiles" && is_get)
|
||||
|| (path == "/api/config" && is_get)
|
||||
|| (path == "/api/config/schema" && is_get)
|
||||
|| (path.starts_with("/api/uploads/") && is_get)
|
||||
// Dashboard read endpoints — allow unauthenticated so the SPA can
|
||||
// render before the user enters their API key.
|
||||
|| path == "/api/models"
|
||||
|| path == "/api/models/aliases"
|
||||
|| path == "/api/providers"
|
||||
|| path == "/api/budget"
|
||||
|| path == "/api/budget/agents"
|
||||
|| path.starts_with("/api/budget/agents/")
|
||||
|| path == "/api/network/status"
|
||||
|| path == "/api/a2a/agents"
|
||||
|| path == "/api/approvals"
|
||||
|| path.starts_with("/api/approvals/")
|
||||
|| path == "/api/channels"
|
||||
|| path == "/api/hands"
|
||||
|| path == "/api/hands/active"
|
||||
|| path.starts_with("/api/hands/")
|
||||
|| path == "/api/skills"
|
||||
|| path == "/api/sessions"
|
||||
|| path == "/api/integrations"
|
||||
|| path == "/api/integrations/available"
|
||||
|| path == "/api/integrations/health"
|
||||
|| path == "/api/workflows"
|
||||
|| path == "/api/logs/stream"
|
||||
|| path.starts_with("/api/cron/")
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/")
|
||||
{
|
||||
|| (path == "/api/models" && is_get)
|
||||
|| (path == "/api/models/aliases" && is_get)
|
||||
|| (path == "/api/providers" && is_get)
|
||||
|| (path == "/api/budget" && is_get)
|
||||
|| (path == "/api/budget/agents" && is_get)
|
||||
|| (path.starts_with("/api/budget/agents/") && is_get)
|
||||
|| (path == "/api/network/status" && is_get)
|
||||
|| (path == "/api/a2a/agents" && is_get)
|
||||
|| (path == "/api/approvals" && is_get)
|
||||
|| (path.starts_with("/api/approvals/") && is_get)
|
||||
|| (path == "/api/channels" && is_get)
|
||||
|| (path == "/api/hands" && is_get)
|
||||
|| (path == "/api/hands/active" && is_get)
|
||||
|| (path.starts_with("/api/hands/") && is_get)
|
||||
|| (path == "/api/skills" && is_get)
|
||||
|| (path == "/api/sessions" && is_get)
|
||||
|| (path == "/api/integrations" && is_get)
|
||||
|| (path == "/api/integrations/available" && is_get)
|
||||
|| (path == "/api/integrations/health" && is_get)
|
||||
|| (path == "/api/workflows" && is_get)
|
||||
|| path == "/api/logs/stream" // SSE stream, read-only
|
||||
|| (path.starts_with("/api/cron/") && is_get)
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/");
|
||||
|
||||
if is_public {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// If no API key configured (empty, whitespace-only, or missing), skip auth
|
||||
// entirely. Users who don't set api_key accept that all endpoints are open.
|
||||
// To secure the dashboard, set a non-empty api_key in config.toml.
|
||||
let api_key = api_key.trim();
|
||||
if api_key.is_empty() {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
@@ -218,6 +211,10 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
"cache-control",
|
||||
"no-store, no-cache, must-revalidate".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"strict-transport-security",
|
||||
"max-age=63072000; includeSubDomains".parse().unwrap(),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
@@ -179,9 +179,8 @@ fn resolve_agent(state: &AppState, model: &str) -> Option<(AgentId, String)> {
|
||||
return Some((entry.id, entry.name.clone()));
|
||||
}
|
||||
|
||||
// 4. Fallback → first registered agent
|
||||
let agents = state.kernel.registry.list();
|
||||
agents.first().map(|e| (e.id, e.name.clone()))
|
||||
// No match — return None so the caller returns a proper 404
|
||||
None
|
||||
}
|
||||
|
||||
// ── Message conversion ──────────────────────────────────────────────────────
|
||||
@@ -336,7 +335,7 @@ pub async fn chat_completions(
|
||||
index: 0,
|
||||
message: ChoiceMessage {
|
||||
role: "assistant",
|
||||
content: Some(result.response),
|
||||
content: Some(crate::ws::strip_think_tags(&result.response)),
|
||||
tool_calls: None,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
|
||||
+654
-142
File diff suppressed because it is too large
Load Diff
@@ -50,11 +50,12 @@ pub async fn build_router(
|
||||
channels_config: tokio::sync::RwLock::new(channels_config),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
// is protected anyway. For development, permissive CORS is convenient.
|
||||
let cors = if state.kernel.config.api_key.is_empty() {
|
||||
let cors = if state.kernel.config.api_key.trim().is_empty() {
|
||||
// No auth → restrict CORS to localhost origins (include both 127.0.0.1 and localhost)
|
||||
let port = listen_addr.port();
|
||||
let mut origins: Vec<axum::http::HeaderValue> = vec![
|
||||
@@ -102,7 +103,8 @@ pub async fn build_router(
|
||||
.allow_headers(tower_http::cors::Any)
|
||||
};
|
||||
|
||||
let api_key = state.kernel.config.api_key.clone();
|
||||
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let gcra_limiter = rate_limiter::create_rate_limiter();
|
||||
|
||||
let app = Router::new()
|
||||
@@ -126,7 +128,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}",
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent),
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/mode",
|
||||
@@ -740,7 +742,8 @@ pub async fn run_daemon(
|
||||
if info_path.exists() {
|
||||
if let Ok(existing) = std::fs::read_to_string(info_path) {
|
||||
if let Ok(info) = serde_json::from_str::<DaemonInfo>(&existing) {
|
||||
if is_process_alive(info.pid) {
|
||||
// PID alive AND the health endpoint responds → truly running
|
||||
if is_process_alive(info.pid) && is_daemon_responding(&info.listen_addr) {
|
||||
return Err(format!(
|
||||
"Another daemon (PID {}) is already running at {}",
|
||||
info.pid, info.listen_addr
|
||||
@@ -749,7 +752,8 @@ pub async fn run_daemon(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stale PID file, remove it
|
||||
// Stale PID file (process dead or different process reused PID), remove it
|
||||
info!("Removing stale daemon info file");
|
||||
let _ = std::fs::remove_file(info_path);
|
||||
}
|
||||
|
||||
@@ -771,7 +775,22 @@ pub async fn run_daemon(
|
||||
info!("WebChat UI available at http://{addr}/",);
|
||||
info!("WebSocket endpoint: ws://{addr}/api/agents/{{id}}/ws",);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
// Use SO_REUSEADDR to allow binding immediately after reboot (avoids TIME_WAIT).
|
||||
let socket = socket2::Socket::new(
|
||||
if addr.is_ipv4() {
|
||||
socket2::Domain::IPV4
|
||||
} else {
|
||||
socket2::Domain::IPV6
|
||||
},
|
||||
socket2::Type::STREAM,
|
||||
None,
|
||||
)?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(1024)?;
|
||||
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
|
||||
@@ -891,3 +910,26 @@ fn is_process_alive(pid: u32) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an OpenFang daemon is actually responding at the given address.
|
||||
/// This avoids false positives where a different process reused the same PID
|
||||
/// after a system reboot.
|
||||
fn is_daemon_responding(addr: &str) -> bool {
|
||||
// Quick TCP connect check — don't make a full HTTP request to avoid delays
|
||||
let addr_only = addr
|
||||
.strip_prefix("http://")
|
||||
.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()
|
||||
} else {
|
||||
// Fallback: try connecting to hostname
|
||||
std::net::TcpStream::connect(addr_only)
|
||||
.map(|_| true)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,9 +140,23 @@ impl StreamChunker {
|
||||
}
|
||||
|
||||
/// Find the last occurrence of a pattern within a byte range.
|
||||
///
|
||||
/// Both `range.start` and `range.end` are clamped to the nearest valid UTF-8
|
||||
/// char boundary so that slicing never panics on multi-byte content.
|
||||
fn find_last_in_range(text: &str, pattern: &str, range: &std::ops::Range<usize>) -> Option<usize> {
|
||||
let search_text = &text[range.start..range.end.min(text.len())];
|
||||
search_text.rfind(pattern).map(|pos| range.start + pos)
|
||||
let len = text.len();
|
||||
// Clamp end to text length and walk back to a char boundary
|
||||
let mut end = range.end.min(len);
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
// Walk start forward to the nearest char boundary (never past end)
|
||||
let mut start = range.start.min(end);
|
||||
while start < end && !text.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
let search_text = &text[start..end];
|
||||
search_text.rfind(pattern).map(|pos| start + pos)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request to spawn an agent from a TOML manifest string.
|
||||
/// Request to spawn an agent from a TOML manifest string or a template name.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SpawnRequest {
|
||||
/// Agent manifest as TOML string.
|
||||
/// Agent manifest as TOML string (optional if `template` is provided).
|
||||
#[serde(default)]
|
||||
pub manifest_toml: String,
|
||||
/// Template name from `~/.openfang/agents/{template}/agent.toml`.
|
||||
/// When provided and `manifest_toml` is empty, the template is loaded automatically.
|
||||
#[serde(default)]
|
||||
pub template: Option<String>,
|
||||
/// Optional Ed25519 signed manifest envelope (JSON).
|
||||
/// When present, the signature is verified before spawning.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -146,19 +146,30 @@ pub async fn agent_ws(
|
||||
uri: axum::http::Uri,
|
||||
) -> impl IntoResponse {
|
||||
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
|
||||
let api_key = &state.kernel.config.api_key;
|
||||
// Trim whitespace so empty/whitespace-only api_key disables auth.
|
||||
let api_key_raw = &state.kernel.config.api_key;
|
||||
let api_key = api_key_raw.trim();
|
||||
if !api_key.is_empty() {
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks on API key
|
||||
let ct_eq = |token: &str, key: &str| -> bool {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != key.len() {
|
||||
return false;
|
||||
}
|
||||
token.as_bytes().ct_eq(key.as_bytes()).into()
|
||||
};
|
||||
|
||||
let header_auth = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|token| token == api_key)
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
let query_auth = uri
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(|token| token == api_key)
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !header_auth && !query_auth {
|
||||
@@ -621,8 +632,12 @@ async fn handle_text_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip <think>...</think> blocks from model output
|
||||
// (e.g. MiniMax, DeepSeek reasoning tokens)
|
||||
let cleaned_response = strip_think_tags(&result.response);
|
||||
|
||||
// Guard: ensure we never send an empty response
|
||||
let content = if result.response.trim().is_empty() {
|
||||
let content = if cleaned_response.trim().is_empty() {
|
||||
format!(
|
||||
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
|
||||
result.total_usage.input_tokens,
|
||||
@@ -630,7 +645,7 @@ async fn handle_text_message(
|
||||
result.iterations,
|
||||
)
|
||||
} else {
|
||||
result.response
|
||||
cleaned_response
|
||||
};
|
||||
|
||||
// Estimate context pressure from last call
|
||||
@@ -796,12 +811,19 @@ async fn handle_command(
|
||||
} else {
|
||||
match state.kernel.set_agent_model(agent_id, args) {
|
||||
Ok(()) => {
|
||||
let msg = if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
format!("Model switched to: {} (provider: {})", entry.manifest.model.model, entry.manifest.model.provider)
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
let model = &entry.manifest.model.model;
|
||||
let provider = &entry.manifest.model.provider;
|
||||
serde_json::json!({
|
||||
"type": "command_result",
|
||||
"command": cmd,
|
||||
"message": format!("Model switched to: {model} (provider: {provider})"),
|
||||
"model": model,
|
||||
"provider": provider
|
||||
})
|
||||
} else {
|
||||
format!("Model switched to: {args}")
|
||||
};
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")})
|
||||
@@ -1114,10 +1136,19 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(),
|
||||
llm_errors::LlmErrorCategory::ModelNotFound => {
|
||||
"Model unavailable. Use /model to see options.".to_string()
|
||||
if inner.contains("localhost:11434") || inner.contains("ollama") {
|
||||
"Model not found on Ollama. Run `ollama pull <model>` to download it, then try again. Use /model to see options.".to_string()
|
||||
} else {
|
||||
"Model unavailable. Use /model to see options or check your provider configuration.".to_string()
|
||||
}
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Format => {
|
||||
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
|
||||
// Claude Code CLI errors have actionable messages — pass them through
|
||||
if inner.contains("Claude Code CLI") || inner.contains("claude auth") {
|
||||
classified.raw_message.clone()
|
||||
} else {
|
||||
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
|
||||
}
|
||||
}
|
||||
_ => classified.sanitized_message,
|
||||
}
|
||||
@@ -1152,6 +1183,27 @@ fn extract_status_code(s: &str) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip `<think>...</think>` blocks from model output.
|
||||
///
|
||||
/// Some models (MiniMax, DeepSeek, etc.) wrap their reasoning in `<think>` tags.
|
||||
/// These are internal chain-of-thought and shouldn't be shown to the user.
|
||||
pub fn strip_think_tags(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut remaining = text;
|
||||
while let Some(start) = remaining.find("<think>") {
|
||||
result.push_str(&remaining[..start]);
|
||||
if let Some(end) = remaining[start..].find("</think>") {
|
||||
remaining = &remaining[(start + end + 8)..]; // 8 = "</think>".len()
|
||||
} else {
|
||||
// Unclosed <think> tag — strip to end
|
||||
remaining = "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1228,4 +1280,18 @@ mod tests {
|
||||
fn test_sanitize_trims_whitespace() {
|
||||
assert_eq!(sanitize_user_input(" hello "), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_think_tags() {
|
||||
assert_eq!(
|
||||
strip_think_tags("<think>reasoning here</think>The answer is 42."),
|
||||
"The answer is 42."
|
||||
);
|
||||
assert_eq!(
|
||||
strip_think_tags("Hello <think>\nsome thinking\n</think> world"),
|
||||
"Hello world"
|
||||
);
|
||||
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
|
||||
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,32 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Card-based flex containers for agent chips and similar inline layouts */
|
||||
.card-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Nested list indentation inside cards, detail panels, and modals */
|
||||
.card ul, .card ol,
|
||||
.detail-grid ul, .detail-grid ol,
|
||||
.modal ul, .modal ol,
|
||||
.info-card ul, .info-card ol {
|
||||
padding-left: 18px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.card ul ul, .card ol ol,
|
||||
.modal ul ul, .modal ol ol {
|
||||
padding-left: 16px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.card li, .modal li, .info-card li {
|
||||
margin-bottom: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Glow effect on card hover */
|
||||
.card-glow {
|
||||
overflow: hidden;
|
||||
@@ -90,13 +116,17 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge + .badge { margin-left: 4px; }
|
||||
|
||||
.badge-running { background: rgba(74,222,128,0.12); color: var(--success); }
|
||||
.badge-suspended { background: rgba(245,158,11,0.12); color: var(--warning); }
|
||||
@@ -110,7 +140,7 @@
|
||||
.badge-error { background: rgba(239,68,68,0.12); color: var(--error); }
|
||||
.badge-muted { background: rgba(148,163,184,0.12); color: var(--text-dim); }
|
||||
.badge-info { background: rgba(59,130,246,0.12); color: var(--info); }
|
||||
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; }
|
||||
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; padding: 2px 6px; }
|
||||
.text-danger { color: var(--error); }
|
||||
|
||||
/* Tables */
|
||||
@@ -949,6 +979,14 @@ mark.search-highlight {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-switcher-search select {
|
||||
max-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.model-switcher-search select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.model-switcher-search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
[data-theme="light"] .sidebar-logo img,
|
||||
[data-theme="light"] .message-avatar img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
|
||||
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
||||
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
|
||||
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
||||
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
|
||||
</div>
|
||||
@@ -735,7 +736,7 @@
|
||||
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
|
||||
</div>
|
||||
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
|
||||
@keydown.enter.prevent="if(!$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.enter.prevent="if(!$event.isComposing && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false; showModelPicker = false"
|
||||
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@@ -769,6 +770,12 @@
|
||||
<div class="model-switcher-search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
|
||||
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
|
||||
<option value="">All</option>
|
||||
<template x-for="pn in switcherProviders" :key="pn">
|
||||
<option :value="pn" x-text="pn"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="modelSwitching" style="display:flex;align-items:center;justify-content:center;padding:12px;gap:8px">
|
||||
<div class="tool-card-spinner"></div>
|
||||
@@ -841,6 +848,9 @@
|
||||
<div class="text-xs text-dim font-mono" style="font-size:11px" x-text="agent.model_name"></div>
|
||||
</div>
|
||||
<span class="badge" :class="'badge-' + agent.state.toLowerCase()" x-text="agent.state" style="font-size:10px"></span>
|
||||
<button class="agent-chip-config-btn" @click.stop="showDetail(agent)" title="Agent settings" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--text-dim);transition:all 0.15s;flex-shrink:0" @mouseenter="$el.style.borderColor='var(--accent)';$el.style.color='var(--accent)';$el.style.background='var(--surface2)'" @mouseleave="$el.style.borderColor='var(--border)';$el.style.color='var(--text-dim)';$el.style.background='transparent'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -912,6 +922,36 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value" x-text="detailAgent.created_at ? new Date(detailAgent.created_at).toLocaleString() : '-'"></span></div>
|
||||
|
||||
<!-- Fallback Model Chain -->
|
||||
<div class="detail-row" style="align-items:flex-start">
|
||||
<span class="detail-label">Fallbacks</span>
|
||||
<div style="flex:1">
|
||||
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
|
||||
<div>
|
||||
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
|
||||
<div class="flex gap-1 items-center" style="margin-bottom:4px">
|
||||
<span class="badge" style="font-size:11px;font-family:var(--font-mono)" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger)" @click="removeFallback(idx)">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!detailAgent._fallbacks || detailAgent._fallbacks.length === 0">
|
||||
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
|
||||
</template>
|
||||
<template x-if="!editingFallback">
|
||||
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px;margin-top:4px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
|
||||
</template>
|
||||
<template x-if="editingFallback">
|
||||
<div class="flex gap-1 mt-1" style="align-items:center">
|
||||
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
|
||||
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" @click="chatWithAgent(detailAgent); showDetailModal = false">Chat</button>
|
||||
@@ -1428,7 +1468,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Label</label>
|
||||
<input class="form-input" x-model="selectedNode.label" style="font-size:11px">
|
||||
<input class="form-input" x-model="selectedNode.label" @input="applyNodeEdit()" style="font-size:11px">
|
||||
</div>
|
||||
|
||||
<!-- Agent config -->
|
||||
@@ -1436,7 +1476,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Agent</label>
|
||||
<select class="form-select" x-model="selectedNode.config.agent_name" style="font-size:11px">
|
||||
<select class="form-select" x-model="selectedNode.config.agent_name" @change="applyNodeEdit()" style="font-size:11px">
|
||||
<option value="">Select agent...</option>
|
||||
<template x-for="a in agents" :key="a.id || a.name">
|
||||
<option :value="a.name" x-text="a.name"></option>
|
||||
@@ -1445,11 +1485,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Prompt Template</label>
|
||||
<textarea class="form-textarea" x-model="selectedNode.config.prompt" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
|
||||
<textarea class="form-textarea" x-model="selectedNode.config.prompt" @input="applyNodeEdit()" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Model (optional)</label>
|
||||
<input class="form-input" x-model="selectedNode.config.model" style="font-size:11px" placeholder="Default model">
|
||||
<input class="form-input" x-model="selectedNode.config.model" @input="applyNodeEdit()" style="font-size:11px" placeholder="Default model">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1459,7 +1499,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Expression</label>
|
||||
<input class="form-input" x-model="selectedNode.config.expression" style="font-size:11px" placeholder="output.contains('yes')">
|
||||
<input class="form-input" x-model="selectedNode.config.expression" @input="applyNodeEdit()" style="font-size:11px" placeholder="output.contains('yes')">
|
||||
</div>
|
||||
<div class="text-xs text-dim">Top port = true, bottom port = false</div>
|
||||
</div>
|
||||
@@ -1470,11 +1510,11 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Max Iterations</label>
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" style="font-size:11px" min="1" max="100">
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" @input="applyNodeEdit()" style="font-size:11px" min="1" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Until (stop condition)</label>
|
||||
<input class="form-input" x-model="selectedNode.config.until" style="font-size:11px" placeholder="output === 'done'">
|
||||
<input class="form-input" x-model="selectedNode.config.until" @input="applyNodeEdit()" style="font-size:11px" placeholder="output === 'done'">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1484,7 +1524,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Fan-out Count</label>
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" style="font-size:11px" min="2" max="10">
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" @input="applyNodeEdit()" style="font-size:11px" min="2" max="10">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1494,7 +1534,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Strategy</label>
|
||||
<select class="form-select" x-model="selectedNode.config.strategy" style="font-size:11px">
|
||||
<select class="form-select" x-model="selectedNode.config.strategy" @change="applyNodeEdit()" style="font-size:11px">
|
||||
<option value="all">Wait for all</option>
|
||||
<option value="first">First to finish</option>
|
||||
<option value="majority">Majority vote</option>
|
||||
@@ -2787,7 +2827,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
|
||||
<!-- Text type -->
|
||||
<template x-if="setting.setting_type === 'text'">
|
||||
<input type="text" class="input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
|
||||
<input type="text" class="form-input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3003,6 +3043,30 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Add Custom Provider -->
|
||||
<div class="info-card mt-4" style="border:1px solid var(--border)">
|
||||
<h4 style="margin-top:0">Add Custom Provider</h4>
|
||||
<p class="text-xs text-dim mb-2">Connect any OpenAI-compatible API (vLLM, LiteLLM, LocalAI, etc.)</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
||||
<div>
|
||||
<label class="text-xs text-dim">Provider Name</label>
|
||||
<input class="form-input" x-model="customProviderName" placeholder="e.g. my-local-llm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Base URL (required)</label>
|
||||
<input class="form-input" x-model="customProviderUrl" placeholder="http://localhost:8080/v1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="text-xs text-dim">API Key (optional)</label>
|
||||
<input class="form-input" type="password" x-model="customProviderKey" placeholder="sk-... (leave blank if not needed)">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="addCustomProvider()" :disabled="!customProviderName.trim() || !customProviderUrl.trim() || addingCustomProvider">
|
||||
<span x-show="!addingCustomProvider">Add Provider</span>
|
||||
<span x-show="addingCustomProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
||||
</button>
|
||||
<span class="text-xs text-dim ml-2" x-text="customProviderStatus"></span>
|
||||
</div>
|
||||
<div class="empty-state" x-show="!providers.length">
|
||||
<h4>No providers found</h4>
|
||||
<p class="hint">Provider information could not be loaded. Check that the API is running.</p>
|
||||
@@ -3067,7 +3131,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
<div class="table-wrap" x-show="filteredModels.length">
|
||||
<table>
|
||||
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th></tr></thead>
|
||||
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="m in filteredModels" :key="m.id">
|
||||
<tr>
|
||||
@@ -3078,6 +3142,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<td class="text-xs" x-text="formatCost(m.input_cost_per_m)"></td>
|
||||
<td class="text-xs" x-text="formatCost(m.output_cost_per_m)"></td>
|
||||
<td><span class="badge" :class="m.available ? 'badge-success' : 'badge-muted'" x-text="m.available ? 'Available' : 'Needs Key'"></span></td>
|
||||
<td><button x-show="m.tier === 'custom'" class="btn btn-ghost btn-sm" @click="deleteCustomModel(m.id)" title="Delete custom model" style="padding:2px 6px;font-size:11px;color:var(--text-muted)">✕</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
@@ -3307,11 +3372,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<!-- Network tab -->
|
||||
<div x-show="tab === 'network'" x-data="{
|
||||
netStatus: null, a2aAgents: [], a2aDiscoverUrl: '', a2aDiscovering: false,
|
||||
async loadNetStatus() { try { this.netStatus = await (await fetch('/api/network/status')).json(); } catch(e) {} },
|
||||
async loadA2aAgents() { try { let r = await (await fetch('/api/a2a/agents')).json(); this.a2aAgents = r.agents || []; } catch(e) {} },
|
||||
async loadNetStatus() { try { this.netStatus = await OpenFangAPI.get('/api/network/status'); } catch(e) {} },
|
||||
async loadA2aAgents() { try { let r = await OpenFangAPI.get('/api/a2a/agents'); this.a2aAgents = r.agents || []; } catch(e) {} },
|
||||
async discoverA2a() {
|
||||
if (!this.a2aDiscoverUrl) return; this.a2aDiscovering = true;
|
||||
try { await fetch('/api/a2a/discover', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:this.a2aDiscoverUrl})}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
|
||||
try { await OpenFangAPI.post('/api/a2a/discover', {url:this.a2aDiscoverUrl}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
|
||||
this.a2aDiscovering = false;
|
||||
}
|
||||
}" x-init="loadNetStatus(); loadA2aAgents()">
|
||||
@@ -3399,8 +3464,8 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
this.budgetLoading = true;
|
||||
try {
|
||||
let [b, a] = await Promise.all([
|
||||
fetch('/api/budget').then(r => r.json()),
|
||||
fetch('/api/budget/agents').then(r => r.json())
|
||||
OpenFangAPI.get('/api/budget'),
|
||||
OpenFangAPI.get('/api/budget/agents')
|
||||
]);
|
||||
this.budgetData = b;
|
||||
this.agentRanking = a.agents || [];
|
||||
@@ -3423,10 +3488,10 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
if (+this.editMonthly !== this.budgetData.monthly_limit) body.max_monthly_usd = +this.editMonthly;
|
||||
let alertVal = (+this.editAlert) / 100;
|
||||
if (Math.abs(alertVal - this.budgetData.alert_threshold) > 0.001) body.alert_threshold = alertVal;
|
||||
await fetch('/api/budget', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
await OpenFangAPI.put('/api/budget', body);
|
||||
this.editMode = false;
|
||||
await this.loadBudget();
|
||||
} catch(e) { alert('Failed to save: ' + e); }
|
||||
} catch(e) { OpenFangToast.error('Failed to save: ' + (e.message || e)); }
|
||||
this.saving = false;
|
||||
},
|
||||
pctColor(pct) { return pct >= 0.8 ? '#ef4444' : pct >= 0.5 ? '#eab308' : '#22c55e'; },
|
||||
@@ -4388,7 +4453,29 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj)">
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider === 'claude-code'">
|
||||
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
|
||||
<div class="card-header">Configure Claude Code</div>
|
||||
<div class="text-xs text-dim mb-2" style="line-height:1.8">
|
||||
Claude Code uses its own CLI authentication — no API key needed.
|
||||
</div>
|
||||
<div style="background:var(--bg);border-radius:4px;padding:10px 12px;margin-bottom:12px;font-size:12px;line-height:1.8">
|
||||
<div><span style="color:var(--accent)">1.</span> Install: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
|
||||
<div><span style="color:var(--accent)">2.</span> Authenticate: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">claude auth</code></div>
|
||||
<div><span style="color:var(--accent)">3.</span> Click <strong>Detect</strong> below to verify</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="detectClaudeCode()" :disabled="testingProvider">
|
||||
<span x-show="!testingProvider">Detect Claude Code</span>
|
||||
<span x-show="testingProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
||||
</button>
|
||||
<div x-show="testResult" class="mt-2">
|
||||
<div x-show="testResult && testResult.status === 'ok'" class="badge badge-success" style="padding:6px 12px">Claude Code detected<span x-show="testResult && testResult.latency_ms" x-text="' (' + (testResult ? testResult.latency_ms : '') + 'ms)'"></span></div>
|
||||
<div x-show="testResult && testResult.status !== 'ok'" class="badge badge-error" style="padding:6px 12px">Claude Code CLI not detected. Make sure you’ve run: <code>npm install -g @anthropic-ai/claude-code && claude auth</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider !== 'claude-code'">
|
||||
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
|
||||
<div class="card-header" x-text="'Configure ' + selectedProviderObj.display_name"></div>
|
||||
<div class="text-xs text-dim mb-2" x-show="selectedProviderObj.api_key_env">
|
||||
|
||||
@@ -161,6 +161,17 @@ var OpenFangAPI = (function() {
|
||||
return fetch(BASE + path, opts).then(function(r) {
|
||||
if (_connectionState !== 'connected') setConnectionState('connected');
|
||||
if (!r.ok) {
|
||||
// On 401, auto-show auth prompt so the user can re-enter their key
|
||||
if (r.status === 401 && typeof Alpine !== 'undefined') {
|
||||
try {
|
||||
var store = Alpine.store('app');
|
||||
if (store && !store.showAuthPrompt) {
|
||||
_authToken = '';
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
store.showAuthPrompt = true;
|
||||
}
|
||||
} catch(e2) { /* ignore Alpine errors */ }
|
||||
}
|
||||
return r.text().then(function(text) {
|
||||
var msg = '';
|
||||
try {
|
||||
|
||||
@@ -63,6 +63,9 @@ function agentsPage() {
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
modelSaving: false,
|
||||
// -- Fallback chain --
|
||||
editingFallback: false,
|
||||
newFallbackValue: '',
|
||||
|
||||
// -- Templates state --
|
||||
tplTemplates: [],
|
||||
@@ -316,12 +319,15 @@ function agentsPage() {
|
||||
OpenFangAPI.wsDisconnect();
|
||||
},
|
||||
|
||||
showDetail(agent) {
|
||||
async showDetail(agent) {
|
||||
this.detailAgent = agent;
|
||||
this.detailAgent._fallbacks = [];
|
||||
this.detailTab = 'info';
|
||||
this.agentFiles = [];
|
||||
this.editingFile = null;
|
||||
this.fileContent = '';
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
this.configForm = {
|
||||
name: agent.name || '',
|
||||
system_prompt: agent.system_prompt || '',
|
||||
@@ -331,6 +337,11 @@ function agentsPage() {
|
||||
vibe: (agent.identity && agent.identity.vibe) || ''
|
||||
};
|
||||
this.showDetailModal = true;
|
||||
// Fetch full agent detail to get fallback_models
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
|
||||
this.detailAgent._fallbacks = full.fallback_models || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
},
|
||||
|
||||
killAgent(agent) {
|
||||
@@ -400,7 +411,7 @@ function agentsPage() {
|
||||
var f = this.spawnForm;
|
||||
var si = this.spawnIdentity;
|
||||
var lines = [
|
||||
'name = "' + f.name + '"',
|
||||
'name = "' + f.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"',
|
||||
'module = "builtin:chat"'
|
||||
];
|
||||
if (f.profile && f.profile !== 'custom') {
|
||||
@@ -409,7 +420,7 @@ function agentsPage() {
|
||||
lines.push('', '[model]');
|
||||
lines.push('provider = "' + f.provider + '"');
|
||||
lines.push('model = "' + f.model + '"');
|
||||
lines.push('system_prompt = "' + f.systemPrompt.replace(/"/g, '\\"') + '"');
|
||||
lines.push('system_prompt = """\n' + f.systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"') + '\n"""');
|
||||
if (f.profile === 'custom') {
|
||||
lines.push('', '[capabilities]');
|
||||
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
|
||||
@@ -586,8 +597,9 @@ function agentsPage() {
|
||||
if (!this.detailAgent || !this.newModelValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
OpenFangToast.success('Model changed (memory reset)');
|
||||
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
var providerInfo = (resp && resp.provider) ? ' (provider: ' + resp.provider + ')' : '';
|
||||
OpenFangToast.success('Model changed' + providerInfo + ' (memory reset)');
|
||||
this.editingModel = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
// Refresh detailAgent
|
||||
@@ -601,6 +613,41 @@ function agentsPage() {
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Fallback model chain ──
|
||||
async addFallback() {
|
||||
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
|
||||
var parts = this.newFallbackValue.trim().split('/');
|
||||
var provider = parts.length > 1 ? parts[0] : this.detailAgent.model_provider;
|
||||
var model = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
|
||||
if (!this.detailAgent._fallbacks) this.detailAgent._fallbacks = [];
|
||||
this.detailAgent._fallbacks.push({ provider: provider, model: model });
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback added: ' + provider + '/' + model);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.pop();
|
||||
}
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
},
|
||||
|
||||
async removeFallback(idx) {
|
||||
if (!this.detailAgent || !this.detailAgent._fallbacks) return;
|
||||
var removed = this.detailAgent._fallbacks.splice(idx, 1);
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback removed');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.splice(idx, 0, removed[0]);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tool filters ──
|
||||
async loadToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
|
||||
@@ -37,6 +37,7 @@ function chatPage() {
|
||||
// Model switcher dropdown
|
||||
showModelSwitcher: false,
|
||||
modelSwitcherFilter: '',
|
||||
modelSwitcherProviderFilter: '',
|
||||
modelSwitcherIdx: 0,
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
@@ -99,14 +100,25 @@ function chatPage() {
|
||||
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
|
||||
},
|
||||
|
||||
get switcherProviders() {
|
||||
var seen = {};
|
||||
(this._modelCache || []).forEach(function(m) { seen[m.provider] = true; });
|
||||
return Object.keys(seen).sort();
|
||||
},
|
||||
|
||||
get filteredSwitcherModels() {
|
||||
var models = this._modelCache || [];
|
||||
if (!this.modelSwitcherFilter) return models;
|
||||
var f = this.modelSwitcherFilter.toLowerCase();
|
||||
var provFilter = this.modelSwitcherProviderFilter;
|
||||
var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : '';
|
||||
if (!provFilter && !textFilter) return models;
|
||||
return models.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(f) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
if (provFilter && m.provider !== provFilter) return false;
|
||||
if (textFilter) {
|
||||
return m.id.toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(textFilter) !== -1;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -220,6 +232,7 @@ function chatPage() {
|
||||
var now = Date.now();
|
||||
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
|
||||
this.modelSwitcherFilter = '';
|
||||
this.modelSwitcherProviderFilter = '';
|
||||
this.modelSwitcherIdx = 0;
|
||||
this.showModelSwitcher = true;
|
||||
this.$nextTick(function() {
|
||||
@@ -234,6 +247,7 @@ function chatPage() {
|
||||
self._modelCacheTime = Date.now();
|
||||
self.modelPickerList = models;
|
||||
self.modelSwitcherFilter = '';
|
||||
self.modelSwitcherProviderFilter = '';
|
||||
self.modelSwitcherIdx = 0;
|
||||
self.showModelSwitcher = true;
|
||||
self.$nextTick(function() {
|
||||
@@ -250,9 +264,10 @@ function chatPage() {
|
||||
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
this.modelSwitching = true;
|
||||
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function() {
|
||||
self.currentAgent.model_name = model.id;
|
||||
self.currentAgent.model_provider = model.provider;
|
||||
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) {
|
||||
// Use server-resolved model/provider to stay in sync (fixes #387/#466)
|
||||
self.currentAgent.model_name = (resp && resp.model) || model.id;
|
||||
self.currentAgent.model_provider = (resp && resp.provider) || model.provider;
|
||||
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
|
||||
self.showModelSwitcher = false;
|
||||
self.modelSwitching = false;
|
||||
@@ -406,9 +421,13 @@ function chatPage() {
|
||||
case '/model':
|
||||
if (self.currentAgent) {
|
||||
if (cmdArgs) {
|
||||
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function() {
|
||||
self.currentAgent.model_name = cmdArgs;
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`', meta: '', tools: [] });
|
||||
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) {
|
||||
// Use server-resolved model/provider (fixes #387/#466)
|
||||
var resolvedModel = (resp && resp.model) || cmdArgs;
|
||||
var resolvedProvider = (resp && resp.provider) || '';
|
||||
self.currentAgent.model_name = resolvedModel;
|
||||
if (resolvedProvider) { self.currentAgent.model_provider = resolvedProvider; }
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + resolvedModel + '`' + (resolvedProvider ? ' (provider: `' + resolvedProvider + '`)' : ''), meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); });
|
||||
} else {
|
||||
@@ -517,7 +536,10 @@ function chatPage() {
|
||||
is_error: !!t.is_error
|
||||
};
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools };
|
||||
var images = (m.images || []).map(function(img) {
|
||||
return { file_id: img.file_id, filename: img.filename || 'image' };
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools, images: images };
|
||||
});
|
||||
self.$nextTick(function() { self.scrollToBottom(); });
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ function settingsPage() {
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
customProviderName: '',
|
||||
customProviderUrl: '',
|
||||
customProviderKey: '',
|
||||
customProviderStatus: '',
|
||||
addingCustomProvider: false,
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
@@ -258,6 +263,17 @@ function settingsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomModel(modelId) {
|
||||
if (!confirm('Delete custom model "' + modelId + '"?')) return;
|
||||
try {
|
||||
await OpenFangAPI.del('/api/models/custom/' + encodeURIComponent(modelId));
|
||||
OpenFangToast.success('Model deleted');
|
||||
await this.loadModels();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete: ' + (e.message || 'Unknown error'));
|
||||
}
|
||||
},
|
||||
|
||||
async loadConfigSchema() {
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
@@ -279,11 +295,14 @@ function settingsPage() {
|
||||
|
||||
async saveConfigField(section, field, value) {
|
||||
var key = section + '.' + field;
|
||||
// Root-level fields (api_key, api_listen, log_level) use just the field name
|
||||
var sectionMeta = this.configSchema && this.configSchema[section];
|
||||
var path = (sectionMeta && sectionMeta.root_level) ? field : key;
|
||||
this.configSaving[key] = true;
|
||||
try {
|
||||
await OpenFangAPI.post('/api/config/set', { path: key, value: value });
|
||||
await OpenFangAPI.post('/api/config/set', { path: path, value: value });
|
||||
this.configDirty[key] = false;
|
||||
OpenFangToast.success('Saved ' + key);
|
||||
OpenFangToast.success('Saved ' + field);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save: ' + e.message);
|
||||
}
|
||||
@@ -333,7 +352,10 @@ function settingsPage() {
|
||||
|
||||
providerAuthText(p) {
|
||||
if (p.auth_status === 'configured') return 'Configured';
|
||||
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'Not Set';
|
||||
if (p.auth_status === 'not_set' || p.auth_status === 'missing') {
|
||||
if (p.id === 'claude-code') return 'Not Installed';
|
||||
return 'Not Set';
|
||||
}
|
||||
return 'No Key Needed';
|
||||
},
|
||||
|
||||
@@ -488,6 +510,34 @@ function settingsPage() {
|
||||
this.providerUrlSaving[provider.id] = false;
|
||||
},
|
||||
|
||||
async addCustomProvider() {
|
||||
var name = this.customProviderName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
||||
if (!name) { OpenFangToast.error('Please enter a provider name'); return; }
|
||||
var url = this.customProviderUrl.trim();
|
||||
if (!url) { OpenFangToast.error('Please enter a base URL'); return; }
|
||||
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
OpenFangToast.error('URL must start with http:// or https://'); return;
|
||||
}
|
||||
this.addingCustomProvider = true;
|
||||
this.customProviderStatus = '';
|
||||
try {
|
||||
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(name) + '/url', { base_url: url });
|
||||
if (this.customProviderKey.trim()) {
|
||||
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(name) + '/key', { key: this.customProviderKey.trim() });
|
||||
}
|
||||
this.customProviderName = '';
|
||||
this.customProviderUrl = '';
|
||||
this.customProviderKey = '';
|
||||
this.customProviderStatus = '';
|
||||
OpenFangToast.success('Provider "' + name + '" added' + (result.reachable ? ' (reachable)' : ' (not reachable yet)'));
|
||||
await this.loadProviders();
|
||||
} catch(e) {
|
||||
this.customProviderStatus = 'Error: ' + (e.message || 'Failed');
|
||||
OpenFangToast.error('Failed to add provider: ' + e.message);
|
||||
}
|
||||
this.addingCustomProvider = false;
|
||||
},
|
||||
|
||||
// -- Security methods --
|
||||
async loadSecurity() {
|
||||
this.secLoading = true;
|
||||
|
||||
@@ -283,11 +283,13 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get canGoNext() {
|
||||
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider;
|
||||
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider || this.claudeCodeDetected;
|
||||
if (this.step === 3) return this.agentName.trim().length > 0;
|
||||
return true;
|
||||
},
|
||||
|
||||
claudeCodeDetected: false,
|
||||
|
||||
get hasConfiguredProvider() {
|
||||
var self = this;
|
||||
return this.providers.some(function(p) {
|
||||
@@ -320,7 +322,7 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get popularProviders() {
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
|
||||
return this.providers.filter(function(p) {
|
||||
return popular.indexOf(p.id) >= 0;
|
||||
}).sort(function(a, b) {
|
||||
@@ -329,7 +331,7 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get otherProviders() {
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
|
||||
return this.providers.filter(function(p) {
|
||||
return popular.indexOf(p.id) < 0;
|
||||
});
|
||||
@@ -355,7 +357,8 @@ function wizardPage() {
|
||||
fireworks: { url: 'https://fireworks.ai/account/api-keys', text: 'Get your key from Fireworks AI' },
|
||||
perplexity: { url: 'https://www.perplexity.ai/settings/api', text: 'Get your key from Perplexity Settings' },
|
||||
cohere: { url: 'https://dashboard.cohere.com/api-keys', text: 'Get your key from the Cohere Dashboard' },
|
||||
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' }
|
||||
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' },
|
||||
'claude-code': { url: 'https://docs.anthropic.com/en/docs/claude-code', text: 'Install: npm install -g @anthropic-ai/claude-code && claude auth (no API key needed)' }
|
||||
};
|
||||
return help[id] || null;
|
||||
},
|
||||
@@ -408,6 +411,28 @@ function wizardPage() {
|
||||
this.testingProvider = false;
|
||||
},
|
||||
|
||||
async detectClaudeCode() {
|
||||
this.testingProvider = true;
|
||||
this.testResult = null;
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/providers/claude-code/test', {});
|
||||
this.testResult = result;
|
||||
if (result.status === 'ok') {
|
||||
this.claudeCodeDetected = true;
|
||||
this.keySaved = true;
|
||||
this.setupSummary.provider = 'Claude Code';
|
||||
OpenFangToast.success('Claude Code detected (' + (result.latency_ms || '?') + 'ms)');
|
||||
} else {
|
||||
this.testResult = { status: 'error', error: 'Claude Code CLI not detected' };
|
||||
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
|
||||
}
|
||||
} catch(e) {
|
||||
this.testResult = { status: 'error', error: e.message };
|
||||
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
|
||||
}
|
||||
this.testingProvider = false;
|
||||
},
|
||||
|
||||
// ── Step 3: Agent creation ──
|
||||
|
||||
selectTemplate(index) {
|
||||
@@ -468,13 +493,14 @@ function wizardPage() {
|
||||
gemini: 'gemini-2.5-flash',
|
||||
groq: 'llama-3.3-70b-versatile',
|
||||
deepseek: 'deepseek-chat',
|
||||
openrouter: 'openrouter/auto',
|
||||
openrouter: 'openrouter/google/gemini-2.5-flash',
|
||||
mistral: 'mistral-large-latest',
|
||||
together: 'meta-llama/Llama-3-70b-chat-hf',
|
||||
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||
perplexity: 'llama-3.1-sonar-large-128k-online',
|
||||
cohere: 'command-r-plus',
|
||||
xai: 'grok-2'
|
||||
xai: 'grok-2',
|
||||
'claude-code': 'claude-code/sonnet'
|
||||
};
|
||||
return defaults[providerId] || '';
|
||||
},
|
||||
|
||||
@@ -38,6 +38,11 @@ function workflowBuilder() {
|
||||
],
|
||||
|
||||
_renderScheduled: false,
|
||||
_lastClickNodeId: null,
|
||||
_lastClickTime: 0,
|
||||
_didDrag: false,
|
||||
_didConnect: false,
|
||||
_didPan: false,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
@@ -334,8 +339,23 @@ function workflowBuilder() {
|
||||
|
||||
onNodeMouseDown: function(node, e) {
|
||||
e.stopPropagation();
|
||||
// Detect double-click manually — the native dblclick event never fires
|
||||
// because scheduleRender() destroys and recreates all SVG elements between
|
||||
// the first and second click, so the browser loses the DOM target for dblclick.
|
||||
var now = Date.now();
|
||||
if (this._lastClickNodeId === node.id && (now - this._lastClickTime) < 350) {
|
||||
// Double-click detected — open editor instead of starting drag
|
||||
this._lastClickNodeId = null;
|
||||
this._lastClickTime = 0;
|
||||
this.editNode(node);
|
||||
return;
|
||||
}
|
||||
this._lastClickNodeId = node.id;
|
||||
this._lastClickTime = now;
|
||||
|
||||
this.selectedNode = node;
|
||||
this.selectedConnection = null;
|
||||
this._didDrag = false;
|
||||
this.dragging = node.id;
|
||||
var rect = this._getCanvasRect();
|
||||
this.dragOffset = {
|
||||
@@ -350,6 +370,7 @@ function workflowBuilder() {
|
||||
this.selectedConnection = null;
|
||||
this.showNodeEditor = false;
|
||||
// Start canvas pan
|
||||
this._didPan = false;
|
||||
this.canvasDragging = true;
|
||||
this.canvasDragStart = { x: e.clientX - this.canvasOffset.x * this.zoom, y: e.clientY - this.canvasOffset.y * this.zoom };
|
||||
},
|
||||
@@ -357,6 +378,7 @@ function workflowBuilder() {
|
||||
onCanvasMouseMove: function(e) {
|
||||
var rect = this._getCanvasRect();
|
||||
if (this.dragging) {
|
||||
this._didDrag = true;
|
||||
var node = this.getNode(this.dragging);
|
||||
if (node) {
|
||||
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
|
||||
@@ -364,12 +386,14 @@ function workflowBuilder() {
|
||||
}
|
||||
this.scheduleRender();
|
||||
} else if (this.connecting) {
|
||||
this._didConnect = true;
|
||||
this.connectPreview = {
|
||||
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
|
||||
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
|
||||
};
|
||||
this.scheduleRender();
|
||||
} else if (this.canvasDragging) {
|
||||
this._didPan = true;
|
||||
this.canvasOffset = {
|
||||
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
|
||||
y: (e.clientY - this.canvasDragStart.y) / this.zoom
|
||||
@@ -378,11 +402,19 @@ function workflowBuilder() {
|
||||
},
|
||||
|
||||
onCanvasMouseUp: function() {
|
||||
// Only re-render if something actually moved. Rendering on every mouseup
|
||||
// destroys SVG elements between clicks, which prevents dblclick detection.
|
||||
var needsRender = this._didDrag || this._didConnect || this._didPan;
|
||||
this.dragging = null;
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.canvasDragging = false;
|
||||
this.scheduleRender();
|
||||
this._didDrag = false;
|
||||
this._didConnect = false;
|
||||
this._didPan = false;
|
||||
if (needsRender) {
|
||||
this.scheduleRender();
|
||||
}
|
||||
},
|
||||
|
||||
onCanvasWheel: function(e) {
|
||||
@@ -427,6 +459,12 @@ function workflowBuilder() {
|
||||
editNode: function(node) {
|
||||
this.selectedNode = node;
|
||||
this.showNodeEditor = true;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// Called from editor panel inputs to reflect changes on the canvas SVG
|
||||
applyNodeEdit: function() {
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── TOML Generation ──────────────────────────────────
|
||||
|
||||
@@ -77,6 +77,7 @@ async fn start_test_server_with_provider(
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -705,6 +706,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let api_key_state = state.kernel.config.api_key.clone();
|
||||
|
||||
@@ -114,6 +114,7 @@ async fn test_full_daemon_lifecycle() {
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -238,6 +239,7 @@ async fn test_server_immediate_responsiveness() {
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -58,6 +58,7 @@ async fn start_test_server() -> TestServer {
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -26,6 +26,7 @@ hmac = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
html-escape = { workspace = true }
|
||||
|
||||
lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
|
||||
@@ -215,7 +215,7 @@ impl BlueskyAdapter {
|
||||
let chunks = split_message(text, MAX_MESSAGE_LEN);
|
||||
|
||||
for chunk in chunks {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
|
||||
let mut record = serde_json::json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
@@ -435,7 +435,11 @@ impl ChannelAdapter for BlueskyAdapter {
|
||||
service_url
|
||||
);
|
||||
if let Some(ref seen) = last_seen_at {
|
||||
url.push_str(&format!("&seenAt={}", seen));
|
||||
let encoded: String = url::form_urlencoded::Serializer::new(String::new())
|
||||
.append_pair("seenAt", seen)
|
||||
.finish();
|
||||
url.push('&');
|
||||
url.push_str(&encoded);
|
||||
}
|
||||
|
||||
let resp = match client.get(&url).bearer_auth(&token).send().await {
|
||||
@@ -492,7 +496,7 @@ impl ChannelAdapter for BlueskyAdapter {
|
||||
if last_seen_at.is_some() {
|
||||
let mark_url = format!("{}/xrpc/app.bsky.notification.updateSeen", service_url);
|
||||
let mark_body = serde_json::json!({
|
||||
"seenAt": Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
|
||||
"seenAt": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
});
|
||||
let _ = client
|
||||
.post(&mark_url)
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
|
||||
use crate::formatter;
|
||||
use crate::router::AgentRouter;
|
||||
use crate::types::{ChannelAdapter, ChannelContent, ChannelMessage, ChannelUser};
|
||||
use crate::types::{
|
||||
default_phase_emoji, AgentPhase, ChannelAdapter, ChannelContent, ChannelMessage, ChannelUser,
|
||||
LifecycleReaction,
|
||||
};
|
||||
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};
|
||||
@@ -25,6 +29,26 @@ pub trait ChannelBridgeHandle: Send + Sync {
|
||||
/// Send a message to an agent and get the text response.
|
||||
async fn send_message(&self, agent_id: AgentId, message: &str) -> Result<String, String>;
|
||||
|
||||
/// Send a message with structured content blocks (text + images) to an agent.
|
||||
///
|
||||
/// Default implementation extracts text from blocks and falls back to `send_message()`.
|
||||
async fn send_message_with_blocks(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
blocks: Vec<ContentBlock>,
|
||||
) -> Result<String, String> {
|
||||
// Default: extract text from blocks and send as plain text
|
||||
let text: String = blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
self.send_message(agent_id, &text).await
|
||||
}
|
||||
|
||||
/// Find an agent by name, returning its ID.
|
||||
async fn find_agent_by_name(&self, name: &str) -> Result<Option<AgentId>, String>;
|
||||
|
||||
@@ -359,6 +383,25 @@ async fn send_response(
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a lifecycle reaction (best-effort, non-blocking for supported adapters).
|
||||
///
|
||||
/// Silently ignores errors — reactions are non-critical UX polish.
|
||||
/// For Telegram, the underlying HTTP call is already fire-and-forget (spawned internally),
|
||||
/// so this await returns almost immediately.
|
||||
async fn send_lifecycle_reaction(
|
||||
adapter: &dyn ChannelAdapter,
|
||||
user: &ChannelUser,
|
||||
message_id: &str,
|
||||
phase: AgentPhase,
|
||||
) {
|
||||
let reaction = LifecycleReaction {
|
||||
emoji: default_phase_emoji(&phase).to_string(),
|
||||
phase,
|
||||
remove_previous: true,
|
||||
};
|
||||
let _ = adapter.send_reaction(user, message_id, &reaction).await;
|
||||
}
|
||||
|
||||
/// Dispatch a single incoming message — handles bot commands or routes to an agent.
|
||||
///
|
||||
/// Applies per-channel policies (DM/group filtering, rate limiting, formatting, threading).
|
||||
@@ -407,8 +450,15 @@ async fn dispatch_message(
|
||||
}
|
||||
}
|
||||
GroupPolicy::MentionOnly => {
|
||||
// Pass through — adapters should only forward mentioned messages.
|
||||
// This is a hint for adapters, not enforced here.
|
||||
// Only allow messages where the bot was @mentioned or commands.
|
||||
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 { .. });
|
||||
if !was_mentioned && !is_command {
|
||||
debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
GroupPolicy::All => {}
|
||||
}
|
||||
@@ -439,24 +489,53 @@ async fn dispatch_message(
|
||||
}
|
||||
}
|
||||
|
||||
let text = match &message.content {
|
||||
ChannelContent::Text(t) => t.clone(),
|
||||
ChannelContent::Command { name, args } => {
|
||||
let result = handle_command(name, args, handle, router, &message.sender).await;
|
||||
send_response(adapter, &message.sender, result, thread_id, output_format).await;
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
send_response(
|
||||
// Handle commands first (early return)
|
||||
if let ChannelContent::Command { ref name, ref args } = message.content {
|
||||
let result = handle_command(name, args, handle, router, &message.sender).await;
|
||||
send_response(adapter, &message.sender, result, thread_id, output_format).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// For images: download, base64 encode, and send as multimodal content blocks
|
||||
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 { .. })) {
|
||||
// We have actual image data — send as structured blocks for vision
|
||||
dispatch_with_blocks(
|
||||
blocks,
|
||||
message,
|
||||
handle,
|
||||
router,
|
||||
adapter,
|
||||
&message.sender,
|
||||
"I can only handle text messages for now.".to_string(),
|
||||
ct_str,
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
// Image download failed — fall through to text description below
|
||||
}
|
||||
|
||||
let text = match &message.content {
|
||||
ChannelContent::Text(t) => t.clone(),
|
||||
ChannelContent::Command { .. } => unreachable!(), // handled above
|
||||
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 } => {
|
||||
format!("[User sent a file ({filename}): {url}]")
|
||||
}
|
||||
ChannelContent::Voice { ref url, duration_seconds } => {
|
||||
format!("[User sent a voice message ({duration_seconds}s): {url}]")
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
format!("[User shared location: {lat}, {lon}]")
|
||||
}
|
||||
};
|
||||
|
||||
// Check if it's a slash command embedded in text (e.g. "/agents")
|
||||
@@ -641,15 +720,210 @@ async fn dispatch_message(
|
||||
// Send typing indicator (best-effort)
|
||||
let _ = adapter.send_typing(&message.sender).await;
|
||||
|
||||
// Lifecycle reaction: ⏳ Queued → 🤔 Thinking → ✅ Done / ❌ Error
|
||||
let msg_id = &message.platform_message_id;
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
|
||||
|
||||
// Send to agent and relay response
|
||||
match handle.send_message(agent_id, &text).await {
|
||||
Ok(response) => {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
|
||||
send_response(adapter, &message.sender, response, thread_id, output_format).await;
|
||||
handle
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
|
||||
warn!("Agent error for {agent_id}: {e}");
|
||||
let err_msg = format!("Agent error: {e}");
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
err_msg.clone(),
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
handle
|
||||
.record_delivery(
|
||||
agent_id,
|
||||
ct_str,
|
||||
&message.sender.platform_id,
|
||||
false,
|
||||
Some(&err_msg),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Download an image from a URL and build content blocks for multimodal LLM input.
|
||||
///
|
||||
/// Returns a `Vec<ContentBlock>` containing an image block (base64-encoded) and
|
||||
/// optionally a text block for the caption. If the download fails, returns a
|
||||
/// text-only block describing the failure.
|
||||
async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec<ContentBlock> {
|
||||
use base64::Engine;
|
||||
|
||||
// 5 MB limit to prevent memory abuse from oversized images
|
||||
const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = match client.get(url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("Failed to download image from channel: {e}");
|
||||
return vec![ContentBlock::Text {
|
||||
text: format!("[Image download failed: {e}]"),
|
||||
}];
|
||||
}
|
||||
};
|
||||
|
||||
// Detect media type from Content-Type header, fall back to URL extension
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string());
|
||||
|
||||
let media_type = content_type.unwrap_or_else(|| {
|
||||
if url.contains(".png") {
|
||||
"image/png".to_string()
|
||||
} else if url.contains(".gif") {
|
||||
"image/gif".to_string()
|
||||
} else if url.contains(".webp") {
|
||||
"image/webp".to_string()
|
||||
} else {
|
||||
"image/jpeg".to_string()
|
||||
}
|
||||
});
|
||||
|
||||
let bytes = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("Failed to read image bytes: {e}");
|
||||
return vec![ContentBlock::Text {
|
||||
text: format!("[Image read failed: {e}]"),
|
||||
}];
|
||||
}
|
||||
};
|
||||
|
||||
if bytes.len() > MAX_IMAGE_BYTES {
|
||||
warn!(
|
||||
"Image too large ({} bytes), skipping vision — sending as text",
|
||||
bytes.len()
|
||||
);
|
||||
let desc = match caption {
|
||||
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 }];
|
||||
}
|
||||
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
let mut blocks = Vec::new();
|
||||
|
||||
// Caption as text block first (gives the LLM context about the image)
|
||||
if let Some(cap) = caption {
|
||||
if !cap.is_empty() {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: cap.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
blocks.push(ContentBlock::Image { media_type, data });
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Dispatch a multimodal message (content blocks) to an agent, handling routing
|
||||
/// and RBAC the same way as the text path.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dispatch_with_blocks(
|
||||
blocks: Vec<ContentBlock>,
|
||||
message: &ChannelMessage,
|
||||
handle: &Arc<dyn ChannelBridgeHandle>,
|
||||
router: &Arc<AgentRouter>,
|
||||
adapter: &dyn ChannelAdapter,
|
||||
ct_str: &str,
|
||||
thread_id: Option<&str>,
|
||||
output_format: OutputFormat,
|
||||
) {
|
||||
// Route to agent (same logic as text path)
|
||||
let agent_id = router.resolve(
|
||||
&message.channel,
|
||||
&message.sender.platform_id,
|
||||
message.sender.openfang_user.as_deref(),
|
||||
);
|
||||
|
||||
let agent_id = match agent_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let fallback = handle.find_agent_by_name("assistant").await.ok().flatten();
|
||||
let fallback = match fallback {
|
||||
Some(id) => Some(id),
|
||||
None => handle
|
||||
.list_agents()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|agents| agents.first().map(|(id, _)| *id)),
|
||||
};
|
||||
match fallback {
|
||||
Some(id) => {
|
||||
router.set_user_default(message.sender.platform_id.clone(), id);
|
||||
id
|
||||
}
|
||||
None => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"No agents available. Start the dashboard at http://127.0.0.1:4200 to create one.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// RBAC check
|
||||
if let Err(denied) = handle
|
||||
.authorize_channel_user(ct_str, &message.sender.platform_id, "chat")
|
||||
.await
|
||||
{
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
format!("Access denied: {denied}"),
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = adapter.send_typing(&message.sender).await;
|
||||
|
||||
// Lifecycle reaction: ⏳ Queued → 🤔 Thinking → ✅ Done / ❌ Error
|
||||
let msg_id = &message.platform_message_id;
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
|
||||
|
||||
match handle.send_message_with_blocks(agent_id, blocks).await {
|
||||
Ok(response) => {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
|
||||
send_response(adapter, &message.sender, response, thread_id, output_format).await;
|
||||
handle
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
|
||||
warn!("Agent error for {agent_id}: {e}");
|
||||
let err_msg = format!("Agent error: {e}");
|
||||
send_response(
|
||||
@@ -1112,4 +1386,52 @@ mod tests {
|
||||
"irc"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message_with_blocks_default_fallback() {
|
||||
// The default implementation of send_message_with_blocks extracts text
|
||||
// from blocks and calls send_message
|
||||
let agent_id = AgentId::new();
|
||||
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
|
||||
agents: Mutex::new(vec![(agent_id, "vision-agent".to_string())]),
|
||||
});
|
||||
|
||||
let blocks = vec![
|
||||
ContentBlock::Text {
|
||||
text: "What is in this photo?".to_string(),
|
||||
},
|
||||
ContentBlock::Image {
|
||||
media_type: "image/jpeg".to_string(),
|
||||
data: "base64data".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// Default impl should extract text and call send_message
|
||||
let result = handle
|
||||
.send_message_with_blocks(agent_id, blocks)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "Echo: What is in this photo?");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message_with_blocks_image_only() {
|
||||
// When there's no text block, the default should still work
|
||||
let agent_id = AgentId::new();
|
||||
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
|
||||
agents: Mutex::new(vec![(agent_id, "vision-agent".to_string())]),
|
||||
});
|
||||
|
||||
let blocks = vec![ContentBlock::Image {
|
||||
media_type: "image/png".to_string(),
|
||||
data: "base64data".to_string(),
|
||||
}];
|
||||
|
||||
// Default impl sends empty text when no text blocks
|
||||
let result = handle
|
||||
.send_message_with_blocks(agent_id, blocks)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "Echo: ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ pub struct DiscordAdapter {
|
||||
token: Zeroizing<String>,
|
||||
client: reqwest::Client,
|
||||
allowed_guilds: Vec<String>,
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
@@ -51,12 +53,20 @@ pub struct DiscordAdapter {
|
||||
}
|
||||
|
||||
impl DiscordAdapter {
|
||||
pub fn new(token: String, allowed_guilds: Vec<String>, intents: u64) -> Self {
|
||||
pub fn new(
|
||||
token: String,
|
||||
allowed_guilds: Vec<String>,
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
token: Zeroizing::new(token),
|
||||
client: reqwest::Client::new(),
|
||||
allowed_guilds,
|
||||
allowed_users,
|
||||
ignore_bots,
|
||||
intents,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
@@ -147,6 +157,8 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
let token = self.token.clone();
|
||||
let intents = self.intents;
|
||||
let allowed_guilds = self.allowed_guilds.clone();
|
||||
let allowed_users = self.allowed_users.clone();
|
||||
let ignore_bots = self.ignore_bots;
|
||||
let bot_user_id = self.bot_user_id.clone();
|
||||
let session_id_store = self.session_id.clone();
|
||||
let resume_url_store = self.resume_gateway_url.clone();
|
||||
@@ -307,7 +319,7 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
|
||||
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
|
||||
if let Some(msg) =
|
||||
parse_discord_message(d, &bot_user_id, &allowed_guilds)
|
||||
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users, ignore_bots)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
@@ -423,6 +435,8 @@ async fn parse_discord_message(
|
||||
d: &serde_json::Value,
|
||||
bot_user_id: &Arc<RwLock<Option<String>>>,
|
||||
allowed_guilds: &[String],
|
||||
allowed_users: &[String],
|
||||
ignore_bots: bool,
|
||||
) -> Option<ChannelMessage> {
|
||||
let author = d.get("author")?;
|
||||
let author_id = author["id"].as_str()?;
|
||||
@@ -434,8 +448,14 @@ async fn parse_discord_message(
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out other bots
|
||||
if author["bot"].as_bool() == Some(true) {
|
||||
// Filter out other bots (configurable via ignore_bots)
|
||||
if ignore_bots && author["bot"].as_bool() == Some(true) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Filter by allowed users
|
||||
if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == author_id) {
|
||||
debug!("Discord: ignoring message from unlisted user {author_id}");
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -486,6 +506,29 @@ async fn parse_discord_message(
|
||||
ChannelContent::Text(content_text.to_string())
|
||||
};
|
||||
|
||||
// Determine if this is a group message (guild_id present = server channel)
|
||||
let is_group = d["guild_id"].as_str().is_some();
|
||||
|
||||
// Check if bot was @mentioned (for MentionOnly policy enforcement)
|
||||
let was_mentioned = if let Some(ref bid) = *bot_user_id.read().await {
|
||||
// Check Discord mentions array
|
||||
let mentioned_in_array = d["mentions"]
|
||||
.as_array()
|
||||
.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}>"));
|
||||
mentioned_in_array || mentioned_in_content
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
if was_mentioned {
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
|
||||
}
|
||||
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Discord,
|
||||
platform_message_id: message_id.to_string(),
|
||||
@@ -497,9 +540,9 @@ async fn parse_discord_message(
|
||||
content,
|
||||
target_agent: None,
|
||||
timestamp,
|
||||
is_group: true,
|
||||
is_group,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -523,7 +566,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).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");
|
||||
@@ -545,7 +588,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -565,7 +608,52 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_ignore_bots_false_allows_other_bots() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "ch1",
|
||||
"content": "Bot message",
|
||||
"author": {
|
||||
"id": "other_bot",
|
||||
"username": "somebot",
|
||||
"discriminator": "0",
|
||||
"bot": true
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
// With ignore_bots=false, other bots' messages should be allowed
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
|
||||
assert!(msg.is_some());
|
||||
let msg = msg.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "somebot");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Bot message"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_ignore_bots_false_still_filters_self() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "ch1",
|
||||
"content": "My own message",
|
||||
"author": {
|
||||
"id": "bot123",
|
||||
"username": "openfang",
|
||||
"discriminator": "0",
|
||||
"bot": true
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
// Even with ignore_bots=false, the bot's own messages must still be filtered
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -586,11 +674,11 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &["999".into()]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -609,7 +697,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -634,7 +722,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -653,7 +741,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "alice#1234");
|
||||
}
|
||||
|
||||
@@ -675,16 +763,105 @@ mod tests {
|
||||
});
|
||||
|
||||
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).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")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_allowed_users_filter() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "ch1",
|
||||
"content": "Hello",
|
||||
"author": {
|
||||
"id": "user999",
|
||||
"username": "bob",
|
||||
"discriminator": "0"
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
// Not in allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()], true).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()], true).await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_mention_detection() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
|
||||
// Message with bot mentioned in mentions array
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "ch1",
|
||||
"guild_id": "guild1",
|
||||
"content": "Hey <@bot123> help me",
|
||||
"mentions": [{"id": "bot123", "username": "openfang"}],
|
||||
"author": {
|
||||
"id": "user1",
|
||||
"username": "alice",
|
||||
"discriminator": "0"
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
// Message without mention in group
|
||||
let d2 = serde_json::json!({
|
||||
"id": "msg2",
|
||||
"channel_id": "ch1",
|
||||
"guild_id": "guild1",
|
||||
"content": "Just chatting",
|
||||
"author": {
|
||||
"id": "user1",
|
||||
"username": "alice",
|
||||
"discriminator": "0"
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert!(msg2.is_group);
|
||||
assert!(!msg2.metadata.contains_key("was_mentioned"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_dm_not_group() {
|
||||
let bot_id = Arc::new(RwLock::new(None));
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "dm-ch1",
|
||||
"content": "Hello",
|
||||
"author": {
|
||||
"id": "user1",
|
||||
"username": "alice",
|
||||
"discriminator": "0"
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
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()], 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);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,21 @@ use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// SASL PLAIN authenticator for IMAP servers that reject LOGIN
|
||||
/// (e.g., Lark/Larksuite which only advertise AUTH=PLAIN).
|
||||
struct PlainAuthenticator {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl imap::Authenticator for PlainAuthenticator {
|
||||
type Response = String;
|
||||
fn process(&self, _data: &[u8]) -> Self::Response {
|
||||
// SASL PLAIN: \0<username>\0<password>
|
||||
format!("\x00{}\x00{}", self.username, self.password)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reply context for email threading (In-Reply-To / Subject continuity).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ReplyCtx {
|
||||
@@ -203,9 +218,22 @@ fn fetch_unseen_emails(
|
||||
let client = imap::connect((host, port), host, &tls)
|
||||
.map_err(|e| format!("IMAP connect failed: {e}"))?;
|
||||
|
||||
let mut session = client
|
||||
.login(username, password)
|
||||
.map_err(|(e, _)| format!("IMAP login failed: {e}"))?;
|
||||
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
|
||||
// that reject LOGIN and only support AUTH=PLAIN (SASL).
|
||||
let mut session = match client.login(username, password) {
|
||||
Ok(s) => s,
|
||||
Err((login_err, client)) => {
|
||||
let authenticator = PlainAuthenticator {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
client
|
||||
.authenticate("PLAIN", &authenticator)
|
||||
.map_err(|(e, _)| {
|
||||
format!("IMAP login failed: {login_err}; AUTH=PLAIN also failed: {e}")
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
|
||||
@@ -298,17 +298,8 @@ fn strip_html_tags(html: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Decode HTML entities
|
||||
let decoded = result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ");
|
||||
|
||||
// Decode HTML entities (handles named, decimal, and hex entities)
|
||||
let decoded = html_escape::decode_html_entities(&result);
|
||||
decoded.trim().to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ 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: {}...)", &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());
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
LifecycleReaction,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
@@ -13,7 +14,7 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Maximum backoff duration on API failures.
|
||||
@@ -23,13 +24,18 @@ const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
/// Telegram long-polling timeout (seconds) — sent as the `timeout` parameter to getUpdates.
|
||||
const LONG_POLL_TIMEOUT: u64 = 30;
|
||||
|
||||
/// Default Telegram Bot API base URL.
|
||||
const DEFAULT_API_URL: &str = "https://api.telegram.org";
|
||||
|
||||
/// Telegram Bot API adapter using long-polling.
|
||||
pub struct TelegramAdapter {
|
||||
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
|
||||
token: Zeroizing<String>,
|
||||
client: reqwest::Client,
|
||||
allowed_users: Vec<i64>,
|
||||
allowed_users: Vec<String>,
|
||||
poll_interval: Duration,
|
||||
/// Base URL for Telegram Bot API (supports proxies/mirrors).
|
||||
api_base_url: String,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
}
|
||||
@@ -39,13 +45,24 @@ impl TelegramAdapter {
|
||||
///
|
||||
/// `token` is the raw bot token (read from env by the caller).
|
||||
/// `allowed_users` is the list of Telegram user IDs allowed to interact (empty = allow all).
|
||||
pub fn new(token: String, allowed_users: Vec<i64>, poll_interval: Duration) -> Self {
|
||||
/// `api_url` overrides the Telegram Bot API base URL (for proxies/mirrors).
|
||||
pub fn new(
|
||||
token: String,
|
||||
allowed_users: Vec<String>,
|
||||
poll_interval: Duration,
|
||||
api_url: Option<String>,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let api_base_url = api_url
|
||||
.unwrap_or_else(|| DEFAULT_API_URL.to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
Self {
|
||||
token: Zeroizing::new(token),
|
||||
client: reqwest::Client::new(),
|
||||
allowed_users,
|
||||
poll_interval,
|
||||
api_base_url,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
}
|
||||
@@ -53,7 +70,7 @@ impl TelegramAdapter {
|
||||
|
||||
/// Validate the bot token by calling `getMe`.
|
||||
pub async fn validate_token(&self) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let url = format!("https://api.telegram.org/bot{}/getMe", self.token.as_str());
|
||||
let url = format!("{}/bot{}/getMe", self.api_base_url, self.token.as_str());
|
||||
let resp: serde_json::Value = self.client.get(&url).send().await?.json().await?;
|
||||
|
||||
if resp["ok"].as_bool() != Some(true) {
|
||||
@@ -75,7 +92,8 @@ impl TelegramAdapter {
|
||||
text: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendMessage",
|
||||
"{}/bot{}/sendMessage",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
|
||||
@@ -111,7 +129,8 @@ impl TelegramAdapter {
|
||||
caption: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendPhoto",
|
||||
"{}/bot{}/sendPhoto",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let mut body = serde_json::json!({
|
||||
@@ -138,7 +157,8 @@ impl TelegramAdapter {
|
||||
filename: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendDocument",
|
||||
"{}/bot{}/sendDocument",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
@@ -161,7 +181,8 @@ impl TelegramAdapter {
|
||||
voice_url: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendVoice",
|
||||
"{}/bot{}/sendVoice",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
@@ -184,7 +205,8 @@ impl TelegramAdapter {
|
||||
lon: f64,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendLocation",
|
||||
"{}/bot{}/sendLocation",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
@@ -203,7 +225,8 @@ impl TelegramAdapter {
|
||||
/// Call `sendChatAction` to show "typing..." indicator.
|
||||
async fn api_send_typing(&self, chat_id: i64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendChatAction",
|
||||
"{}/bot{}/sendChatAction",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
@@ -213,6 +236,37 @@ impl TelegramAdapter {
|
||||
let _ = self.client.post(&url).json(&body).send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `setMessageReaction` on the Telegram API (fire-and-forget).
|
||||
///
|
||||
/// Sets or replaces the bot's emoji reaction on a message. Each new call
|
||||
/// automatically replaces the previous reaction, so there is no need to
|
||||
/// explicitly remove old ones.
|
||||
fn fire_reaction(&self, chat_id: i64, message_id: i64, emoji: &str) {
|
||||
let url = format!(
|
||||
"{}/bot{}/setMessageReaction",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"reaction": [{"type": "emoji", "emoji": emoji}],
|
||||
});
|
||||
let client = self.client.clone();
|
||||
tokio::spawn(async move {
|
||||
match client.post(&url).json(&body).send().await {
|
||||
Ok(resp) if !resp.status().is_success() => {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
debug!("Telegram setMessageReaction failed: {body_text}");
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Telegram setMessageReaction error: {e}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -238,7 +292,8 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
// still be active on Telegram's side for ~30s, causing 409 errors.
|
||||
{
|
||||
let delete_url = format!(
|
||||
"https://api.telegram.org/bot{}/deleteWebhook",
|
||||
"{}/bot{}/deleteWebhook",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
match self
|
||||
@@ -259,6 +314,7 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
let client = self.client.clone();
|
||||
let allowed_users = self.allowed_users.clone();
|
||||
let poll_interval = self.poll_interval;
|
||||
let api_base_url = self.api_base_url.clone();
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -272,7 +328,7 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
}
|
||||
|
||||
// Build getUpdates request
|
||||
let url = format!("https://api.telegram.org/bot{}/getUpdates", token.as_str());
|
||||
let url = format!("{}/bot{}/getUpdates", api_base_url, token.as_str());
|
||||
let mut params = serde_json::json!({
|
||||
"timeout": LONG_POLL_TIMEOUT,
|
||||
"allowed_updates": ["message", "edited_message"],
|
||||
@@ -318,10 +374,14 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle conflict (another bot instance polling)
|
||||
// Handle conflict (another bot instance or stale session polling).
|
||||
// On daemon restart, the old long-poll may still be active on Telegram's
|
||||
// side for up to 30s. Retry with backoff instead of stopping permanently.
|
||||
if status.as_u16() == 409 {
|
||||
error!("Telegram 409 Conflict — another bot instance is running. Stopping.");
|
||||
break;
|
||||
warn!("Telegram 409 Conflict — stale polling session, retrying in {backoff:?}");
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
@@ -367,7 +427,7 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
}
|
||||
|
||||
// Parse the message
|
||||
let msg = match parse_telegram_update(update, &allowed_users) {
|
||||
let msg = match parse_telegram_update(update, &allowed_users, token.as_str(), &client, &api_base_url).await {
|
||||
Some(m) => m,
|
||||
None => continue, // filtered out or unparseable
|
||||
};
|
||||
@@ -437,6 +497,23 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
self.api_send_typing(chat_id).await
|
||||
}
|
||||
|
||||
async fn send_reaction(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
message_id: &str,
|
||||
reaction: &LifecycleReaction,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let chat_id: i64 = user
|
||||
.platform_id
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid Telegram chat_id: {}", user.platform_id))?;
|
||||
let msg_id: i64 = message_id
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid Telegram message_id: {message_id}"))?;
|
||||
self.fire_reaction(chat_id, msg_id, &reaction.emoji);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
Ok(())
|
||||
@@ -445,9 +522,36 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
|
||||
/// Parse a Telegram update JSON into a `ChannelMessage`, or `None` if filtered/unparseable.
|
||||
/// Handles both `message` and `edited_message` update types.
|
||||
fn parse_telegram_update(
|
||||
/// Resolve a Telegram file_id to a download URL via the Bot API.
|
||||
async fn telegram_get_file_url(
|
||||
token: &str,
|
||||
client: &reqwest::Client,
|
||||
file_id: &str,
|
||||
api_base_url: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!("{api_base_url}/bot{token}/getFile");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({"file_id": file_id}))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
if body["ok"].as_bool() != Some(true) {
|
||||
return None;
|
||||
}
|
||||
let file_path = body["result"]["file_path"].as_str()?;
|
||||
Some(format!(
|
||||
"{api_base_url}/file/bot{token}/{file_path}"
|
||||
))
|
||||
}
|
||||
|
||||
async fn parse_telegram_update(
|
||||
update: &serde_json::Value,
|
||||
allowed_users: &[i64],
|
||||
allowed_users: &[String],
|
||||
token: &str,
|
||||
client: &reqwest::Client,
|
||||
api_base_url: &str,
|
||||
) -> Option<ChannelMessage> {
|
||||
let message = update
|
||||
.get("message")
|
||||
@@ -455,8 +559,9 @@ fn parse_telegram_update(
|
||||
let from = message.get("from")?;
|
||||
let user_id = from["id"].as_i64()?;
|
||||
|
||||
// Security: check allowed_users
|
||||
if !allowed_users.is_empty() && !allowed_users.contains(&user_id) {
|
||||
// Security: check allowed_users (compare as strings for consistency)
|
||||
let user_id_str = user_id.to_string();
|
||||
if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == &user_id_str) {
|
||||
debug!("Telegram: ignoring message from unlisted user {user_id}");
|
||||
return None;
|
||||
}
|
||||
@@ -472,41 +577,81 @@ fn parse_telegram_update(
|
||||
|
||||
let chat_type = message["chat"]["type"].as_str().unwrap_or("private");
|
||||
let is_group = chat_type == "group" || chat_type == "supergroup";
|
||||
|
||||
let text = message["text"].as_str()?;
|
||||
let message_id = message["message_id"].as_i64().unwrap_or(0);
|
||||
let timestamp = message["date"]
|
||||
.as_i64()
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
|
||||
// Parse bot commands (Telegram sends entities for /commands)
|
||||
let content = if let Some(entities) = message["entities"].as_array() {
|
||||
let is_bot_command = entities
|
||||
.iter()
|
||||
.any(|e| e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0));
|
||||
if is_bot_command {
|
||||
let parts: Vec<&str> = text.splitn(2, ' ').collect();
|
||||
let cmd_name = parts[0].trim_start_matches('/');
|
||||
// Strip @botname from command (e.g. /agents@mybot -> agents)
|
||||
let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name);
|
||||
let args = if parts.len() > 1 {
|
||||
parts[1].split_whitespace().map(String::from).collect()
|
||||
// Determine content: text, photo, document, voice, or location
|
||||
let content = if let Some(text) = message["text"].as_str() {
|
||||
// Parse bot commands (Telegram sends entities for /commands)
|
||||
if let Some(entities) = message["entities"].as_array() {
|
||||
let is_bot_command = entities.iter().any(|e| {
|
||||
e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0)
|
||||
});
|
||||
if is_bot_command {
|
||||
let parts: Vec<&str> = text.splitn(2, ' ').collect();
|
||||
let cmd_name = parts[0].trim_start_matches('/');
|
||||
let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name);
|
||||
let args = if parts.len() > 1 {
|
||||
parts[1].split_whitespace().map(String::from).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
ChannelContent::Command {
|
||||
name: cmd_name.to_string(),
|
||||
args,
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
ChannelContent::Command {
|
||||
name: cmd_name.to_string(),
|
||||
args,
|
||||
ChannelContent::Text(text.to_string())
|
||||
}
|
||||
} else {
|
||||
ChannelContent::Text(text.to_string())
|
||||
}
|
||||
} else if let Some(photos) = message["photo"].as_array() {
|
||||
// Photos come as array of sizes; pick the largest (last)
|
||||
let file_id = photos
|
||||
.last()
|
||||
.and_then(|p| p["file_id"].as_str())
|
||||
.unwrap_or("");
|
||||
let caption = message["caption"].as_str().map(String::from);
|
||||
match telegram_get_file_url(token, client, file_id, api_base_url).await {
|
||||
Some(url) => ChannelContent::Image { url, caption },
|
||||
None => ChannelContent::Text(format!(
|
||||
"[Photo received{}]",
|
||||
caption.as_deref().map(|c| format!(": {c}")).unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
} else if message.get("document").is_some() {
|
||||
let file_id = message["document"]["file_id"].as_str().unwrap_or("");
|
||||
let filename = message["document"]["file_name"]
|
||||
.as_str()
|
||||
.unwrap_or("document")
|
||||
.to_string();
|
||||
match telegram_get_file_url(token, client, file_id, api_base_url).await {
|
||||
Some(url) => ChannelContent::File { url, filename },
|
||||
None => ChannelContent::Text(format!("[Document received: {filename}]")),
|
||||
}
|
||||
} else if message.get("voice").is_some() {
|
||||
let file_id = message["voice"]["file_id"].as_str().unwrap_or("");
|
||||
let duration = message["voice"]["duration"].as_u64().unwrap_or(0) as u32;
|
||||
match telegram_get_file_url(token, client, file_id, api_base_url).await {
|
||||
Some(url) => ChannelContent::Voice {
|
||||
url,
|
||||
duration_seconds: duration,
|
||||
},
|
||||
None => ChannelContent::Text(format!("[Voice message, {duration}s]")),
|
||||
}
|
||||
} else if message.get("location").is_some() {
|
||||
let lat = message["location"]["latitude"].as_f64().unwrap_or(0.0);
|
||||
let lon = message["location"]["longitude"].as_f64().unwrap_or(0.0);
|
||||
ChannelContent::Location { lat, lon }
|
||||
} else {
|
||||
ChannelContent::Text(text.to_string())
|
||||
// Unsupported message type (stickers, polls, etc.)
|
||||
return None;
|
||||
};
|
||||
|
||||
// Use chat_id as the platform_id (so responses go to the right chat)
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Telegram,
|
||||
platform_message_id: message_id.to_string(),
|
||||
@@ -590,8 +735,12 @@ fn sanitize_telegram_html(text: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_update() {
|
||||
fn test_client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_update() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123456,
|
||||
"message": {
|
||||
@@ -610,15 +759,16 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Telegram);
|
||||
assert_eq!(msg.sender.display_name, "Alice Smith");
|
||||
assert_eq!(msg.sender.platform_id, "111222333");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello, agent!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_command() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_command() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123457,
|
||||
"message": {
|
||||
@@ -641,7 +791,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -651,8 +802,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_users_filter() {
|
||||
#[tokio::test]
|
||||
async fn test_allowed_users_filter() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123458,
|
||||
"message": {
|
||||
@@ -670,21 +821,25 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_telegram_update(&update, &[]);
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Non-matching allowed_users = filter out
|
||||
let msg = parse_telegram_update(&update, &[111, 222]);
|
||||
let blocked: Vec<String> = vec!["111".to_string(), "222".to_string()];
|
||||
let msg = parse_telegram_update(&update, &blocked, "fake:token", &client, DEFAULT_API_URL).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// Matching allowed_users = allow
|
||||
let msg = parse_telegram_update(&update, &[999]);
|
||||
let allowed: Vec<String> = vec!["999".to_string()];
|
||||
let msg = parse_telegram_update(&update, &allowed, "fake:token", &client, DEFAULT_API_URL).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_edited_message() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_edited_message() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123459,
|
||||
"edited_message": {
|
||||
@@ -704,7 +859,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).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!"));
|
||||
@@ -725,8 +881,8 @@ mod tests {
|
||||
assert_eq!(b4, Duration::from_secs(60)); // stays at cap
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_with_botname() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_command_with_botname() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 100,
|
||||
"message": {
|
||||
@@ -739,7 +895,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agents");
|
||||
@@ -748,4 +905,121 @@ mod tests {
|
||||
other => panic!("Expected Command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_location() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 200,
|
||||
"message": {
|
||||
"message_id": 50,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": 123, "type": "private" },
|
||||
"date": 1700000000,
|
||||
"location": { "latitude": 51.5074, "longitude": -0.1278 }
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
assert!(matches!(msg.content, ChannelContent::Location { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_photo_fallback() {
|
||||
// When getFile fails (fake token), photo messages should fall back to
|
||||
// a text description rather than being silently dropped.
|
||||
let update = serde_json::json!({
|
||||
"update_id": 300,
|
||||
"message": {
|
||||
"message_id": 60,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": 123, "type": "private" },
|
||||
"date": 1700000000,
|
||||
"photo": [
|
||||
{ "file_id": "small_id", "file_unique_id": "a", "width": 90, "height": 90, "file_size": 1234 },
|
||||
{ "file_id": "large_id", "file_unique_id": "b", "width": 800, "height": 600, "file_size": 45678 }
|
||||
],
|
||||
"caption": "Check this out"
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
// With a fake token, getFile will fail, so we get a text fallback
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.contains("Photo received"));
|
||||
assert!(t.contains("Check this out"));
|
||||
}
|
||||
ChannelContent::Image { caption, .. } => {
|
||||
// If somehow the HTTP call succeeded (unlikely with fake token),
|
||||
// verify caption was extracted
|
||||
assert_eq!(caption.as_deref(), Some("Check this out"));
|
||||
}
|
||||
other => panic!("Expected Text or Image fallback for photo, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_document_fallback() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 301,
|
||||
"message": {
|
||||
"message_id": 61,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": 123, "type": "private" },
|
||||
"date": 1700000000,
|
||||
"document": {
|
||||
"file_id": "doc_id",
|
||||
"file_unique_id": "c",
|
||||
"file_name": "report.pdf",
|
||||
"file_size": 102400
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.contains("Document received"));
|
||||
assert!(t.contains("report.pdf"));
|
||||
}
|
||||
ChannelContent::File { filename, .. } => {
|
||||
assert_eq!(filename, "report.pdf");
|
||||
}
|
||||
other => panic!("Expected Text or File for document, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_voice_fallback() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 302,
|
||||
"message": {
|
||||
"message_id": 62,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": 123, "type": "private" },
|
||||
"date": 1700000000,
|
||||
"voice": {
|
||||
"file_id": "voice_id",
|
||||
"file_unique_id": "d",
|
||||
"duration": 15
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.contains("Voice message"));
|
||||
assert!(t.contains("15s"));
|
||||
}
|
||||
ChannelContent::Voice { duration_seconds, .. } => {
|
||||
assert_eq!(*duration_seconds, 15);
|
||||
}
|
||||
other => panic!("Expected Text or Voice for voice message, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+107
-28
@@ -1073,11 +1073,23 @@ pub(crate) fn find_daemon() -> Option<String> {
|
||||
}
|
||||
|
||||
/// Build an HTTP client for daemon calls.
|
||||
///
|
||||
/// When api_key is configured in config.toml, the client automatically
|
||||
/// 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 {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client")
|
||||
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();
|
||||
if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {key}")) {
|
||||
headers.insert(reqwest::header::AUTHORIZATION, val);
|
||||
}
|
||||
builder = builder.default_headers(headers);
|
||||
}
|
||||
|
||||
builder.build().expect("Failed to build HTTP client")
|
||||
}
|
||||
|
||||
/// Helper: send a request to the daemon and parse the JSON body.
|
||||
@@ -1163,6 +1175,12 @@ fn cmd_init(quick: bool) {
|
||||
|
||||
if quick {
|
||||
cmd_init_quick(&openfang_dir);
|
||||
} else if !std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
|| !std::io::IsTerminal::is_terminal(&std::io::stdout())
|
||||
{
|
||||
ui::hint("Non-interactive terminal detected — running in quick mode");
|
||||
ui::hint("For the interactive wizard, run: openfang init (in a terminal)");
|
||||
cmd_init_quick(&openfang_dir);
|
||||
} else {
|
||||
cmd_init_interactive(&openfang_dir);
|
||||
}
|
||||
@@ -1284,8 +1302,17 @@ fn launch_desktop_app(_openfang_dir: &std::path::Path) {
|
||||
if let Some(base) = find_daemon() {
|
||||
let url = format!("{base}/");
|
||||
if !open_in_browser(&url) {
|
||||
ui::hint(&format!("Visit: {url}"));
|
||||
// Browser launch failed entirely (e.g., sandbox EPERM,
|
||||
// no display server, container environment).
|
||||
ui::hint("Could not open a browser automatically.");
|
||||
}
|
||||
// Always print the URL so the user can open it manually,
|
||||
// even when open_in_browser reported success — the spawned
|
||||
// opener may still fail asynchronously.
|
||||
ui::hint(&format!("Dashboard: {url}"));
|
||||
} else {
|
||||
ui::hint("Daemon is not running. Start it with: openfang start");
|
||||
ui::hint("Then open: http://127.0.0.1:4200");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1306,8 +1333,14 @@ fn detect_best_provider() -> (&'static str, &'static str, &'static str) {
|
||||
ui::success("Detected Gemini (GOOGLE_API_KEY)");
|
||||
return ("gemini", "GOOGLE_API_KEY", "gemini-2.5-flash");
|
||||
}
|
||||
// Check if Ollama is running locally (no API key needed)
|
||||
if check_ollama_available() {
|
||||
ui::success("Detected Ollama running locally (no API key needed)");
|
||||
return ("ollama", "OLLAMA_API_KEY", "llama3.2");
|
||||
}
|
||||
ui::hint("No LLM provider API keys found");
|
||||
ui::hint("Groq offers a free tier: https://console.groq.com");
|
||||
ui::hint("Or install Ollama for local models: https://ollama.com");
|
||||
("groq", "GROQ_API_KEY", "llama-3.3-70b-versatile")
|
||||
}
|
||||
|
||||
@@ -1327,12 +1360,21 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
|
||||
(
|
||||
"openrouter",
|
||||
"OPENROUTER_API_KEY",
|
||||
"openrouter/auto",
|
||||
"openrouter/google/gemini-2.5-flash",
|
||||
"OpenRouter",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Quick probe to check if Ollama is running on localhost.
|
||||
fn check_ollama_available() -> bool {
|
||||
std::net::TcpStream::connect_timeout(
|
||||
&std::net::SocketAddr::from(([127, 0, 0, 1], 11434)),
|
||||
std::time::Duration::from_millis(500),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Write config.toml if it doesn't already exist.
|
||||
fn write_config_if_missing(
|
||||
openfang_dir: &std::path::Path,
|
||||
@@ -1434,11 +1476,14 @@ fn cmd_start(config: Option<PathBuf>) {
|
||||
}
|
||||
|
||||
/// Read the api_key from ~/.openfang/config.toml (if any).
|
||||
///
|
||||
/// Returns `None` when the key is missing, empty, or whitespace-only —
|
||||
/// meaning the daemon is running in public (unauthenticated) mode.
|
||||
fn read_api_key() -> Option<String> {
|
||||
let config_path = cli_openfang_home().join("config.toml");
|
||||
let text = std::fs::read_to_string(config_path).ok()?;
|
||||
let table: toml::Value = text.parse().ok()?;
|
||||
let key = table.get("api_key")?.as_str()?;
|
||||
let key = table.get("api_key")?.as_str()?.trim();
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -1450,11 +1495,7 @@ fn cmd_stop() {
|
||||
match find_daemon() {
|
||||
Some(base) => {
|
||||
let client = daemon_client();
|
||||
let mut req = client.post(format!("{base}/api/shutdown"));
|
||||
if let Some(key) = read_api_key() {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
match req.send() {
|
||||
match client.post(format!("{base}/api/shutdown")).send() {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
// Wait for daemon to actually stop (up to 5 seconds)
|
||||
for _ in 0..10 {
|
||||
@@ -2060,20 +2101,23 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
}
|
||||
let answer = prompt_input(" Create default config? [Y/n] ");
|
||||
if answer.is_empty() || answer.starts_with('y') || answer.starts_with('Y') {
|
||||
let default_config = r#"# OpenFang Agent OS configuration
|
||||
let (provider, api_key_env, model) = detect_best_provider();
|
||||
let default_config = format!(
|
||||
r#"# OpenFang Agent OS configuration
|
||||
# See https://github.com/RightNow-AI/openfang for documentation
|
||||
|
||||
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
|
||||
api_listen = "127.0.0.1:4200"
|
||||
|
||||
[default_model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
provider = "{provider}"
|
||||
model = "{model}"
|
||||
api_key_env = "{api_key_env}"
|
||||
|
||||
[memory]
|
||||
decay_rate = 0.05
|
||||
"#;
|
||||
"#
|
||||
);
|
||||
let _ = std::fs::create_dir_all(&openfang_dir);
|
||||
if std::fs::write(&config_path, default_config).is_ok() {
|
||||
restrict_file_permissions(&config_path);
|
||||
@@ -2530,12 +2574,20 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// Check for prompt injection issues in skill definitions
|
||||
// Only flag Critical-severity warnings (Warning-level hits are expected
|
||||
// in bundled skills that mention shell commands in educational context).
|
||||
let skills = skill_reg.list();
|
||||
let mut injection_warnings = 0;
|
||||
for skill in &skills {
|
||||
if let Some(ref prompt) = skill.manifest.prompt_context {
|
||||
let warnings = openfang_skills::verify::SkillVerifier::scan_prompt_content(prompt);
|
||||
if !warnings.is_empty() {
|
||||
let has_critical = warnings.iter().any(|w| {
|
||||
matches!(
|
||||
w.severity,
|
||||
openfang_skills::verify::WarningSeverity::Critical
|
||||
)
|
||||
});
|
||||
if has_critical {
|
||||
injection_warnings += 1;
|
||||
if !json {
|
||||
ui::check_warn(&format!(
|
||||
@@ -2602,7 +2654,7 @@ decay_rate = 0.05
|
||||
checks.push(serde_json::json!({"check": "daemon_uptime", "status": "ok", "secs": uptime}));
|
||||
}
|
||||
if let Some(db_status) = body.get("database").and_then(|v| v.as_str()) {
|
||||
if db_status == "ok" {
|
||||
if db_status == "connected" || db_status == "ok" {
|
||||
if !json {
|
||||
ui::check_ok("Database connectivity: OK");
|
||||
}
|
||||
@@ -2676,12 +2728,18 @@ decay_rate = 0.05
|
||||
match client.get(format!("{base}/api/integrations/health")).send() {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
if let Some(obj) = body.as_object() {
|
||||
let healthy = obj
|
||||
.values()
|
||||
.filter(|v| v.get("healthy").and_then(|h| h.as_bool()).unwrap_or(false))
|
||||
let entries = body.get("health").and_then(|h| h.as_array());
|
||||
if let Some(arr) = entries {
|
||||
let healthy = arr
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
v.get("status")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.eq_ignore_ascii_case("ready"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
let total = obj.len();
|
||||
let total = arr.len();
|
||||
if healthy == total {
|
||||
if !json {
|
||||
ui::check_ok(&format!(
|
||||
@@ -2924,10 +2982,31 @@ pub(crate) fn open_in_browser(url: &str) -> bool {
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.is_ok()
|
||||
// Try multiple openers in order. xdg-open is the standard, but it
|
||||
// (or the browser it launches) can fail with EPERM in sandboxed
|
||||
// environments (containers, Snap, Flatpak, user-namespace
|
||||
// restrictions). Fall through to alternatives if any opener fails.
|
||||
let openers = [
|
||||
"xdg-open",
|
||||
"sensible-browser",
|
||||
"x-www-browser",
|
||||
"firefox",
|
||||
"google-chrome",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
];
|
||||
for opener in &openers {
|
||||
let result = std::process::Command::new(opener)
|
||||
.arg(url)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
if result.is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
|
||||
@@ -216,8 +216,13 @@ fn read_message(reader: &mut impl BufRead) -> io::Result<Option<Value>> {
|
||||
/// Write a Content-Length framed JSON-RPC response to the writer.
|
||||
fn write_message(writer: &mut impl Write, msg: &Value) {
|
||||
let body = serde_json::to_string(msg).unwrap_or_default();
|
||||
let _ = write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body);
|
||||
let _ = writer.flush();
|
||||
if let Err(e) = write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body) {
|
||||
eprintln!("MCP write error: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = writer.flush() {
|
||||
eprintln!("MCP flush error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a JSON-RPC message and return an optional response.
|
||||
@@ -225,6 +230,10 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
let method = msg["method"].as_str().unwrap_or("");
|
||||
let id = msg.get("id").cloned();
|
||||
|
||||
// Per JSON-RPC 2.0 spec: requests MUST have an id field.
|
||||
// Use null if missing so we always send a response.
|
||||
let rid = id.unwrap_or(Value::Null);
|
||||
|
||||
match method {
|
||||
"initialize" => {
|
||||
let result = json!({
|
||||
@@ -234,10 +243,10 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "openfang",
|
||||
"version": "0.1.0"
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
});
|
||||
Some(jsonrpc_response(id?, result))
|
||||
Some(jsonrpc_response(rid, result))
|
||||
}
|
||||
|
||||
"notifications/initialized" => None, // Notification, no response
|
||||
@@ -269,7 +278,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(jsonrpc_response(id?, json!({ "tools": tools })))
|
||||
Some(jsonrpc_response(rid, json!({ "tools": tools })))
|
||||
}
|
||||
|
||||
"tools/call" => {
|
||||
@@ -281,14 +290,14 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
.to_string();
|
||||
|
||||
if message.is_empty() {
|
||||
return Some(jsonrpc_error(id?, -32602, "Missing 'message' argument"));
|
||||
return Some(jsonrpc_error(rid, -32602, "Missing 'message' argument"));
|
||||
}
|
||||
|
||||
let agent_id = match backend.resolve_tool_agent(tool_name) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Some(jsonrpc_error(
|
||||
id?,
|
||||
rid,
|
||||
-32602,
|
||||
&format!("Unknown tool: {tool_name}"),
|
||||
));
|
||||
@@ -297,7 +306,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
|
||||
match backend.send_message(&agent_id, &message) {
|
||||
Ok(response) => Some(jsonrpc_response(
|
||||
id?,
|
||||
rid,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
@@ -306,7 +315,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
}),
|
||||
)),
|
||||
Err(e) => Some(jsonrpc_response(
|
||||
id?,
|
||||
rid,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
@@ -319,8 +328,8 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
}
|
||||
|
||||
_ => {
|
||||
// Unknown method
|
||||
id.map(|id| jsonrpc_error(id, -32601, &format!("Method not found: {method}")))
|
||||
// Unknown method — always respond with error
|
||||
Some(jsonrpc_error(rid, -32601, &format!("Method not found: {method}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
name: "openrouter",
|
||||
display: "OpenRouter",
|
||||
env_var: "OPENROUTER_API_KEY",
|
||||
default_model: "openrouter/auto",
|
||||
default_model: "openrouter/google/gemini-2.5-flash",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
@@ -100,6 +100,102 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "xai",
|
||||
display: "xAI (Grok)",
|
||||
env_var: "XAI_API_KEY",
|
||||
default_model: "grok-4-0709",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "perplexity",
|
||||
display: "Perplexity",
|
||||
env_var: "PERPLEXITY_API_KEY",
|
||||
default_model: "sonar-pro",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "cohere",
|
||||
display: "Cohere",
|
||||
env_var: "COHERE_API_KEY",
|
||||
default_model: "command-a-03-2025",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "cerebras",
|
||||
display: "Cerebras",
|
||||
env_var: "CEREBRAS_API_KEY",
|
||||
default_model: "llama-4-scout-17b-16e-instruct",
|
||||
needs_key: true,
|
||||
hint: "fast inference",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "sambanova",
|
||||
display: "SambaNova",
|
||||
env_var: "SAMBANOVA_API_KEY",
|
||||
default_model: "DeepSeek-R1",
|
||||
needs_key: true,
|
||||
hint: "fast inference",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "qwen",
|
||||
display: "Qwen (Alibaba)",
|
||||
env_var: "QWEN_API_KEY",
|
||||
default_model: "qwen-plus",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "huggingface",
|
||||
display: "Hugging Face",
|
||||
env_var: "HUGGINGFACE_API_KEY",
|
||||
default_model: "meta-llama/Llama-3.3-70B-Instruct",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "github-copilot",
|
||||
display: "GitHub Copilot",
|
||||
env_var: "GITHUB_TOKEN",
|
||||
default_model: "gpt-4o",
|
||||
needs_key: true,
|
||||
hint: "via PAT",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "replicate",
|
||||
display: "Replicate",
|
||||
env_var: "REPLICATE_API_KEY",
|
||||
default_model: "meta/meta-llama-3-70b-instruct",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "venice",
|
||||
display: "Venice.ai",
|
||||
env_var: "VENICE_API_KEY",
|
||||
default_model: "venice-uncensored",
|
||||
needs_key: true,
|
||||
hint: "uncensored",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ai21",
|
||||
display: "AI21",
|
||||
env_var: "AI21_API_KEY",
|
||||
default_model: "jamba-1.5-large",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
display: "Claude Code",
|
||||
env_var: "",
|
||||
default_model: "claude-code/sonnet",
|
||||
needs_key: false,
|
||||
hint: "no API key",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ollama",
|
||||
display: "Ollama",
|
||||
@@ -116,6 +212,14 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: false,
|
||||
hint: "local",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "vllm",
|
||||
display: "vLLM",
|
||||
env_var: "VLLM_API_KEY",
|
||||
default_model: "local-model",
|
||||
needs_key: false,
|
||||
hint: "local",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Public result type ─────────────────────────────────────────────────────
|
||||
@@ -287,15 +391,23 @@ impl State {
|
||||
self.provider_order.clear();
|
||||
let gemini_via_google = std::env::var("GOOGLE_API_KEY").is_ok();
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
let detected =
|
||||
std::env::var(p.env_var).is_ok() || (p.name == "gemini" && gemini_via_google);
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|
||||
|| (p.name == "gemini" && gemini_via_google)
|
||||
};
|
||||
if detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
let detected =
|
||||
std::env::var(p.env_var).is_ok() || (p.name == "gemini" && gemini_via_google);
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|
||||
|| (p.name == "gemini" && gemini_via_google)
|
||||
};
|
||||
if !detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
@@ -334,7 +446,10 @@ impl State {
|
||||
|
||||
fn is_provider_detected(&self, prov_idx: usize) -> bool {
|
||||
let p = &PROVIDERS[prov_idx];
|
||||
std::env::var(p.env_var).is_ok()
|
||||
if p.name == "claude-code" {
|
||||
return openfang_runtime::drivers::claude_code::claude_code_available();
|
||||
}
|
||||
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|
||||
|| (p.name == "gemini" && std::env::var("GOOGLE_API_KEY").is_ok())
|
||||
}
|
||||
|
||||
@@ -468,6 +583,13 @@ fn tier_label(tier: ModelTier) -> &'static str {
|
||||
// ── Entry point ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn run() -> InitResult {
|
||||
// Guard against non-TTY environments (Docker, piped, CI/CD)
|
||||
if !std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
|| !std::io::IsTerminal::is_terminal(&std::io::stdout())
|
||||
{
|
||||
return InitResult::Cancelled;
|
||||
}
|
||||
|
||||
let original_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
ratatui::restore();
|
||||
@@ -988,6 +1110,12 @@ complex_threshold = 500
|
||||
};
|
||||
|
||||
let config_path = openfang_dir.join("config.toml");
|
||||
let api_key_line = if p.env_var.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("api_key_env = \"{}\"", p.env_var)
|
||||
};
|
||||
|
||||
let config = format!(
|
||||
r#"# OpenFang Agent OS configuration
|
||||
# See https://github.com/RightNow-AI/openfang for documentation
|
||||
@@ -997,13 +1125,12 @@ api_listen = "127.0.0.1:4200"
|
||||
[default_model]
|
||||
provider = "{provider}"
|
||||
model = "{model}"
|
||||
api_key_env = "{env_var}"
|
||||
{api_key_line}
|
||||
|
||||
[memory]
|
||||
decay_rate = 0.05
|
||||
{routing_section}"#,
|
||||
provider = p.name,
|
||||
env_var = p.env_var,
|
||||
);
|
||||
|
||||
match std::fs::write(&config_path, &config) {
|
||||
@@ -1600,7 +1727,13 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
Span::styled(" ", Style::default())
|
||||
};
|
||||
let name_span = Span::raw(format!("{:<14}", p.display));
|
||||
let hint_text = if detected {
|
||||
let hint_text = if p.name == "claude-code" {
|
||||
if detected {
|
||||
"CLI detected".to_string()
|
||||
} else {
|
||||
"no API key needed".to_string()
|
||||
}
|
||||
} else if detected {
|
||||
format!("{} detected", p.env_var)
|
||||
} else if !p.needs_key {
|
||||
"local, no key needed".to_string()
|
||||
@@ -1918,11 +2051,7 @@ fn draw_routing_pick(f: &mut Frame, area: Rect, state: &mut State, tier: usize)
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or(&state.routing_models[t]);
|
||||
let display = if short.len() > 14 {
|
||||
&short[..14]
|
||||
} else {
|
||||
short
|
||||
};
|
||||
let display = openfang_types::truncate_str(short, 14);
|
||||
summary_spans.push(Span::styled(
|
||||
format!("{name}:{display}"),
|
||||
Style::default().fg(*c),
|
||||
|
||||
@@ -317,7 +317,10 @@ 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}", &a.id[..12])
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&a.id, 12)
|
||||
)
|
||||
} else {
|
||||
a.id.clone()
|
||||
};
|
||||
@@ -405,7 +408,10 @@ 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}", &kv.value[..39])
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&kv.value, 39)
|
||||
)
|
||||
} else {
|
||||
kv.value.clone()
|
||||
};
|
||||
|
||||
@@ -149,7 +149,10 @@ 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}", &p.node_id[..12])
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&p.node_id, 12)
|
||||
)
|
||||
} else {
|
||||
p.node_id.clone()
|
||||
};
|
||||
|
||||
@@ -251,7 +251,10 @@ 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}", &s.id[..12])
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&s.id, 12)
|
||||
)
|
||||
} else {
|
||||
s.id.clone()
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
ProviderInfo {
|
||||
name: "openrouter",
|
||||
env_var: "OPENROUTER_API_KEY",
|
||||
default_model: "anthropic/claude-sonnet-4-20250514",
|
||||
default_model: "google/gemini-2.5-flash",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
@@ -127,6 +127,12 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
default_model: "codegeex-4",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
env_var: "",
|
||||
default_model: "claude-code/sonnet",
|
||||
needs_key: false,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ollama",
|
||||
env_var: "OLLAMA_API_KEY",
|
||||
@@ -215,13 +221,23 @@ impl WizardState {
|
||||
self.provider_order.clear();
|
||||
// Detected providers first
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
if std::env::var(p.env_var).is_ok() {
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
|
||||
};
|
||||
if detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
// Then the rest
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
if std::env::var(p.env_var).is_err() {
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
|
||||
};
|
||||
if !detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
@@ -376,6 +392,8 @@ impl WizardState {
|
||||
|
||||
let api_key_line = if !self.api_key_input.is_empty() {
|
||||
format!("api_key = \"{}\"", self.api_key_input)
|
||||
} else if p.env_var.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("api_key_env = \"{}\"", p.env_var)
|
||||
};
|
||||
@@ -506,9 +524,15 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut WizardState) {
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let p = &PROVIDERS[idx];
|
||||
let hint = if !p.needs_key {
|
||||
let hint = if p.name == "claude-code" {
|
||||
if openfang_runtime::drivers::claude_code::claude_code_available() {
|
||||
"CLI detected".to_string()
|
||||
} else {
|
||||
"no API key needed".to_string()
|
||||
}
|
||||
} else if !p.needs_key {
|
||||
"local, no key needed".to_string()
|
||||
} else if std::env::var(p.env_var).is_ok() {
|
||||
} else if !p.env_var.is_empty() && std::env::var(p.env_var).is_ok() {
|
||||
format!("{} detected", p.env_var)
|
||||
} else {
|
||||
format!("requires {}", p.env_var)
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["cloud", "amazon", "infrastructure", "s3", "ec2", "lambda", "devops"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-aws"]
|
||||
args = ["-y", "@aws-mcp/server-aws"]
|
||||
|
||||
[[required_env]]
|
||||
name = "AWS_ACCESS_KEY_ID"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["cloud", "microsoft", "infrastructure", "azure", "devops", "enterprise"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-azure"]
|
||||
args = ["-y", "@azure/mcp@latest", "server", "start"]
|
||||
|
||||
[[required_env]]
|
||||
name = "AZURE_SUBSCRIPTION_ID"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["git", "vcs", "code", "pull-requests", "ci", "atlassian"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-bitbucket"]
|
||||
args = ["-y", "@atlassian-mcp-server/bitbucket"]
|
||||
|
||||
[[required_env]]
|
||||
name = "BITBUCKET_USERNAME"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["search", "web", "brave", "api", "information-retrieval"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-brave-search"]
|
||||
args = ["-y", "@modelcontextprotocol/server-brave-search"]
|
||||
|
||||
[[required_env]]
|
||||
name = "BRAVE_API_KEY"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["chat", "messaging", "community", "gaming", "voice"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-discord"]
|
||||
args = ["-y", "mcp-discord"]
|
||||
|
||||
[[required_env]]
|
||||
name = "DISCORD_BOT_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["files", "storage", "cloud-storage", "sync", "sharing"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-dropbox"]
|
||||
args = ["-y", "@microagents/mcp-server-dropbox"]
|
||||
|
||||
[[required_env]]
|
||||
name = "DROPBOX_ACCESS_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["search", "database", "indexing", "analytics", "full-text"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-elasticsearch"]
|
||||
args = ["-y", "@elastic/mcp-server-elasticsearch"]
|
||||
|
||||
[[required_env]]
|
||||
name = "ELASTICSEARCH_URL"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["search", "web", "ai", "neural", "semantic", "information-retrieval"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-exa"]
|
||||
args = ["-y", "exa-mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "EXA_API_KEY"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["cloud", "google", "infrastructure", "gce", "gcs", "bigquery", "devops"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-gcp"]
|
||||
args = ["-y", "@google-cloud/gcloud-mcp"]
|
||||
|
||||
[[required_env]]
|
||||
name = "GOOGLE_APPLICATION_CREDENTIALS"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["git", "vcs", "code", "issues", "pull-requests", "ci"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-github"]
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
|
||||
[[required_env]]
|
||||
name = "GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["git", "vcs", "code", "merge-requests", "ci", "devops"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-gitlab"]
|
||||
args = ["-y", "@modelcontextprotocol/server-gitlab"]
|
||||
|
||||
[[required_env]]
|
||||
name = "GITLAB_PERSONAL_ACCESS_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["email", "google", "messaging", "inbox", "communication"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@anthropic/server-gmail"]
|
||||
args = ["-y", "@gongrzhe/server-gmail-autoauth-mcp"]
|
||||
|
||||
[oauth]
|
||||
provider = "google"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["calendar", "scheduling", "google", "events", "meetings"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@anthropic/server-google-calendar"]
|
||||
args = ["-y", "@cocal/google-calendar-mcp"]
|
||||
|
||||
[oauth]
|
||||
provider = "google"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["files", "storage", "google", "documents", "cloud-storage"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@anthropic/server-google-drive"]
|
||||
args = ["-y", "@modelcontextprotocol/server-gdrive"]
|
||||
|
||||
[oauth]
|
||||
provider = "google"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["project-management", "issues", "agile", "atlassian", "tracking"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-atlassian"]
|
||||
args = ["-y", "@aashari/mcp-server-atlassian-jira"]
|
||||
|
||||
[[required_env]]
|
||||
name = "JIRA_API_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["project-management", "issues", "agile", "tracking", "sprint"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-linear"]
|
||||
args = ["-y", "linear-mcp"]
|
||||
|
||||
[[required_env]]
|
||||
name = "LINEAR_API_KEY"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["database", "nosql", "document", "mongo", "queries"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-mongodb"]
|
||||
args = ["-y", "@mongodb-js/mongodb-mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "MONGODB_URI"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["notes", "wiki", "knowledge-base", "documentation", "databases"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-notion"]
|
||||
args = ["-y", "@notionhq/notion-mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "NOTION_API_KEY"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["database", "sql", "relational", "postgres", "queries"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-postgres"]
|
||||
args = ["-y", "@modelcontextprotocol/server-postgres"]
|
||||
|
||||
[[required_env]]
|
||||
name = "POSTGRES_CONNECTION_STRING"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["database", "cache", "key-value", "in-memory", "nosql"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-redis"]
|
||||
args = ["-y", "@modelcontextprotocol/server-redis"]
|
||||
|
||||
[[required_env]]
|
||||
name = "REDIS_URL"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["monitoring", "errors", "debugging", "observability", "apm"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-sentry"]
|
||||
args = ["-y", "@sentry/mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "SENTRY_AUTH_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["chat", "messaging", "team", "channels", "collaboration"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-slack"]
|
||||
args = ["-y", "@modelcontextprotocol/server-slack"]
|
||||
|
||||
[[required_env]]
|
||||
name = "SLACK_BOT_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["database", "sql", "relational", "sqlite", "local", "embedded"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-sqlite"]
|
||||
args = ["-y", "@modelcontextprotocol/server-sqlite"]
|
||||
|
||||
[[required_env]]
|
||||
name = "SQLITE_DB_PATH"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["chat", "messaging", "microsoft", "enterprise", "collaboration"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-teams"]
|
||||
args = ["-y", "teams-mcp"]
|
||||
|
||||
[oauth]
|
||||
provider = "microsoft"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["tasks", "todo", "project-management", "productivity", "gtd"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-todoist"]
|
||||
args = ["-y", "todoist-mcp"]
|
||||
|
||||
[[required_env]]
|
||||
name = "TODOIST_API_KEY"
|
||||
|
||||
@@ -17,8 +17,8 @@ tools = [
|
||||
key = "python3"
|
||||
label = "Python 3 must be installed"
|
||||
requirement_type = "binary"
|
||||
check_value = "python"
|
||||
description = "Python 3 is required to run Playwright, the browser automation library that powers this hand."
|
||||
check_value = "python3"
|
||||
description = "Python 3 is required for installing and running the Playwright browser automation library. Python 3.8 or newer is recommended."
|
||||
|
||||
[requires.install]
|
||||
macos = "brew install python3"
|
||||
@@ -26,27 +26,25 @@ windows = "winget install Python.Python.3.12"
|
||||
linux_apt = "sudo apt install python3"
|
||||
linux_dnf = "sudo dnf install python3"
|
||||
linux_pacman = "sudo pacman -S python"
|
||||
pip = "python3 --version"
|
||||
manual_url = "https://www.python.org/downloads/"
|
||||
estimated_time = "2-5 min"
|
||||
estimated_time = "1-3 min"
|
||||
|
||||
[[requires]]
|
||||
key = "playwright"
|
||||
label = "Playwright must be installed"
|
||||
key = "chromium"
|
||||
label = "Chromium or Google Chrome must be installed"
|
||||
requirement_type = "binary"
|
||||
check_value = "playwright"
|
||||
description = "Playwright is a browser automation framework. After installing via pip, you also need to install browser binaries."
|
||||
check_value = "chromium"
|
||||
description = "A Chromium-based browser is required. Google Chrome, Chromium, or any Chromium derivative will work. You can also set the CHROME_PATH environment variable to point to your browser binary."
|
||||
|
||||
[requires.install]
|
||||
macos = "pip3 install playwright && playwright install chromium"
|
||||
windows = "pip install playwright && playwright install chromium"
|
||||
linux_apt = "pip3 install playwright && playwright install chromium"
|
||||
pip = "pip install playwright && playwright install chromium"
|
||||
manual_url = "https://playwright.dev/python/docs/intro"
|
||||
estimated_time = "3-5 min"
|
||||
steps = [
|
||||
"Install Playwright: pip install playwright",
|
||||
"Install browser binaries: playwright install chromium",
|
||||
]
|
||||
macos = "brew install --cask google-chrome"
|
||||
windows = "winget install Google.Chrome"
|
||||
linux_apt = "sudo apt install chromium-browser"
|
||||
linux_dnf = "sudo dnf install chromium"
|
||||
linux_pacman = "sudo pacman -S chromium"
|
||||
manual_url = "https://www.google.com/chrome/"
|
||||
estimated_time = "1-3 min"
|
||||
|
||||
# ─── Configurable settings ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ key = "elevenlabs_api_key"
|
||||
label = "ElevenLabs API Key"
|
||||
description = "API key from elevenlabs.io for high-quality text-to-speech. Required when ElevenLabs TTS is selected."
|
||||
setting_type = "text"
|
||||
env_var = "ELEVENLABS_API_KEY"
|
||||
default = ""
|
||||
|
||||
# ─── Publishing settings ────────────────────────────────────────────────────
|
||||
|
||||
@@ -187,7 +187,7 @@ mod tests {
|
||||
assert_eq!(def.name, "Browser Hand");
|
||||
assert_eq!(def.category, crate::HandCategory::Productivity);
|
||||
assert!(def.skill_content.is_some());
|
||||
assert!(!def.requires.is_empty()); // requires python3, playwright
|
||||
assert!(!def.requires.is_empty()); // requires python3 + chromium
|
||||
assert_eq!(def.requires.len(), 2);
|
||||
assert!(def.tools.contains(&"browser_navigate".to_string()));
|
||||
assert!(def.tools.contains(&"browser_click".to_string()));
|
||||
|
||||
@@ -29,6 +29,8 @@ pub enum HandError {
|
||||
TomlParse(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Config error: {0}")]
|
||||
Config(String),
|
||||
}
|
||||
|
||||
pub type HandResult<T> = Result<T, HandError>;
|
||||
@@ -172,6 +174,10 @@ pub struct HandSetting {
|
||||
pub default: String,
|
||||
#[serde(default)]
|
||||
pub options: Vec<HandSettingOption>,
|
||||
/// Env var name to expose when a text-type setting has a value
|
||||
/// (e.g. `ELEVENLABS_API_KEY` for an API key text field).
|
||||
#[serde(default)]
|
||||
pub env_var: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of resolving user-chosen settings against the schema.
|
||||
@@ -227,6 +233,9 @@ pub fn resolve_settings(
|
||||
HandSettingType::Text => {
|
||||
if !chosen_value.is_empty() {
|
||||
lines.push(format!("- {}: {}", setting.label, chosen_value));
|
||||
if let Some(ref env) = setting.env_var {
|
||||
env_vars.push(env.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -550,6 +559,7 @@ metrics = []
|
||||
binary: None,
|
||||
},
|
||||
],
|
||||
env_var: None,
|
||||
}];
|
||||
|
||||
// User picks groq
|
||||
@@ -583,6 +593,7 @@ metrics = []
|
||||
binary: None,
|
||||
},
|
||||
],
|
||||
env_var: None,
|
||||
}];
|
||||
|
||||
// Empty config → uses default "auto"
|
||||
@@ -604,6 +615,7 @@ metrics = []
|
||||
setting_type: HandSettingType::Toggle,
|
||||
default: "false".to_string(),
|
||||
options: vec![],
|
||||
env_var: None,
|
||||
},
|
||||
HandSetting {
|
||||
key: "custom_model".to_string(),
|
||||
@@ -612,6 +624,7 @@ metrics = []
|
||||
setting_type: HandSettingType::Text,
|
||||
default: String::new(),
|
||||
options: vec![],
|
||||
env_var: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -52,6 +52,59 @@ impl HandRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist active hand state to disk so it survives restarts.
|
||||
pub fn persist_state(&self, path: &std::path::Path) -> HandResult<()> {
|
||||
let entries: Vec<serde_json::Value> = self
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|e| e.status == HandStatus::Active)
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"hand_id": e.hand_id,
|
||||
"config": e.config,
|
||||
"agent_id": e.agent_id,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let json = serde_json::to_string_pretty(&entries)
|
||||
.map_err(|e| HandError::Config(format!("serialize hand state: {e}")))?;
|
||||
std::fs::write(path, json)
|
||||
.map_err(|e| HandError::Config(format!("write hand state: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load persisted hand state and re-activate hands.
|
||||
/// Returns list of (hand_id, config, old_agent_id) that should be activated.
|
||||
/// The `old_agent_id` is the agent UUID from before the restart, used to
|
||||
/// reassign cron jobs to the newly spawned agent (issue #402).
|
||||
pub fn load_state(
|
||||
path: &std::path::Path,
|
||||
) -> Vec<(String, HashMap<String, serde_json::Value>, Option<AgentId>)> {
|
||||
let data = match std::fs::read_to_string(path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let entries: Vec<serde_json::Value> = match serde_json::from_str(&data) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse hand state file: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
entries
|
||||
.into_iter()
|
||||
.filter_map(|e| {
|
||||
let hand_id = e["hand_id"].as_str()?.to_string();
|
||||
let config: HashMap<String, serde_json::Value> =
|
||||
serde_json::from_value(e["config"].clone()).unwrap_or_default();
|
||||
let old_agent_id: Option<AgentId> = e
|
||||
.get("agent_id")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
Some((hand_id, config, old_agent_id))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load all bundled hand definitions. Returns count of definitions loaded.
|
||||
pub fn load_bundled(&self) -> usize {
|
||||
let bundled = bundled::bundled_hands();
|
||||
@@ -311,8 +364,25 @@ impl Default for HandRegistry {
|
||||
fn check_requirement(req: &HandRequirement) -> bool {
|
||||
match req.requirement_type {
|
||||
RequirementType::Binary => {
|
||||
// Check if binary exists on PATH
|
||||
which_binary(&req.check_value)
|
||||
// Special handling for python3: must actually run the command and verify
|
||||
// the output contains "Python 3", because Windows ships a python3.exe
|
||||
// Store shim that exists on PATH but doesn't actually work.
|
||||
if req.check_value == "python3" {
|
||||
return check_python3_available();
|
||||
}
|
||||
// Check if binary exists on PATH.
|
||||
if which_binary(&req.check_value) {
|
||||
return true;
|
||||
}
|
||||
if req.check_value == "chromium" {
|
||||
// Try common Chromium/Chrome binary names across platforms
|
||||
return which_binary("chromium-browser")
|
||||
|| which_binary("google-chrome")
|
||||
|| which_binary("google-chrome-stable")
|
||||
|| which_binary("chrome")
|
||||
|| std::env::var("CHROME_PATH").map(|v| !v.is_empty()).unwrap_or(false);
|
||||
}
|
||||
false
|
||||
}
|
||||
RequirementType::EnvVar | RequirementType::ApiKey => {
|
||||
// Check if env var is set and non-empty
|
||||
@@ -323,6 +393,44 @@ fn check_requirement(req: &HandRequirement) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if Python 3 is actually available by running the command and checking
|
||||
/// the version output. This avoids false negatives from Windows Store shims
|
||||
/// (python3.exe that just opens the Microsoft Store) and false positives from
|
||||
/// Python 2 installations where `python` exists but is Python 2.
|
||||
fn check_python3_available() -> bool {
|
||||
// Try "python3 --version" first (Linux/macOS, some Windows installs)
|
||||
if run_returns_python3("python3") {
|
||||
return true;
|
||||
}
|
||||
// Try "python --version" (Windows commonly uses this, Docker containers too)
|
||||
if run_returns_python3("python") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Run `{cmd} --version` and return true if the output contains "Python 3".
|
||||
fn run_returns_python3(cmd: &str) -> bool {
|
||||
match std::process::Command::new(cmd)
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
{
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
// Python --version may print to stdout or stderr depending on version
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
stdout.contains("Python 3") || stderr.contains("Python 3")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a binary is on PATH (cross-platform).
|
||||
fn which_binary(name: &str) -> bool {
|
||||
let path_var = std::env::var("PATH").unwrap_or_default();
|
||||
|
||||
@@ -23,6 +23,7 @@ crossbeam = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
chrono-tz = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@@ -50,6 +50,25 @@ pub fn load_config(path: Option<&Path>) -> KernelConfig {
|
||||
tbl.remove("include");
|
||||
}
|
||||
|
||||
// Migrate misplaced api_key/api_listen from [api] section to root level.
|
||||
// The old config schema incorrectly grouped these under [api], so many
|
||||
// users have them in the wrong place. Move them up if not already at root.
|
||||
if let toml::Value::Table(ref mut tbl) = root_value {
|
||||
if let Some(toml::Value::Table(api_section)) = tbl.get("api").cloned() {
|
||||
for key in &["api_key", "api_listen", "log_level"] {
|
||||
if !tbl.contains_key(*key) {
|
||||
if let Some(val) = api_section.get(*key) {
|
||||
tracing::info!(
|
||||
key,
|
||||
"Migrating misplaced config field from [api] to root level"
|
||||
);
|
||||
tbl.insert(key.to_string(), val.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match root_value.try_into::<KernelConfig>() {
|
||||
Ok(config) => {
|
||||
info!(path = %config_path.display(), "Loaded configuration");
|
||||
|
||||
@@ -241,6 +241,10 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
|
||||
plan.hot_actions.push(HotAction::ReloadProviderUrls);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
// ----- No-op fields -----
|
||||
|
||||
if old.log_level != new.log_level {
|
||||
|
||||
@@ -216,6 +216,46 @@ impl CronScheduler {
|
||||
self.jobs.iter().map(|r| r.value().job.clone()).collect()
|
||||
}
|
||||
|
||||
/// Reassign all cron jobs from `old_agent_id` to `new_agent_id`.
|
||||
///
|
||||
/// Used when a hand agent is respawned (e.g. after daemon restart) and
|
||||
/// gets a new UUID. Without this, persisted cron jobs would reference
|
||||
/// the stale old agent ID and fail silently.
|
||||
///
|
||||
/// Returns the number of jobs reassigned.
|
||||
pub fn reassign_agent_jobs(&self, old_agent_id: AgentId, new_agent_id: AgentId) -> usize {
|
||||
let mut count = 0;
|
||||
for mut entry in self.jobs.iter_mut() {
|
||||
if entry.value().job.agent_id == old_agent_id {
|
||||
entry.value_mut().job.agent_id = new_agent_id;
|
||||
// Reset consecutive errors so the job gets a fresh start
|
||||
// with the new agent.
|
||||
entry.value_mut().consecutive_errors = 0;
|
||||
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")
|
||||
}) {
|
||||
entry.value_mut().job.enabled = true;
|
||||
entry.value_mut().job.next_run =
|
||||
Some(compute_next_run(&entry.value().job.schedule));
|
||||
}
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
info!(
|
||||
old_agent = %old_agent_id,
|
||||
new_agent = %new_agent_id,
|
||||
count,
|
||||
"Reassigned cron jobs to new agent"
|
||||
);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Total number of tracked jobs.
|
||||
pub fn total_jobs(&self) -> usize {
|
||||
self.jobs.len()
|
||||
@@ -324,7 +364,7 @@ pub fn compute_next_run_after(
|
||||
match schedule {
|
||||
CronSchedule::At { at } => *at,
|
||||
CronSchedule::Every { every_secs } => after + Duration::seconds(*every_secs as i64),
|
||||
CronSchedule::Cron { expr, tz: _ } => {
|
||||
CronSchedule::Cron { expr, tz } => {
|
||||
// Convert standard 5/6-field cron to 7-field for the `cron` crate.
|
||||
// Standard 5-field: min hour dom month dow
|
||||
// 6-field: sec min hour dom month dow
|
||||
@@ -341,10 +381,33 @@ pub fn compute_next_run_after(
|
||||
let base = after + Duration::seconds(1);
|
||||
|
||||
match seven_field.parse::<cron::Schedule>() {
|
||||
Ok(sched) => sched
|
||||
.after(&base)
|
||||
.next()
|
||||
.unwrap_or_else(|| after + Duration::hours(1)),
|
||||
Ok(sched) => {
|
||||
// If a timezone is specified, compute the next fire time in
|
||||
// that timezone so DST and local offsets are respected, then
|
||||
// convert back to UTC for storage.
|
||||
let next_utc = match tz.as_deref() {
|
||||
Some(tz_str) if !tz_str.is_empty() && tz_str != "UTC" => {
|
||||
match tz_str.parse::<chrono_tz::Tz>() {
|
||||
Ok(timezone) => {
|
||||
let base_local = base.with_timezone(&timezone);
|
||||
sched
|
||||
.after(&base_local)
|
||||
.next()
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Invalid timezone '{}' in cron job, falling back to UTC",
|
||||
tz_str
|
||||
);
|
||||
sched.after(&base).next()
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => sched.after(&base).next(),
|
||||
};
|
||||
next_utc.unwrap_or_else(|| after + Duration::hours(1))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse cron expression '{}': {}", expr, e);
|
||||
after + Duration::hours(1)
|
||||
@@ -361,7 +424,7 @@ pub fn compute_next_run_after(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use chrono::{Duration, Timelike};
|
||||
use openfang_types::scheduler::{CronAction, CronDelivery};
|
||||
|
||||
/// Build a minimal valid `CronJob` with an `Every` schedule.
|
||||
@@ -787,4 +850,259 @@ mod tests {
|
||||
status.len()
|
||||
);
|
||||
}
|
||||
|
||||
// -- timezone-aware cron (#473) -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_cron_tz_shifts_next_run() {
|
||||
// "0 9 * * *" in America/New_York (UTC-5 or UTC-4 depending on DST).
|
||||
// The next fire time in UTC should differ from a plain UTC "0 9 * * *".
|
||||
let schedule_utc = CronSchedule::Cron {
|
||||
expr: "0 9 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let schedule_ny = CronSchedule::Cron {
|
||||
expr: "0 9 * * *".into(),
|
||||
tz: Some("America/New_York".into()),
|
||||
};
|
||||
let now = Utc::now();
|
||||
let next_utc = compute_next_run_after(&schedule_utc, now);
|
||||
let next_ny = compute_next_run_after(&schedule_ny, now);
|
||||
|
||||
// The New York schedule should fire at 09:00 Eastern, which is 13:00
|
||||
// or 14:00 UTC (depending on DST). In either case, it should NOT
|
||||
// equal the plain UTC 09:00 result.
|
||||
assert_ne!(
|
||||
next_utc, next_ny,
|
||||
"Timezone-aware schedule should produce a different UTC time"
|
||||
);
|
||||
|
||||
// Verify the New York result, when converted to ET, shows hour 09.
|
||||
let ny_tz: chrono_tz::Tz = "America/New_York".parse().unwrap();
|
||||
let next_ny_local = next_ny.with_timezone(&ny_tz);
|
||||
assert_eq!(
|
||||
next_ny_local.hour(),
|
||||
9,
|
||||
"Expected 09:00 in America/New_York, got {:02}:{:02}",
|
||||
next_ny_local.hour(),
|
||||
next_ny_local.minute()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_tz_none_defaults_to_utc() {
|
||||
// tz: None should behave identically to tz: Some("UTC").
|
||||
let schedule_none = CronSchedule::Cron {
|
||||
expr: "30 12 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let schedule_utc = CronSchedule::Cron {
|
||||
expr: "30 12 * * *".into(),
|
||||
tz: Some("UTC".into()),
|
||||
};
|
||||
let now = Utc::now();
|
||||
let next_none = compute_next_run_after(&schedule_none, now);
|
||||
let next_utc = compute_next_run_after(&schedule_utc, now);
|
||||
assert_eq!(next_none, next_utc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_tz_empty_string_defaults_to_utc() {
|
||||
let schedule_empty = CronSchedule::Cron {
|
||||
expr: "30 12 * * *".into(),
|
||||
tz: Some(String::new()),
|
||||
};
|
||||
let schedule_none = CronSchedule::Cron {
|
||||
expr: "30 12 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let now = Utc::now();
|
||||
assert_eq!(
|
||||
compute_next_run_after(&schedule_empty, now),
|
||||
compute_next_run_after(&schedule_none, now)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_tz_invalid_falls_back_to_utc() {
|
||||
// An invalid timezone string should fall back to UTC, not panic.
|
||||
let schedule_bad = CronSchedule::Cron {
|
||||
expr: "0 9 * * *".into(),
|
||||
tz: Some("Not/A_Timezone".into()),
|
||||
};
|
||||
let schedule_utc = CronSchedule::Cron {
|
||||
expr: "0 9 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let now = Utc::now();
|
||||
let next_bad = compute_next_run_after(&schedule_bad, now);
|
||||
let next_utc = compute_next_run_after(&schedule_utc, now);
|
||||
// Invalid tz falls back to UTC computation — same result.
|
||||
assert_eq!(next_bad, next_utc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_tz_asia_shanghai() {
|
||||
// "0 8 * * *" in Asia/Shanghai (UTC+8) should fire at 00:00 UTC.
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "0 8 * * *".into(),
|
||||
tz: Some("Asia/Shanghai".into()),
|
||||
};
|
||||
let now = Utc::now();
|
||||
let next = compute_next_run_after(&schedule, now);
|
||||
|
||||
let shanghai_tz: chrono_tz::Tz = "Asia/Shanghai".parse().unwrap();
|
||||
let local = next.with_timezone(&shanghai_tz);
|
||||
assert_eq!(local.hour(), 8);
|
||||
assert_eq!(local.minute(), 0);
|
||||
|
||||
// In UTC, 08:00 Shanghai = 00:00 UTC.
|
||||
assert_eq!(next.hour(), 0, "08:00 CST should be 00:00 UTC");
|
||||
}
|
||||
|
||||
// -- reassign_agent_jobs (#461) -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_basic() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let old_agent = AgentId::new();
|
||||
let new_agent = AgentId::new();
|
||||
|
||||
let mut j1 = make_job(old_agent);
|
||||
j1.name = "cron-a".into();
|
||||
let mut j2 = make_job(old_agent);
|
||||
j2.name = "cron-b".into();
|
||||
|
||||
let id1 = sched.add_job(j1, false).unwrap();
|
||||
let id2 = sched.add_job(j2, false).unwrap();
|
||||
|
||||
let count = sched.reassign_agent_jobs(old_agent, new_agent);
|
||||
assert_eq!(count, 2);
|
||||
|
||||
// Both jobs should now belong to the new agent
|
||||
let job1 = sched.get_job(id1).unwrap();
|
||||
assert_eq!(job1.agent_id, new_agent);
|
||||
let job2 = sched.get_job(id2).unwrap();
|
||||
assert_eq!(job2.agent_id, new_agent);
|
||||
|
||||
// Old agent should have zero jobs
|
||||
assert!(sched.list_jobs(old_agent).is_empty());
|
||||
// New agent should have both
|
||||
assert_eq!(sched.list_jobs(new_agent).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_does_not_touch_other_agents() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let agent_a = AgentId::new();
|
||||
let agent_b = AgentId::new();
|
||||
let agent_c = AgentId::new();
|
||||
|
||||
let mut ja = make_job(agent_a);
|
||||
ja.name = "job-a".into();
|
||||
let mut jb = make_job(agent_b);
|
||||
jb.name = "job-b".into();
|
||||
|
||||
let _id_a = sched.add_job(ja, false).unwrap();
|
||||
let id_b = sched.add_job(jb, false).unwrap();
|
||||
|
||||
// Reassign agent_a -> agent_c
|
||||
let count = sched.reassign_agent_jobs(agent_a, agent_c);
|
||||
assert_eq!(count, 1);
|
||||
|
||||
// agent_b's job should be untouched
|
||||
let job_b = sched.get_job(id_b).unwrap();
|
||||
assert_eq!(job_b.agent_id, agent_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_no_match_returns_zero() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let agent = AgentId::new();
|
||||
let other = AgentId::new();
|
||||
|
||||
let job = make_job(agent);
|
||||
sched.add_job(job, false).unwrap();
|
||||
|
||||
// Reassign a non-existent agent
|
||||
let count = sched.reassign_agent_jobs(AgentId::new(), other);
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_resets_consecutive_errors() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let old_agent = AgentId::new();
|
||||
let new_agent = AgentId::new();
|
||||
|
||||
let job = make_job(old_agent);
|
||||
let id = sched.add_job(job, false).unwrap();
|
||||
|
||||
// Simulate some failures
|
||||
sched.record_failure(id, "agent not found");
|
||||
sched.record_failure(id, "agent not found");
|
||||
let meta = sched.get_meta(id).unwrap();
|
||||
assert_eq!(meta.consecutive_errors, 2);
|
||||
|
||||
// Reassign
|
||||
sched.reassign_agent_jobs(old_agent, new_agent);
|
||||
|
||||
// Errors should be reset
|
||||
let meta = sched.get_meta(id).unwrap();
|
||||
assert_eq!(meta.consecutive_errors, 0);
|
||||
assert_eq!(meta.job.agent_id, new_agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_reenables_disabled_stale_jobs() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let old_agent = AgentId::new();
|
||||
let new_agent = AgentId::new();
|
||||
|
||||
let job = make_job(old_agent);
|
||||
let id = sched.add_job(job, false).unwrap();
|
||||
|
||||
// Simulate enough failures to auto-disable (with "not found" message)
|
||||
for _ in 0..MAX_CONSECUTIVE_ERRORS {
|
||||
sched.record_failure(id, "No such agent");
|
||||
}
|
||||
let meta = sched.get_meta(id).unwrap();
|
||||
assert!(!meta.job.enabled, "Job should be auto-disabled");
|
||||
|
||||
// Reassign should re-enable it
|
||||
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_eq!(meta.consecutive_errors, 0);
|
||||
assert_eq!(meta.job.agent_id, new_agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_persists_after_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old_agent = AgentId::new();
|
||||
let new_agent = AgentId::new();
|
||||
|
||||
// Create scheduler, add job, reassign, persist
|
||||
let id = {
|
||||
let sched = CronScheduler::new(tmp.path(), 100);
|
||||
let job = make_job(old_agent);
|
||||
let id = sched.add_job(job, false).unwrap();
|
||||
|
||||
sched.reassign_agent_jobs(old_agent, new_agent);
|
||||
sched.persist().unwrap();
|
||||
id
|
||||
};
|
||||
|
||||
// Load from disk and verify the agent_id was persisted
|
||||
{
|
||||
let sched = CronScheduler::new(tmp.path(), 100);
|
||||
sched.load().unwrap();
|
||||
|
||||
let job = sched.get_job(id).unwrap();
|
||||
assert_eq!(job.agent_id, new_agent);
|
||||
assert!(sched.list_jobs(old_agent).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,8 @@ impl LlmDriver for StubDriver {
|
||||
async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
Err(LlmError::MissingApiKey(
|
||||
"No LLM provider configured. Set an API key (e.g. GROQ_API_KEY) and restart, \
|
||||
or configure a provider via the dashboard."
|
||||
configure a provider via the dashboard, \
|
||||
or use Ollama for local models (no API key needed)."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
@@ -545,10 +546,20 @@ impl OpenFangKernel {
|
||||
.map_err(|e| KernelError::BootFailed(format!("Memory init failed: {e}")))?,
|
||||
);
|
||||
|
||||
// Create LLM driver
|
||||
// Create LLM driver.
|
||||
// For the API key, try: 1) explicit api_key_env from config, 2) provider_api_keys
|
||||
// mapping, 3) auth profiles, 4) convention {PROVIDER}_API_KEY. This ensures
|
||||
// custom providers (e.g. nvidia, azure) work without hardcoded env var names.
|
||||
let default_api_key = if !config.default_model.api_key_env.is_empty() {
|
||||
std::env::var(&config.default_model.api_key_env).ok()
|
||||
} else {
|
||||
// api_key_env not set — resolve using provider_api_keys / convention
|
||||
let env_var = config.resolve_api_key_env(&config.default_model.provider);
|
||||
std::env::var(&env_var).ok()
|
||||
};
|
||||
let driver_config = DriverConfig {
|
||||
provider: config.default_model.provider.clone(),
|
||||
api_key: std::env::var(&config.default_model.api_key_env).ok(),
|
||||
api_key: default_api_key,
|
||||
base_url: config
|
||||
.default_model
|
||||
.base_url
|
||||
@@ -566,20 +577,54 @@ impl OpenFangKernel {
|
||||
warn!(
|
||||
provider = %config.default_model.provider,
|
||||
error = %e,
|
||||
"Primary LLM driver init failed — dashboard will still be accessible"
|
||||
"Primary LLM driver init failed — trying auto-detect"
|
||||
);
|
||||
// Auto-detect: scan env for any configured provider key
|
||||
if let Some((provider, model, env_var)) = drivers::detect_available_provider() {
|
||||
let auto_config = DriverConfig {
|
||||
provider: provider.to_string(),
|
||||
api_key: std::env::var(env_var).ok(),
|
||||
base_url: config.provider_urls.get(provider).cloned(),
|
||||
};
|
||||
match drivers::create_driver(&auto_config) {
|
||||
Ok(d) => {
|
||||
info!(
|
||||
provider = %provider,
|
||||
model = %model,
|
||||
"Auto-detected provider from {} — using as default",
|
||||
env_var
|
||||
);
|
||||
driver_chain.push(d);
|
||||
// Update the running config so agents get the right model
|
||||
config.default_model.provider = provider.to_string();
|
||||
config.default_model.model = model.to_string();
|
||||
config.default_model.api_key_env = env_var.to_string();
|
||||
}
|
||||
Err(e2) => {
|
||||
warn!(provider = %provider, error = %e2, "Auto-detected provider also failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add fallback providers to the chain
|
||||
// Add fallback providers to the chain (with model names for cross-provider fallback)
|
||||
let mut model_chain: Vec<(Arc<dyn LlmDriver>, String)> = Vec::new();
|
||||
// Primary driver uses empty model name (uses the request's model field as-is)
|
||||
for d in &driver_chain {
|
||||
model_chain.push((d.clone(), String::new()));
|
||||
}
|
||||
for fb in &config.fallback_providers {
|
||||
let fb_api_key = if !fb.api_key_env.is_empty() {
|
||||
std::env::var(&fb.api_key_env).ok()
|
||||
} else {
|
||||
// Resolve using provider_api_keys / convention for custom providers
|
||||
let env_var = config.resolve_api_key_env(&fb.provider);
|
||||
std::env::var(&env_var).ok()
|
||||
};
|
||||
let fb_config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
api_key: if fb.api_key_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
std::env::var(&fb.api_key_env).ok()
|
||||
},
|
||||
api_key: fb_api_key,
|
||||
base_url: fb
|
||||
.base_url
|
||||
.clone()
|
||||
@@ -592,7 +637,8 @@ impl OpenFangKernel {
|
||||
model = %fb.model,
|
||||
"Fallback provider configured"
|
||||
);
|
||||
driver_chain.push(d);
|
||||
driver_chain.push(d.clone());
|
||||
model_chain.push((d, fb.model.clone()));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -606,8 +652,8 @@ 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::new(
|
||||
driver_chain,
|
||||
Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::with_models(
|
||||
model_chain,
|
||||
))
|
||||
} else if let Some(single) = driver_chain.into_iter().next() {
|
||||
single
|
||||
@@ -754,12 +800,14 @@ impl OpenFangKernel {
|
||||
Arc<dyn openfang_runtime::embedding::EmbeddingDriver + Send + Sync>,
|
||||
> = {
|
||||
use openfang_runtime::embedding::create_embedding_driver;
|
||||
let configured_model = &config.memory.embedding_model;
|
||||
if let Some(ref provider) = config.memory.embedding_provider {
|
||||
// Explicit config takes priority
|
||||
// Explicit config takes priority — use the configured embedding model
|
||||
let api_key_env = config.memory.embedding_api_key_env.as_deref().unwrap_or("");
|
||||
match create_embedding_driver(provider, "text-embedding-3-small", api_key_env) {
|
||||
let custom_url = config.provider_urls.get(provider.as_str()).map(|s| s.as_str());
|
||||
match create_embedding_driver(provider, configured_model, api_key_env, custom_url) {
|
||||
Ok(d) => {
|
||||
info!(provider = %provider, "Embedding driver configured from memory config");
|
||||
info!(provider = %provider, model = %configured_model, "Embedding driver configured from memory config");
|
||||
Some(Arc::from(d))
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -768,8 +816,13 @@ impl OpenFangKernel {
|
||||
}
|
||||
}
|
||||
} else if std::env::var("OPENAI_API_KEY").is_ok() {
|
||||
match create_embedding_driver("openai", "text-embedding-3-small", "OPENAI_API_KEY")
|
||||
{
|
||||
let model = if configured_model == "all-MiniLM-L6-v2" {
|
||||
"text-embedding-3-small"
|
||||
} else {
|
||||
configured_model.as_str()
|
||||
};
|
||||
let openai_url = config.provider_urls.get("openai").map(|s| s.as_str());
|
||||
match create_embedding_driver("openai", model, "OPENAI_API_KEY", openai_url) {
|
||||
Ok(d) => {
|
||||
info!("Embedding driver auto-detected: OpenAI");
|
||||
Some(Arc::from(d))
|
||||
@@ -781,7 +834,13 @@ impl OpenFangKernel {
|
||||
}
|
||||
} else {
|
||||
// Try Ollama (local, no key needed)
|
||||
match create_embedding_driver("ollama", "nomic-embed-text", "") {
|
||||
let model = if configured_model == "all-MiniLM-L6-v2" {
|
||||
"nomic-embed-text"
|
||||
} else {
|
||||
configured_model.as_str()
|
||||
};
|
||||
let ollama_url = config.provider_urls.get("ollama").map(|s| s.as_str());
|
||||
match create_embedding_driver("ollama", model, "", ollama_url) {
|
||||
Ok(d) => {
|
||||
info!("Embedding driver auto-detected: Ollama (local)");
|
||||
Some(Arc::from(d))
|
||||
@@ -889,7 +948,7 @@ impl OpenFangKernel {
|
||||
workflows: WorkflowEngine::new(),
|
||||
triggers: TriggerEngine::new(),
|
||||
background,
|
||||
audit_log: Arc::new(AuditLog::new()),
|
||||
audit_log: Arc::new(AuditLog::with_db(memory.usage_conn())),
|
||||
metering,
|
||||
default_driver: driver,
|
||||
wasm_sandbox,
|
||||
@@ -1016,24 +1075,31 @@ impl OpenFangKernel {
|
||||
&mut restored_entry.manifest.resources,
|
||||
);
|
||||
|
||||
// Apply default_model to restored agents (same logic as spawn)
|
||||
// Apply default_model to restored agents.
|
||||
//
|
||||
// Two cases:
|
||||
// 1. Agent has empty/default provider → always apply default_model
|
||||
// 2. Agent named "assistant" (auto-spawned) → update to match
|
||||
// default_model so config.toml changes take effect on restart
|
||||
{
|
||||
let dm = &kernel.config.default_model;
|
||||
let is_default_provider = restored_entry.manifest.model.provider.is_empty()
|
||||
|| restored_entry.manifest.model.provider == "default";
|
||||
let is_default_model = restored_entry.manifest.model.model.is_empty()
|
||||
|| restored_entry.manifest.model.model == "default";
|
||||
if is_default_provider && is_default_model {
|
||||
let dm = &kernel.config.default_model;
|
||||
let is_auto_spawned = restored_entry.name == "assistant"
|
||||
&& restored_entry.manifest.description == "General-purpose assistant";
|
||||
if is_default_provider && is_default_model || is_auto_spawned {
|
||||
if !dm.provider.is_empty() {
|
||||
restored_entry.manifest.model.provider = dm.provider.clone();
|
||||
}
|
||||
if !dm.model.is_empty() {
|
||||
restored_entry.manifest.model.model = dm.model.clone();
|
||||
}
|
||||
if !dm.api_key_env.is_empty() && restored_entry.manifest.model.api_key_env.is_none() {
|
||||
if !dm.api_key_env.is_empty() {
|
||||
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.is_none() {
|
||||
if dm.base_url.is_some() {
|
||||
restored_entry.manifest.model.base_url.clone_from(&dm.base_url);
|
||||
}
|
||||
}
|
||||
@@ -1299,12 +1365,47 @@ impl OpenFangKernel {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a multimodal message (text + images) to an agent and get a response.
|
||||
///
|
||||
/// Used by channel bridges when a user sends a photo — the image is downloaded,
|
||||
/// base64 encoded, and passed as `ContentBlock::Image` alongside any caption text.
|
||||
pub async fn send_message_with_blocks(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
message: &str,
|
||||
blocks: Vec<openfang_types::message::ContentBlock>,
|
||||
) -> KernelResult<AgentLoopResult> {
|
||||
let handle: Option<Arc<dyn KernelHandle>> = self
|
||||
.self_handle
|
||||
.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))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a message with an optional kernel handle for inter-agent tools.
|
||||
pub async fn send_message_with_handle(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
message: &str,
|
||||
kernel_handle: Option<Arc<dyn KernelHandle>>,
|
||||
) -> KernelResult<AgentLoopResult> {
|
||||
self.send_message_with_handle_and_blocks(agent_id, message, kernel_handle, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a message with optional content blocks and an optional kernel handle.
|
||||
///
|
||||
/// When `content_blocks` is `Some`, the LLM agent loop receives structured
|
||||
/// multimodal content (text + images) instead of just a text string. This
|
||||
/// enables vision models to process images sent from channels like Telegram.
|
||||
pub async fn send_message_with_handle_and_blocks(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
message: &str,
|
||||
kernel_handle: Option<Arc<dyn KernelHandle>>,
|
||||
content_blocks: Option<Vec<openfang_types::message::ContentBlock>>,
|
||||
) -> KernelResult<AgentLoopResult> {
|
||||
// Enforce quota before running the agent loop
|
||||
self.scheduler
|
||||
@@ -1323,7 +1424,7 @@ 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)
|
||||
self.execute_llm_agent(&entry, agent_id, message, kernel_handle, content_blocks)
|
||||
.await
|
||||
};
|
||||
|
||||
@@ -1711,6 +1812,7 @@ impl OpenFangKernel {
|
||||
Some(&kernel_clone.hooks),
|
||||
ctx_window,
|
||||
Some(&kernel_clone.process_manager),
|
||||
None, // content_blocks (streaming path uses text only for now)
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1930,6 +2032,7 @@ impl OpenFangKernel {
|
||||
agent_id: AgentId,
|
||||
message: &str,
|
||||
kernel_handle: Option<Arc<dyn KernelHandle>>,
|
||||
content_blocks: Option<Vec<openfang_types::message::ContentBlock>>,
|
||||
) -> KernelResult<AgentLoopResult> {
|
||||
// Check metering quota before starting
|
||||
self.metering
|
||||
@@ -2191,6 +2294,7 @@ impl OpenFangKernel {
|
||||
Some(&self.hooks),
|
||||
ctx_window,
|
||||
Some(&self.process_manager),
|
||||
content_blocks,
|
||||
)
|
||||
.await
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
@@ -2292,6 +2396,9 @@ impl OpenFangKernel {
|
||||
.update_session_id(agent_id, new_session.id)
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
|
||||
// Reset quota tracking so /new clears "token quota exceeded"
|
||||
self.scheduler.reset_usage(agent_id);
|
||||
|
||||
info!(agent_id = %agent_id, "Session reset (summary saved to memory)");
|
||||
Ok(())
|
||||
}
|
||||
@@ -2461,7 +2568,7 @@ impl OpenFangKernel {
|
||||
.take(5)
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let truncated = if t.len() > 200 { &t[..200] } else { t };
|
||||
let truncated = openfang_types::truncate_str(t, 200);
|
||||
format!("{}. {}", i + 1, truncated)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -2961,6 +3068,7 @@ impl OpenFangKernel {
|
||||
|
||||
// If an agent with this hand's name already exists, remove it first
|
||||
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);
|
||||
if let Some(old) = existing {
|
||||
info!(agent = %old.name, id = %old.id, "Removing existing hand agent for reactivation");
|
||||
let _ = self.kill_agent(old.id);
|
||||
@@ -2969,6 +3077,18 @@ impl OpenFangKernel {
|
||||
// Spawn the agent
|
||||
let agent_id = self.spawn_agent(manifest)?;
|
||||
|
||||
// Migrate cron jobs from old agent to new agent so they survive restarts.
|
||||
// Without this, persisted cron jobs would reference the stale old UUID
|
||||
// and fail silently (issue #461).
|
||||
if let Some(old_id) = old_agent_id {
|
||||
let migrated = self.cron_scheduler.reassign_agent_jobs(old_id, agent_id);
|
||||
if migrated > 0 {
|
||||
if let Err(e) = self.cron_scheduler.persist() {
|
||||
warn!("Failed to persist cron jobs after agent migration: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Link agent to instance
|
||||
self.hand_registry
|
||||
.set_agent(instance.instance_id, agent_id)
|
||||
@@ -2981,6 +3101,9 @@ impl OpenFangKernel {
|
||||
"Hand activated with agent"
|
||||
);
|
||||
|
||||
// Persist hand state so it survives restarts
|
||||
self.persist_hand_state();
|
||||
|
||||
// Return instance with agent set
|
||||
Ok(self
|
||||
.hand_registry
|
||||
@@ -3012,9 +3135,19 @@ impl OpenFangKernel {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Persist hand state so it survives restarts
|
||||
self.persist_hand_state();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist active hand state to disk.
|
||||
fn persist_hand_state(&self) {
|
||||
let state_path = self.config.home_dir.join("hand_state.json");
|
||||
if let Err(e) = self.hand_registry.persist_state(&state_path) {
|
||||
warn!(error = %e, "Failed to persist hand state");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause a hand (marks it paused; agent stays alive but won't receive new work).
|
||||
pub fn pause_hand(&self, instance_id: uuid::Uuid) -> KernelResult<()> {
|
||||
self.hand_registry
|
||||
@@ -3291,6 +3424,44 @@ impl OpenFangKernel {
|
||||
/// Iterates the agent registry and starts background tasks for agents with
|
||||
/// `Continuous`, `Periodic`, or `Proactive` schedules.
|
||||
pub fn start_background_agents(self: &Arc<Self>) {
|
||||
// Restore previously active hands from persisted state
|
||||
let state_path = self.config.home_dir.join("hand_state.json");
|
||||
let saved_hands = openfang_hands::registry::HandRegistry::load_state(&state_path);
|
||||
if !saved_hands.is_empty() {
|
||||
info!("Restoring {} persisted hand(s)", saved_hands.len());
|
||||
for (hand_id, config, old_agent_id) in saved_hands {
|
||||
match self.activate_hand(&hand_id, config) {
|
||||
Ok(inst) => {
|
||||
info!(hand = %hand_id, instance = %inst.instance_id, "Hand restored");
|
||||
// Reassign cron jobs from the pre-restart agent ID to the
|
||||
// newly spawned agent so scheduled tasks survive daemon
|
||||
// restarts (issue #402). activate_hand only handles
|
||||
// reassignment when an existing agent is found in the live
|
||||
// registry, which is empty on a fresh boot.
|
||||
if let (Some(old_id), Some(new_id)) = (old_agent_id, inst.agent_id) {
|
||||
if old_id != new_id {
|
||||
let migrated =
|
||||
self.cron_scheduler.reassign_agent_jobs(old_id, new_id);
|
||||
if migrated > 0 {
|
||||
info!(
|
||||
hand = %hand_id,
|
||||
old_agent = %old_id,
|
||||
new_agent = %new_id,
|
||||
migrated,
|
||||
"Reassigned cron jobs after restart"
|
||||
);
|
||||
if let Err(e) = self.cron_scheduler.persist() {
|
||||
warn!("Failed to persist cron jobs after hand restore: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(hand = %hand_id, error = %e, "Failed to restore hand"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let agents = self.registry.list();
|
||||
let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> =
|
||||
Vec::new();
|
||||
@@ -3829,25 +4000,36 @@ impl OpenFangKernel {
|
||||
|
||||
/// Resolve the LLM driver for an agent.
|
||||
///
|
||||
/// If the agent's manifest specifies a different provider than the kernel default,
|
||||
/// a dedicated driver is created. Otherwise the kernel's default driver is reused.
|
||||
/// Always creates a fresh driver using current environment variables so that
|
||||
/// API keys saved via the dashboard (`set_provider_key`) take effect immediately
|
||||
/// without requiring a daemon restart. Uses the hot-reloaded default model
|
||||
/// override when available.
|
||||
/// If fallback models are configured, wraps the primary in a `FallbackDriver`.
|
||||
fn resolve_driver(&self, manifest: &AgentManifest) -> KernelResult<Arc<dyn LlmDriver>> {
|
||||
let agent_provider = &manifest.model.provider;
|
||||
let default_provider = &self.config.default_model.provider;
|
||||
|
||||
// If agent uses same provider as kernel default and has no custom overrides, reuse
|
||||
// Use the effective default model: hot-reloaded override takes priority
|
||||
// over the boot-time config. This ensures that when a user saves a new
|
||||
// API key via the dashboard and the default provider is switched,
|
||||
// resolve_driver sees the updated provider/model/api_key_env.
|
||||
let override_guard = self
|
||||
.default_model_override
|
||||
.read()
|
||||
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
|
||||
let effective_default = override_guard
|
||||
.as_ref()
|
||||
.unwrap_or(&self.config.default_model);
|
||||
let default_provider = &effective_default.provider;
|
||||
|
||||
let has_custom_key = manifest.model.api_key_env.is_some();
|
||||
let has_custom_url = manifest.model.base_url.is_some();
|
||||
|
||||
let primary = if agent_provider == default_provider && !has_custom_key && !has_custom_url {
|
||||
Arc::clone(&self.default_driver)
|
||||
} else {
|
||||
// Create a dedicated driver for this agent.
|
||||
//
|
||||
// IMPORTANT: When the agent's provider differs from the default,
|
||||
// we must NOT pass the default provider's API key. Instead, pass None
|
||||
// so create_driver() can look up the correct env var for the target provider.
|
||||
// Always create a fresh driver by reading current env vars.
|
||||
// This ensures API keys saved at runtime (via dashboard POST
|
||||
// /api/providers/{name}/key which calls std::env::set_var) are
|
||||
// picked up immediately — the boot-time default_driver cache is
|
||||
// only used as a final fallback when driver creation fails.
|
||||
let primary = {
|
||||
let api_key = if has_custom_key {
|
||||
// Agent explicitly set an API key env var — use it
|
||||
manifest
|
||||
@@ -3856,29 +4038,26 @@ impl OpenFangKernel {
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
} else if agent_provider == default_provider {
|
||||
// Same provider — use default key
|
||||
std::env::var(&self.config.default_model.api_key_env).ok()
|
||||
} else {
|
||||
// Different provider — check auth profiles first, then let
|
||||
// create_driver() look up the correct env var automatically.
|
||||
if let Some(profiles) = self.config.auth_profiles.get(agent_provider.as_str()) {
|
||||
let mut sorted: Vec<_> = profiles.iter().collect();
|
||||
sorted.sort_by_key(|p| p.priority);
|
||||
sorted
|
||||
.first()
|
||||
.and_then(|best| std::env::var(&best.api_key_env).ok())
|
||||
// Same provider as effective default — use its env var
|
||||
if !effective_default.api_key_env.is_empty() {
|
||||
std::env::var(&effective_default.api_key_env).ok()
|
||||
} else {
|
||||
// Pass None — create_driver() has per-provider env var lookups
|
||||
None
|
||||
let env_var = self.config.resolve_api_key_env(agent_provider);
|
||||
std::env::var(&env_var).ok()
|
||||
}
|
||||
} else {
|
||||
// Different provider — check auth profiles, provider_api_keys,
|
||||
// and convention-based env var. For custom providers (not in the
|
||||
// hardcoded list), this is the primary path for API key resolution.
|
||||
let env_var = self.config.resolve_api_key_env(agent_provider);
|
||||
std::env::var(&env_var).ok()
|
||||
};
|
||||
|
||||
// Don't inherit default provider's base_url when switching providers
|
||||
let base_url = if has_custom_url {
|
||||
manifest.model.base_url.clone()
|
||||
} else if agent_provider == default_provider {
|
||||
self.config
|
||||
.default_model
|
||||
effective_default
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| self.config.provider_urls.get(agent_provider.as_str()).cloned())
|
||||
@@ -3893,9 +4072,27 @@ impl OpenFangKernel {
|
||||
base_url,
|
||||
};
|
||||
|
||||
drivers::create_driver(&driver_config).map_err(|e| {
|
||||
KernelError::BootFailed(format!("Agent LLM driver init failed: {e}"))
|
||||
})?
|
||||
match drivers::create_driver(&driver_config) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
// If fresh driver creation fails (e.g. key not yet set for this
|
||||
// provider), fall back to the boot-time default driver. This
|
||||
// keeps existing agents working while the user is still
|
||||
// configuring providers via the dashboard.
|
||||
if agent_provider == default_provider && !has_custom_key && !has_custom_url {
|
||||
debug!(
|
||||
provider = %agent_provider,
|
||||
error = %e,
|
||||
"Fresh driver creation failed, falling back to boot-time default"
|
||||
);
|
||||
Arc::clone(&self.default_driver)
|
||||
} else {
|
||||
return Err(KernelError::BootFailed(format!(
|
||||
"Agent LLM driver init failed: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If fallback models are configured, wrap in FallbackDriver
|
||||
@@ -3904,12 +4101,16 @@ impl OpenFangKernel {
|
||||
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()
|
||||
} else {
|
||||
// Resolve using provider_api_keys / convention for custom providers
|
||||
let env_var = self.config.resolve_api_key_env(&fb.provider);
|
||||
std::env::var(&env_var).ok()
|
||||
};
|
||||
let config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
api_key: fb
|
||||
.api_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok()),
|
||||
api_key: fb_api_key,
|
||||
base_url: fb
|
||||
.base_url
|
||||
.clone()
|
||||
@@ -4281,6 +4482,10 @@ impl OpenFangKernel {
|
||||
_ => all_builtins,
|
||||
};
|
||||
|
||||
// Track tool names added by skills/MCP — these already passed their own
|
||||
// allowlist filters and should bypass the per-agent capability check.
|
||||
let mut extension_tool_names = std::collections::HashSet::new();
|
||||
|
||||
// Add skill-provided tools (filtered by agent's skill allowlist)
|
||||
let skill_tools = {
|
||||
let registry = self
|
||||
@@ -4294,6 +4499,7 @@ impl OpenFangKernel {
|
||||
}
|
||||
};
|
||||
for skill_tool in skill_tools {
|
||||
extension_tool_names.insert(skill_tool.name.clone());
|
||||
all_tools.push(ToolDefinition {
|
||||
name: skill_tool.name.clone(),
|
||||
description: skill_tool.description.clone(),
|
||||
@@ -4304,6 +4510,9 @@ impl OpenFangKernel {
|
||||
// Add MCP tools (filtered by agent's MCP server allowlist)
|
||||
if let Ok(mcp_tools) = self.mcp_tools.lock() {
|
||||
if mcp_allowlist.is_empty() {
|
||||
for t in mcp_tools.iter() {
|
||||
extension_tool_names.insert(t.name.clone());
|
||||
}
|
||||
all_tools.extend(mcp_tools.iter().cloned());
|
||||
} else {
|
||||
// Normalize allowlist names for matching
|
||||
@@ -4311,16 +4520,19 @@ impl OpenFangKernel {
|
||||
.iter()
|
||||
.map(|s| openfang_runtime::mcp::normalize_name(s))
|
||||
.collect();
|
||||
all_tools.extend(
|
||||
mcp_tools
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
openfang_runtime::mcp::extract_mcp_server(&t.name)
|
||||
.map(|s| normalized.iter().any(|n| n == s))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned(),
|
||||
);
|
||||
let filtered: Vec<_> = mcp_tools
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
openfang_runtime::mcp::extract_mcp_server(&t.name)
|
||||
.map(|s| normalized.iter().any(|n| n == s))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for t in &filtered {
|
||||
extension_tool_names.insert(t.name.clone());
|
||||
}
|
||||
all_tools.extend(filtered);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4356,14 +4568,17 @@ impl OpenFangKernel {
|
||||
return all_tools;
|
||||
}
|
||||
|
||||
// Filter to tools the agent has capability for
|
||||
// Filter to tools the agent has capability for.
|
||||
// MCP and skill tools bypass this check — they already passed their
|
||||
// own allowlist filters above (mcp_servers / skills on the manifest).
|
||||
all_tools
|
||||
.into_iter()
|
||||
.filter(|tool| {
|
||||
caps.iter().any(|c| match c {
|
||||
Capability::ToolInvoke(name) => name == &tool.name || name == "*",
|
||||
_ => false,
|
||||
})
|
||||
extension_tool_names.contains(&tool.name)
|
||||
|| caps.iter().any(|c| match c {
|
||||
Capability::ToolInvoke(name) => name == &tool.name || name == "*",
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -4632,8 +4847,8 @@ fn apply_budget_defaults(
|
||||
budget: &openfang_types::config::BudgetConfig,
|
||||
resources: &mut ResourceQuota,
|
||||
) {
|
||||
// Only override hourly if agent has the built-in default (1.0) and global is set
|
||||
if budget.max_hourly_usd > 0.0 && resources.max_cost_per_hour_usd == 1.0 {
|
||||
// Only override hourly if agent has unlimited (0.0) and global is set
|
||||
if budget.max_hourly_usd > 0.0 && resources.max_cost_per_hour_usd == 0.0 {
|
||||
resources.max_cost_per_hour_usd = budget.max_hourly_usd;
|
||||
}
|
||||
// Only override daily/monthly if agent has unlimited (0.0) and global is set
|
||||
@@ -4651,17 +4866,27 @@ fn apply_budget_defaults(
|
||||
/// This is a defense-in-depth fallback — models should ideally be in the catalog.
|
||||
fn infer_provider_from_model(model: &str) -> Option<String> {
|
||||
let lower = model.to_lowercase();
|
||||
// Check for explicit provider prefix (e.g., "minimax/MiniMax-M2.5")
|
||||
if let Some(prefix) = lower.split('/').next() {
|
||||
// Check for explicit provider prefix with / or : delimiter
|
||||
// (e.g., "minimax/MiniMax-M2.5" or "qwen:qwen-plus")
|
||||
let (prefix, has_delim) = if let Some(idx) = lower.find('/') {
|
||||
(&lower[..idx], true)
|
||||
} else if let Some(idx) = lower.find(':') {
|
||||
(&lower[..idx], true)
|
||||
} else {
|
||||
(lower.as_str(), false)
|
||||
};
|
||||
if has_delim {
|
||||
match prefix {
|
||||
"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" | "moonshot"
|
||||
| "openrouter" => {
|
||||
if model.contains('/') {
|
||||
return Some(prefix.to_string());
|
||||
}
|
||||
| "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
|
||||
"kimi" => {
|
||||
return Some("moonshot".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -4696,6 +4921,8 @@ fn infer_provider_from_model(model: &str) -> Option<String> {
|
||||
Some("qianfan".to_string())
|
||||
} else if lower.starts_with("abab") {
|
||||
Some("minimax".to_string())
|
||||
} else if lower.starts_with("moonshot") || lower.starts_with("kimi") {
|
||||
Some("moonshot".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -5205,7 +5432,7 @@ impl KernelHandle for OpenFangKernel {
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
agents
|
||||
.iter()
|
||||
.map(|(url, card)| (card.name.clone(), url.clone()))
|
||||
.map(|(_, card)| (card.name.clone(), card.url.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -5218,7 +5445,7 @@ impl KernelHandle for OpenFangKernel {
|
||||
agents
|
||||
.iter()
|
||||
.find(|(_, card)| card.name.to_lowercase() == name_lower)
|
||||
.map(|(url, _)| url.clone())
|
||||
.map(|(_, card)| card.url.clone())
|
||||
}
|
||||
|
||||
async fn send_channel_message(
|
||||
@@ -5257,6 +5484,59 @@ impl KernelHandle for OpenFangKernel {
|
||||
Ok(format!("Message sent to {} via {}", recipient, channel))
|
||||
}
|
||||
|
||||
async fn send_channel_media(
|
||||
&self,
|
||||
channel: &str,
|
||||
recipient: &str,
|
||||
media_type: &str,
|
||||
media_url: &str,
|
||||
caption: Option<&str>,
|
||||
filename: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let adapter = self
|
||||
.channel_adapters
|
||||
.get(channel)
|
||||
.ok_or_else(|| {
|
||||
let available: Vec<String> = self
|
||||
.channel_adapters
|
||||
.iter()
|
||||
.map(|e| e.key().clone())
|
||||
.collect();
|
||||
format!(
|
||||
"Channel '{}' not found. Available channels: {:?}",
|
||||
channel, available
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let user = openfang_channels::types::ChannelUser {
|
||||
platform_id: recipient.to_string(),
|
||||
display_name: recipient.to_string(),
|
||||
openfang_user: None,
|
||||
};
|
||||
|
||||
let content = match media_type {
|
||||
"image" => openfang_channels::types::ChannelContent::Image {
|
||||
url: media_url.to_string(),
|
||||
caption: caption.map(|s| s.to_string()),
|
||||
},
|
||||
"file" => openfang_channels::types::ChannelContent::File {
|
||||
url: media_url.to_string(),
|
||||
filename: filename.unwrap_or("file").to_string(),
|
||||
},
|
||||
_ => {
|
||||
return Err(format!("Unsupported media type: '{media_type}'. Use 'image' or 'file'."));
|
||||
}
|
||||
};
|
||||
|
||||
adapter
|
||||
.send(&user, content)
|
||||
.await
|
||||
.map_err(|e| format!("Channel media send failed: {e}"))?;
|
||||
|
||||
Ok(format!("{} sent to {} via {}", media_type, recipient, channel))
|
||||
}
|
||||
|
||||
async fn spawn_agent_checked(
|
||||
&self,
|
||||
manifest_toml: &str,
|
||||
|
||||
@@ -343,6 +343,11 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
return (0.40, 0.40);
|
||||
}
|
||||
|
||||
// ── Venice.ai ──────────────────────────────────────────────
|
||||
if model.contains("venice") {
|
||||
return (0.20, 0.90);
|
||||
}
|
||||
|
||||
// ── Open-source (Groq, Together, etc.) ─────────────────────
|
||||
if model.contains("llama-4-maverick") {
|
||||
return (0.50, 0.77);
|
||||
@@ -376,11 +381,20 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
}
|
||||
|
||||
// ── Zhipu / GLM ─────────────────────────────────────────────
|
||||
if model.contains("glm-4-flash") {
|
||||
return (0.10, 0.10);
|
||||
if model.contains("glm-5") {
|
||||
return (1.00, 3.20);
|
||||
}
|
||||
if model.contains("glm-4.7") {
|
||||
return (0.60, 2.20);
|
||||
}
|
||||
if model.contains("glm-4-flash") || model.contains("glm-4.5-flash") {
|
||||
return (0.0, 0.0); // free tier
|
||||
}
|
||||
if model.contains("glm-4.5") {
|
||||
return (0.60, 2.20);
|
||||
}
|
||||
if model.contains("glm") {
|
||||
return (1.50, 5.00);
|
||||
return (0.60, 2.20);
|
||||
}
|
||||
if model.contains("codegeex") {
|
||||
return (0.10, 0.10);
|
||||
@@ -391,6 +405,20 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
return (0.80, 0.80);
|
||||
}
|
||||
|
||||
// ── Volcano Engine / Doubao ────────────────────────────────
|
||||
if model.contains("doubao-seed-code") {
|
||||
return (0.50, 1.00);
|
||||
}
|
||||
if model.contains("doubao") && model.contains("mini") {
|
||||
return (0.10, 0.10);
|
||||
}
|
||||
if model.contains("doubao") && model.contains("lite") {
|
||||
return (0.30, 0.60);
|
||||
}
|
||||
if model.contains("doubao") {
|
||||
return (0.80, 2.00);
|
||||
}
|
||||
|
||||
// ── Baidu ERNIE ─────────────────────────────────────────────
|
||||
if model.contains("ernie") {
|
||||
return (2.00, 6.00);
|
||||
|
||||
@@ -177,6 +177,21 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's fallback model chain.
|
||||
pub fn update_fallback_models(
|
||||
&self,
|
||||
id: AgentId,
|
||||
fallback_models: Vec<openfang_types::agent::FallbackModel>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
entry.manifest.fallback_models = fallback_models;
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's skill allowlist.
|
||||
pub fn update_skills(&self, id: AgentId, skills: Vec<String>) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
|
||||
@@ -100,6 +100,15 @@ impl AgentScheduler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset usage tracking for an agent (e.g. on session reset).
|
||||
pub fn reset_usage(&self, agent_id: AgentId) {
|
||||
if let Some(mut tracker) = self.usage.get_mut(&agent_id) {
|
||||
tracker.total_tokens = 0;
|
||||
tracker.tool_calls = 0;
|
||||
tracker.window_start = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Abort an agent's active task.
|
||||
pub fn abort_task(&self, agent_id: AgentId) {
|
||||
if let Some((_, handle)) = self.tasks.remove(&agent_id) {
|
||||
|
||||
@@ -54,10 +54,11 @@ impl SetupWizard {
|
||||
/// model configuration, capabilities, and schedule.
|
||||
pub fn build_plan(intent: AgentIntent) -> SetupPlan {
|
||||
// Map model tier to provider/model
|
||||
// Use "default" so the kernel applies config.toml's [default_model].
|
||||
// Only "complex" tier gets an explicit Anthropic override.
|
||||
let (provider, model) = match intent.model_tier.as_str() {
|
||||
"simple" => ("groq", "llama-3.3-70b-versatile"),
|
||||
"complex" => ("anthropic", "claude-sonnet-4-20250514"),
|
||||
_ => ("groq", "llama-3.3-70b-versatile"), // medium default
|
||||
_ => ("default", "default"),
|
||||
};
|
||||
|
||||
// Build capabilities from intent
|
||||
@@ -285,7 +286,7 @@ mod tests {
|
||||
let plan = SetupWizard::build_plan(intent);
|
||||
|
||||
assert_eq!(plan.manifest.name, "research-bot");
|
||||
assert_eq!(plan.manifest.model.provider, "groq");
|
||||
assert_eq!(plan.manifest.model.provider, "default");
|
||||
assert!(plan
|
||||
.manifest
|
||||
.capabilities
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Current schema version.
|
||||
const SCHEMA_VERSION: u32 = 7;
|
||||
const SCHEMA_VERSION: u32 = 8;
|
||||
|
||||
/// Run all migrations to bring the database up to date.
|
||||
pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
@@ -39,6 +39,10 @@ pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
migrate_v7(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 8 {
|
||||
migrate_v8(conn)?;
|
||||
}
|
||||
|
||||
set_schema_version(conn, SCHEMA_VERSION)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -299,6 +303,31 @@ fn migrate_v7(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Version 8: Add audit_entries table for persistent Merkle audit trail.
|
||||
fn migrate_v8(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS audit_entries (
|
||||
seq INTEGER PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_agent ON audit_entries(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_entries(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_entries(action);
|
||||
|
||||
INSERT OR IGNORE INTO migrations (version, applied_at, description)
|
||||
VALUES (8, datetime('now'), 'Add audit_entries table for persistent Merkle audit trail');
|
||||
",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -130,11 +130,19 @@ impl StructuredStore {
|
||||
"ALTER TABLE agents ADD COLUMN session_id TEXT DEFAULT ''",
|
||||
[],
|
||||
);
|
||||
// Add identity column (migration compat)
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE agents ADD COLUMN identity TEXT DEFAULT '{}'",
|
||||
[],
|
||||
);
|
||||
|
||||
let identity_json = serde_json::to_string(&entry.identity)
|
||||
.map_err(|e| OpenFangError::Serialization(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO agents (id, name, manifest, state, created_at, updated_at, session_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET name = ?2, manifest = ?3, state = ?4, updated_at = ?6, session_id = ?7",
|
||||
"INSERT INTO agents (id, name, manifest, state, created_at, updated_at, session_id, identity)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(id) DO UPDATE SET name = ?2, manifest = ?3, state = ?4, updated_at = ?6, session_id = ?7, identity = ?8",
|
||||
rusqlite::params![
|
||||
entry.id.0.to_string(),
|
||||
entry.name,
|
||||
@@ -143,6 +151,7 @@ impl StructuredStore {
|
||||
entry.created_at.to_rfc3339(),
|
||||
now,
|
||||
entry.session_id.0.to_string(),
|
||||
identity_json,
|
||||
],
|
||||
)
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
@@ -157,10 +166,13 @@ impl StructuredStore {
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents WHERE id = ?1")
|
||||
.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id, identity FROM agents WHERE id = ?1")
|
||||
.or_else(|_| {
|
||||
// Fallback without session_id column for old DBs
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents WHERE id = ?1")
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents WHERE id = ?1")
|
||||
.or_else(|_| {
|
||||
// Fallback without session_id column for old DBs
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents WHERE id = ?1")
|
||||
})
|
||||
})
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
|
||||
@@ -175,11 +187,16 @@ impl StructuredStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str))
|
||||
let identity_str: Option<String> = if col_count >= 8 {
|
||||
row.get(7).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str)) => {
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str)) => {
|
||||
let manifest = rmp_serde::from_slice(&manifest_blob)
|
||||
.map_err(|e| OpenFangError::Serialization(e.to_string()))?;
|
||||
let state = serde_json::from_str(&state_str)
|
||||
@@ -191,6 +208,9 @@ impl StructuredStore {
|
||||
.and_then(|s| uuid::Uuid::parse_str(&s).ok())
|
||||
.map(openfang_types::agent::SessionId)
|
||||
.unwrap_or_else(openfang_types::agent::SessionId::new);
|
||||
let identity = identity_str
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Some(AgentEntry {
|
||||
id: agent_id,
|
||||
name,
|
||||
@@ -203,7 +223,7 @@ impl StructuredStore {
|
||||
children: vec![],
|
||||
session_id,
|
||||
tags: vec![],
|
||||
identity: Default::default(),
|
||||
identity,
|
||||
onboarding_completed: false,
|
||||
onboarding_completed_at: None,
|
||||
}))
|
||||
@@ -239,11 +259,14 @@ impl StructuredStore {
|
||||
.lock()
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
|
||||
// Try with session_id column first, fall back without
|
||||
// Try with identity+session_id columns first, fall back gracefully
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents",
|
||||
"SELECT id, name, manifest, state, created_at, updated_at, session_id, identity FROM agents",
|
||||
)
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents")
|
||||
})
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents")
|
||||
})
|
||||
@@ -262,6 +285,11 @@ impl StructuredStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let identity_str: Option<String> = if col_count >= 8 {
|
||||
row.get(7).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((
|
||||
id_str,
|
||||
name,
|
||||
@@ -269,6 +297,7 @@ impl StructuredStore {
|
||||
state_str,
|
||||
created_str,
|
||||
session_id_str,
|
||||
identity_str,
|
||||
))
|
||||
})
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
@@ -278,7 +307,7 @@ 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) = match row {
|
||||
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}");
|
||||
@@ -342,6 +371,10 @@ impl StructuredStore {
|
||||
.map(openfang_types::agent::SessionId)
|
||||
.unwrap_or_else(openfang_types::agent::SessionId::new);
|
||||
|
||||
let identity = identity_str
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
agents.push(AgentEntry {
|
||||
id: agent_id,
|
||||
name,
|
||||
@@ -354,7 +387,7 @@ impl StructuredStore {
|
||||
children: vec![],
|
||||
session_id,
|
||||
tags: vec![],
|
||||
identity: Default::default(),
|
||||
identity,
|
||||
onboarding_completed: false,
|
||||
onboarding_completed_at: None,
|
||||
});
|
||||
|
||||
@@ -64,11 +64,11 @@ struct OpenClawModels {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct OpenClawRootTools {
|
||||
#[allow(dead_code)]
|
||||
profile: Option<String>,
|
||||
profile: Option<serde_json::Value>,
|
||||
#[allow(dead_code)]
|
||||
allow: Option<Vec<String>>,
|
||||
allow: Option<serde_json::Value>,
|
||||
#[allow(dead_code)]
|
||||
deny: Option<Vec<String>>,
|
||||
deny: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -110,17 +110,36 @@ struct OpenClawAgentEntry {
|
||||
model: Option<OpenClawAgentModel>,
|
||||
tools: Option<OpenClawAgentTools>,
|
||||
workspace: Option<String>,
|
||||
skills: Option<Vec<String>>,
|
||||
skills: Option<serde_json::Value>,
|
||||
identity: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct OpenClawAgentTools {
|
||||
profile: Option<String>,
|
||||
allow: Option<Vec<String>>,
|
||||
deny: Option<Vec<String>>,
|
||||
also_allow: Option<Vec<String>>,
|
||||
profile: Option<serde_json::Value>,
|
||||
allow: Option<serde_json::Value>,
|
||||
deny: Option<serde_json::Value>,
|
||||
also_allow: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
}
|
||||
|
||||
/// 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::String(s) => vec![s.clone()],
|
||||
serde_json::Value::Object(map) => map.keys().cloned().collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -149,7 +168,7 @@ struct OpenClawChannels {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct OpenClawTelegramConfig {
|
||||
bot_token: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
group_policy: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
@@ -162,7 +181,7 @@ struct OpenClawDiscordConfig {
|
||||
guilds: Option<serde_json::Value>,
|
||||
dm_policy: Option<String>,
|
||||
group_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -173,7 +192,7 @@ struct OpenClawSlackConfig {
|
||||
app_token: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
group_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -182,7 +201,7 @@ struct OpenClawSlackConfig {
|
||||
struct OpenClawWhatsAppConfig {
|
||||
auth_dir: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
group_policy: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
@@ -195,7 +214,7 @@ struct OpenClawSignalConfig {
|
||||
http_port: Option<u16>,
|
||||
account: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -205,9 +224,9 @@ struct OpenClawMatrixConfig {
|
||||
homeserver: Option<String>,
|
||||
user_id: Option<String>,
|
||||
access_token: Option<String>,
|
||||
rooms: Option<Vec<String>>,
|
||||
rooms: Option<serde_json::Value>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -228,7 +247,7 @@ struct OpenClawTeamsConfig {
|
||||
app_password: Option<String>,
|
||||
tenant_id: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -240,9 +259,9 @@ struct OpenClawIrcConfig {
|
||||
tls: Option<bool>,
|
||||
nick: Option<String>,
|
||||
password: Option<String>,
|
||||
channels: Option<Vec<String>>,
|
||||
channels: Option<serde_json::Value>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -252,7 +271,7 @@ struct OpenClawMattermostConfig {
|
||||
bot_token: Option<String>,
|
||||
base_url: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -272,7 +291,7 @@ struct OpenClawIMessageConfig {
|
||||
cli_path: Option<String>,
|
||||
db_path: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -282,7 +301,7 @@ struct OpenClawBlueBubblesConfig {
|
||||
server_url: Option<String>,
|
||||
password: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -488,16 +507,18 @@ fn build_channel_table(
|
||||
fields: Vec<(&str, toml::Value)>,
|
||||
dm_policy: Option<&str>,
|
||||
group_policy: Option<&str>,
|
||||
allow_from: Option<&[String]>,
|
||||
allow_from: Option<&serde_json::Value>,
|
||||
) -> toml::Value {
|
||||
let mut table = toml::map::Map::new();
|
||||
for (key, val) in fields {
|
||||
table.insert(key.to_string(), val);
|
||||
}
|
||||
|
||||
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_from.is_some_and(|a| !a.is_empty());
|
||||
dm_policy.is_some() || group_policy.is_some() || !allow_list.is_empty();
|
||||
|
||||
if has_overrides {
|
||||
let mut overrides = toml::map::Map::new();
|
||||
@@ -515,14 +536,12 @@ fn build_channel_table(
|
||||
toml::Value::String(mapped.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(users) = allow_from {
|
||||
if !users.is_empty() {
|
||||
let arr: Vec<toml::Value> = users
|
||||
.iter()
|
||||
.map(|u| toml::Value::String(u.clone()))
|
||||
.collect();
|
||||
overrides.insert("allowed_users".to_string(), toml::Value::Array(arr));
|
||||
}
|
||||
if !allow_list.is_empty() {
|
||||
let arr: Vec<toml::Value> = allow_list
|
||||
.iter()
|
||||
.map(|u| toml::Value::String(u.clone()))
|
||||
.collect();
|
||||
overrides.insert("allowed_users".to_string(), toml::Value::Array(arr));
|
||||
}
|
||||
table.insert("overrides".to_string(), toml::Value::Table(overrides));
|
||||
}
|
||||
@@ -811,13 +830,14 @@ fn scan_from_json5(base: &Path, config_path: &Path, result: &mut ScanResult) {
|
||||
.tools
|
||||
.as_ref()
|
||||
.and_then(|t| t.allow.as_ref())
|
||||
.map(|a| a.len())
|
||||
.map(|a| extract_string_list(a).len())
|
||||
.or_else(|| {
|
||||
entry
|
||||
.tools
|
||||
.as_ref()
|
||||
.and_then(|t| t.profile.as_ref())
|
||||
.map(|p| tools_for_profile(p).len())
|
||||
.and_then(extract_profile)
|
||||
.map(|p| tools_for_profile(&p).len())
|
||||
})
|
||||
.unwrap_or(3);
|
||||
|
||||
@@ -1253,7 +1273,8 @@ fn migrate_channels_from_json(
|
||||
"bot_token_env",
|
||||
toml::Value::String("TELEGRAM_BOT_TOKEN".into()),
|
||||
)];
|
||||
if let Some(ref users) = tg.allow_from {
|
||||
if let Some(ref users_val) = tg.allow_from {
|
||||
let users = extract_string_list(users_val);
|
||||
if !users.is_empty() {
|
||||
let arr: Vec<toml::Value> = users
|
||||
.iter()
|
||||
@@ -1268,7 +1289,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
tg.dm_policy.as_deref(),
|
||||
tg.group_policy.as_deref(),
|
||||
tg.allow_from.as_deref(),
|
||||
tg.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1295,7 +1316,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
dc.dm_policy.as_deref(),
|
||||
dc.group_policy.as_deref(),
|
||||
dc.allow_from.as_deref(),
|
||||
dc.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1331,7 +1352,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
sl.dm_policy.as_deref(),
|
||||
sl.group_policy.as_deref(),
|
||||
sl.allow_from.as_deref(),
|
||||
sl.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1372,7 +1393,8 @@ fn migrate_channels_from_json(
|
||||
"access_token_env",
|
||||
toml::Value::String("WHATSAPP_ACCESS_TOKEN".into()),
|
||||
)];
|
||||
if let Some(ref users) = wa.allow_from {
|
||||
if let Some(ref users_val) = wa.allow_from {
|
||||
let users = extract_string_list(users_val);
|
||||
if !users.is_empty() {
|
||||
let arr: Vec<toml::Value> = users
|
||||
.iter()
|
||||
@@ -1387,7 +1409,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
wa.dm_policy.as_deref(),
|
||||
wa.group_policy.as_deref(),
|
||||
wa.allow_from.as_deref(),
|
||||
wa.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1418,7 +1440,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
sig.dm_policy.as_deref(),
|
||||
None,
|
||||
sig.allow_from.as_deref(),
|
||||
sig.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1445,7 +1467,8 @@ fn migrate_channels_from_json(
|
||||
if let Some(ref uid) = mx.user_id {
|
||||
fields.push(("user_id", toml::Value::String(uid.clone())));
|
||||
}
|
||||
if let Some(ref rooms) = mx.rooms {
|
||||
if let Some(ref rooms_val) = mx.rooms {
|
||||
let rooms = extract_string_list(rooms_val);
|
||||
if !rooms.is_empty() {
|
||||
let arr: Vec<toml::Value> = rooms
|
||||
.iter()
|
||||
@@ -1460,7 +1483,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
mx.dm_policy.as_deref(),
|
||||
None,
|
||||
mx.allow_from.as_deref(),
|
||||
mx.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1534,7 +1557,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
tm.dm_policy.as_deref(),
|
||||
None,
|
||||
tm.allow_from.as_deref(),
|
||||
tm.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1567,7 +1590,8 @@ fn migrate_channels_from_json(
|
||||
if irc.password.is_some() {
|
||||
fields.push(("password_env", toml::Value::String("IRC_PASSWORD".into())));
|
||||
}
|
||||
if let Some(ref chans) = irc.channels {
|
||||
if let Some(ref chans_val) = irc.channels {
|
||||
let chans = extract_string_list(chans_val);
|
||||
if !chans.is_empty() {
|
||||
let arr: Vec<toml::Value> = chans
|
||||
.iter()
|
||||
@@ -1582,7 +1606,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
irc.dm_policy.as_deref(),
|
||||
None,
|
||||
irc.allow_from.as_deref(),
|
||||
irc.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1612,7 +1636,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
mm.dm_policy.as_deref(),
|
||||
None,
|
||||
mm.allow_from.as_deref(),
|
||||
mm.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1770,9 +1794,10 @@ fn convert_agent_from_json(
|
||||
// Resolve tools
|
||||
let mut unmapped_tools = Vec::new();
|
||||
let tools: Vec<String> = if let Some(ref agent_tools) = entry.tools {
|
||||
if let Some(ref allow) = agent_tools.allow {
|
||||
if let Some(ref allow_val) = agent_tools.allow {
|
||||
let allow = extract_string_list(allow_val);
|
||||
let mut mapped = Vec::new();
|
||||
for t in allow {
|
||||
for t in &allow {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
@@ -1782,8 +1807,9 @@ fn convert_agent_from_json(
|
||||
}
|
||||
}
|
||||
// also_allow
|
||||
if let Some(ref also) = agent_tools.also_allow {
|
||||
for t in also {
|
||||
if let Some(ref also_val) = agent_tools.also_allow {
|
||||
let also = extract_string_list(also_val);
|
||||
for t in &also {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
@@ -1794,8 +1820,9 @@ fn convert_agent_from_json(
|
||||
}
|
||||
}
|
||||
mapped
|
||||
} else if let Some(ref profile) = agent_tools.profile {
|
||||
tools_for_profile(profile)
|
||||
} else if let Some(ref profile_val) = agent_tools.profile {
|
||||
let profile_name = extract_profile(profile_val).unwrap_or_default();
|
||||
tools_for_profile(&profile_name)
|
||||
} else {
|
||||
resolve_default_tools(defaults)
|
||||
}
|
||||
@@ -1894,8 +1921,10 @@ fn convert_agent_from_json(
|
||||
|
||||
// Tool profile hint
|
||||
if let Some(ref agent_tools) = entry.tools {
|
||||
if let Some(ref profile) = agent_tools.profile {
|
||||
toml_str.push_str(&format!("\nprofile = \"{profile}\"\n"));
|
||||
if let Some(ref profile_val) = agent_tools.profile {
|
||||
if let Some(profile) = extract_profile(profile_val) {
|
||||
toml_str.push_str(&format!("\nprofile = \"{profile}\"\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1905,12 +1934,15 @@ fn convert_agent_from_json(
|
||||
fn resolve_default_tools(defaults: Option<&OpenClawAgentDefaults>) -> Vec<String> {
|
||||
if let Some(defs) = defaults {
|
||||
if let Some(ref tools) = defs.tools {
|
||||
if let Some(ref profile) = tools.profile {
|
||||
return tools_for_profile(profile);
|
||||
if let Some(ref profile_val) = tools.profile {
|
||||
if let Some(profile) = extract_profile(profile_val) {
|
||||
return tools_for_profile(&profile);
|
||||
}
|
||||
}
|
||||
if let Some(ref allow) = tools.allow {
|
||||
if let Some(ref allow_val) = tools.allow {
|
||||
let allow = extract_string_list(allow_val);
|
||||
let mut mapped = Vec::new();
|
||||
for t in allow {
|
||||
for t in &allow {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
|
||||
@@ -29,7 +29,9 @@ hex = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
tokio-tungstenite = "0.24"
|
||||
shlex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -87,8 +87,8 @@ pub struct A2aTask {
|
||||
/// Optional session identifier for conversation continuity.
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
/// Current task status.
|
||||
pub status: A2aTaskStatus,
|
||||
/// Current task status (accepts both string and object forms).
|
||||
pub status: A2aTaskStatusWrapper,
|
||||
/// Messages exchanged during the task.
|
||||
#[serde(default)]
|
||||
pub messages: Vec<A2aMessage>,
|
||||
@@ -115,6 +115,44 @@ pub enum A2aTaskStatus {
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Wrapper that accepts either a bare status string (`"completed"`)
|
||||
/// or the object form (`{"state": "completed", "message": null}`)
|
||||
/// used by some A2A implementations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum A2aTaskStatusWrapper {
|
||||
/// Object form: `{"state": "completed", "message": ...}`.
|
||||
Object {
|
||||
state: A2aTaskStatus,
|
||||
#[serde(default)]
|
||||
message: Option<serde_json::Value>,
|
||||
},
|
||||
/// Bare enum form: `"completed"`.
|
||||
Enum(A2aTaskStatus),
|
||||
}
|
||||
|
||||
impl A2aTaskStatusWrapper {
|
||||
/// Extract the underlying `A2aTaskStatus` regardless of encoding form.
|
||||
pub fn state(&self) -> &A2aTaskStatus {
|
||||
match self {
|
||||
Self::Object { state, .. } => state,
|
||||
Self::Enum(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<A2aTaskStatus> for A2aTaskStatusWrapper {
|
||||
fn from(status: A2aTaskStatus) -> Self {
|
||||
Self::Enum(status)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<A2aTaskStatus> for A2aTaskStatusWrapper {
|
||||
fn eq(&self, other: &A2aTaskStatus) -> bool {
|
||||
self.state() == other
|
||||
}
|
||||
}
|
||||
|
||||
/// A2A message in a task conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct A2aMessage {
|
||||
@@ -145,9 +183,23 @@ pub enum A2aPart {
|
||||
|
||||
/// A2A artifact produced by a task.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct A2aArtifact {
|
||||
/// Artifact name.
|
||||
pub name: String,
|
||||
/// Artifact name (optional per spec).
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Human-readable description.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Arbitrary metadata.
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
/// Artifact index in the sequence.
|
||||
#[serde(default)]
|
||||
pub index: Option<u32>,
|
||||
/// Whether this is the last chunk of a streamed artifact.
|
||||
#[serde(default)]
|
||||
pub last_chunk: Option<bool>,
|
||||
/// Artifact content parts.
|
||||
pub parts: Vec<A2aPart>,
|
||||
}
|
||||
@@ -185,7 +237,7 @@ impl A2aTaskStore {
|
||||
.iter()
|
||||
.filter(|(_, t)| {
|
||||
matches!(
|
||||
t.status,
|
||||
t.status.state(),
|
||||
A2aTaskStatus::Completed | A2aTaskStatus::Failed | A2aTaskStatus::Cancelled
|
||||
)
|
||||
})
|
||||
@@ -211,7 +263,7 @@ impl A2aTaskStore {
|
||||
pub fn update_status(&self, task_id: &str, status: A2aTaskStatus) -> bool {
|
||||
let mut tasks = self.tasks.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(task) = tasks.get_mut(task_id) {
|
||||
task.status = status;
|
||||
task.status = status.into();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -224,7 +276,7 @@ impl A2aTaskStore {
|
||||
if let Some(task) = tasks.get_mut(task_id) {
|
||||
task.messages.push(response);
|
||||
task.artifacts.extend(artifacts);
|
||||
task.status = A2aTaskStatus::Completed;
|
||||
task.status = A2aTaskStatus::Completed.into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +285,7 @@ impl A2aTaskStore {
|
||||
let mut tasks = self.tasks.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(task) = tasks.get_mut(task_id) {
|
||||
task.messages.push(error_message);
|
||||
task.status = A2aTaskStatus::Failed;
|
||||
task.status = A2aTaskStatus::Failed.into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,7 +542,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "task-1".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Submitted,
|
||||
status: A2aTaskStatus::Submitted.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -498,30 +550,71 @@ mod tests {
|
||||
|
||||
// Simulate progression
|
||||
let working = A2aTask {
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
..task.clone()
|
||||
};
|
||||
assert_eq!(working.status, A2aTaskStatus::Working);
|
||||
|
||||
let completed = A2aTask {
|
||||
status: A2aTaskStatus::Completed,
|
||||
status: A2aTaskStatus::Completed.into(),
|
||||
..task.clone()
|
||||
};
|
||||
assert_eq!(completed.status, A2aTaskStatus::Completed);
|
||||
|
||||
let cancelled = A2aTask {
|
||||
status: A2aTaskStatus::Cancelled,
|
||||
status: A2aTaskStatus::Cancelled.into(),
|
||||
..task.clone()
|
||||
};
|
||||
assert_eq!(cancelled.status, A2aTaskStatus::Cancelled);
|
||||
|
||||
let failed = A2aTask {
|
||||
status: A2aTaskStatus::Failed,
|
||||
status: A2aTaskStatus::Failed.into(),
|
||||
..task
|
||||
};
|
||||
assert_eq!(failed.status, A2aTaskStatus::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a2a_task_status_wrapper_object_form() {
|
||||
// Test deserialization of the object form: {"state": "completed", "message": null}
|
||||
let json = r#"{"state":"completed","message":null}"#;
|
||||
let wrapper: A2aTaskStatusWrapper = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(wrapper, A2aTaskStatus::Completed);
|
||||
assert_eq!(wrapper.state(), &A2aTaskStatus::Completed);
|
||||
|
||||
// Test with a message payload
|
||||
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);
|
||||
|
||||
// Test bare string form
|
||||
let json_bare = r#""completed""#;
|
||||
let wrapper3: A2aTaskStatusWrapper = serde_json::from_str(json_bare).unwrap();
|
||||
assert_eq!(wrapper3, A2aTaskStatus::Completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a2a_artifact_optional_fields() {
|
||||
// name is now optional — artifact with no name should deserialize
|
||||
let json = r#"{"parts":[{"type":"text","text":"hello"}]}"#;
|
||||
let artifact: A2aArtifact = serde_json::from_str(json).unwrap();
|
||||
assert!(artifact.name.is_none());
|
||||
assert!(artifact.description.is_none());
|
||||
assert!(artifact.metadata.is_none());
|
||||
assert!(artifact.index.is_none());
|
||||
assert!(artifact.last_chunk.is_none());
|
||||
assert_eq!(artifact.parts.len(), 1);
|
||||
|
||||
// Full artifact with all optional fields
|
||||
let json_full = r#"{"name":"output.txt","description":"The result","metadata":{"key":"val"},"index":0,"lastChunk":true,"parts":[]}"#;
|
||||
let full: A2aArtifact = serde_json::from_str(json_full).unwrap();
|
||||
assert_eq!(full.name.as_deref(), Some("output.txt"));
|
||||
assert_eq!(full.description.as_deref(), Some("The result"));
|
||||
assert_eq!(full.index, Some(0));
|
||||
assert_eq!(full.last_chunk, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a2a_message_serde() {
|
||||
let msg = A2aMessage {
|
||||
@@ -554,7 +647,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-1".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -571,7 +664,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-2".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -599,7 +692,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-3".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -618,7 +711,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: format!("t-{i}"),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Completed,
|
||||
status: A2aTaskStatus::Completed.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
@@ -630,7 +723,7 @@ mod tests {
|
||||
let task = A2aTask {
|
||||
id: "t-2".to_string(),
|
||||
session_id: None,
|
||||
status: A2aTaskStatus::Working,
|
||||
status: A2aTaskStatus::Working.into(),
|
||||
messages: vec![],
|
||||
artifacts: vec![],
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,15 @@
|
||||
//! Every auditable event is appended to an append-only log where each entry
|
||||
//! contains the SHA-256 hash of its own contents concatenated with the hash of
|
||||
//! the previous entry, forming a tamper-evident chain (similar to a blockchain).
|
||||
//!
|
||||
//! When a database connection is provided (`with_db`), entries are persisted to
|
||||
//! the `audit_entries` table (schema V8) so the trail survives daemon restarts.
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Categories of auditable actions within the agent runtime.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -77,26 +81,102 @@ fn compute_entry_hash(
|
||||
/// An append-only, tamper-evident audit log using a Merkle hash chain.
|
||||
///
|
||||
/// Thread-safe — all access is serialised through internal mutexes.
|
||||
/// Optionally backed by SQLite for persistence across daemon restarts.
|
||||
pub struct AuditLog {
|
||||
entries: Mutex<Vec<AuditEntry>>,
|
||||
tip: Mutex<String>,
|
||||
/// Optional database connection for persistent storage.
|
||||
db: Option<Arc<Mutex<Connection>>>,
|
||||
}
|
||||
|
||||
impl AuditLog {
|
||||
/// Creates a new empty audit log.
|
||||
/// Creates a new empty audit log (in-memory only, no persistence).
|
||||
///
|
||||
/// The initial tip hash is 64 zero characters (the "genesis" sentinel).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(Vec::new()),
|
||||
tip: Mutex::new("0".repeat(64)),
|
||||
db: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an audit log backed by a database connection.
|
||||
///
|
||||
/// On construction, loads all existing entries from the `audit_entries`
|
||||
/// table and verifies the Merkle chain integrity. New entries are written
|
||||
/// to both the in-memory chain and the database.
|
||||
pub fn with_db(conn: Arc<Mutex<Connection>>) -> Self {
|
||||
let mut entries = Vec::new();
|
||||
let mut tip = "0".repeat(64);
|
||||
|
||||
// Load existing entries from database
|
||||
if let Ok(db) = conn.lock() {
|
||||
let result = db.prepare(
|
||||
"SELECT seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash FROM audit_entries ORDER BY seq ASC",
|
||||
);
|
||||
if let Ok(mut stmt) = result {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let action_str: String = row.get(3)?;
|
||||
let action = match action_str.as_str() {
|
||||
"ToolInvoke" => AuditAction::ToolInvoke,
|
||||
"CapabilityCheck" => AuditAction::CapabilityCheck,
|
||||
"AgentSpawn" => AuditAction::AgentSpawn,
|
||||
"AgentKill" => AuditAction::AgentKill,
|
||||
"AgentMessage" => AuditAction::AgentMessage,
|
||||
"MemoryAccess" => AuditAction::MemoryAccess,
|
||||
"FileAccess" => AuditAction::FileAccess,
|
||||
"NetworkAccess" => AuditAction::NetworkAccess,
|
||||
"ShellExec" => AuditAction::ShellExec,
|
||||
"AuthAttempt" => AuditAction::AuthAttempt,
|
||||
"WireConnect" => AuditAction::WireConnect,
|
||||
"ConfigChange" => AuditAction::ConfigChange,
|
||||
_ => AuditAction::ToolInvoke, // fallback
|
||||
};
|
||||
Ok(AuditEntry {
|
||||
seq: row.get(0)?,
|
||||
timestamp: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
action,
|
||||
detail: row.get(4)?,
|
||||
outcome: row.get(5)?,
|
||||
prev_hash: row.get(6)?,
|
||||
hash: row.get(7)?,
|
||||
})
|
||||
});
|
||||
if let Ok(rows) = rows {
|
||||
for entry in rows.flatten() {
|
||||
tip = entry.hash.clone();
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let count = entries.len();
|
||||
let log = Self {
|
||||
entries: Mutex::new(entries),
|
||||
tip: Mutex::new(tip),
|
||||
db: Some(conn),
|
||||
};
|
||||
|
||||
// Verify chain integrity on load
|
||||
if count > 0 {
|
||||
if let Err(e) = log.verify_integrity() {
|
||||
tracing::error!("Audit trail integrity check FAILED on boot: {e}");
|
||||
} else {
|
||||
tracing::info!("Audit trail loaded: {count} entries, chain integrity OK");
|
||||
}
|
||||
}
|
||||
|
||||
log
|
||||
}
|
||||
|
||||
/// Records a new auditable event and returns the SHA-256 hash of the entry.
|
||||
///
|
||||
/// The entry is atomically appended to the chain with the current tip as
|
||||
/// its `prev_hash`, and the tip is advanced to the new hash.
|
||||
/// If a database connection is available, the entry is also persisted.
|
||||
pub fn record(
|
||||
&self,
|
||||
agent_id: impl Into<String>,
|
||||
@@ -119,7 +199,7 @@ impl AuditLog {
|
||||
seq, ×tamp, &agent_id, &action, &detail, &outcome, &prev_hash,
|
||||
);
|
||||
|
||||
entries.push(AuditEntry {
|
||||
let entry = AuditEntry {
|
||||
seq,
|
||||
timestamp,
|
||||
agent_id,
|
||||
@@ -128,8 +208,28 @@ impl AuditLog {
|
||||
outcome,
|
||||
prev_hash,
|
||||
hash: hash.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
// Persist to database if available
|
||||
if let Some(ref db) = self.db {
|
||||
if let Ok(conn) = db.lock() {
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO audit_entries (seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
rusqlite::params![
|
||||
entry.seq as i64,
|
||||
&entry.timestamp,
|
||||
&entry.agent_id,
|
||||
entry.action.to_string(),
|
||||
&entry.detail,
|
||||
&entry.outcome,
|
||||
&entry.prev_hash,
|
||||
&entry.hash,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
*tip = hash.clone();
|
||||
hash
|
||||
}
|
||||
@@ -271,4 +371,52 @@ mod tests {
|
||||
assert_eq!(log.tip_hash(), h2);
|
||||
assert_ne!(h2, h1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audit_persists_to_db() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE audit_entries (
|
||||
seq INTEGER PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let db = Arc::new(Mutex::new(conn));
|
||||
|
||||
// Record entries with DB
|
||||
let log = AuditLog::with_db(Arc::clone(&db));
|
||||
log.record("agent-1", AuditAction::AgentSpawn, "spawn test", "ok");
|
||||
log.record("agent-1", AuditAction::ShellExec, "ls", "ok");
|
||||
assert_eq!(log.len(), 2);
|
||||
|
||||
// Verify entries in database
|
||||
let db_conn = db.lock().unwrap();
|
||||
let count: i64 = db_conn
|
||||
.query_row("SELECT COUNT(*) FROM audit_entries", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
drop(db_conn);
|
||||
|
||||
// Simulate restart: create new AuditLog from same DB
|
||||
let log2 = AuditLog::with_db(Arc::clone(&db));
|
||||
assert_eq!(log2.len(), 2);
|
||||
assert!(log2.verify_integrity().is_ok());
|
||||
|
||||
// Chain continues correctly after restart
|
||||
log2.record("agent-2", AuditAction::ToolInvoke, "file_read", "ok");
|
||||
assert_eq!(log2.len(), 3);
|
||||
assert!(log2.verify_integrity().is_ok());
|
||||
|
||||
// Verify tip is correct
|
||||
let entries = log2.recent(3);
|
||||
assert_eq!(entries[2].prev_hash, entries[1].hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,22 +65,23 @@ pub fn truncate_tool_result_dynamic(content: &str, budget: &ContextBudget) -> St
|
||||
}
|
||||
|
||||
// Find last newline before the cap to break cleanly (char-boundary safe)
|
||||
let safe_cap = if content.is_char_boundary(cap) {
|
||||
cap
|
||||
} else {
|
||||
content[..cap].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
let search_start = safe_cap.saturating_sub(200);
|
||||
let break_point = content[search_start..safe_cap]
|
||||
let mut safe_cap = cap.min(content.len());
|
||||
while safe_cap > 0 && !content.is_char_boundary(safe_cap) {
|
||||
safe_cap -= 1;
|
||||
}
|
||||
let mut search_start = safe_cap.saturating_sub(200);
|
||||
// Ensure search_start is a valid char boundary
|
||||
while search_start > 0 && !content.is_char_boundary(search_start) {
|
||||
search_start -= 1;
|
||||
}
|
||||
let mut break_point = content[search_start..safe_cap]
|
||||
.rfind('\n')
|
||||
.map(|pos| search_start + pos)
|
||||
.unwrap_or(safe_cap.saturating_sub(100));
|
||||
// Ensure break_point is also a char boundary
|
||||
let break_point = if content.is_char_boundary(break_point) {
|
||||
break_point
|
||||
} else {
|
||||
content[..break_point].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
while break_point > 0 && !content.is_char_boundary(break_point) {
|
||||
break_point -= 1;
|
||||
}
|
||||
|
||||
format!(
|
||||
"{}\n\n[TRUNCATED: result was {} chars, showing first {} (budget: {}% of {}K context window)]",
|
||||
@@ -201,28 +202,16 @@ fn truncate_to(content: &str, max_chars: usize) -> String {
|
||||
if content.len() <= max_chars {
|
||||
return content.to_string();
|
||||
}
|
||||
let keep = max_chars.saturating_sub(80).min(content.len());
|
||||
// Ensure keep is a valid char boundary
|
||||
let keep = if content.is_char_boundary(keep) {
|
||||
keep
|
||||
} else {
|
||||
content[..keep]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let search_start = keep.saturating_sub(100);
|
||||
// Ensure search_start is a valid char boundary
|
||||
let search_start = if content.is_char_boundary(search_start) {
|
||||
search_start
|
||||
} else {
|
||||
content[..search_start]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let mut keep = max_chars.saturating_sub(80).min(content.len());
|
||||
// Walk back to a valid char boundary
|
||||
while keep > 0 && !content.is_char_boundary(keep) {
|
||||
keep -= 1;
|
||||
}
|
||||
let mut search_start = keep.saturating_sub(100);
|
||||
// Walk back to a valid char boundary
|
||||
while search_start > 0 && !content.is_char_boundary(search_start) {
|
||||
search_start -= 1;
|
||||
}
|
||||
// Try to break at newline
|
||||
let break_point = content[search_start..keep]
|
||||
.rfind('\n')
|
||||
@@ -319,4 +308,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_tool_result_multibyte_chinese() {
|
||||
// Tiny budget: cap = 30% of 100 * 2.0 = 60 bytes
|
||||
let budget = ContextBudget::new(100);
|
||||
// Each Chinese char is 3 bytes in UTF-8; 100 chars = 300 bytes
|
||||
let content: String = "\u{4f60}\u{597d}\u{4e16}\u{754c}".repeat(25);
|
||||
assert_eq!(content.len(), 300);
|
||||
// Must not panic on multi-byte content
|
||||
let result = truncate_tool_result_dynamic(&content, &budget);
|
||||
assert!(result.contains("[TRUNCATED:"));
|
||||
// The visible portion must be valid UTF-8 (implicit: no panic)
|
||||
assert!(result.is_char_boundary(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_to_multibyte_emoji() {
|
||||
// Each emoji is 4 bytes; 200 emojis = 800 bytes
|
||||
let content: String = "\u{1f600}".repeat(200);
|
||||
let result = truncate_to(&content, 100);
|
||||
assert!(result.contains("[COMPACTED:"));
|
||||
// Must not panic and must produce valid UTF-8
|
||||
assert!(result.is_char_boundary(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_guard_multibyte_tool_results() {
|
||||
let budget = ContextBudget::new(100);
|
||||
// Chinese text: 500 chars * 3 bytes = 1500 bytes
|
||||
let big_chinese: String = "\u{4e2d}\u{6587}\u{6d4b}\u{8bd5}\u{6570}\u{636e}".repeat(83);
|
||||
let mut messages = vec![Message {
|
||||
role: openfang_types::message::Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: big_chinese,
|
||||
is_error: false,
|
||||
}]),
|
||||
}];
|
||||
// Must not panic on multi-byte content
|
||||
let compacted = apply_context_guard(&mut messages, &budget, &[]);
|
||||
assert!(compacted > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,13 +102,11 @@ pub fn recover_from_overflow(
|
||||
for block in blocks.iter_mut() {
|
||||
if let ContentBlock::ToolResult { content, .. } = block {
|
||||
if content.len() > tool_truncation_limit {
|
||||
let keep = tool_truncation_limit.saturating_sub(80);
|
||||
// Find a valid char boundary at or before `keep`
|
||||
let safe_keep = if content.is_char_boundary(keep) {
|
||||
keep
|
||||
} else {
|
||||
content[..keep].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
let mut safe_keep = tool_truncation_limit.saturating_sub(80);
|
||||
// Walk back to a valid char boundary
|
||||
while safe_keep > 0 && !content.is_char_boundary(safe_keep) {
|
||||
safe_keep -= 1;
|
||||
}
|
||||
*content = format!(
|
||||
"{}\n\n[OVERFLOW RECOVERY: truncated from {} to {} chars]",
|
||||
&content[..safe_keep],
|
||||
@@ -244,4 +242,26 @@ mod tests {
|
||||
// we should cascade through stages
|
||||
assert_ne!(stage, RecoveryStage::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stage3_multibyte_tool_truncation() {
|
||||
// Chinese text (3 bytes per char) in tool results must not panic
|
||||
let chinese_result: String = "\u{4f60}\u{597d}\u{4e16}\u{754c}".repeat(1250); // 5000 chars, 15000 bytes
|
||||
let mut msgs = vec![
|
||||
Message::user("hi"),
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: chinese_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
},
|
||||
];
|
||||
// Tiny context window to force stage 3 tool truncation
|
||||
let stage = recover_from_overflow(&mut msgs, "system", &[], 500);
|
||||
// Must not panic — the truncation at byte boundaries could split a 3-byte char
|
||||
assert_ne!(stage, RecoveryStage::None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,19 +61,15 @@ fn validate_image_name(image: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// SECURITY: Sanitize command — reject dangerous shell metacharacters.
|
||||
/// Delegates to the comprehensive subprocess_sandbox check.
|
||||
fn validate_command(command: &str) -> Result<(), String> {
|
||||
if command.is_empty() {
|
||||
return Err("Command cannot be empty".into());
|
||||
}
|
||||
// Reject backticks and $() which could enable command injection
|
||||
let dangerous = ["`", "$(", "${"];
|
||||
for pattern in &dangerous {
|
||||
if command.contains(pattern) {
|
||||
return Err(format!(
|
||||
"Command contains disallowed pattern '{}' — potential injection",
|
||||
pattern
|
||||
));
|
||||
}
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
|
||||
return Err(format!(
|
||||
"Command blocked: contains {reason} — potential injection"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -205,21 +201,23 @@ pub async fn exec_in_sandbox(
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let exit_code = output.status.code().unwrap_or(-1);
|
||||
|
||||
// Truncate large outputs
|
||||
// Truncate large outputs (char-boundary safe to avoid UTF-8 panics)
|
||||
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]",
|
||||
&stdout[..max_output],
|
||||
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]",
|
||||
&stderr[..max_output],
|
||||
safe_end,
|
||||
stderr.len()
|
||||
)
|
||||
} else {
|
||||
@@ -489,7 +487,12 @@ mod tests {
|
||||
fn test_validate_command_valid() {
|
||||
assert!(validate_command("python script.py").is_ok());
|
||||
assert!(validate_command("ls -la /workspace").is_ok());
|
||||
assert!(validate_command("echo hello | grep h").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_command_pipe_blocked() {
|
||||
// SECURITY: Pipes now blocked by comprehensive metacharacter check
|
||||
assert!(validate_command("echo hello | grep h").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -415,12 +415,13 @@ impl LlmDriver for AnthropicDriver {
|
||||
}
|
||||
}
|
||||
"content_block_delta" => {
|
||||
let block_idx = json["index"].as_u64().unwrap_or(0) as usize;
|
||||
let delta = &json["delta"];
|
||||
match delta["type"].as_str().unwrap_or("") {
|
||||
"text_delta" => {
|
||||
if let Some(text) = delta["text"].as_str() {
|
||||
if let Some(ContentBlockAccum::Text(ref mut t)) =
|
||||
blocks.last_mut()
|
||||
blocks.get_mut(block_idx)
|
||||
{
|
||||
t.push_str(text);
|
||||
}
|
||||
@@ -436,7 +437,7 @@ impl LlmDriver for AnthropicDriver {
|
||||
if let Some(ContentBlockAccum::ToolUse {
|
||||
ref mut input_json,
|
||||
..
|
||||
}) = blocks.last_mut()
|
||||
}) = blocks.get_mut(block_idx)
|
||||
{
|
||||
input_json.push_str(partial);
|
||||
}
|
||||
@@ -450,7 +451,7 @@ impl LlmDriver for AnthropicDriver {
|
||||
"thinking_delta" => {
|
||||
if let Some(thinking) = delta["thinking"].as_str() {
|
||||
if let Some(ContentBlockAccum::Thinking(ref mut t)) =
|
||||
blocks.last_mut()
|
||||
blocks.get_mut(block_idx)
|
||||
{
|
||||
t.push_str(thinking);
|
||||
}
|
||||
@@ -460,11 +461,12 @@ impl LlmDriver for AnthropicDriver {
|
||||
}
|
||||
}
|
||||
"content_block_stop" => {
|
||||
let block_idx = json["index"].as_u64().unwrap_or(0) as usize;
|
||||
if let Some(ContentBlockAccum::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input_json,
|
||||
}) = blocks.last()
|
||||
}) = blocks.get(block_idx)
|
||||
{
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(input_json).unwrap_or_default();
|
||||
|
||||
@@ -12,6 +12,38 @@ use serde::Deserialize;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Environment variable names (and suffixes) to strip from the subprocess
|
||||
/// to prevent leaking API keys from other providers. We keep the full env
|
||||
/// intact (so Node.js, NVM, SSL, proxies, etc. all work) and only remove
|
||||
/// secrets that belong to other LLM providers.
|
||||
const SENSITIVE_ENV_EXACT: &[&str] = &[
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"GROQ_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"MISTRAL_API_KEY",
|
||||
"TOGETHER_API_KEY",
|
||||
"FIREWORKS_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"PERPLEXITY_API_KEY",
|
||||
"COHERE_API_KEY",
|
||||
"AI21_API_KEY",
|
||||
"CEREBRAS_API_KEY",
|
||||
"SAMBANOVA_API_KEY",
|
||||
"HUGGINGFACE_API_KEY",
|
||||
"XAI_API_KEY",
|
||||
"REPLICATE_API_TOKEN",
|
||||
"BRAVE_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"ELEVENLABS_API_KEY",
|
||||
];
|
||||
|
||||
/// Suffixes that indicate a secret — remove any env var ending with these
|
||||
/// unless it starts with `CLAUDE_`.
|
||||
const SENSITIVE_SUFFIXES: &[&str] = &["_SECRET", "_TOKEN", "_PASSWORD"];
|
||||
|
||||
/// LLM driver that delegates to the Claude Code CLI.
|
||||
pub struct ClaudeCodeDriver {
|
||||
cli_path: String,
|
||||
@@ -80,13 +112,44 @@ impl ClaudeCodeDriver {
|
||||
_ => Some(stripped.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply security env filtering to a command.
|
||||
///
|
||||
/// Instead of `env_clear()` (which breaks Node.js, NVM, SSL, proxies),
|
||||
/// we keep the full environment and only remove known sensitive API keys
|
||||
/// from other LLM providers.
|
||||
fn apply_env_filter(cmd: &mut tokio::process::Command) {
|
||||
for key in SENSITIVE_ENV_EXACT {
|
||||
cmd.env_remove(key);
|
||||
}
|
||||
// Remove any env var with a sensitive suffix, unless it's CLAUDE_*
|
||||
for (key, _) in std::env::vars() {
|
||||
if key.starts_with("CLAUDE_") {
|
||||
continue;
|
||||
}
|
||||
let upper = key.to_uppercase();
|
||||
for suffix in SENSITIVE_SUFFIXES {
|
||||
if upper.ends_with(suffix) {
|
||||
cmd.env_remove(&key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON output from `claude -p --output-format json`.
|
||||
///
|
||||
/// The CLI may return the response text in different fields depending on
|
||||
/// version: `result`, `content`, or `text`. We try all three.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClaudeJsonOutput {
|
||||
result: Option<String>,
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
#[serde(default)]
|
||||
usage: Option<ClaudeUsage>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
@@ -134,7 +197,8 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
cmd.arg("--model").arg(model);
|
||||
}
|
||||
|
||||
// SECURITY: Don't inherit all env vars — only safe ones
|
||||
Self::apply_env_filter(&mut cmd);
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
@@ -143,13 +207,41 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(format!("Failed to spawn claude CLI: {e}")))?;
|
||||
.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
|
||||
)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let detail = if !stderr.is_empty() { &stderr } else { &stdout };
|
||||
let code = output.status.code().unwrap_or(1);
|
||||
|
||||
// Provide actionable error messages
|
||||
let message = if detail.contains("not authenticated")
|
||||
|| detail.contains("auth")
|
||||
|| detail.contains("login")
|
||||
|| detail.contains("credentials")
|
||||
{
|
||||
format!(
|
||||
"Claude Code CLI is not authenticated. Run: claude auth\nDetail: {detail}"
|
||||
)
|
||||
} else if detail.contains("permission")
|
||||
|| detail.contains("--dangerously-skip-permissions")
|
||||
{
|
||||
format!(
|
||||
"Claude Code CLI requires permissions acceptance. \
|
||||
Run: claude --dangerously-skip-permissions (once to accept)\nDetail: {detail}"
|
||||
)
|
||||
} else {
|
||||
format!("Claude Code CLI exited with code {code}: {detail}")
|
||||
};
|
||||
|
||||
return Err(LlmError::Api {
|
||||
status: output.status.code().unwrap_or(1) as u16,
|
||||
message: format!("Claude CLI failed: {stderr}"),
|
||||
status: code as u16,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,7 +249,10 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
|
||||
// Try JSON parse first
|
||||
if let Ok(parsed) = serde_json::from_str::<ClaudeJsonOutput>(&stdout) {
|
||||
let text = parsed.result.unwrap_or_default();
|
||||
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() }],
|
||||
@@ -195,12 +290,15 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
cmd.arg("-p")
|
||||
.arg(&prompt)
|
||||
.arg("--output-format")
|
||||
.arg("stream-json");
|
||||
.arg("stream-json")
|
||||
.arg("--verbose");
|
||||
|
||||
if let Some(ref model) = model_flag {
|
||||
cmd.arg("--model").arg(model);
|
||||
}
|
||||
|
||||
Self::apply_env_filter(&mut cmd);
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
@@ -208,7 +306,11 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| LlmError::Http(format!("Failed to spawn claude CLI: {e}")))?;
|
||||
.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
|
||||
)))?;
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
@@ -232,7 +334,7 @@ impl LlmDriver for ClaudeCodeDriver {
|
||||
match serde_json::from_str::<ClaudeStreamEvent>(&line) {
|
||||
Ok(event) => {
|
||||
match event.r#type.as_str() {
|
||||
"content" | "text" => {
|
||||
"content" | "text" | "assistant" | "content_block_delta" => {
|
||||
if let Some(ref content) = event.content {
|
||||
full_text.push_str(content);
|
||||
let _ = tx
|
||||
@@ -316,10 +418,16 @@ pub fn claude_code_available() -> bool {
|
||||
|| claude_credentials_exist()
|
||||
}
|
||||
|
||||
/// Check if Claude credentials file exists (~/.claude/.credentials.json).
|
||||
/// Check if Claude credentials file exists.
|
||||
///
|
||||
/// Different Claude CLI versions store credentials at different paths:
|
||||
/// - `~/.claude/.credentials.json` (older versions)
|
||||
/// - `~/.claude/credentials.json` (newer versions)
|
||||
fn claude_credentials_exist() -> bool {
|
||||
if let Some(home) = home_dir() {
|
||||
home.join(".claude").join(".credentials.json").exists()
|
||||
let claude_dir = home.join(".claude");
|
||||
claude_dir.join(".credentials.json").exists()
|
||||
|| claude_dir.join("credentials.json").exists()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -402,4 +510,14 @@ mod tests {
|
||||
let driver = ClaudeCodeDriver::new(Some(String::new()));
|
||||
assert_eq!(driver.cli_path, "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sensitive_env_list_coverage() {
|
||||
// Ensure all major provider keys are in the strip list
|
||||
assert!(SENSITIVE_ENV_EXACT.contains(&"OPENAI_API_KEY"));
|
||||
assert!(SENSITIVE_ENV_EXACT.contains(&"ANTHROPIC_API_KEY"));
|
||||
assert!(SENSITIVE_ENV_EXACT.contains(&"GEMINI_API_KEY"));
|
||||
assert!(SENSITIVE_ENV_EXACT.contains(&"GROQ_API_KEY"));
|
||||
assert!(SENSITIVE_ENV_EXACT.contains(&"DEEPSEEK_API_KEY"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,11 @@ impl CopilotDriver {
|
||||
token.base_url.clone()
|
||||
};
|
||||
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()),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,9 @@ struct GeminiInlineData {
|
||||
struct GeminiFunctionCallData {
|
||||
name: String,
|
||||
args: serde_json::Value,
|
||||
/// Gemini 2.5+ thinking models return this on functionCall parts.
|
||||
#[serde(rename = "thoughtSignature", default, skip_serializing_if = "Option::is_none")]
|
||||
thought_signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -161,8 +164,29 @@ struct GeminiErrorResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct GeminiErrorDetail {
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
code: Option<u16>,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a Gemini error response body, handling multiple Google API error formats.
|
||||
fn parse_gemini_error(body: &str) -> String {
|
||||
if let Ok(e) = serde_json::from_str::<GeminiErrorResponse>(body) {
|
||||
let mut msg = e.error.message;
|
||||
if let Some(status) = e.error.status {
|
||||
msg = format!("{status}: {msg}");
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
body.to_string()
|
||||
}
|
||||
|
||||
// ── Message conversion ─────────────────────────────────────────────────
|
||||
@@ -202,6 +226,7 @@ fn convert_messages(
|
||||
function_call: GeminiFunctionCallData {
|
||||
name: name.clone(),
|
||||
args: input.clone(),
|
||||
thought_signature: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -393,8 +418,8 @@ impl LlmDriver for GeminiDriver {
|
||||
let max_retries = 3;
|
||||
for attempt in 0..=max_retries {
|
||||
let url = format!(
|
||||
"{}/v1beta/models/{}:generateContent",
|
||||
self.base_url, request.model
|
||||
"{}/v1beta/models/{}:generateContent?key={}",
|
||||
self.base_url, request.model, self.api_key.as_str()
|
||||
);
|
||||
debug!(url = %url, attempt, "Sending Gemini API request");
|
||||
|
||||
@@ -430,9 +455,13 @@ impl LlmDriver for GeminiDriver {
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let message = serde_json::from_str::<GeminiErrorResponse>(&body)
|
||||
.map(|e| e.error.message)
|
||||
.unwrap_or(body);
|
||||
let message = parse_gemini_error(&body);
|
||||
if status == 401 || status == 403 {
|
||||
return Err(LlmError::AuthenticationFailed(message));
|
||||
}
|
||||
if status == 404 {
|
||||
return Err(LlmError::ModelNotFound(message));
|
||||
}
|
||||
return Err(LlmError::Api { status, message });
|
||||
}
|
||||
|
||||
@@ -473,8 +502,8 @@ impl LlmDriver for GeminiDriver {
|
||||
let max_retries = 3;
|
||||
for attempt in 0..=max_retries {
|
||||
let url = format!(
|
||||
"{}/v1beta/models/{}:streamGenerateContent?alt=sse",
|
||||
self.base_url, request.model
|
||||
"{}/v1beta/models/{}:streamGenerateContent?alt=sse&key={}",
|
||||
self.base_url, request.model, self.api_key.as_str()
|
||||
);
|
||||
debug!(url = %url, attempt, "Sending Gemini streaming request");
|
||||
|
||||
@@ -513,9 +542,13 @@ impl LlmDriver for GeminiDriver {
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let message = serde_json::from_str::<GeminiErrorResponse>(&body)
|
||||
.map(|e| e.error.message)
|
||||
.unwrap_or(body);
|
||||
let message = parse_gemini_error(&body);
|
||||
if status == 401 || status == 403 {
|
||||
return Err(LlmError::AuthenticationFailed(message));
|
||||
}
|
||||
if status == 404 {
|
||||
return Err(LlmError::ModelNotFound(message));
|
||||
}
|
||||
return Err(LlmError::Api { status, message });
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ pub mod openai;
|
||||
use crate::llm_driver::{DriverConfig, LlmDriver, LlmError};
|
||||
use openfang_types::model_catalog::{
|
||||
AI21_BASE_URL, ANTHROPIC_BASE_URL, CEREBRAS_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL,
|
||||
FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL,
|
||||
FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LEMONADE_BASE_URL,
|
||||
LMSTUDIO_BASE_URL,
|
||||
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_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, VLLM_BASE_URL, XAI_BASE_URL,
|
||||
ZHIPU_BASE_URL, ZHIPU_CODING_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;
|
||||
|
||||
@@ -88,6 +90,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "LMSTUDIO_API_KEY",
|
||||
key_required: false,
|
||||
}),
|
||||
"lemonade" => Some(ProviderDefaults {
|
||||
base_url: LEMONADE_BASE_URL,
|
||||
api_key_env: "LEMONADE_API_KEY",
|
||||
key_required: false,
|
||||
}),
|
||||
"perplexity" => Some(ProviderDefaults {
|
||||
base_url: PERPLEXITY_BASE_URL,
|
||||
api_key_env: "PERPLEXITY_API_KEY",
|
||||
@@ -148,7 +155,7 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "MOONSHOT_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"qwen" | "dashscope" => Some(ProviderDefaults {
|
||||
"qwen" | "dashscope" | "model_studio" => Some(ProviderDefaults {
|
||||
base_url: QWEN_BASE_URL,
|
||||
api_key_env: "DASHSCOPE_API_KEY",
|
||||
key_required: true,
|
||||
@@ -168,11 +175,36 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "ZHIPU_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"zai" => Some(ProviderDefaults {
|
||||
base_url: ZAI_BASE_URL,
|
||||
api_key_env: "ZHIPU_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"zai_coding" => Some(ProviderDefaults {
|
||||
base_url: ZAI_CODING_BASE_URL,
|
||||
api_key_env: "ZHIPU_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"qianfan" | "baidu" => Some(ProviderDefaults {
|
||||
base_url: QIANFAN_BASE_URL,
|
||||
api_key_env: "QIANFAN_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"volcengine" | "doubao" => Some(ProviderDefaults {
|
||||
base_url: VOLCENGINE_BASE_URL,
|
||||
api_key_env: "VOLCENGINE_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"volcengine_coding" => Some(ProviderDefaults {
|
||||
base_url: VOLCENGINE_CODING_BASE_URL,
|
||||
api_key_env: "VOLCENGINE_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"venice" => Some(ProviderDefaults {
|
||||
base_url: VENICE_BASE_URL,
|
||||
api_key_env: "VENICE_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -309,27 +341,84 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
return Ok(Arc::new(openai::OpenAIDriver::new(api_key, base_url)));
|
||||
}
|
||||
|
||||
// Unknown provider — if base_url is set, treat as custom OpenAI-compatible
|
||||
// Unknown provider — if base_url is set, treat as custom OpenAI-compatible.
|
||||
// For custom providers, try the convention {PROVIDER_UPPER}_API_KEY as env var
|
||||
// when no explicit api_key was passed. This lets users just set e.g. NVIDIA_API_KEY
|
||||
// in their environment and use provider = "nvidia" without extra config.
|
||||
if let Some(ref base_url) = config.base_url {
|
||||
let api_key = config.api_key.clone().unwrap_or_default();
|
||||
let api_key = config.api_key.clone().unwrap_or_else(|| {
|
||||
let env_var = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"));
|
||||
std::env::var(&env_var).unwrap_or_default()
|
||||
});
|
||||
return Ok(Arc::new(openai::OpenAIDriver::new(
|
||||
api_key,
|
||||
base_url.clone(),
|
||||
)));
|
||||
}
|
||||
|
||||
// No base_url either — last resort: check if the user set an API key env var
|
||||
// using the convention {PROVIDER_UPPER}_API_KEY. If found, use OpenAI-compatible
|
||||
// driver with a default base URL derived from common patterns.
|
||||
{
|
||||
let env_var = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"));
|
||||
if let Ok(api_key) = std::env::var(&env_var) {
|
||||
if !api_key.is_empty() {
|
||||
return Err(LlmError::Api {
|
||||
status: 0,
|
||||
message: format!(
|
||||
"Provider '{}' has API key ({} is set) but no base_url configured. \
|
||||
Add base_url to your [default_model] config or set it in [provider_urls].",
|
||||
provider, env_var
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(LlmError::Api {
|
||||
status: 0,
|
||||
message: format!(
|
||||
"Unknown provider '{}'. Supported: anthropic, gemini, openai, groq, openrouter, \
|
||||
deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, perplexity, \
|
||||
cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot, \
|
||||
codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
venice, codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
provider
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect the first available provider by scanning environment variables.
|
||||
///
|
||||
/// Returns `(provider, model, api_key_env)` for the first provider that has a
|
||||
/// configured API key, checked in a user-friendly priority order.
|
||||
pub fn detect_available_provider() -> Option<(&'static str, &'static str, &'static str)> {
|
||||
// Priority: popular cloud providers first, then niche, then local
|
||||
const PROBE_ORDER: &[(&str, &str, &str)] = &[
|
||||
("openai", "gpt-4o", "OPENAI_API_KEY"),
|
||||
("anthropic", "claude-sonnet-4-20250514", "ANTHROPIC_API_KEY"),
|
||||
("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"),
|
||||
("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"),
|
||||
("xai", "grok-2", "XAI_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() {
|
||||
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() {
|
||||
return Some(("gemini", "gemini-2.5-flash", "GOOGLE_API_KEY"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// List all known provider names.
|
||||
pub fn known_providers() -> &'static [&'static str] {
|
||||
&[
|
||||
@@ -360,6 +449,8 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
"zhipu",
|
||||
"zhipu_coding",
|
||||
"qianfan",
|
||||
"volcengine",
|
||||
"venice",
|
||||
"codex",
|
||||
"claude-code",
|
||||
]
|
||||
@@ -455,9 +546,10 @@ mod tests {
|
||||
assert!(providers.contains(&"zhipu"));
|
||||
assert!(providers.contains(&"zhipu_coding"));
|
||||
assert!(providers.contains(&"qianfan"));
|
||||
assert!(providers.contains(&"volcengine"));
|
||||
assert!(providers.contains(&"codex"));
|
||||
assert!(providers.contains(&"claude-code"));
|
||||
assert_eq!(providers.len(), 29);
|
||||
assert_eq!(providers.len(), 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -497,4 +589,61 @@ mod tests {
|
||||
assert_eq!(d.api_key_env, "HF_API_KEY");
|
||||
assert!(d.key_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_provider_convention_env_var() {
|
||||
// Set NVIDIA_API_KEY env var, then create a custom "nvidia" provider with base_url.
|
||||
// The driver should pick up the key automatically via convention.
|
||||
let unique_key = "test-nvidia-key-12345";
|
||||
std::env::set_var("NVIDIA_API_KEY", unique_key);
|
||||
let config = DriverConfig {
|
||||
provider: "nvidia".to_string(),
|
||||
api_key: None, // not explicitly passed
|
||||
base_url: Some("https://integrate.api.nvidia.com/v1".to_string()),
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_ok(), "Custom provider with env var convention should succeed");
|
||||
std::env::remove_var("NVIDIA_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_provider_no_key_no_url_errors() {
|
||||
// Custom provider with neither API key nor base_url should error.
|
||||
let config = DriverConfig {
|
||||
provider: "nvidia".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_provider_key_no_url_helpful_error() {
|
||||
// Custom provider with key set (via env) but no base_url should give helpful error.
|
||||
let unique_key = "test-nvidia-key-67890";
|
||||
std::env::set_var("NVIDIA_API_KEY", unique_key);
|
||||
let config = DriverConfig {
|
||||
provider: "nvidia".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
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);
|
||||
std::env::remove_var("NVIDIA_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_provider_explicit_key_with_url() {
|
||||
// When api_key is explicitly passed, it should be used regardless of env var.
|
||||
let config = DriverConfig {
|
||||
provider: "my-custom-provider".to_string(),
|
||||
api_key: Some("explicit-key".to_string()),
|
||||
base_url: Some("https://api.example.com/v1".to_string()),
|
||||
};
|
||||
let driver = create_driver(&config);
|
||||
assert!(driver.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct OpenAIDriver {
|
||||
api_key: Zeroizing<String>,
|
||||
base_url: String,
|
||||
client: reqwest::Client,
|
||||
extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl OpenAIDriver {
|
||||
@@ -25,8 +26,15 @@ impl OpenAIDriver {
|
||||
api_key: Zeroizing::new(api_key),
|
||||
base_url,
|
||||
client: reqwest::Client::new(),
|
||||
extra_headers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a driver with additional HTTP headers (e.g. for Copilot IDE auth).
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -39,13 +47,17 @@ struct OaiRequest {
|
||||
/// New token limit field required by GPT-5 and o-series reasoning models.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_completion_tokens: Option<u32>,
|
||||
temperature: f32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
tools: Vec<OaiTool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
stream: bool,
|
||||
/// Request usage stats in streaming responses (OpenAI extension, supported by Groq et al).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream_options: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Returns true if a model uses `max_completion_tokens` instead of `max_tokens`.
|
||||
@@ -58,6 +70,24 @@ fn uses_completion_tokens(model: &str) -> bool {
|
||||
|| m.starts_with("o4")
|
||||
}
|
||||
|
||||
/// Returns true if a model rejects the `temperature` parameter.
|
||||
///
|
||||
/// OpenAI's o-series reasoning models and GPT-5-mini variants only accept
|
||||
/// `temperature=1` (the default). Sending any other value causes a 400 error.
|
||||
/// We proactively omit `temperature` for these models to avoid wasting a retry.
|
||||
fn rejects_temperature(model: &str) -> bool {
|
||||
let m = model.to_lowercase();
|
||||
// o-series reasoning models: o1, o1-mini, o1-preview, o3, o3-mini, o3-pro, o4-mini, etc.
|
||||
m.starts_with("o1")
|
||||
|| m.starts_with("o3")
|
||||
|| m.starts_with("o4")
|
||||
// GPT-5-mini is a reasoning model that rejects temperature
|
||||
|| m.starts_with("gpt-5-mini")
|
||||
|| m.starts_with("gpt5-mini")
|
||||
// Catch any model explicitly tagged as "reasoning"
|
||||
|| m.contains("-reasoning")
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OaiMessage {
|
||||
role: String,
|
||||
@@ -202,7 +232,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
has_tool_results = true;
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OaiMessageContent::Text(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()),
|
||||
});
|
||||
@@ -250,10 +282,19 @@ impl LlmDriver for OpenAIDriver {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_tool_calls = !tool_calls.is_empty();
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "assistant".to_string(),
|
||||
// ZHIPU (GLM) rejects assistant messages where content is
|
||||
// null or omitted when tool_calls are present (error 1214).
|
||||
// Always send an empty string so every OpenAI-compat
|
||||
// provider gets a valid payload.
|
||||
content: if text_parts.is_empty() {
|
||||
None
|
||||
if has_tool_calls {
|
||||
Some(OaiMessageContent::Text(String::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(OaiMessageContent::Text(text_parts.join("")))
|
||||
},
|
||||
@@ -301,10 +342,11 @@ impl LlmDriver for OpenAIDriver {
|
||||
messages: oai_messages,
|
||||
max_tokens: mt,
|
||||
max_completion_tokens: mct,
|
||||
temperature: request.temperature,
|
||||
temperature: if rejects_temperature(&request.model) { None } else { Some(request.temperature) },
|
||||
tools: oai_tools,
|
||||
tool_choice,
|
||||
stream: false,
|
||||
stream_options: None,
|
||||
};
|
||||
|
||||
let max_retries = 3;
|
||||
@@ -322,6 +364,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
req_builder = req_builder
|
||||
.header("authorization", format!("Bearer {}", self.api_key.as_str()));
|
||||
}
|
||||
for (k, v) in &self.extra_headers {
|
||||
req_builder = req_builder.header(k, v);
|
||||
}
|
||||
|
||||
let resp = req_builder
|
||||
.send()
|
||||
@@ -360,6 +405,18 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// o-series / reasoning models: strip temperature if rejected
|
||||
if status == 400
|
||||
&& body.contains("temperature")
|
||||
&& body.contains("unsupported_parameter")
|
||||
&& oai_request.temperature.is_some()
|
||||
&& attempt < max_retries
|
||||
{
|
||||
warn!(model = %oai_request.model, "Stripping temperature for this model");
|
||||
oai_request.temperature = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
|
||||
if status == 400
|
||||
&& body.contains("max_tokens")
|
||||
@@ -388,6 +445,28 @@ impl LlmDriver for OpenAIDriver {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Model doesn't support function calling — retry without tools
|
||||
// (e.g. GLM-5 on DashScope returns 500 "internal error" when tools are sent)
|
||||
let body_lower = body.to_lowercase();
|
||||
if !oai_request.tools.is_empty()
|
||||
&& attempt < max_retries
|
||||
&& (status == 500
|
||||
|| body_lower.contains("internal error")
|
||||
|| (status == 400
|
||||
&& (body_lower.contains("does not support tools")
|
||||
|| body_lower.contains("tool")
|
||||
&& body_lower.contains("not supported"))))
|
||||
{
|
||||
warn!(
|
||||
model = %oai_request.model,
|
||||
status,
|
||||
"Model may not support tools, retrying without tools"
|
||||
);
|
||||
oai_request.tools.clear();
|
||||
oai_request.tool_choice = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(LlmError::Api {
|
||||
status,
|
||||
message: body,
|
||||
@@ -523,7 +602,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
{
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OaiMessageContent::Text(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()),
|
||||
});
|
||||
@@ -550,10 +631,15 @@ impl LlmDriver for OpenAIDriver {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_tool_calls = !tool_calls_out.is_empty();
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: if text_parts.is_empty() {
|
||||
None
|
||||
if has_tool_calls {
|
||||
Some(OaiMessageContent::Text(String::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(OaiMessageContent::Text(text_parts.join("")))
|
||||
},
|
||||
@@ -601,10 +687,11 @@ impl LlmDriver for OpenAIDriver {
|
||||
messages: oai_messages,
|
||||
max_tokens: mt,
|
||||
max_completion_tokens: mct,
|
||||
temperature: request.temperature,
|
||||
temperature: if rejects_temperature(&request.model) { None } else { Some(request.temperature) },
|
||||
tools: oai_tools,
|
||||
tool_choice,
|
||||
stream: true,
|
||||
stream_options: Some(serde_json::json!({"include_usage": true})),
|
||||
};
|
||||
|
||||
// Retry loop for the initial HTTP request
|
||||
@@ -623,6 +710,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
req_builder = req_builder
|
||||
.header("authorization", format!("Bearer {}", self.api_key.as_str()));
|
||||
}
|
||||
for (k, v) in &self.extra_headers {
|
||||
req_builder = req_builder.header(k, v);
|
||||
}
|
||||
|
||||
let resp = req_builder
|
||||
.send()
|
||||
@@ -662,6 +752,18 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// o-series / reasoning models: strip temperature if rejected
|
||||
if status == 400
|
||||
&& body.contains("temperature")
|
||||
&& body.contains("unsupported_parameter")
|
||||
&& oai_request.temperature.is_some()
|
||||
&& attempt < max_retries
|
||||
{
|
||||
warn!(model = %oai_request.model, "Stripping temperature for this model (stream)");
|
||||
oai_request.temperature = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
|
||||
if status == 400
|
||||
&& body.contains("max_tokens")
|
||||
@@ -690,6 +792,40 @@ impl LlmDriver for OpenAIDriver {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Provider doesn't support stream_options — retry without it
|
||||
if status == 400
|
||||
&& oai_request.stream_options.is_some()
|
||||
&& attempt < max_retries
|
||||
&& (body.contains("stream_options")
|
||||
|| body.contains("stream_option")
|
||||
|| body.contains("Unrecognized request argument"))
|
||||
{
|
||||
warn!(model = %oai_request.model, "Stripping stream_options (unsupported by provider)");
|
||||
oai_request.stream_options = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Model doesn't support function calling — retry without tools
|
||||
let body_lower = body.to_lowercase();
|
||||
if !oai_request.tools.is_empty()
|
||||
&& attempt < max_retries
|
||||
&& (status == 500
|
||||
|| body_lower.contains("internal error")
|
||||
|| (status == 400
|
||||
&& (body_lower.contains("does not support tools")
|
||||
|| body_lower.contains("tool")
|
||||
&& body_lower.contains("not supported"))))
|
||||
{
|
||||
warn!(
|
||||
model = %oai_request.model,
|
||||
status,
|
||||
"Model may not support tools (stream), retrying without tools"
|
||||
);
|
||||
oai_request.tools.clear();
|
||||
oai_request.tool_choice = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(LlmError::Api {
|
||||
status,
|
||||
message: body,
|
||||
@@ -703,10 +839,13 @@ impl LlmDriver for OpenAIDriver {
|
||||
let mut tool_accum: Vec<(String, String, String)> = Vec::new();
|
||||
let mut finish_reason: Option<String> = None;
|
||||
let mut usage = TokenUsage::default();
|
||||
let mut chunk_count: u32 = 0;
|
||||
let mut sse_line_count: u32 = 0;
|
||||
|
||||
let mut byte_stream = resp.bytes_stream();
|
||||
while let Some(chunk_result) = byte_stream.next().await {
|
||||
let chunk = chunk_result.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
chunk_count += 1;
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
// Process complete lines
|
||||
@@ -718,6 +857,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
continue;
|
||||
}
|
||||
|
||||
sse_line_count += 1;
|
||||
let data = match line.strip_prefix("data:") {
|
||||
Some(d) => d.trim_start(),
|
||||
None => continue,
|
||||
@@ -812,6 +952,33 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// Log stream summary for diagnostics
|
||||
let is_empty_stream = text_content.is_empty()
|
||||
&& tool_accum.is_empty()
|
||||
&& usage.input_tokens == 0
|
||||
&& usage.output_tokens == 0;
|
||||
if is_empty_stream {
|
||||
warn!(
|
||||
chunks = chunk_count,
|
||||
sse_lines = sse_line_count,
|
||||
finish = ?finish_reason,
|
||||
buffer_remaining = buffer.len(),
|
||||
"SSE stream returned empty: 0 content, 0 tokens — likely a silently failed request"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
chunks = chunk_count,
|
||||
sse_lines = sse_line_count,
|
||||
text_len = text_content.len(),
|
||||
tool_count = tool_accum.len(),
|
||||
finish = ?finish_reason,
|
||||
input_tokens = usage.input_tokens,
|
||||
output_tokens = usage.output_tokens,
|
||||
buffer_remaining = buffer.len(),
|
||||
"SSE stream completed"
|
||||
);
|
||||
}
|
||||
|
||||
// Build the final response
|
||||
let mut content = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
@@ -1008,4 +1175,94 @@ mod tests {
|
||||
let resp = result.unwrap();
|
||||
assert_eq!(resp.tool_calls[0].name, "shell_exec");
|
||||
}
|
||||
|
||||
// ----- rejects_temperature tests -----
|
||||
|
||||
#[test]
|
||||
fn test_rejects_temperature_o1_models() {
|
||||
assert!(rejects_temperature("o1"));
|
||||
assert!(rejects_temperature("o1-mini"));
|
||||
assert!(rejects_temperature("o1-mini-2024-09-12"));
|
||||
assert!(rejects_temperature("o1-preview"));
|
||||
assert!(rejects_temperature("o1-preview-2024-09-12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_temperature_o3_models() {
|
||||
assert!(rejects_temperature("o3"));
|
||||
assert!(rejects_temperature("o3-mini"));
|
||||
assert!(rejects_temperature("o3-mini-2025-01-31"));
|
||||
assert!(rejects_temperature("o3-pro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_temperature_o4_models() {
|
||||
assert!(rejects_temperature("o4-mini"));
|
||||
assert!(rejects_temperature("o4-mini-2025-04-16"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_temperature_gpt5_mini() {
|
||||
assert!(rejects_temperature("gpt-5-mini"));
|
||||
assert!(rejects_temperature("gpt-5-mini-2025-08-07"));
|
||||
assert!(rejects_temperature("gpt5-mini"));
|
||||
assert!(rejects_temperature("GPT-5-MINI-2025-08-07"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_temperature_reasoning_suffix() {
|
||||
assert!(rejects_temperature("some-model-reasoning"));
|
||||
assert!(rejects_temperature("deepseek-r1-reasoning"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_does_not_reject_temperature_normal_models() {
|
||||
assert!(!rejects_temperature("gpt-4o"));
|
||||
assert!(!rejects_temperature("gpt-4o-mini"));
|
||||
assert!(!rejects_temperature("gpt-5"));
|
||||
assert!(!rejects_temperature("gpt-5-2025-06-01"));
|
||||
assert!(!rejects_temperature("claude-sonnet-4-20250514"));
|
||||
assert!(!rejects_temperature("llama-3.3-70b-versatile"));
|
||||
assert!(!rejects_temperature("deepseek-chat"));
|
||||
}
|
||||
|
||||
// ----- uses_completion_tokens tests -----
|
||||
|
||||
#[test]
|
||||
fn test_uses_completion_tokens_gpt5() {
|
||||
assert!(uses_completion_tokens("gpt-5"));
|
||||
assert!(uses_completion_tokens("gpt-5-mini"));
|
||||
assert!(uses_completion_tokens("gpt-5-mini-2025-08-07"));
|
||||
assert!(uses_completion_tokens("gpt5-mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uses_completion_tokens_o_series() {
|
||||
assert!(uses_completion_tokens("o1"));
|
||||
assert!(uses_completion_tokens("o1-mini"));
|
||||
assert!(uses_completion_tokens("o3"));
|
||||
assert!(uses_completion_tokens("o3-mini"));
|
||||
assert!(uses_completion_tokens("o3-pro"));
|
||||
assert!(uses_completion_tokens("o4-mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_does_not_use_completion_tokens_normal_models() {
|
||||
assert!(!uses_completion_tokens("gpt-4o"));
|
||||
assert!(!uses_completion_tokens("gpt-4o-mini"));
|
||||
assert!(!uses_completion_tokens("llama-3.3-70b"));
|
||||
}
|
||||
|
||||
// ----- extract_max_tokens_limit tests -----
|
||||
|
||||
#[test]
|
||||
fn test_extract_max_tokens_limit() {
|
||||
let body = r#"max_tokens must be less than or equal to `8192`"#;
|
||||
assert_eq!(extract_max_tokens_limit(body), Some(8192));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_max_tokens_limit_no_match() {
|
||||
assert_eq!(extract_max_tokens_limit("some random error"), None);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user