Compare commits

...
32 Commits
Author SHA1 Message Date
jaberjaber23 52bacf0946 community fixes 2026-03-14 22:49:43 +03:00
jaberjaber23 d55e1b8545 community batch v0.4.0 2026-03-12 23:33:19 +03:00
jaberjaber23 0c059d1dc1 bump v0.3.49 2026-03-12 18:34:16 +03:00
jaberjaber23 b6b8b4ebe1 fix community issues 2026-03-12 16:42:39 +03:00
jaberjaber23 14f4845170 trader dashboard 2026-03-12 06:20:46 +03:00
jaberjaber23 be8a589986 bump v0.3.47 2026-03-12 01:23:35 +03:00
jaberjaber23 951e8d0feb fix 11 issues 2026-03-12 01:22:45 +03:00
jaberjaber23 98f8d1ca79 fix community PRs (inspired by #438 @pandego, #433 @ozekimasaki, #417 @f-liva, #392 @cryptonahue, #410 @hobostay, #413 @castorinop, #275 @woodcoal, #464 @citadelgrad, #419 @shipdocs, #480 @skeltavik, #439 @modship) 2026-03-11 03:25:16 +03:00
jaberjaber23 24f5717ae9 fix streaming-think, cron-orphans 2026-03-10 18:24:33 +03:00
jaberjaber23 f10eefdc0e fix 6 issues 2026-03-10 17:28:06 +03:00
jaberjaber23 edd0fed518 fix 7 issues 2026-03-10 16:53:04 +03:00
jaberjaber23 86b50070e8 fix gemini-schema 2026-03-10 04:09:26 +03:00
jaberjaber23 c6aab08faa fix temperature, free-models 2026-03-10 04:06:24 +03:00
jaberjaber23 62ec09d0ae bump version 2026-03-10 02:16:05 +03:00
jaberjaber23 f6f9cf7e9f fix claude-code 2026-03-10 02:14:49 +03:00
jaberjaber23 56aeb499c9 fix tool-schema 2026-03-10 01:41:33 +03:00
jaberjaber23 b4e6a693f5 version bump 2026-03-10 01:27:39 +03:00
jaberjaber23 48d5418c91 claude code fix
Fix Claude Code provider setup flow: wizard now shows Detect button instead of API key input for keyless providers, TUI wizards include claude-code in provider list, credentials detection checks both .credentials.json paths, subprocess env_clear prevents API key leaks. Fixes #376 #303.
2026-03-10 01:19:02 +03:00
jaberjaber23 cc93ef4571 community fixes
Fix tool name mapping so LLM-hallucinated aliases (fs-write, fsRead, writeFile, etc.) normalize to canonical names before capability check and dispatch (#349). Fix provider keys not loading after dashboard save by creating fresh drivers that read current env vars instead of stale boot-time cache (#465, #458, #355). Fix Moonshot/kimi model IDs and provider inference (#428). Add Telegram message reactions for agent lifecycle feedback (#435). Add configurable api_url for Telegram proxy support (#477). Add Discord ignore_bots config option (#403). Fix openfang init EPERM crash with 7-browser fallback on Linux (#389). Add text-based tool call parsing for models without native function calling — [TOOL_CALL], <tool_call>, bare JSON patterns (#354, #332). Fix pre-existing Windows test failures with cross-platform paths. 1948 tests pass, 0 clippy warnings.
2026-03-10 00:40:45 +03:00
jaberjaber23 3e069798f9 community fixes
Fix 8 issues: empty LLM response after ~4 rounds by re-validating message pairs after history trim (#460), MCP tools permission denied by bypassing ToolInvoke capability filter for extension tools (#352), Telegram photos silently dropped now downloaded and passed as multimodal ContentBlock::Image (#362), workflow visual builder double-click editing and live property updates (#357), Claude Code provider card reflects actual install/auth status (#376), Python 3 detection runs actual command instead of path lookup (#405), hand agent_id persisted for cron job reassignment on restart (#402), CLI sends auth headers on all commands not just stop (#478). 1921 tests pass, 0 clippy warnings.
2026-03-09 23:07:08 +03:00
jaberjaber23 ad10aa5e80 community fixes
Fix 12 GitHub issues: SSE streaming token counts (#stream_options), UTF-8 boundary panics (#472), cron timezone scheduling (#473), TOML multiline system_prompt (#463), dashboard 401 auth interceptor (#468), custom provider env var convention (#471), cron stale agent_id reassignment (#461), concurrent provider probing with cache (#474), model switch provider sync (#466/#387), OpenRouter real models (#385), embedding URL normalization (#395), ZHIPU content format (#384), Fish shell PATH detection (#372). 1915 tests pass, 0 clippy warnings.
2026-03-09 21:19:50 +03:00
jaberjaber23 385aee8e56 fix streaming
- Add stream_options (include_usage) for accurate token counts in streaming mode
- Add fallback for providers that don't support stream_options
- Add SSE stream diagnostic logging
2026-03-09 04:57:04 +03:00
jaberjaber23 a00327abe9 fix auth 2026-03-09 03:19:14 +03:00
jaberjaber23 487555a5e5 bump version 2026-03-09 02:18:51 +03:00
jaberjaber23 9d51426cb4 fix bugs 2026-03-09 02:16:36 +03:00
jaberjaber23 6fab720843 bump version 2026-03-08 22:53:48 +03:00
jaberjaber23 4667f497ef fix csp 2026-03-08 22:51:21 +03:00
jaberjaber23 eba9198827 community fixes 2026-03-08 22:29:54 +03:00
jaberjaber23 f2413949bc shell hardening 2026-03-08 20:20:34 +03:00
jaberjaber23 9e230f423e security hardening 2026-03-08 16:59:48 +03:00
jaberjaber23 8138b7e0e8 version bump 2026-03-08 04:05:39 +03:00
jaberjaber23 cfae867908 batch fixes 2026-03-08 04:04:37 +03:00
139 changed files with 15873 additions and 1627 deletions
+3 -1
View File
@@ -183,7 +183,9 @@ jobs:
run: cargo build --release --target ${{ matrix.target }} --bin openfang
- name: Ad-hoc codesign CLI binary (macOS)
if: runner.os == 'macOS'
run: codesign --force --sign - target/${{ matrix.target }}/release/openfang
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
+283 -325
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.3.28"
version = "0.4.1"
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"] }
@@ -61,7 +62,7 @@ clap = { version = "4", features = ["derive"] }
clap_complete = "4"
# HTTP client (for LLM drivers)
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
# Async trait
async-trait = "0.1"
+10 -4
View File
@@ -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
View File
@@ -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
+3
View File
@@ -33,6 +33,9 @@ governor = { workspace = true }
tokio-stream = { workspace = true }
subtle = { workspace = true }
base64 = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
hex = { workspace = true }
socket2 = { workspace = true }
reqwest = { workspace = true }
+112 -18
View File
@@ -56,6 +56,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{error, info, warn};
use openfang_runtime::str_utils::safe_truncate_str;
/// Wraps `OpenFangKernel` to implement `ChannelBridgeHandle`.
pub struct KernelBridgeAdapter {
kernel: Arc<OpenFangKernel>,
@@ -73,6 +75,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))
}
@@ -351,7 +380,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| t.agent_id.to_string());
let status = if t.enabled { "on" } else { "off" };
let id_short = &t.id.0.to_string()[..8];
let id_str = t.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
msg.push_str(&format!(
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
id_short,
@@ -390,7 +420,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.kernel
.triggers
.register(agent.id, pattern, prompt.to_string(), 0);
let id_short = &trigger_id.0.to_string()[..8];
let id_str = trigger_id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
}
@@ -405,7 +436,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
1 => {
let t = matched[0];
if self.kernel.triggers.remove(t.id) {
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
let id_str = t.id.0.to_string();
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
} else {
"Failed to remove trigger.".to_string()
}
@@ -428,7 +460,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| job.agent_id.to_string());
let status = if job.enabled { "on" } else { "off" };
let id_short = &job.id.0.to_string()[..8];
let id_str = job.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let sched = match &job.schedule {
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
@@ -450,6 +483,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
msg
}
#[allow(dead_code)]
async fn manage_schedule_text(&self, action: &str, args: &[String]) -> String {
match action {
"add" => {
@@ -488,7 +522,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
match self.kernel.cron_scheduler.add_job(job, false) {
Ok(id) => {
let id_short = &id.0.to_string()[..8];
let id_str = id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
}
Err(e) => format!("Failed to create job: {e}"),
@@ -510,7 +545,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
let j = matched[0];
match self.kernel.cron_scheduler.remove_job(j.id) {
Ok(_) => {
format!("Job [{}] '{}' removed.", &j.id.0.to_string()[..8], j.name)
let id_str = j.id.0.to_string();
format!("Job [{}] '{}' removed.", safe_truncate_str(&id_str, 8), j.name)
}
Err(e) => format!("Failed to remove job: {e}"),
}
@@ -539,10 +575,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
openfang_types::scheduler::CronAction::SystemEvent { text } => {
text.clone()
}
openfang_types::scheduler::CronAction::WorkflowRun {
workflow_id,
input,
..
} => {
format!(
"Run workflow {workflow_id}{}",
input
.as_deref()
.map(|i| format!(" with input: {i}"))
.unwrap_or_default()
)
}
};
match self.kernel.send_message(j.agent_id, &message).await {
Ok(result) => {
let id_short = &j.id.0.to_string()[..8];
let id_str = j.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] ran:\n{}", result.response)
}
Err(e) => format!("Failed to run job: {e}"),
@@ -562,7 +612,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
let mut msg = format!("Pending approvals ({}):\n", pending.len());
for req in &pending {
let id_short = &req.id.to_string()[..8];
let id_str = req.id.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
let age = if age_secs >= 60 {
format!("{}m", age_secs / 60)
@@ -603,10 +654,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
) {
Ok(_) => {
let verb = if approve { "Approved" } else { "Rejected" };
let id_str = req.id.to_string();
format!(
"{} [{}] {}{}",
verb,
&req.id.to_string()[..8],
safe_truncate_str(&id_str, 8),
req.tool_name,
req.agent_id
)
@@ -646,9 +698,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
));
}
self.kernel
.set_agent_model(agent_id, model)
.set_agent_model(agent_id, model, None)
.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> {
@@ -774,6 +835,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
recipient: &str,
success: bool,
error: Option<&str>,
thread_id: Option<&str>,
) {
let receipt = if success {
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
@@ -786,9 +848,13 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
};
self.kernel.delivery_tracker.record(agent_id, receipt);
// Persist last channel for cron CronDelivery::LastChannel
// Persist last channel for cron CronDelivery::LastChannel.
// Include thread_id when present so forum-topic context survives restarts.
if success {
let kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
let mut kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
if let Some(tid) = thread_id {
kv_val["thread_id"] = serde_json::json!(tid);
}
let _ = self
.kernel
.memory
@@ -934,16 +1000,40 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
}
}
/// Read a token from an env var, returning None with a warning if missing/empty.
fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
match std::env::var(env_var) {
/// Resolve a token: if the value looks like an actual secret (contains `:`,
/// starts with `xoxb-`, `xapp-`, `sk-`, etc.), use it directly.
/// Otherwise treat it as an env var name and look it up.
fn read_token(env_var_or_token: &str, adapter_name: &str) -> Option<String> {
// Heuristic: actual tokens contain `:` (Telegram, Discord) or start with
// known prefixes. Env var names are uppercase ASCII identifiers.
let looks_like_token = env_var_or_token.contains(':')
|| env_var_or_token.starts_with("xoxb-")
|| env_var_or_token.starts_with("xapp-")
|| env_var_or_token.starts_with("sk-")
|| env_var_or_token.starts_with("Bearer ");
if looks_like_token {
warn!(
"{adapter_name}: config field contains what looks like an actual token \
rather than an env var name — using it directly. \
Tip: store the token in an env var and use the var name instead for security."
);
return Some(env_var_or_token.to_string());
}
match std::env::var(env_var_or_token) {
Ok(t) if !t.is_empty() => Some(t),
Ok(_) => {
warn!("{adapter_name} bot token env var '{env_var}' is empty, skipping");
warn!(
"{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping"
);
None
}
Err(_) => {
warn!("{adapter_name} bot token env var '{env_var}' not set, skipping");
warn!(
"{adapter_name} token env var '{env_var_or_token}' not set, skipping. \
Set it with: export {env_var_or_token}=<your-token>"
);
None
}
}
@@ -1030,6 +1120,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()));
}
@@ -1042,6 +1133,7 @@ pub async fn start_channel_bridge_with_config(
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()));
@@ -1056,6 +1148,8 @@ pub async fn start_channel_bridge_with_config(
app_token,
bot_token,
sl_config.allowed_channels.clone(),
sl_config.auto_thread_reply,
sl_config.thread_ttl_hours,
));
adapters.push((adapter, sl_config.default_agent.clone()));
}
+1
View File
@@ -9,6 +9,7 @@ pub mod openai_compat;
pub mod rate_limiter;
pub mod routes;
pub mod server;
pub mod session_auth;
pub mod stream_chunker;
pub mod stream_dedup;
pub mod types;
+92 -39
View File
@@ -43,19 +43,29 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
response
}
/// Authentication state passed to the auth middleware.
#[derive(Clone)]
pub struct AuthState {
pub api_key: String,
pub auth_enabled: bool,
pub session_secret: String,
}
/// 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).
///
/// When dashboard auth is enabled, session cookies are also accepted.
pub async fn auth(
axum::extract::State(api_key): axum::extract::State<String>,
axum::extract::State(auth_state): axum::extract::State<AuthState>,
request: Request<Body>,
next: Next,
) -> Response<Body> {
// If no API key configured, skip authentication entirely (open access).
if api_key.is_empty() {
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();
@@ -64,55 +74,74 @@ 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 == "/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/")
{
|| path == "/api/auth/login"
|| path == "/api/auth/logout"
|| (path == "/api/auth/check" && is_get);
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_trimmed = auth_state.api_key.trim().to_string();
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
return next.run(request).await;
}
let api_key = api_key_trimmed.as_str();
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
let bearer_token = request
.headers()
@@ -157,6 +186,17 @@ pub async fn auth(
return next.run(request).await;
}
// Check session cookie (dashboard login sessions)
if auth_state.auth_enabled {
if let Some(token) = extract_session_cookie(&request) {
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
.is_some()
{
return next.run(request).await;
}
}
}
// Determine error message: was a credential provided but wrong, or missing entirely?
let credential_provided = header_auth.is_some() || query_auth.is_some();
let error_msg = if credential_provided {
@@ -174,6 +214,19 @@ pub async fn auth(
.unwrap_or_default()
}
/// Extract the `openfang_session` cookie value from a request.
fn extract_session_cookie(request: &Request<Body>) -> Option<String> {
request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies
.split(';')
.find_map(|c| c.trim().strip_prefix("openfang_session=").map(|v| v.to_string()))
})
}
/// Security headers middleware — applied to ALL API responses.
pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Body> {
let mut response = next.run(request).await;
+1 -1
View File
@@ -203,7 +203,7 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
.iter()
.filter_map(|part| match part {
OaiContentPart::Text { text } => {
Some(ContentBlock::Text { text: text.clone() })
Some(ContentBlock::Text { text: text.clone(), provider_metadata: None })
}
OaiContentPart::ImageUrl { image_url } => {
// Parse data URI: data:{media_type};base64,{data}
+655 -68
View File
@@ -36,6 +36,10 @@ pub struct AppState {
/// ClawHub response cache — prevents 429 rate limiting on rapid dashboard refreshes.
/// Maps cache key → (fetched_at, response_json) with 120s TTL.
pub clawhub_cache: DashMap<String, (Instant, serde_json::Value)>,
/// Probe cache for local provider health checks (ollama/vllm/lmstudio).
/// Avoids blocking the `/api/providers` endpoint on TCP timeouts to
/// unreachable local services. 60-second TTL.
pub provider_probe_cache: openfang_runtime::provider_health::ProbeCache,
}
/// POST /api/agents — Spawn a new agent.
@@ -43,9 +47,49 @@ pub async fn spawn_agent(
State(state): State<Arc<AppState>>,
Json(req): Json<SpawnRequest>,
) -> impl IntoResponse {
// Resolve template name → manifest_toml if template is provided and manifest_toml is empty
let manifest_toml = if req.manifest_toml.trim().is_empty() {
if let Some(ref tmpl_name) = req.template {
// Sanitize template name to prevent path traversal
let safe_name = tmpl_name
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>();
if safe_name.is_empty() || safe_name != *tmpl_name {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid template name"})),
);
}
let tmpl_path = state
.kernel
.config
.home_dir
.join("agents")
.join(&safe_name)
.join("agent.toml");
match std::fs::read_to_string(&tmpl_path) {
Ok(content) => content,
Err(_) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("Template '{}' not found", safe_name)})),
);
}
}
} else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Either 'manifest_toml' or 'template' is required"})),
);
}
} else {
req.manifest_toml.clone()
};
// SECURITY: Reject oversized manifests to prevent parser memory exhaustion.
const MAX_MANIFEST_SIZE: usize = 1024 * 1024; // 1MB
if req.manifest_toml.len() > MAX_MANIFEST_SIZE {
if manifest_toml.len() > MAX_MANIFEST_SIZE {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": "Manifest too large (max 1MB)"})),
@@ -57,7 +101,7 @@ pub async fn spawn_agent(
match state.kernel.verify_signed_manifest(signed_json) {
Ok(verified_toml) => {
// Ensure the signed manifest matches the provided manifest_toml
if verified_toml.trim() != req.manifest_toml.trim() {
if verified_toml.trim() != manifest_toml.trim() {
tracing::warn!("Signed manifest content does not match manifest_toml");
return (
StatusCode::BAD_REQUEST,
@@ -83,7 +127,7 @@ pub async fn spawn_agent(
}
}
let manifest: AgentManifest = match toml::from_str(&req.manifest_toml) {
let manifest: AgentManifest = match toml::from_str(&manifest_toml) {
Ok(m) => m,
Err(e) => {
tracing::warn!("Invalid manifest TOML: {e}");
@@ -409,7 +453,7 @@ pub async fn get_agent_session(
let mut texts = Vec::new();
for b in blocks {
match b {
openfang_types::message::ContentBlock::Text { text } => {
openfang_types::message::ContentBlock::Text { text, .. } => {
texts.push(text.clone());
}
openfang_types::message::ContentBlock::Image {
@@ -447,6 +491,7 @@ pub async fn get_agent_session(
id,
name,
input,
..
} => {
let tool_idx = tools.len();
tools.push(serde_json::json!({
@@ -807,6 +852,177 @@ pub async fn list_workflow_runs(
Json(list)
}
/// GET /api/workflows/:id — Get a single workflow by ID.
pub async fn get_workflow(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let workflow_id = WorkflowId(match id.parse() {
Ok(u) => u,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid workflow ID"})),
);
}
});
match state.kernel.workflows.get_workflow(workflow_id).await {
Some(w) => (
StatusCode::OK,
Json(serde_json::json!({
"id": w.id.to_string(),
"name": w.name,
"description": w.description,
"steps": w.steps,
"created_at": w.created_at.to_rfc3339(),
})),
),
None => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Workflow not found"})),
),
}
}
/// PUT /api/workflows/:id — Update a workflow definition.
pub async fn update_workflow(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<serde_json::Value>,
) -> impl IntoResponse {
let workflow_id = WorkflowId(match id.parse() {
Ok(u) => u,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid workflow ID"})),
);
}
});
let name = req["name"].as_str().unwrap_or("unnamed").to_string();
let description = req["description"].as_str().unwrap_or("").to_string();
let steps_json = match req["steps"].as_array() {
Some(s) => s,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Missing 'steps' array"})),
);
}
};
let mut steps = Vec::new();
for s in steps_json {
let step_name = s["name"].as_str().unwrap_or("step").to_string();
let agent = if let Some(id) = s["agent_id"].as_str() {
StepAgent::ById { id: id.to_string() }
} else if let Some(name) = s["agent_name"].as_str() {
StepAgent::ByName {
name: name.to_string(),
}
} else {
return (
StatusCode::BAD_REQUEST,
Json(
serde_json::json!({"error": format!("Step '{}' needs 'agent_id' or 'agent_name'", step_name)}),
),
);
};
let mode = match s["mode"].as_str().unwrap_or("sequential") {
"fan_out" => StepMode::FanOut,
"collect" => StepMode::Collect,
"conditional" => StepMode::Conditional {
condition: s["condition"].as_str().unwrap_or("").to_string(),
},
"loop" => StepMode::Loop {
max_iterations: s["max_iterations"].as_u64().unwrap_or(5) as u32,
until: s["until"].as_str().unwrap_or("").to_string(),
},
_ => StepMode::Sequential,
};
let error_mode = match s["error_mode"].as_str().unwrap_or("fail") {
"skip" => ErrorMode::Skip,
"retry" => ErrorMode::Retry {
max_retries: s["max_retries"].as_u64().unwrap_or(3) as u32,
},
_ => ErrorMode::Fail,
};
steps.push(WorkflowStep {
name: step_name,
agent,
prompt_template: s["prompt"].as_str().unwrap_or("{{input}}").to_string(),
mode,
timeout_secs: s["timeout_secs"].as_u64().unwrap_or(120),
error_mode,
output_var: s["output_var"].as_str().map(String::from),
});
}
let updated = Workflow {
id: workflow_id,
name,
description,
steps,
created_at: chrono::Utc::now(), // preserved by engine
};
if state
.kernel
.workflows
.update_workflow(workflow_id, updated)
.await
{
(
StatusCode::OK,
Json(serde_json::json!({"status": "updated", "workflow_id": id})),
)
} else {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Workflow not found"})),
)
}
}
/// DELETE /api/workflows/:id — Delete a workflow definition.
pub async fn delete_workflow(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let workflow_id = WorkflowId(match id.parse() {
Ok(u) => u,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid workflow ID"})),
);
}
});
if state
.kernel
.workflows
.remove_workflow(workflow_id)
.await
{
(
StatusCode::OK,
Json(serde_json::json!({"status": "removed", "workflow_id": id})),
)
} else {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Workflow not found"})),
)
}
}
// ---------------------------------------------------------------------------
// Trigger routes
// ---------------------------------------------------------------------------
@@ -3138,8 +3354,7 @@ pub async fn clawhub_search(
Err(e) => {
let msg = format!("{e}");
tracing::warn!("ClawHub search failed: {msg}");
// Propagate 429 status instead of masking as 200
let status = if msg.contains("429") || msg.contains("rate limit") {
let status = if is_clawhub_rate_limit(&e) {
StatusCode::TOO_MANY_REQUESTS
} else {
StatusCode::OK
@@ -3207,7 +3422,7 @@ pub async fn clawhub_browse(
Err(e) => {
let msg = format!("{e}");
tracing::warn!("ClawHub browse failed: {msg}");
let status = if msg.contains("429") || msg.contains("rate limit") {
let status = if is_clawhub_rate_limit(&e) {
StatusCode::TOO_MANY_REQUESTS
} else {
StatusCode::OK
@@ -3275,10 +3490,14 @@ pub async fn clawhub_skill_detail(
})),
)
}
Err(e) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("{e}")})),
),
Err(e) => {
let status = if is_clawhub_rate_limit(&e) {
StatusCode::TOO_MANY_REQUESTS
} else {
StatusCode::NOT_FOUND
};
(status, Json(serde_json::json!({"error": format!("{e}")})))
}
}
}
@@ -3379,11 +3598,11 @@ pub async fn clawhub_install(
}
Err(e) => {
let msg = format!("{e}");
let status = if msg.contains("SecurityBlocked") {
let status = if matches!(e, openfang_skills::SkillError::SecurityBlocked(_)) {
StatusCode::FORBIDDEN
} else if msg.contains("429") || msg.contains("rate limit") {
} else if is_clawhub_rate_limit(&e) {
StatusCode::TOO_MANY_REQUESTS
} else if msg.contains("Network error") || msg.contains("returned 4") || msg.contains("returned 5") {
} else if matches!(e, openfang_skills::SkillError::Network(_)) {
StatusCode::BAD_GATEWAY
} else {
StatusCode::INTERNAL_SERVER_ERROR
@@ -3394,6 +3613,11 @@ pub async fn clawhub_install(
}
}
/// Check whether a SkillError represents a ClawHub rate-limit (429).
fn is_clawhub_rate_limit(err: &openfang_skills::SkillError) -> bool {
matches!(err, openfang_skills::SkillError::RateLimited(_))
}
/// Convert a browse entry (nested stats/tags) to a flat JSON object for the frontend.
fn clawhub_browse_entry_to_json(
entry: &openfang_skills::clawhub::ClawHubBrowseEntry,
@@ -3436,7 +3660,10 @@ pub async fn list_hands(State(state): State<Arc<AppState>>) -> impl IntoResponse
.hand_registry
.check_requirements(&d.id)
.unwrap_or_default();
let all_satisfied = reqs.iter().all(|(_, ok)| *ok);
let readiness = state.kernel.hand_registry.readiness(&d.id);
let requirements_met = readiness.as_ref().map(|r| r.requirements_met).unwrap_or(false);
let active = readiness.as_ref().map(|r| r.active).unwrap_or(false);
let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false);
serde_json::json!({
"id": d.id,
"name": d.name,
@@ -3444,11 +3671,14 @@ pub async fn list_hands(State(state): State<Arc<AppState>>) -> impl IntoResponse
"category": d.category,
"icon": d.icon,
"tools": d.tools,
"requirements_met": all_satisfied,
"requirements_met": requirements_met,
"active": active,
"degraded": degraded,
"requirements": reqs.iter().map(|(r, ok)| serde_json::json!({
"key": r.key,
"label": r.label,
"satisfied": ok,
"optional": r.optional,
})).collect::<Vec<_>>(),
"dashboard_metrics": d.dashboard.metrics.len(),
"has_settings": !d.settings.is_empty(),
@@ -3493,7 +3723,10 @@ pub async fn get_hand(
.hand_registry
.check_requirements(&hand_id)
.unwrap_or_default();
let all_satisfied = reqs.iter().all(|(_, ok)| *ok);
let readiness = state.kernel.hand_registry.readiness(&hand_id);
let requirements_met = readiness.as_ref().map(|r| r.requirements_met).unwrap_or(false);
let active = readiness.as_ref().map(|r| r.active).unwrap_or(false);
let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false);
let settings_status = state
.kernel
.hand_registry
@@ -3508,7 +3741,9 @@ pub async fn get_hand(
"category": def.category,
"icon": def.icon,
"tools": def.tools,
"requirements_met": all_satisfied,
"requirements_met": requirements_met,
"active": active,
"degraded": degraded,
"requirements": reqs.iter().map(|(r, ok)| {
let mut req_json = serde_json::json!({
"key": r.key,
@@ -3516,6 +3751,7 @@ pub async fn get_hand(
"type": format!("{:?}", r.requirement_type),
"check_value": r.check_value,
"satisfied": ok,
"optional": r.optional,
});
if let Some(ref desc) = r.description {
req_json["description"] = serde_json::json!(desc);
@@ -3564,12 +3800,17 @@ pub async fn check_hand_deps(
.hand_registry
.check_requirements(&hand_id)
.unwrap_or_default();
let all_satisfied = reqs.iter().all(|(_, ok)| *ok);
let readiness = state.kernel.hand_registry.readiness(&hand_id);
let requirements_met = readiness.as_ref().map(|r| r.requirements_met).unwrap_or(false);
let active = readiness.as_ref().map(|r| r.active).unwrap_or(false);
let degraded = readiness.as_ref().map(|r| r.degraded).unwrap_or(false);
(
StatusCode::OK,
Json(serde_json::json!({
"hand_id": def.id,
"requirements_met": all_satisfied,
"requirements_met": requirements_met,
"active": active,
"degraded": degraded,
"server_platform": server_platform(),
"requirements": reqs.iter().map(|(r, ok)| {
let mut req_json = serde_json::json!({
@@ -3578,6 +3819,7 @@ pub async fn check_hand_deps(
"type": format!("{:?}", r.requirement_type),
"check_value": r.check_value,
"satisfied": ok,
"optional": r.optional,
});
if let Some(ref desc) = r.description {
req_json["description"] = serde_json::json!(desc);
@@ -4082,15 +4324,25 @@ pub async fn hand_stats(
}
};
// Read dashboard metrics from agent's structured memory
// Read dashboard metrics from shared structured memory (memory_store uses shared namespace)
let shared_id = openfang_kernel::kernel::shared_memory_agent_id();
let mut metrics = serde_json::Map::new();
for metric in &def.dashboard.metrics {
// Try shared memory first (where memory_store tool writes), fall back to agent-specific
let value = state
.kernel
.memory
.structured_get(agent_id, &metric.memory_key)
.structured_get(shared_id, &metric.memory_key)
.ok()
.flatten()
.or_else(|| {
state
.kernel
.memory
.structured_get(agent_id, &metric.memory_key)
.ok()
.flatten()
})
.unwrap_or(serde_json::Value::Null);
metrics.insert(
metric.label.clone(),
@@ -4706,6 +4958,9 @@ pub async fn update_budget(
if let Some(v) = body["alert_threshold"].as_f64() {
(*config_ptr).budget.alert_threshold = v.clamp(0.0, 1.0);
}
if let Some(v) = body["default_max_llm_tokens_per_hour"].as_u64() {
(*config_ptr).budget.default_max_llm_tokens_per_hour = v;
}
}
let status = state
@@ -4746,6 +5001,10 @@ pub async fn agent_budget_status(
let daily = usage_store.query_daily(agent_id).unwrap_or(0.0);
let monthly = usage_store.query_monthly(agent_id).unwrap_or(0.0);
// Token usage from scheduler
let token_usage = state.kernel.scheduler.get_usage(agent_id);
let tokens_used = token_usage.map(|(t, _)| t).unwrap_or(0);
(
StatusCode::OK,
Json(serde_json::json!({
@@ -4766,6 +5025,11 @@ pub async fn agent_budget_status(
"limit": quota.max_cost_per_month_usd,
"pct": if quota.max_cost_per_month_usd > 0.0 { monthly / quota.max_cost_per_month_usd } else { 0.0 },
},
"tokens": {
"used": tokens_used,
"limit": quota.max_llm_tokens_per_hour,
"pct": if quota.max_llm_tokens_per_hour > 0 { tokens_used as f64 / quota.max_llm_tokens_per_hour as f64 } else { 0.0 },
},
})),
)
}
@@ -4788,6 +5052,7 @@ pub async fn agent_budget_ranking(State(state): State<Arc<AppState>>) -> impl In
"hourly_limit": entry.manifest.resources.max_cost_per_hour_usd,
"daily_limit": entry.manifest.resources.max_cost_per_day_usd,
"monthly_limit": entry.manifest.resources.max_cost_per_month_usd,
"max_llm_tokens_per_hour": entry.manifest.resources.max_llm_tokens_per_hour,
}))
} else {
None
@@ -4817,18 +5082,19 @@ pub async fn update_agent_budget(
let hourly = body["max_cost_per_hour_usd"].as_f64();
let daily = body["max_cost_per_day_usd"].as_f64();
let monthly = body["max_cost_per_month_usd"].as_f64();
let tokens = body["max_llm_tokens_per_hour"].as_u64();
if hourly.is_none() && daily.is_none() && monthly.is_none() {
if hourly.is_none() && daily.is_none() && monthly.is_none() && tokens.is_none() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Provide at least one of: max_cost_per_hour_usd, max_cost_per_day_usd, max_cost_per_month_usd"})),
Json(serde_json::json!({"error": "Provide at least one of: max_cost_per_hour_usd, max_cost_per_day_usd, max_cost_per_month_usd, max_llm_tokens_per_hour"})),
);
}
match state
.kernel
.registry
.update_resources(agent_id, hourly, daily, monthly)
.update_resources(agent_id, hourly, daily, monthly, tokens)
{
Ok(()) => {
// Persist updated entry
@@ -5112,7 +5378,8 @@ pub async fn patch_agent(
}
}
if let Some(model) = body.get("model").and_then(|v| v.as_str()) {
if let Err(e) = state.kernel.set_agent_model(agent_id, model) {
let explicit_provider = body.get("provider").and_then(|v| v.as_str());
if let Err(e) = state.kernel.set_agent_model(agent_id, model, explicit_provider) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("{e}")})),
@@ -5491,6 +5758,10 @@ pub async fn get_model(
///
/// For local providers (ollama, vllm, lmstudio), also probes reachability and
/// discovers available models via their health endpoints.
///
/// Probes run **concurrently** and results are **cached for 60 seconds** so the
/// endpoint responds instantly on repeated dashboard loads even when local
/// providers are unreachable (fixes #474).
pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let provider_list: Vec<openfang_types::model_catalog::ProviderInfo> = {
let catalog = state
@@ -5501,9 +5772,34 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
catalog.list_providers().to_vec()
};
// Collect local providers that need probing
let local_providers: Vec<(usize, String, String)> = provider_list
.iter()
.enumerate()
.filter(|(_, p)| !p.key_required && !p.base_url.is_empty())
.map(|(i, p)| (i, p.id.clone(), p.base_url.clone()))
.collect();
// Fire all probes concurrently (cached results return instantly)
let cache = &state.provider_probe_cache;
let probe_futures: Vec<_> = local_providers
.iter()
.map(|(_, id, url)| {
openfang_runtime::provider_health::probe_provider_cached(id, url, cache)
})
.collect();
let probe_results = futures::future::join_all(probe_futures).await;
// Index probe results by provider list position for O(1) lookup
let mut probe_map: HashMap<usize, openfang_runtime::provider_health::ProbeResult> =
HashMap::with_capacity(local_providers.len());
for ((idx, _, _), result) in local_providers.iter().zip(probe_results.into_iter()) {
probe_map.insert(*idx, result);
}
let mut providers: Vec<serde_json::Value> = Vec::with_capacity(provider_list.len());
for p in &provider_list {
for (i, p) in provider_list.iter().enumerate() {
let mut entry = serde_json::json!({
"id": p.id,
"display_name": p.display_name,
@@ -5514,10 +5810,9 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
"base_url": p.base_url,
});
// For local providers, add reachability info via health probe
if !p.key_required {
// For local providers, attach the probe result
if let Some(probe) = probe_map.remove(&i) {
entry["is_local"] = serde_json::json!(true);
let probe = openfang_runtime::provider_health::probe_provider(&p.id, &p.base_url).await;
entry["reachable"] = serde_json::json!(probe.reachable);
entry["latency_ms"] = serde_json::json!(probe.latency_ms);
if !probe.discovered_models.is_empty() {
@@ -5530,6 +5825,9 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
if let Some(err) = &probe.error {
entry["error"] = serde_json::json!(err);
}
} else if !p.key_required {
// Local provider with empty base_url (e.g. claude-code) — skip probing
entry["is_local"] = serde_json::json!(true);
}
providers.push(entry);
@@ -6328,11 +6626,23 @@ pub async fn set_model(
)
}
};
match state.kernel.set_agent_model(agent_id, model) {
Ok(()) => (
StatusCode::OK,
Json(serde_json::json!({"status": "ok", "model": model})),
),
let explicit_provider = body["provider"].as_str();
match state.kernel.set_agent_model(agent_id, model, explicit_provider) {
Ok(()) => {
// Return the resolved model+provider so frontend stays in sync.
// The model name may have been normalized (provider prefix stripped),
// so we read it back from the registry instead of echoing the raw input.
let (resolved_model, resolved_provider) = state
.kernel
.registry
.get(agent_id)
.map(|e| (e.manifest.model.model.clone(), e.manifest.model.provider.clone()))
.unwrap_or_else(|| (model.to_string(), String::new()));
(
StatusCode::OK,
Json(serde_json::json!({"status": "ok", "model": resolved_model, "provider": resolved_provider})),
)
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e}")})),
@@ -6655,10 +6965,119 @@ pub async fn set_provider_key(
.unwrap_or_else(|e| e.into_inner())
.detect_auth();
(
StatusCode::OK,
Json(serde_json::json!({"status": "saved", "provider": name})),
)
// Auto-switch default provider if current default has no working key.
// This fixes the common case where a user adds e.g. a Gemini key via dashboard
// but their agent still tries to use the previous provider (which has no key).
//
// Read the effective default from the hot-reload override (if set) rather than
// the stale boot-time config — a previous set_provider_key call may have already
// switched the default.
let (current_provider, current_key_env) = {
let guard = state
.kernel
.default_model_override
.read()
.unwrap_or_else(|e| e.into_inner());
match guard.as_ref() {
Some(dm) => (dm.provider.clone(), dm.api_key_env.clone()),
None => (
state.kernel.config.default_model.provider.clone(),
state.kernel.config.default_model.api_key_env.clone(),
),
}
};
let current_has_key = if current_key_env.is_empty() {
false
} else {
std::env::var(&current_key_env)
.ok()
.filter(|v| !v.is_empty())
.is_some()
};
let switched = if !current_has_key && current_provider != name {
// Find a default model for the newly-keyed provider
let default_model = {
let catalog = state.kernel.model_catalog.read().unwrap_or_else(|e| e.into_inner());
catalog.default_model_for_provider(&name)
};
if let Some(model_id) = default_model {
// Update config.toml to persist the switch
let config_path = state.kernel.config.home_dir.join("config.toml");
let update_toml = format!(
"\n[default_model]\nprovider = \"{}\"\nmodel = \"{}\"\napi_key_env = \"{}\"\n",
name, model_id, env_var
);
backup_config(&config_path);
if let Ok(existing) = std::fs::read_to_string(&config_path) {
let cleaned = remove_toml_section(&existing, "default_model");
let _ = std::fs::write(&config_path, format!("{}\n{}", cleaned.trim(), update_toml));
} else {
let _ = std::fs::write(&config_path, update_toml);
}
// Hot-update the in-memory default model override so resolve_driver()
// immediately creates drivers for the new provider — no restart needed.
{
let new_dm = openfang_types::config::DefaultModelConfig {
provider: name.clone(),
model: model_id,
api_key_env: env_var.clone(),
base_url: None,
};
let mut guard = state
.kernel
.default_model_override
.write()
.unwrap_or_else(|e| e.into_inner());
*guard = Some(new_dm);
}
true
} else {
false
}
} else if current_provider == name {
// User is saving a key for the CURRENT default provider. The env var is
// already set (set_var above), but we must ensure default_model_override
// has the correct api_key_env so resolve_driver reads the right variable.
let needs_update = {
let guard = state
.kernel
.default_model_override
.read()
.unwrap_or_else(|e| e.into_inner());
match guard.as_ref() {
Some(dm) => dm.api_key_env != env_var,
None => state.kernel.config.default_model.api_key_env != env_var,
}
};
if needs_update {
let mut guard = state
.kernel
.default_model_override
.write()
.unwrap_or_else(|e| e.into_inner());
let base = guard
.clone()
.unwrap_or_else(|| state.kernel.config.default_model.clone());
*guard = Some(openfang_types::config::DefaultModelConfig {
api_key_env: env_var.clone(),
..base
});
}
false
} else {
false
};
let mut resp = serde_json::json!({"status": "saved", "provider": name});
if switched {
resp["switched_default"] = serde_json::json!(true);
resp["message"] = serde_json::json!(
format!("API key saved and default provider switched to '{}'.", name)
);
}
(StatusCode::OK, Json(resp))
}
/// DELETE /api/providers/{name}/key — Remove an API key for a provider.
@@ -6672,15 +7091,13 @@ pub async fn delete_provider_key(
.model_catalog
.read()
.unwrap_or_else(|e| e.into_inner());
match catalog.get_provider(&name) {
Some(p) => p.api_key_env.clone(),
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})),
);
}
}
catalog
.get_provider(&name)
.map(|p| p.api_key_env.clone())
.unwrap_or_else(|| {
// Custom/unknown provider — derive env var from convention
format!("{}_API_KEY", name.to_uppercase().replace('-', "_"))
})
};
if env_var.is_empty() {
@@ -6768,6 +7185,7 @@ pub async fn test_provider(
} else {
Some(base_url)
},
skip_permissions: true,
};
match openfang_runtime::drivers::create_driver(&driver_config) {
@@ -8037,11 +8455,15 @@ pub async fn patch_agent_config(
}
}
// Update model/provider
// Update model/provider — use set_agent_model for catalog-based provider
// resolution when provider is not explicitly provided (fixes #387/#466:
// changing model from another provider without specifying provider now
// auto-resolves the correct provider from the model catalog).
if let Some(ref new_model) = req.model {
if !new_model.is_empty() {
if let Some(ref new_provider) = req.provider {
if !new_provider.is_empty() {
// Explicit provider given — use it directly
if state
.kernel
.registry
@@ -8057,27 +8479,23 @@ pub async fn patch_agent_config(
Json(serde_json::json!({"error": "Agent not found"})),
);
}
} else if state
.kernel
.registry
.update_model(agent_id, new_model.clone())
.is_err()
{
} else {
// Provider is empty string — resolve from catalog
if let Err(e) = state.kernel.set_agent_model(agent_id, new_model, None) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
}
} else {
// No provider field at all — resolve from catalog
if let Err(e) = state.kernel.set_agent_model(agent_id, new_model, None) {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found"})),
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
} else if state
.kernel
.registry
.update_model(agent_id, new_model.clone())
.is_err()
{
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found"})),
);
}
}
}
@@ -8932,7 +9350,8 @@ pub async fn config_schema(
Json(serde_json::json!({
"sections": {
"api": {
"general": {
"root_level": true,
"fields": {
"api_listen": "string",
"api_key": "string",
@@ -10278,3 +10697,171 @@ pub async fn comms_task(
),
}
}
// ── Dashboard Authentication (username/password sessions) ──
/// POST /api/auth/login — Authenticate with username/password, returns session token.
pub async fn auth_login(
State(state): State<Arc<AppState>>,
Json(req): Json<serde_json::Value>,
) -> axum::response::Response {
use axum::response::Response;
use axum::body::Body;
let auth_cfg = &state.kernel.config.auth;
if !auth_cfg.enabled {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.header("content-type", "application/json")
.body(Body::from(serde_json::json!({"error": "Auth not enabled"}).to_string()))
.unwrap();
}
let username = req.get("username").and_then(|v| v.as_str()).unwrap_or("");
let password = req.get("password").and_then(|v| v.as_str()).unwrap_or("");
// Constant-time username comparison to prevent timing attacks
let username_ok = {
use subtle::ConstantTimeEq;
let stored = auth_cfg.username.as_bytes();
let provided = username.as_bytes();
if stored.len() != provided.len() {
false
} else {
bool::from(stored.ct_eq(provided))
}
};
if !username_ok || !crate::session_auth::verify_password(password, &auth_cfg.password_hash) {
// Audit log the failed attempt
state.kernel.audit_log.record(
"system",
openfang_runtime::audit::AuditAction::AuthAttempt,
"dashboard login failed",
format!("username: {username}"),
);
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("content-type", "application/json")
.body(Body::from(serde_json::json!({"error": "Invalid credentials"}).to_string()))
.unwrap();
}
// Derive the session secret the same way as server.rs
let api_key = state.kernel.config.api_key.trim().to_string();
let secret = if !api_key.is_empty() {
api_key
} else {
auth_cfg.password_hash.clone()
};
let token =
crate::session_auth::create_session_token(username, &secret, auth_cfg.session_ttl_hours);
let ttl_secs = auth_cfg.session_ttl_hours * 3600;
let cookie = format!(
"openfang_session={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl_secs}"
);
state.kernel.audit_log.record(
"system",
openfang_runtime::audit::AuditAction::AuthAttempt,
"dashboard login success",
format!("username: {username}"),
);
Response::builder()
.status(StatusCode::OK)
.header("content-type", "application/json")
.header("set-cookie", &cookie)
.body(Body::from(serde_json::json!({
"status": "ok",
"token": token,
"username": username,
}).to_string()))
.unwrap()
}
/// POST /api/auth/logout — Clear the session cookie.
pub async fn auth_logout() -> impl IntoResponse {
let cookie = "openfang_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0";
(
StatusCode::OK,
[("content-type", "application/json"), ("set-cookie", cookie)],
serde_json::json!({"status": "ok"}).to_string(),
)
}
/// GET /api/auth/check — Check current authentication state.
pub async fn auth_check(
State(state): State<Arc<AppState>>,
request: axum::http::Request<axum::body::Body>,
) -> impl IntoResponse {
let auth_cfg = &state.kernel.config.auth;
if !auth_cfg.enabled {
return Json(serde_json::json!({
"authenticated": true,
"mode": "none",
}));
}
// Derive the session secret the same way as server.rs
let api_key = state.kernel.config.api_key.trim().to_string();
let secret = if !api_key.is_empty() {
api_key
} else {
auth_cfg.password_hash.clone()
};
// Check session cookie
let session_user = request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies
.split(';')
.find_map(|c| c.trim().strip_prefix("openfang_session=").map(|v| v.to_string()))
})
.and_then(|token| crate::session_auth::verify_session_token(&token, &secret));
if let Some(username) = session_user {
Json(serde_json::json!({
"authenticated": true,
"mode": "session",
"username": username,
}))
} else {
Json(serde_json::json!({
"authenticated": false,
"mode": "session",
}))
}
}
/// Remove a `[section]` and its contents from a TOML string.
#[allow(dead_code)]
fn backup_config(config_path: &std::path::Path) {
let backup = config_path.with_extension("toml.bak");
let _ = std::fs::copy(config_path, backup);
}
fn remove_toml_section(content: &str, section: &str) -> String {
let header = format!("[{}]", section);
let mut result = String::new();
let mut skipping = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == header {
skipping = true;
continue;
}
if skipping && trimmed.starts_with('[') {
skipping = false;
}
if !skipping {
result.push_str(line);
result.push('\n');
}
}
result
}
+37 -3
View File
@@ -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,19 @@ 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 auth_state = crate::middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
session_secret: if !api_key.is_empty() {
api_key.clone()
} else if state.kernel.config.auth.enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
},
};
let gcra_limiter = rate_limiter::create_rate_limiter();
let app = Router::new()
@@ -286,6 +299,10 @@ pub async fn build_router(
"/api/workflows",
axum::routing::get(routes::list_workflows).post(routes::create_workflow),
)
.route(
"/api/workflows/{id}",
axum::routing::get(routes::get_workflow).put(routes::update_workflow).delete(routes::delete_workflow),
)
.route(
"/api/workflows/{id}/run",
axum::routing::post(routes::run_workflow),
@@ -420,6 +437,10 @@ pub async fn build_router(
"/api/comms/task",
axum::routing::post(routes::comms_task),
)
;
// Split into a second router chunk to stay within axum's type nesting limit.
let app = app
// Tools endpoint
.route("/api/tools", axum::routing::get(routes::list_tools))
// Config endpoints
@@ -669,8 +690,21 @@ pub async fn build_router(
"/v1/models",
axum::routing::get(crate::openai_compat::list_models),
)
// Dashboard authentication endpoints
.route(
"/api/auth/login",
axum::routing::post(routes::auth_login),
)
.route(
"/api/auth/logout",
axum::routing::post(routes::auth_logout),
)
.route(
"/api/auth/check",
axum::routing::get(routes::auth_check),
)
.layer(axum::middleware::from_fn_with_state(
api_key,
auth_state,
middleware::auth,
))
.layer(axum::middleware::from_fn_with_state(
+109
View File
@@ -0,0 +1,109 @@
//! Stateless session token authentication for the dashboard.
//! Tokens are HMAC-SHA256 signed and contain username + expiry.
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Create a session token: base64(username:expiry_unix:hmac_hex)
pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> String {
use base64::Engine;
let expiry = chrono::Utc::now().timestamp() + (ttl_hours as i64 * 3600);
let payload = format!("{username}:{expiry}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key");
mac.update(payload.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
}
/// Verify a session token. Returns the username if valid and not expired.
pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(token)
.ok()?;
let decoded_str = String::from_utf8(decoded).ok()?;
let parts: Vec<&str> = decoded_str.splitn(3, ':').collect();
if parts.len() != 3 {
return None;
}
let (username, expiry_str, provided_sig) = (parts[0], parts[1], parts[2]);
let expiry: i64 = expiry_str.parse().ok()?;
if chrono::Utc::now().timestamp() > expiry {
return None;
}
let payload = format!("{username}:{expiry_str}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(payload.as_bytes());
let expected_sig = hex::encode(mac.finalize().into_bytes());
use subtle::ConstantTimeEq;
if provided_sig.len() != expected_sig.len() {
return None;
}
if provided_sig
.as_bytes()
.ct_eq(expected_sig.as_bytes())
.into()
{
Some(username.to_string())
} else {
None
}
}
/// Hash a password with SHA256 for config storage.
pub fn hash_password(password: &str) -> String {
use sha2::Digest;
hex::encode(Sha256::digest(password.as_bytes()))
}
/// Verify a password against a stored SHA256 hash (constant-time).
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
let computed = hash_password(password);
use subtle::ConstantTimeEq;
if computed.len() != stored_hash.len() {
return false;
}
computed.as_bytes().ct_eq(stored_hash.as_bytes()).into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_password() {
let hash = hash_password("secret123");
assert!(verify_password("secret123", &hash));
assert!(!verify_password("wrong", &hash));
}
#[test]
fn test_create_and_verify_token() {
let token = create_session_token("admin", "my-secret", 1);
let user = verify_session_token(&token, "my-secret");
assert_eq!(user, Some("admin".to_string()));
}
#[test]
fn test_token_wrong_secret() {
let token = create_session_token("admin", "my-secret", 1);
let user = verify_session_token(&token, "wrong-secret");
assert_eq!(user, None);
}
#[test]
fn test_token_invalid_base64() {
let user = verify_session_token("not-valid-base64!!!", "secret");
assert_eq!(user, None);
}
#[test]
fn test_password_hash_length_mismatch() {
assert!(!verify_password("x", "short"));
}
}
+16 -2
View File
@@ -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)]
+7 -2
View File
@@ -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)]
+4 -1
View File
@@ -81,13 +81,16 @@ const WEBCHAT_HTML: &str = concat!(
include_str!("../static/vendor/github-dark.min.css"),
"\n</style>\n",
include_str!("../static/index_body.html"),
// Vendor libs: marked + highlight first (used by app.js)
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
"<script>\n",
include_str!("../static/vendor/marked.min.js"),
"\n</script>\n",
"<script>\n",
include_str!("../static/vendor/highlight.min.js"),
"\n</script>\n",
"<script>\n",
include_str!("../static/vendor/chart.umd.min.js"),
"\n</script>\n",
// App code
"<script>\n",
include_str!("../static/js/api.js"),
+63 -16
View File
@@ -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 {
@@ -798,14 +809,21 @@ async fn handle_command(
serde_json::json!({"type": "error", "content": "Agent not found"})
}
} else {
match state.kernel.set_agent_model(agent_id, args) {
match state.kernel.set_agent_model(agent_id, args, None) {
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}")})
@@ -1101,6 +1119,9 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
let status = extract_status_code(&inner);
let classified = llm_errors::classify_error(&inner, status);
// Build a user-facing message. The classified.sanitized_message now
// includes a redacted excerpt of the raw error (issue #493 fix), so we
// use it as the base and only override for cases that need extra context.
match classified.category {
llm_errors::LlmErrorCategory::ContextOverflow => {
"Context is full. Try /compact or /new.".to_string()
@@ -1108,24 +1129,33 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
llm_errors::LlmErrorCategory::RateLimit => {
if let Some(delay_ms) = classified.suggested_delay_ms {
let secs = (delay_ms / 1000).max(1);
format!("Provider rate limited. Wait ~{secs}s and try again.")
format!("Rate limited. Wait ~{secs}s and try again.")
} else {
"Provider rate limited. Wait a moment and try again.".to_string()
"Rate limited. Wait a moment and try again.".to_string()
}
}
llm_errors::LlmErrorCategory::Billing => {
"Check provider account status (billing issue detected).".to_string()
format!("Billing issue. {}", classified.sanitized_message)
}
llm_errors::LlmErrorCategory::Auth => {
// Show the actual error detail so users can diagnose (issue #493).
// The sanitized_message already redacts secrets.
classified.sanitized_message.clone()
}
llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(),
llm_errors::LlmErrorCategory::ModelNotFound => {
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()
"Model not found on Ollama. Run `ollama pull <model>` first. Use /model to see options.".to_string()
} else {
"Model unavailable. Use /model to see options or check your provider configuration.".to_string()
format!("{}. Use /model to see options.", classified.sanitized_message)
}
}
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 {
classified.sanitized_message.clone()
}
}
_ => classified.sanitized_message,
}
@@ -1133,6 +1163,14 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
/// Try to extract an HTTP status code from an error string.
fn extract_status_code(s: &str) -> Option<u16> {
// "API error (NNN):" — the format produced by LlmError::Api Display impl
if let Some(idx) = s.find("API error (") {
let after = &s[idx + 11..];
let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(code) = num.parse::<u16>() {
return Some(code);
}
}
// "status: NNN"
if let Some(idx) = s.find("status: ") {
let after = &s[idx + 8..];
@@ -1251,6 +1289,15 @@ mod tests {
);
assert_eq!(extract_status_code("StatusCode(401)"), Some(401));
assert_eq!(extract_status_code("some random error"), None);
// LlmError::Api Display format (issue #493 fix)
assert_eq!(
extract_status_code("LLM driver error: API error (403): quota exceeded"),
Some(403)
);
assert_eq!(
extract_status_code("API error (401): invalid api key"),
Some(401)
);
}
#[test]
@@ -3238,3 +3238,206 @@ mark.search-highlight {
.comms-event-row:hover { background: var(--bg-hover); }
.comms-event-time { min-width: 50px; text-align: right; }
.comms-event-detail { margin-left: auto; }
/* ═══════════════════════════════════════════════════════════════════════════
Trader Dashboard
═══════════════════════════════════════════════════════════════════════════ */
.trader-dashboard {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
width: 96vw;
max-width: 1200px;
max-height: 92vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.trader-dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-card);
z-index: 10;
border-radius: 12px 12px 0 0;
}
.trader-dashboard-body {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* KPI Cards */
.trader-kpi-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
}
@media (max-width: 900px) {
.trader-kpi-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 540px) {
.trader-kpi-row { grid-template-columns: repeat(2, 1fr); }
}
.trader-kpi-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
text-align: center;
}
.trader-kpi-label {
font-size: 0.7rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.trader-kpi-value {
font-size: 1.15rem;
font-weight: 700;
color: var(--text);
font-family: var(--font-mono);
}
.kpi-positive { color: var(--success) !important; }
.kpi-negative { color: var(--error) !important; }
/* Chart Rows */
.trader-chart-row {
display: flex;
gap: 12px;
}
@media (max-width: 768px) {
.trader-chart-row { flex-direction: column; }
}
.trader-chart-panel {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
min-width: 0;
}
.trader-chart-title {
font-size: 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
font-weight: 600;
}
.trader-chart-wrap {
position: relative;
width: 100%;
min-height: 180px;
}
.trader-chart-wrap canvas {
width: 100% !important;
height: 100% !important;
}
.trader-chart-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 0.85rem;
}
/* Heatmap Table */
.trader-heatmap-wrap {
overflow-x: auto;
}
.trader-heatmap-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.trader-heatmap-table th {
text-align: left;
padding: 6px 10px;
color: var(--text-dim);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.trader-heatmap-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
}
.heatmap-positive { color: var(--success); font-weight: 600; }
.heatmap-negative { color: var(--error); font-weight: 600; }
/* Signal Badges */
.signal-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.3px;
}
.signal-strong_buy, .signal-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.signal-sell, .signal-strong_sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
.signal-hold { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
/* Confidence Bar */
.confidence-bar-wrap {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
}
.confidence-bar {
height: 6px;
border-radius: 3px;
transition: width 0.3s ease;
}
.conf-high { background: var(--success); }
.conf-mid { background: var(--warning); }
.conf-low { background: var(--error); }
.confidence-label {
font-size: 0.7rem;
color: var(--text-dim);
min-width: 32px;
font-family: var(--font-mono);
}
/* Trades Table */
.trader-trades-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.trader-trades-table th {
text-align: left;
padding: 6px 10px;
color: var(--text-dim);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.trader-trades-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-mono);
font-size: 0.78rem;
}
.trade-side-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 0.68rem;
font-weight: 700;
}
.trade-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.trade-sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
+295 -50
View File
@@ -1,12 +1,28 @@
<body x-data="app" :data-theme="theme">
<!-- API Key Auth Prompt -->
<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: '' }">
<!-- Auth Prompt (API Key or Username/Password) -->
<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: '', loginUser: '', loginPass: '' }">
<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>
<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>
<!-- Session login mode -->
<template x-if="$store.app.authMode === 'session'">
<div>
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">Sign In</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">Enter your dashboard credentials.</p>
<input type="text" x-model="loginUser" placeholder="Username" autocomplete="username" 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.5rem">
<input type="password" x-model="loginPass" placeholder="Password" autocomplete="current-password" @keydown.enter="$store.app.sessionLogin(loginUser, loginPass)" 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.sessionLogin(loginUser, loginPass)" 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">Sign In</button>
</div>
</template>
<!-- API key mode -->
<template x-if="$store.app.authMode === 'apikey'">
<div>
<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 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>
</template>
</div>
</div>
@@ -170,6 +186,10 @@
</div>
<div class="sidebar-footer">
<div x-show="$store.app.sessionUser" style="padding:4px 16px;display:flex;align-items:center;justify-content:space-between">
<span class="text-xs text-dim" x-text="$store.app.sessionUser" style="letter-spacing:0.5px"></span>
<button @click="$store.app.sessionLogout()" class="btn btn-ghost btn-sm" style="font-size:11px;padding:2px 8px;opacity:0.7" title="Sign out">Logout</button>
</div>
<div class="sidebar-label text-xs text-dim" style="padding:0 16px 4px;letter-spacing:0.5px">Ctrl+K agents | Ctrl+N new</div>
</div>
<div class="sidebar-toggle" @click="toggleSidebar()" x-text="sidebarCollapsed ? '\u276F' : '\u276E'"></div>
@@ -735,7 +755,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.keyCode !== 229 && !$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)}"
@@ -768,7 +788,7 @@
<div class="model-switcher-dropdown" x-show="showModelSwitcher" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform translate-y-1" x-transition:enter-end="opacity-100 transform translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<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])">
<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="!$event.isComposing && $event.keyCode !== 229 && 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">
@@ -1090,7 +1110,7 @@
<div x-show="spawnStep === 1">
<div class="form-group">
<label>Agent Name</label>
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="nextStep()">
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) nextStep()">
</div>
<div class="form-group">
<label>Emoji</label>
@@ -1319,7 +1339,9 @@
<td class="text-xs" x-text="new Date(wf.created_at).toLocaleDateString()"></td>
<td>
<button class="btn btn-primary btn-sm" @click="showRunModal(wf)">Run</button>
<button class="btn btn-ghost btn-sm" @click="showEditModal(wf)">Edit</button>
<button class="btn btn-ghost btn-sm" @click="viewRuns(wf)">History</button>
<button class="btn btn-danger btn-sm" @click="deleteWorkflow(wf)">Delete</button>
</td>
</tr>
</template>
@@ -1384,6 +1406,39 @@
</div>
</div>
</template>
<!-- Edit modal -->
<template x-if="editModal">
<div class="modal-overlay" @click.self="editModal = null" @keydown.escape.window="editModal = null">
<div class="modal">
<div class="modal-header"><h3 x-text="'Edit: ' + editModal.name"></h3><button class="modal-close" @click="editModal = null">&times;</button></div>
<div class="form-group"><label>Name</label><input class="form-input" x-model="editWf.name" placeholder="Workflow name"></div>
<div class="form-group"><label>Description</label><input class="form-input" x-model="editWf.description" placeholder="What does this workflow do?"></div>
<div class="mb-4">
<div class="form-group" style="margin:0"><label>Steps</label></div>
<div class="text-xs text-dim mb-2">Each step runs an agent. Use <code style="color:var(--accent)">{{input}}</code> in prompts to pass the previous step's output.</div>
<template x-for="(step, i) in editWf.steps" :key="i">
<div class="card mt-2" style="padding:10px">
<div class="flex gap-2 items-center">
<span class="text-xs text-dim font-bold" x-text="'#' + (i+1)" style="width:24px"></span>
<input class="form-input" style="flex:1" x-model="step.name" placeholder="Step name">
<input class="form-input" style="flex:1" x-model="step.agent_name" placeholder="Agent name">
<select class="form-select" style="width:120px" x-model="step.mode">
<option value="sequential">Sequential</option>
<option value="fan_out">Fan Out</option>
<option value="conditional">Conditional</option>
<option value="loop">Loop</option>
</select>
<button class="btn btn-danger btn-sm" @click="editWf.steps.splice(i,1)">&times;</button>
</div>
<input class="form-input mt-2" x-model="step.prompt" placeholder="Prompt template (use {{input}})">
</div>
</template>
<button class="btn btn-ghost btn-sm mt-2" @click="editWf.steps.push({name:'',agent_name:'',mode:'sequential',prompt:'{{input}}'})">+ Add Step</button>
</div>
<button class="btn btn-primary btn-block" @click="saveWorkflow()">Save Changes</button>
</div>
</div>
</template>
</div>
</div>
</template>
@@ -1467,7 +1522,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 -->
@@ -1475,7 +1530,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>
@@ -1484,11 +1539,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>
@@ -1498,7 +1553,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>
@@ -1509,11 +1564,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>
@@ -1523,7 +1578,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>
@@ -1533,7 +1588,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>
@@ -2171,7 +2226,7 @@
<!-- Search bar with live search and clear button -->
<div class="search-input mb-4" style="position:relative">
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<button x-show="clawhubSearch" @click="clearSearch()" class="search-clear-btn" title="Clear search (Esc)">&times;</button>
</div>
@@ -2529,6 +2584,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Actions -->
<div class="flex gap-2 mt-3">
<button class="btn btn-ghost btn-sm" @click="loadStats(inst)">Stats</button>
<template x-if="isTraderHand(inst)">
<button class="btn btn-primary btn-sm" @click="openDashboard(inst)">Dashboard</button>
</template>
<template x-if="isBrowserHand(inst)">
<button class="btn btn-ghost btn-sm" @click="openBrowserViewer(inst)">View Browser</button>
</template>
@@ -2641,9 +2699,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- ═══ Step 1: Dependencies ═══ -->
<div class="hand-wizard-body" x-show="setupStep === 1">
<template x-for="req in (setupWizard.requirements || [])" :key="req.key">
<div class="dep-card" :class="req.satisfied ? 'dep-met' : 'dep-missing'">
<div class="dep-card" :class="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'dep-met' : 'dep-missing'">
<div class="dep-card-header">
<div class="dep-status-icon" :class="[req.satisfied ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="req.satisfied ? '\u2713' : '\u2717'"></div>
<div class="dep-status-icon" :class="[(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? '\u2713' : '\u2717'"></div>
<span class="dep-card-title" x-text="req.label"></span>
<template x-if="req.install && req.install.estimated_time">
<span class="dep-time-badge" x-text="req.install.estimated_time"></span>
@@ -2686,24 +2744,32 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</ol>
</template>
<!-- API Key: numbered steps + signup link -->
<!-- API Key: input field + numbered steps + signup link -->
<template x-if="req.type === 'ApiKey' && req.install">
<div>
<template x-if="req.install.steps && req.install.steps.length">
<ol class="api-key-steps">
<template x-for="step in req.install.steps" :key="step">
<li x-text="step"></li>
</template>
</ol>
</template>
<template x-if="req.install.env_example">
<div class="install-block" style="margin-top:8px">
<div class="install-cmd">
<code x-text="req.install.env_example"></code>
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
<div style="margin-bottom:10px">
<label class="text-xs text-dim" style="display:block;margin-bottom:4px" x-text="'Paste your ' + req.label + ':'"></label>
<input type="password" class="form-input" x-model="apiKeyInputs[req.key]" :placeholder="req.label" style="width:100%;font-family:var(--font-mono);font-size:12px">
<div class="text-xs" style="margin-top:4px;color:var(--green)" x-show="apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== ''">&check; Token entered</div>
</div>
<details style="margin-bottom:8px">
<summary class="text-xs text-dim" style="cursor:pointer;user-select:none">Or set as environment variable</summary>
<template x-if="req.install.steps && req.install.steps.length">
<ol class="api-key-steps">
<template x-for="step in req.install.steps" :key="step">
<li x-text="step"></li>
</template>
</ol>
</template>
<template x-if="req.install.env_example">
<div class="install-block" style="margin-top:8px">
<div class="install-cmd">
<code x-text="req.install.env_example"></code>
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
</div>
</div>
</div>
</template>
</template>
</details>
<div class="flex gap-2 mt-2">
<template x-if="req.install.signup_url">
<a :href="req.install.signup_url" target="_blank" rel="noopener" class="btn btn-primary btn-sm">Get API Key &rarr;</a>
@@ -2936,6 +3002,152 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</template>
<!-- Trader Dashboard Modal -->
<template x-if="dashboardOpen">
<div class="modal-overlay" @click.self="closeDashboard()" @keydown.escape.window="closeDashboard()">
<div class="trader-dashboard">
<!-- Header -->
<div class="trader-dashboard-header">
<div class="flex items-center gap-2">
<span style="font-size:1.4rem">&#x1F4C8;</span>
<div>
<div style="font-weight:600;font-size:1.1rem" x-text="dashboardData ? (dashboardData.agent_name || 'Trading Hand') : 'Trading Hand'"></div>
<div class="text-xs text-dim">Live Trading Dashboard</div>
</div>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm" @click="refreshDashboard()">Refresh</button>
<button class="modal-close" @click="closeDashboard()">&times;</button>
</div>
</div>
<!-- Loading -->
<div x-show="dashboardLoading" class="text-center" style="padding:60px 0">
<div class="spinner"></div>
<div class="text-dim mt-2">Loading dashboard data...</div>
</div>
<!-- Dashboard Content -->
<div class="trader-dashboard-body" x-show="!dashboardLoading && dashboardData">
<!-- KPI Row -->
<div class="trader-kpi-row">
<div class="trader-kpi-card">
<div class="trader-kpi-label">Portfolio Value</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.portfolio_value || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Total P&amp;L</div>
<div class="trader-kpi-value" :class="dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('+') ? 'kpi-positive' : (dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('-') ? 'kpi-negative' : '')" x-text="dashboardData ? (dashboardData.total_pnl || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Win Rate</div>
<div class="trader-kpi-value" x-text="dashboardData && dashboardData.win_rate ? (dashboardData.win_rate + '%') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Sharpe Ratio</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.sharpe_ratio || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Max Drawdown</div>
<div class="trader-kpi-value kpi-negative" x-text="dashboardData && dashboardData.max_drawdown ? (dashboardData.max_drawdown + '%') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Trades</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.trades_count || '0') : '0'"></div>
</div>
</div>
<!-- Charts Row 1: Equity Curve + Daily P&L -->
<div class="trader-chart-row">
<div class="trader-chart-panel" style="flex:2">
<div class="trader-chart-title">Equity Curve</div>
<div class="trader-chart-wrap">
<canvas id="traderEquityChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.equity_curve || !dashboardData.equity_curve.length">No equity data yet</div>
</div>
</div>
<div class="trader-chart-panel" style="flex:1">
<div class="trader-chart-title">Daily P&amp;L</div>
<div class="trader-chart-wrap">
<canvas id="traderPnlChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.daily_pnl || !dashboardData.daily_pnl.length">No P&amp;L data yet</div>
</div>
</div>
</div>
<!-- Charts Row 2: Signal Radar + Watchlist Heatmap -->
<div class="trader-chart-row">
<div class="trader-chart-panel" style="flex:1;max-width:320px">
<div class="trader-chart-title">Signal Radar</div>
<div class="trader-chart-wrap" style="max-height:280px">
<canvas id="traderRadarChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.signal_radar">No signal data yet</div>
</div>
</div>
<div class="trader-chart-panel" style="flex:2">
<div class="trader-chart-title">Watchlist Heatmap</div>
<div class="trader-heatmap-wrap" x-show="dashboardData && dashboardData.watchlist_heatmap && dashboardData.watchlist_heatmap.length">
<table class="trader-heatmap-table">
<thead>
<tr><th>Ticker</th><th>Change</th><th>Signal</th><th>Confidence</th></tr>
</thead>
<tbody>
<template x-for="item in (dashboardData ? dashboardData.watchlist_heatmap || [] : [])" :key="item.ticker">
<tr>
<td style="font-weight:600" x-text="item.ticker"></td>
<td :class="item.change_pct >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(item.change_pct >= 0 ? '+' : '') + item.change_pct + '%'"></td>
<td><span class="signal-badge" :class="'signal-' + (item.signal || 'hold').toLowerCase()" x-text="item.signal || 'HOLD'"></span></td>
<td>
<div class="confidence-bar-wrap">
<div class="confidence-bar" :style="'width:' + (item.confidence || 0) + '%'" :class="item.confidence >= 70 ? 'conf-high' : (item.confidence >= 40 ? 'conf-mid' : 'conf-low')"></div>
<span class="confidence-label" x-text="(item.confidence || 0) + '%'"></span>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.watchlist_heatmap || !dashboardData.watchlist_heatmap.length">No watchlist data yet</div>
</div>
</div>
<!-- Recent Trades Table -->
<div class="trader-chart-panel">
<div class="trader-chart-title">Recent Trades</div>
<div x-show="dashboardData && dashboardData.recent_trades && dashboardData.recent_trades.length">
<table class="trader-trades-table">
<thead>
<tr><th>Date</th><th>Ticker</th><th>Side</th><th>Price</th><th>Qty</th><th>P&amp;L</th></tr>
</thead>
<tbody>
<template x-for="trade in (dashboardData ? dashboardData.recent_trades || [] : [])" :key="trade.date + trade.ticker">
<tr>
<td class="text-dim" x-text="trade.date"></td>
<td style="font-weight:600" x-text="trade.ticker"></td>
<td><span class="trade-side-badge" :class="trade.side === 'BUY' ? 'trade-buy' : 'trade-sell'" x-text="trade.side"></span></td>
<td x-text="'$' + Number(trade.price || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
<td x-text="trade.qty"></td>
<td :class="(trade.pnl || 0) >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(trade.pnl >= 0 ? '+$' : '-$') + Math.abs(trade.pnl || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.recent_trades || !dashboardData.recent_trades.length">No trades yet</div>
</div>
</div>
<!-- No data state -->
<div x-show="!dashboardLoading && !dashboardData" class="text-center" style="padding:60px 0">
<div style="font-size:2rem;margin-bottom:8px">&#x1F4C8;</div>
<div class="text-dim">Could not load dashboard data.</div>
<button class="btn btn-ghost btn-sm mt-3" @click="refreshDashboard()">Retry</button>
</div>
</div>
</div>
</template>
<!-- Activation result toast -->
<div x-show="activateResult" x-transition class="info-card" style="position:fixed;bottom:24px;right:24px;z-index:200;max-width:360px" @click="activateResult = null">
<div class="flex items-center gap-2">
@@ -3371,11 +3583,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()">
@@ -3457,14 +3669,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div x-show="tab === 'budget'" x-data="{
budgetData: null, agentRanking: [], budgetLoading: true,
editMode: false,
editHourly: '', editDaily: '', editMonthly: '', editAlert: '',
editHourly: '', editDaily: '', editMonthly: '', editAlert: '', editTokenLimit: '',
saving: false,
async loadBudget() {
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 || [];
@@ -3476,6 +3688,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
this.editDaily = this.budgetData.daily_limit || 0;
this.editMonthly = this.budgetData.monthly_limit || 0;
this.editAlert = ((this.budgetData.alert_threshold || 0.8) * 100).toFixed(0);
this.editTokenLimit = this.budgetData.default_max_llm_tokens_per_hour || 0;
this.editMode = true;
},
async saveBudget() {
@@ -3487,12 +3700,14 @@ 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) });
if (+this.editTokenLimit !== (this.budgetData.default_max_llm_tokens_per_hour || 0)) body.default_max_llm_tokens_per_hour = +this.editTokenLimit;
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;
},
fmtTokens(v) { return v > 0 ? (v >= 1000000 ? (v/1000000).toFixed(1)+'M' : v >= 1000 ? (v/1000).toFixed(0)+'K' : v) : 'per-agent'; },
pctColor(pct) { return pct >= 0.8 ? '#ef4444' : pct >= 0.5 ? '#eab308' : '#22c55e'; },
fmtUsd(v) { return v > 0 ? '$' + v.toFixed(4) : 'unlimited'; }
}" x-init="loadBudget()">
@@ -3532,9 +3747,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</div>
</div>
<div class="text-xs text-dim mb-3" x-show="budgetData.alert_threshold > 0 && !editMode">
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
</div>
<div class="text-xs text-dim mb-3" x-show="!editMode">
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
</div>
<!-- Edit limits form -->
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
@@ -3556,7 +3774,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
</div>
</div>
<div class="text-xs text-dim">Set to 0 for unlimited. Changes apply immediately (in-memory, not persisted to config.toml).</div>
<div style="margin-bottom:8px">
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
</div>
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
</div>
@@ -3564,7 +3786,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
<div class="table-wrap" x-show="agentRanking.length">
<table>
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th></tr></thead>
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
<tbody>
<template x-for="a in agentRanking" :key="a.agent_id">
<tr>
@@ -3573,6 +3795,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
</tr>
</template>
</tbody>
@@ -4452,7 +4675,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 &mdash; 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&rsquo;ve run: <code>npm install -g @anthropic-ai/claude-code &amp;&amp; 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">
@@ -4542,7 +4787,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div class="card" style="border-left:3px solid var(--accent)">
<div class="form-group" style="margin-bottom:8px">
<label>Agent Name</label>
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="createAgent()">
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) createAgent()">
</div>
<div class="text-xs text-dim" x-text="'Will use ' + templates[selectedTemplate].provider + ' / ' + templates[selectedTemplate].model + ' with ' + profileInfo(templates[selectedTemplate].profile).label + ' profile'"></div>
<div class="mt-2">
@@ -4588,7 +4833,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Input -->
<div style="display:flex;gap:8px;margin-top:12px">
<input class="form-input" type="text" x-model="tryItInput" placeholder="Type a message..."
@keydown.enter="sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
<button class="btn btn-primary btn-sm" @click="sendTryItMessage(tryItInput)" :disabled="tryItSending || !tryItInput.trim()">Send</button>
</div>
</div>
+11
View File
@@ -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 {
+46 -4
View File
@@ -104,6 +104,8 @@ document.addEventListener('alpine:init', function() {
focusMode: localStorage.getItem('openfang-focus') === 'true',
showOnboarding: false,
showAuthPrompt: false,
authMode: 'apikey',
sessionUser: null,
toggleFocusMode() {
this.focusMode = !this.focusMode;
@@ -155,16 +157,33 @@ document.addEventListener('alpine:init', function() {
async checkAuth() {
try {
// Use a protected endpoint (not in the public allowlist) to detect
// whether the server requires an API key.
// First check if session-based auth is configured
var authInfo = await OpenFangAPI.get('/api/auth/check');
if (authInfo.mode === 'none') {
// No session auth — fall back to API key detection
this.authMode = 'apikey';
this.sessionUser = null;
} else if (authInfo.mode === 'session') {
this.authMode = 'session';
if (authInfo.authenticated) {
this.sessionUser = authInfo.username;
this.showAuthPrompt = false;
return;
}
// Session auth enabled but not authenticated — show login prompt
this.showAuthPrompt = true;
return;
}
} catch(e) { /* ignore — fall through to API key check */ }
// API key mode detection
try {
await OpenFangAPI.get('/api/tools');
this.showAuthPrompt = false;
} catch(e) {
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
// Only show prompt if we don't already have a saved key
var saved = localStorage.getItem('openfang-api-key');
if (saved) {
// Saved key might be stale — clear it and show prompt
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
@@ -181,6 +200,29 @@ document.addEventListener('alpine:init', function() {
this.refreshAgents();
},
async sessionLogin(username, password) {
try {
var result = await OpenFangAPI.post('/api/auth/login', { username: username, password: password });
if (result.status === 'ok') {
this.sessionUser = result.username;
this.showAuthPrompt = false;
this.refreshAgents();
} else {
OpenFangToast.error(result.error || 'Login failed');
}
} catch(e) {
OpenFangToast.error(e.message || 'Login failed');
}
},
async sessionLogout() {
try {
await OpenFangAPI.post('/api/auth/logout');
} catch(e) { /* ignore */ }
this.sessionUser = null;
this.showAuthPrompt = true;
},
clearApiKey() {
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
+34 -8
View File
@@ -1,6 +1,21 @@
// OpenFang Agents Page — Multi-step spawn wizard, detail view with tabs, file editor, personality presets
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n""").
* Backslashes are escaped, and runs of 3+ consecutive double-quotes are
* broken up so the TOML parser never sees an unintended closing delimiter.
*/
function tomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("...").
* Backslashes, double-quotes, and common control chars are escaped.
*/
function tomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function agentsPage() {
return {
tab: 'agents',
@@ -378,7 +393,7 @@ function agentsPage() {
},
// ── Multi-step wizard navigation ──
openSpawnWizard() {
async openSpawnWizard() {
this.showSpawnModal = true;
this.spawnStep = 1;
this.spawnMode = 'wizard';
@@ -386,8 +401,18 @@ function agentsPage() {
this.selectedPreset = '';
this.soulContent = '';
this.spawnForm.name = '';
this.spawnForm.provider = 'groq';
this.spawnForm.model = 'llama-3.3-70b-versatile';
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
this.spawnForm.profile = 'full';
try {
var res = await fetch('/api/status');
if (res.ok) {
var status = await res.json();
if (status.default_provider) this.spawnForm.provider = status.default_provider;
if (status.default_model) this.spawnForm.model = status.default_model;
}
} catch(e) { /* keep hardcoded defaults */ }
},
nextStep() {
@@ -411,7 +436,7 @@ function agentsPage() {
var f = this.spawnForm;
var si = this.spawnIdentity;
var lines = [
'name = "' + f.name + '"',
'name = "' + tomlBasicEscape(f.name) + '"',
'module = "builtin:chat"'
];
if (f.profile && f.profile !== 'custom') {
@@ -420,7 +445,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' + tomlMultilineEscape(f.systemPrompt) + '\n"""');
if (f.profile === 'custom') {
lines.push('', '[capabilities]');
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
@@ -597,8 +622,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
@@ -697,12 +723,12 @@ function agentsPage() {
},
async spawnBuiltin(t) {
var toml = 'name = "' + t.name + '"\n';
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
var toml = 'name = "' + tomlBasicEscape(t.name) + '"\n';
toml += 'description = "' + tomlBasicEscape(t.description) + '"\n';
toml += 'module = "builtin:chat"\n';
toml += 'profile = "' + t.profile + '"\n\n';
toml += '[model]\nprovider = "' + t.provider + '"\nmodel = "' + t.model + '"\n';
toml += 'system_prompt = """\n' + t.system_prompt + '\n"""\n';
toml += 'system_prompt = """\n' + tomlMultilineEscape(t.system_prompt) + '\n"""\n';
try {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
+32 -12
View File
@@ -264,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;
@@ -420,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 {
@@ -645,17 +650,32 @@ function chatPage() {
// Show tool/phase progress so the user sees the agent is working
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) {
var detail = data.detail || data.phase || 'Working...';
// Context warning: show prominently
// Skip phases that have no user-meaningful display text — "streaming"
// and "done" are lifecycle signals, not status to show in the chat bubble.
if (data.phase === 'streaming' || data.phase === 'done') {
break;
}
// Context warning: show prominently as a separate system message
if (data.phase === 'context_warning') {
this.messages.push({ id: ++msgId, role: 'system', text: detail, meta: '', tools: [] });
var cwDetail = data.detail || 'Context limit reached.';
this.messages.push({ id: ++msgId, role: 'system', text: cwDetail, meta: '', tools: [] });
} else if (data.phase === 'thinking' && this.thinkingMode === 'stream') {
// Stream reasoning tokens to a collapsible panel
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
phaseMsg._reasoning += (detail || '') + '\n';
phaseMsg._reasoning += (data.detail || '') + '\n';
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
} else {
phaseMsg.text = detail;
} else if (phaseMsg.thinking) {
// Only update text on messages still in thinking state (not yet
// receiving streamed content) to avoid overwriting accumulated text.
var phaseDetail;
if (data.phase === 'tool_use') {
phaseDetail = 'Using ' + (data.detail || 'tool') + '...';
} else if (data.phase === 'thinking') {
phaseDetail = 'Thinking...';
} else {
phaseDetail = data.detail || 'Working...';
}
phaseMsg.text = phaseDetail;
}
}
this.scrollToBottom();
+453 -3
View File
@@ -18,6 +18,15 @@ function handsPage() {
browserViewerOpen: false,
_browserPollTimer: null,
// ── Trader Dashboard State ────────────────────────────────────────────
dashboardOpen: false,
dashboardLoading: false,
dashboardData: null,
_dashboardInst: null,
_chartEquity: null,
_chartPnl: null,
_chartRadar: null,
// ── Setup Wizard State ──────────────────────────────────────────────
setupWizard: null,
setupStep: 1,
@@ -27,6 +36,7 @@ function handsPage() {
_clipboardTimer: null,
detectedPlatform: 'linux',
installPlatforms: {},
apiKeyInputs: {},
async loadData() {
this.loading = true;
@@ -101,11 +111,15 @@ function handsPage() {
} else {
this._detectClientPlatform();
}
// Initialize per-requirement platform selections
// Initialize per-requirement platform selections and API key inputs
this.installPlatforms = {};
this.apiKeyInputs = {};
if (data.requirements) {
for (var j = 0; j < data.requirements.length; j++) {
this.installPlatforms[data.requirements[j].key] = this.detectedPlatform;
if (data.requirements[j].type === 'ApiKey') {
this.apiKeyInputs[data.requirements[j].key] = '';
}
}
}
this.setupWizard = data;
@@ -274,7 +288,10 @@ function handsPage() {
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
var count = 0;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
if (this.setupWizard.requirements[i].satisfied) count++;
var req = this.setupWizard.requirements[i];
if (req.satisfied) { count++; continue; }
// Count API key reqs as met if user entered a value
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') count++;
}
return count;
},
@@ -285,7 +302,34 @@ function handsPage() {
},
get setupAllReqsMet() {
return this.setupReqsTotal > 0 && this.setupReqsMet === this.setupReqsTotal;
if (!this.setupWizard || !this.setupWizard.requirements) return false;
if (this.setupReqsTotal === 0) return false;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.satisfied) continue;
// API key reqs are satisfied if the user entered a value in the input
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') continue;
return false;
}
return true;
},
getSettingKeyForReq(req) {
// Find the matching setting key for an API key requirement.
// Convention: setting key is the lowercase version of the requirement key.
if (!this.setupWizard || !this.setupWizard.settings) return null;
var lowerKey = req.key.toLowerCase();
for (var i = 0; i < this.setupWizard.settings.length; i++) {
if (this.setupWizard.settings[i].key === lowerKey) return lowerKey;
}
// Fallback: try matching by check_value lowercased
if (req.check_value) {
var lowerCheck = req.check_value.toLowerCase();
for (var j = 0; j < this.setupWizard.settings.length; j++) {
if (this.setupWizard.settings[j].key === lowerCheck) return lowerCheck;
}
}
return null;
},
get setupHasReqs() {
@@ -297,6 +341,10 @@ function handsPage() {
},
setupNextStep() {
// When leaving step 1, sync API key inputs into settings values
if (this.setupStep === 1) {
this._syncApiKeysToSettings();
}
if (this.setupStep === 1 && this.setupHasSettings) {
this.setupStep = 2;
} else if (this.setupStep === 1) {
@@ -306,6 +354,19 @@ function handsPage() {
}
},
_syncApiKeysToSettings() {
if (!this.setupWizard || !this.setupWizard.requirements) return;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
},
setupPrevStep() {
if (this.setupStep === 3 && this.setupHasSettings) {
this.setupStep = 2;
@@ -323,11 +384,24 @@ function handsPage() {
this.setupChecking = false;
this.clipboardMsg = null;
this.installPlatforms = {};
this.apiKeyInputs = {};
},
async launchHand() {
if (!this.setupWizard) return;
var handId = this.setupWizard.id;
// Sync API key inputs from step 1 into settings values
if (this.setupWizard.requirements) {
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
}
var config = {};
for (var key in this.settingsValues) {
config[key] = this.settingsValues[key];
@@ -499,6 +573,382 @@ function handsPage() {
this.stopBrowserPolling();
this.browserViewerOpen = false;
this.browserViewer = null;
},
// ── Trader Dashboard ──────────────────────────────────────────────────
isTraderHand(inst) {
return inst.hand_id === 'trader';
},
async openDashboard(inst) {
this._dashboardInst = inst;
this.dashboardOpen = true;
this.dashboardLoading = true;
this.dashboardData = null;
await this._fetchDashboardData(inst);
this.dashboardLoading = false;
// Render charts after DOM update
var self = this;
setTimeout(function() { self._renderCharts(); }, 60);
},
async refreshDashboard() {
if (!this._dashboardInst) return;
this.dashboardLoading = true;
await this._fetchDashboardData(this._dashboardInst);
this.dashboardLoading = false;
var self = this;
setTimeout(function() { self._renderCharts(); }, 60);
},
closeDashboard() {
this.dashboardOpen = false;
this._destroyCharts();
this.dashboardData = null;
this._dashboardInst = null;
},
async _fetchDashboardData(inst) {
var data = {
agent_name: inst.agent_name || inst.hand_id,
portfolio_value: null,
total_pnl: null,
win_rate: null,
sharpe_ratio: null,
max_drawdown: null,
trades_count: null,
equity_curve: [],
daily_pnl: [],
watchlist_heatmap: [],
signal_radar: null,
recent_trades: []
};
// Fetch basic stats from the hand stats endpoint
try {
var stats = await OpenFangAPI.get('/api/hands/instances/' + inst.instance_id + '/stats');
var m = stats.metrics || {};
if (m['Portfolio Value']) data.portfolio_value = this._metricVal(m['Portfolio Value']);
if (m['Total P&L']) data.total_pnl = this._metricVal(m['Total P&L']);
if (m['Win Rate']) data.win_rate = this._metricVal(m['Win Rate']);
if (m['Sharpe Ratio']) data.sharpe_ratio = this._metricVal(m['Sharpe Ratio']);
if (m['Max Drawdown']) data.max_drawdown = this._metricVal(m['Max Drawdown']);
if (m['Trades Executed']) data.trades_count = this._metricVal(m['Trades Executed']);
} catch(e) {
// Stats endpoint might fail — continue with KV data
}
// Fetch rich chart data from agent memory KV
var agentId = inst.agent_id || 'shared';
var kvKeys = [
'trader_hand_equity_curve',
'trader_hand_daily_pnl',
'trader_hand_watchlist_heatmap',
'trader_hand_signal_radar',
'trader_hand_recent_trades',
'trader_hand_portfolio_value',
'trader_hand_total_pnl',
'trader_hand_win_rate',
'trader_hand_sharpe_ratio',
'trader_hand_max_drawdown',
'trader_hand_trades_count'
];
for (var i = 0; i < kvKeys.length; i++) {
try {
var resp = await OpenFangAPI.get('/api/memory/agents/' + agentId + '/kv/' + kvKeys[i]);
if (resp && resp.value !== null && resp.value !== undefined) {
var val = resp.value;
this._applyKvToData(data, kvKeys[i], val);
}
} catch(e) {
// Key might not exist yet — that's fine
}
}
this.dashboardData = data;
},
_metricVal(metric) {
if (!metric) return null;
var v = metric.value;
if (v === null || v === undefined) return null;
// Values come as JSON values — could be string, number, etc.
if (typeof v === 'string') return v;
return String(v);
},
_applyKvToData(data, key, val) {
// Values from KV can be strings (JSON-encoded) or already parsed
var parsed = val;
if (typeof val === 'string') {
try { parsed = JSON.parse(val); } catch(e) { parsed = val; }
}
switch(key) {
case 'trader_hand_portfolio_value':
if (!data.portfolio_value) data.portfolio_value = String(parsed);
break;
case 'trader_hand_total_pnl':
if (!data.total_pnl) data.total_pnl = String(parsed);
break;
case 'trader_hand_win_rate':
if (!data.win_rate) data.win_rate = String(parsed);
break;
case 'trader_hand_sharpe_ratio':
if (!data.sharpe_ratio) data.sharpe_ratio = String(parsed);
break;
case 'trader_hand_max_drawdown':
if (!data.max_drawdown) data.max_drawdown = String(parsed);
break;
case 'trader_hand_trades_count':
if (!data.trades_count) data.trades_count = String(parsed);
break;
case 'trader_hand_equity_curve':
if (Array.isArray(parsed)) data.equity_curve = parsed;
break;
case 'trader_hand_daily_pnl':
if (Array.isArray(parsed)) data.daily_pnl = parsed;
break;
case 'trader_hand_watchlist_heatmap':
if (Array.isArray(parsed)) data.watchlist_heatmap = parsed;
break;
case 'trader_hand_signal_radar':
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data.signal_radar = parsed;
break;
case 'trader_hand_recent_trades':
if (Array.isArray(parsed)) data.recent_trades = parsed;
break;
}
},
_destroyCharts() {
if (this._chartEquity) { this._chartEquity.destroy(); this._chartEquity = null; }
if (this._chartPnl) { this._chartPnl.destroy(); this._chartPnl = null; }
if (this._chartRadar) { this._chartRadar.destroy(); this._chartRadar = null; }
},
_renderCharts() {
if (typeof Chart === 'undefined') return;
this._destroyCharts();
if (!this.dashboardData) return;
var d = this.dashboardData;
// Detect theme
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
var gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
var textColor = isDark ? '#8A8380' : '#6B6560';
var accentColor = '#FF5C00';
var successColor = isDark ? '#4ADE80' : '#22C55E';
var errorColor = '#EF4444';
// ── Equity Curve ──
if (d.equity_curve && d.equity_curve.length > 0) {
var eqCanvas = document.getElementById('traderEquityChart');
if (eqCanvas) {
var labels = [];
var values = [];
for (var i = 0; i < d.equity_curve.length; i++) {
labels.push(d.equity_curve[i].date || '');
values.push(parseFloat(d.equity_curve[i].value) || 0);
}
// Determine gradient
var eqCtx = eqCanvas.getContext('2d');
var gradient = eqCtx.createLinearGradient(0, 0, 0, eqCanvas.parentElement.clientHeight || 180);
gradient.addColorStop(0, isDark ? 'rgba(255, 92, 0, 0.25)' : 'rgba(255, 92, 0, 0.15)');
gradient.addColorStop(1, 'rgba(255, 92, 0, 0)');
this._chartEquity = new Chart(eqCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
data: values,
borderColor: accentColor,
backgroundColor: gradient,
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: d.equity_curve.length > 20 ? 0 : 3,
pointHoverRadius: 5,
pointBackgroundColor: accentColor
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) {
return '$' + ctx.parsed.y.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
}
}
},
scales: {
x: {
grid: { color: gridColor },
ticks: { color: textColor, maxTicksLimit: 8, font: { size: 10 } }
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { size: 10 },
callback: function(v) { return '$' + v.toLocaleString(); }
}
}
}
}
});
}
}
// ── Daily P&L Bar Chart ──
if (d.daily_pnl && d.daily_pnl.length > 0) {
var pnlCanvas = document.getElementById('traderPnlChart');
if (pnlCanvas) {
var pnlLabels = [];
var pnlValues = [];
var pnlColors = [];
for (var j = 0; j < d.daily_pnl.length; j++) {
pnlLabels.push(d.daily_pnl[j].date || '');
var pnlVal = parseFloat(d.daily_pnl[j].pnl) || 0;
pnlValues.push(pnlVal);
pnlColors.push(pnlVal >= 0 ? successColor : errorColor);
}
this._chartPnl = new Chart(pnlCanvas.getContext('2d'), {
type: 'bar',
data: {
labels: pnlLabels,
datasets: [{
data: pnlValues,
backgroundColor: pnlColors,
borderRadius: 3,
borderSkipped: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) {
var v = ctx.parsed.y;
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: textColor, maxTicksLimit: 7, font: { size: 10 } }
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { size: 10 },
callback: function(v) {
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString();
}
}
}
}
}
});
}
}
// ── Signal Radar Chart ──
if (d.signal_radar) {
var radarCanvas = document.getElementById('traderRadarChart');
if (radarCanvas) {
var radarLabels = [];
var radarValues = [];
var keys = ['technical', 'fundamental', 'sentiment', 'macro'];
var displayLabels = ['Technical', 'Fundamental', 'Sentiment', 'Macro'];
for (var k = 0; k < keys.length; k++) {
radarLabels.push(displayLabels[k]);
radarValues.push(parseFloat(d.signal_radar[keys[k]]) || 0);
}
this._chartRadar = new Chart(radarCanvas.getContext('2d'), {
type: 'radar',
data: {
labels: radarLabels,
datasets: [{
data: radarValues,
borderColor: accentColor,
backgroundColor: isDark ? 'rgba(255, 92, 0, 0.2)' : 'rgba(255, 92, 0, 0.12)',
borderWidth: 2,
pointBackgroundColor: accentColor,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) { return ctx.parsed.r + '/100'; }
}
}
},
scales: {
r: {
min: 0,
max: 100,
beginAtZero: true,
grid: { color: gridColor },
angleLines: { color: gridColor },
pointLabels: {
color: textColor,
font: { size: 11, weight: '600' }
},
ticks: {
color: textColor,
backdropColor: 'transparent',
stepSize: 25,
font: { size: 9 }
}
}
}
}
});
}
}
}
};
}
@@ -295,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);
}
@@ -349,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';
},
+39 -5
View File
@@ -1,6 +1,16 @@
// OpenFang Setup Wizard — First-run guided setup (Provider + Agent + Channel)
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n"""). */
function wizardTomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("..."). */
function wizardTomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function wizardPage() {
return {
step: 1,
@@ -283,11 +293,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) {
@@ -409,6 +421,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) {
@@ -438,12 +472,12 @@ function wizardPage() {
}
var toml = '[agent]\n';
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
toml += 'name = "' + wizardTomlBasicEscape(name) + '"\n';
toml += 'description = "' + wizardTomlBasicEscape(tpl.description) + '"\n';
toml += 'profile = "' + tpl.profile + '"\n\n';
toml += '[model]\nprovider = "' + provider + '"\n';
toml += 'model = "' + model + '"\n';
toml += 'system_prompt = """\n' + tpl.system_prompt + '\n"""\n';
toml += 'system_prompt = """\n' + wizardTomlMultilineEscape(tpl.system_prompt) + '\n"""\n';
this.creatingAgent = true;
try {
@@ -469,7 +503,7 @@ 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',
@@ -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 ──────────────────────────────────
@@ -13,6 +13,8 @@ function workflowsPage() {
loading: true,
loadError: '',
newWf: { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] },
editModal: null,
editWf: { name: '', description: '', steps: [] },
// -- Workflows methods --
async loadWorkflows() {
@@ -74,6 +76,57 @@ function workflowsPage() {
} catch(e) {
OpenFangToast.error('Failed to load run history: ' + e.message);
}
},
async deleteWorkflow(wf) {
if (!confirm('Delete workflow "' + wf.name + '"? This cannot be undone.')) return;
try {
await OpenFangAPI.delete('/api/workflows/' + wf.id);
OpenFangToast.success('Workflow "' + wf.name + '" deleted');
await this.loadWorkflows();
} catch(e) {
OpenFangToast.error('Failed to delete workflow: ' + e.message);
}
},
async showEditModal(wf) {
try {
var full = await OpenFangAPI.get('/api/workflows/' + wf.id);
this.editWf = {
name: full.name || '',
description: full.description || '',
steps: (full.steps || []).map(function(s) {
return {
name: s.name || '',
agent_name: (s.agent && s.agent.name) || '',
mode: s.mode || 'sequential',
prompt: s.prompt_template || '{{input}}'
};
})
};
if (this.editWf.steps.length === 0) {
this.editWf.steps.push({ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' });
}
this.editModal = wf;
} catch(e) {
OpenFangToast.error('Failed to load workflow: ' + e.message);
}
},
async saveWorkflow() {
if (!this.editModal) return;
var steps = this.editWf.steps.map(function(s) {
return { name: s.name || 'step', agent_name: s.agent_name, mode: s.mode, prompt: s.prompt || '{{input}}' };
});
try {
var wfName = this.editWf.name;
await OpenFangAPI.put('/api/workflows/' + this.editModal.id, { name: wfName, description: this.editWf.description, steps: steps });
this.editModal = null;
OpenFangToast.success('Workflow "' + wfName + '" updated');
await this.loadWorkflows();
} catch(e) {
OpenFangToast.error('Failed to update workflow: ' + e.message);
}
}
};
}
File diff suppressed because one or more lines are too long
@@ -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,9 +706,21 @@ 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();
let api_key = state.kernel.config.api_key.trim().to_string();
let auth_state = middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
session_secret: if !api_key.is_empty() {
api_key.clone()
} else if state.kernel.config.auth.enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
},
};
let app = Router::new()
.route("/api/health", axum::routing::get(routes::health))
@@ -751,7 +764,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
)
.route("/api/shutdown", axum::routing::post(routes::shutdown))
.layer(axum::middleware::from_fn_with_state(
api_key_state,
auth_state,
middleware::auth,
))
.layer(axum::middleware::from_fn(middleware::request_logging))
@@ -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()
+1
View File
@@ -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()
+2 -2
View File
@@ -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",
@@ -496,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)
+537 -19
View File
@@ -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>;
@@ -111,6 +135,9 @@ pub trait ChannelBridgeHandle: Send + Sync {
}
/// Record a delivery result for tracking (optional — default no-op).
///
/// `thread_id` preserves Telegram forum-topic context so cron/workflow
/// delivery can target the same topic later.
async fn record_delivery(
&self,
_agent_id: AgentId,
@@ -118,6 +145,7 @@ pub trait ChannelBridgeHandle: Send + Sync {
_recipient: &str,
_success: bool,
_error: Option<&str>,
_thread_id: Option<&str>,
) {
// Default: no tracking
}
@@ -264,6 +292,15 @@ impl BridgeManager {
}
/// Start an adapter: subscribe to its message stream and spawn a dispatch task.
///
/// Each incoming message is dispatched as a concurrent task so that slow LLM
/// calls (10-30s) don't block subsequent messages. This prevents voice/media
/// messages sent in quick succession from appearing "lost" — all messages
/// begin processing immediately. Per-agent serialization (to prevent session
/// corruption) is handled by the kernel's `agent_msg_locks`.
///
/// A semaphore limits concurrent dispatch tasks to prevent unbounded memory
/// growth under burst traffic.
pub async fn start_adapter(
&mut self,
adapter: Arc<dyn ChannelAdapter>,
@@ -275,6 +312,10 @@ impl BridgeManager {
let adapter_clone = adapter.clone();
let mut shutdown = self.shutdown_rx.clone();
// Limit concurrent dispatch tasks to prevent unbounded growth.
// 32 is generous — most setups have 1-5 concurrent users.
let semaphore = Arc::new(tokio::sync::Semaphore::new(32));
let task = tokio::spawn(async move {
let mut stream = std::pin::pin!(stream);
loop {
@@ -282,13 +323,28 @@ impl BridgeManager {
msg = stream.next() => {
match msg {
Some(message) => {
dispatch_message(
&message,
&handle,
&router,
adapter_clone.as_ref(),
&rate_limiter,
).await;
// Spawn each dispatch as a concurrent task so the stream
// loop is never blocked by slow LLM calls. The kernel's
// per-agent lock ensures session integrity.
let handle = handle.clone();
let router = router.clone();
let adapter = adapter_clone.clone();
let rate_limiter = rate_limiter.clone();
let sem = semaphore.clone();
tokio::spawn(async move {
// Acquire semaphore permit (blocks if 32 tasks are in flight).
let _permit = match sem.acquire().await {
Ok(p) => p,
Err(_) => return, // semaphore closed — shutting down
};
dispatch_message(
&message,
&handle,
&router,
adapter.as_ref(),
&rate_limiter,
).await;
});
}
None => {
info!("Channel adapter {} stream ended", adapter_clone.name());
@@ -359,6 +415,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).
@@ -383,6 +458,7 @@ async fn dispatch_message(
.and_then(|o| o.output_format)
.unwrap_or(channel_default_format);
let threading_enabled = overrides.as_ref().map(|o| o.threading).unwrap_or(false);
let lifecycle_reactions = overrides.as_ref().map(|o| o.lifecycle_reactions).unwrap_or(true);
let thread_id = if threading_enabled {
message.thread_id.as_deref()
} else {
@@ -446,19 +522,44 @@ 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;
// 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,
ct_str,
thread_id,
output_format,
lifecycle_reactions,
)
.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 } => {
let desc = match 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}]"),
};
desc
}
}
ChannelContent::File { ref url, ref filename } => {
format!("[User sent a file ({filename}): {url}]")
@@ -469,6 +570,9 @@ async fn dispatch_message(
ChannelContent::Location { lat, lon } => {
format!("[User shared location: {lat}, {lon}]")
}
ChannelContent::FileData { ref filename, .. } => {
format!("[User sent a local file: {filename}]")
}
};
// Check if it's a slash command embedded in text (e.g. "/agents")
@@ -645,7 +749,7 @@ async fn dispatch_message(
if let Some(reply) = handle.check_auto_reply(agent_id, &text).await {
send_response(adapter, &message.sender, reply, thread_id, output_format).await;
handle
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None)
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
.await;
return;
}
@@ -653,17 +757,30 @@ 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;
if lifecycle_reactions {
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) => {
if lifecycle_reactions {
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)
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
.await;
}
Err(e) => {
if lifecycle_reactions {
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}");
let err_msg = sanitize_agent_error(&e.to_string());
send_response(
adapter,
&message.sender,
@@ -679,6 +796,309 @@ async fn dispatch_message(
&message.sender.platform_id,
false,
Some(&err_msg),
thread_id,
)
.await;
}
}
}
fn sanitize_agent_error(raw: &str) -> String {
let lower = raw.to_lowercase();
if lower.contains("rate limit")
|| lower.contains("rate_limit")
|| lower.contains("429")
|| lower.contains("too many requests")
|| lower.contains("resource_exhausted")
{
return "Rate limit reached, please try again later.".to_string();
}
if lower.contains("authentication")
|| lower.contains("unauthorized")
|| lower.contains("invalid api key")
|| lower.contains("invalid x-goog-api-key")
|| lower.contains("incorrect api key")
|| lower.contains("permission denied")
|| lower.contains("billing")
|| lower.contains("quota exceeded")
{
return "Service temporarily unavailable.".to_string();
}
if lower.contains("context length")
|| lower.contains("token limit")
|| lower.contains("too many tokens")
|| lower.contains("maximum context")
|| lower.contains("max_tokens")
|| lower.contains("context window")
{
return "Message too long, try a shorter request.".to_string();
}
if lower.contains("overloaded")
|| lower.contains("503")
|| lower.contains("502")
|| lower.contains("server error")
|| lower.contains("internal error")
{
return "The AI service is temporarily overloaded, please try again shortly.".to_string();
}
if lower.contains("timeout") || lower.contains("timed out") || lower.contains("deadline") {
return "Request timed out, please try again.".to_string();
}
if lower.contains("model not found") || lower.contains("model_not_found") {
return "The requested model is currently unavailable.".to_string();
}
let cleaned = raw
.strip_prefix("LLM driver error: ")
.or_else(|| raw.strip_prefix("Agent error: "))
.unwrap_or(raw);
if let Some(first_sentence_end) = cleaned.find(". ") {
let first = &cleaned[..=first_sentence_end];
if first.len() < cleaned.len() / 2 {
return format!("Agent error: {first}");
}
}
if cleaned.contains('{') || cleaned.len() > 200 {
return "Something went wrong processing your request. Please try again.".to_string();
}
format!("Agent error: {cleaned}")
}
/// Detect image format from the first few magic bytes.
///
/// Returns `Some("image/...")` for JPEG, PNG, GIF, and WebP.
fn detect_image_magic(bytes: &[u8]) -> Option<String> {
if bytes.len() >= 3 && bytes[..3] == [0xFF, 0xD8, 0xFF] {
return Some("image/jpeg".to_string());
}
if bytes.len() >= 4 && bytes[..4] == [0x89, 0x50, 0x4E, 0x47] {
return Some("image/png".to_string());
}
if bytes.len() >= 4 && bytes[..4] == [0x47, 0x49, 0x46, 0x38] {
return Some("image/gif".to_string());
}
if bytes.len() >= 12 && bytes[..4] == [0x52, 0x49, 0x46, 0x46] && bytes[8..12] == [0x57, 0x45, 0x42, 0x50]
{
return Some("image/webp".to_string());
}
None
}
/// Guess image media type from the URL file extension.
fn media_type_from_url(url: &str) -> String {
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 {
// JPEG is the most common image format — safe default
"image/jpeg".to_string()
}
}
/// 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}]"),
provider_metadata: None,
}];
}
};
// Detect media type from Content-Type header — but only trust it if it's
// actually an image/* type. Many APIs (Telegram, S3 pre-signed URLs) return
// `application/octet-stream` for all files, which breaks vision.
let header_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string())
.filter(|ct| ct.starts_with("image/"));
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}]"),
provider_metadata: None,
}];
}
};
// Three-tier media type detection:
// 1. Trusted Content-Type header (only if image/*)
// 2. Magic byte sniffing (most reliable for binary data)
// 3. URL extension fallback
let media_type = header_type.unwrap_or_else(|| {
detect_image_magic(&bytes).unwrap_or_else(|| media_type_from_url(url))
});
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, provider_metadata: None }];
}
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(),
provider_metadata: None,
});
}
}
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,
lifecycle_reactions: bool,
) {
// 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;
if lifecycle_reactions {
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) => {
if lifecycle_reactions {
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, thread_id)
.await;
}
Err(e) => {
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
}
warn!("Agent error for {agent_id}: {e}");
let err_msg = sanitize_agent_error(&e.to_string());
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),
thread_id,
)
.await;
}
@@ -1124,4 +1544,102 @@ 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(),
provider_metadata: None,
},
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: ");
}
#[test]
fn test_detect_image_magic_jpeg() {
let bytes = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
assert_eq!(detect_image_magic(&bytes), Some("image/jpeg".to_string()));
}
#[test]
fn test_detect_image_magic_png() {
let bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
assert_eq!(detect_image_magic(&bytes), Some("image/png".to_string()));
}
#[test]
fn test_detect_image_magic_gif() {
let bytes = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
assert_eq!(detect_image_magic(&bytes), Some("image/gif".to_string()));
}
#[test]
fn test_detect_image_magic_webp() {
let bytes = [
0x52, 0x49, 0x46, 0x46, // RIFF
0x00, 0x00, 0x00, 0x00, // size (don't care)
0x57, 0x45, 0x42, 0x50, // WEBP
];
assert_eq!(detect_image_magic(&bytes), Some("image/webp".to_string()));
}
#[test]
fn test_detect_image_magic_unknown() {
let bytes = [0x00, 0x01, 0x02, 0x03];
assert_eq!(detect_image_magic(&bytes), None);
}
#[test]
fn test_detect_image_magic_empty() {
assert_eq!(detect_image_magic(&[]), None);
}
#[test]
fn test_media_type_from_url() {
assert_eq!(media_type_from_url("https://example.com/photo.png"), "image/png");
assert_eq!(media_type_from_url("https://example.com/anim.gif"), "image/gif");
assert_eq!(media_type_from_url("https://example.com/img.webp"), "image/webp");
assert_eq!(media_type_from_url("https://example.com/photo.jpg"), "image/jpeg");
// No extension — defaults to JPEG
assert_eq!(media_type_from_url("https://api.telegram.org/file/bot123/photos/file_42"), "image/jpeg");
}
}
+69 -19
View File
@@ -40,6 +40,7 @@ pub struct DiscordAdapter {
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>,
@@ -56,6 +57,7 @@ impl DiscordAdapter {
token: String,
allowed_guilds: Vec<String>,
allowed_users: Vec<String>,
ignore_bots: bool,
intents: u64,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
@@ -64,6 +66,7 @@ impl DiscordAdapter {
client: reqwest::Client::new(),
allowed_guilds,
allowed_users,
ignore_bots,
intents,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
@@ -155,6 +158,7 @@ impl ChannelAdapter for DiscordAdapter {
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();
@@ -315,7 +319,7 @@ impl ChannelAdapter for DiscordAdapter {
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
if let Some(msg) =
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users)
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users, ignore_bots)
.await
{
debug!(
@@ -432,6 +436,7 @@ async fn parse_discord_message(
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()?;
@@ -443,8 +448,8 @@ 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;
}
@@ -561,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");
@@ -583,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());
}
@@ -603,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());
}
@@ -624,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());
}
@@ -647,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");
@@ -672,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());
}
@@ -691,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");
}
@@ -713,7 +763,7 @@ 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")
@@ -736,15 +786,15 @@ mod tests {
});
// Not in allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()]).await;
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()]).await;
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, &[], &[]).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
assert!(msg.is_some());
}
@@ -767,7 +817,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!(msg.is_group);
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
@@ -785,7 +835,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[]).await.unwrap();
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true).await.unwrap();
assert!(msg2.is_group);
assert!(!msg2.metadata.contains_key("was_mentioned"));
}
@@ -805,13 +855,13 @@ 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!(!msg.is_group);
}
#[test]
fn test_discord_adapter_creation() {
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], 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);
}
+31 -3
View File
@@ -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();
+138 -5
View File
@@ -12,7 +12,7 @@ use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{info, warn};
use tracing::{debug, info, warn};
use zeroize::Zeroizing;
const SYNC_TIMEOUT_MS: u64 = 30000;
@@ -35,6 +35,8 @@ pub struct MatrixAdapter {
shutdown_rx: watch::Receiver<bool>,
/// Sync token for resuming /sync.
since_token: Arc<RwLock<Option<String>>>,
/// Whether to auto-accept room invites.
auto_accept_invites: bool,
}
impl MatrixAdapter {
@@ -55,6 +57,7 @@ impl MatrixAdapter {
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
since_token: Arc::new(RwLock::new(None)),
auto_accept_invites: true,
}
}
@@ -116,12 +119,86 @@ impl MatrixAdapter {
Ok(user_id)
}
#[allow(dead_code)]
#[cfg(test)]
fn is_allowed_room(&self, room_id: &str) -> bool {
self.allowed_rooms.is_empty() || self.allowed_rooms.iter().any(|r| r == room_id)
}
}
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
async fn accept_invite(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
room_id: &str,
) {
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/join");
match client
.post(&url)
.bearer_auth(access_token)
.json(&serde_json::json!({}))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
info!("Matrix: auto-accepted invite to {room_id}");
}
Ok(resp) => {
let status = resp.status();
warn!("Matrix: failed to accept invite to {room_id}: {status}");
}
Err(e) => {
warn!("Matrix: error accepting invite to {room_id}: {e}");
}
}
}
/// Get the number of joined members in a room.
async fn get_room_member_count(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
room_id: &str,
) -> Option<usize> {
let url = format!(
"{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members"
);
let resp = client
.get(&url)
.bearer_auth(access_token)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body["joined"].as_object().map(|m| m.len())
}
/// Do an initial /sync with timeout=0 to get the since token without processing events.
/// This prevents replaying old messages when the adapter first connects.
async fn initial_sync(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
) -> Option<String> {
let url = format!(
"{homeserver}/_matrix/client/v3/sync?timeout=0&filter={{\"room\":{{\"timeline\":{{\"limit\":0}}}}}}"
);
let resp = client
.get(&url)
.bearer_auth(access_token)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body["next_batch"].as_str().map(String::from)
}
#[async_trait]
impl ChannelAdapter for MatrixAdapter {
fn name(&self) -> &str {
@@ -148,6 +225,15 @@ impl ChannelAdapter for MatrixAdapter {
let client = self.client.clone();
let since_token = Arc::clone(&self.since_token);
let mut shutdown_rx = self.shutdown_rx.clone();
let auto_accept = self.auto_accept_invites;
// FIX #4: Do an initial sync to get the since token, skipping old messages.
if since_token.read().await.is_none() {
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
info!("Matrix: initial sync complete, skipping old messages");
*since_token.write().await = Some(token);
}
}
tokio::spawn(async move {
let mut backoff = Duration::from_secs(1);
@@ -168,7 +254,7 @@ impl ChannelAdapter for MatrixAdapter {
info!("Matrix adapter shutting down");
break;
}
result = client.get(&url).bearer_auth(&*access_token).send() => {
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
match result {
Ok(r) => r,
Err(e) => {
@@ -203,6 +289,21 @@ impl ChannelAdapter for MatrixAdapter {
*since_token.write().await = Some(next.to_string());
}
// FIX #1: Auto-accept room invites.
if auto_accept {
if let Some(invites) = body["rooms"]["invite"].as_object() {
for (room_id, _invite_data) in invites {
if !allowed_rooms.is_empty()
&& !allowed_rooms.iter().any(|r| r == room_id)
{
debug!("Matrix: ignoring invite to {room_id} (not in allowed_rooms)");
continue;
}
accept_invite(&client, &homeserver, access_token.as_str(), room_id).await;
}
}
}
// Process room events
if let Some(rooms) = body["rooms"]["join"].as_object() {
for (room_id, room_data) in rooms {
@@ -245,6 +346,38 @@ impl ChannelAdapter for MatrixAdapter {
let event_id = event["event_id"].as_str().unwrap_or("").to_string();
// FIX #2: Detect @mentions in message text.
let mut metadata = HashMap::new();
if content.contains(&user_id) {
metadata.insert(
"was_mentioned".to_string(),
serde_json::json!(true),
);
}
// FIX #3: Determine if room is a DM (2 members) or group.
let is_group = get_room_member_count(
&client,
&homeserver,
access_token.as_str(),
room_id,
)
.await
.map(|count| count > 2)
.unwrap_or(true);
// For DMs, auto-set was_mentioned so dm_policy works.
if !is_group {
metadata.insert(
"was_mentioned".to_string(),
serde_json::json!(true),
);
metadata.insert(
"is_dm".to_string(),
serde_json::json!(true),
);
}
let channel_msg = ChannelMessage {
channel: ChannelType::Matrix,
platform_message_id: event_id,
@@ -256,9 +389,9 @@ impl ChannelAdapter for MatrixAdapter {
content: msg_content,
target_agent: None,
timestamp: Utc::now(),
is_group: true,
is_group,
thread_id: None,
metadata: HashMap::new(),
metadata,
};
if tx.send(channel_msg).await.is_err() {
+1 -1
View File
@@ -339,7 +339,7 @@ impl ChannelAdapter for NostrAdapter {
platform_id: sender_pubkey.clone(),
display_name: format!(
"{}...",
&sender_pubkey[..8.min(sender_pubkey.len())]
openfang_types::truncate_str(&sender_pubkey, 8)
),
openfang_user: None,
},
+136 -16
View File
@@ -7,11 +7,12 @@ use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use dashmap::DashMap;
use futures::{SinkExt, Stream, StreamExt};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
@@ -32,10 +33,22 @@ pub struct SlackAdapter {
shutdown_rx: watch::Receiver<bool>,
/// Bot's own user ID (populated after auth.test).
bot_user_id: Arc<RwLock<Option<String>>>,
/// Threads where the bot was @-mentioned. Maps thread_ts -> last interaction time.
active_threads: Arc<DashMap<String, Instant>>,
/// How long to track a thread after last interaction.
thread_ttl: Duration,
/// Whether auto-thread-reply is enabled.
auto_thread_reply: bool,
}
impl SlackAdapter {
pub fn new(app_token: String, bot_token: String, allowed_channels: Vec<String>) -> Self {
pub fn new(
app_token: String,
bot_token: String,
allowed_channels: Vec<String>,
auto_thread_reply: bool,
thread_ttl_hours: u64,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
app_token: Zeroizing::new(app_token),
@@ -45,6 +58,9 @@ impl SlackAdapter {
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
bot_user_id: Arc::new(RwLock::new(None)),
active_threads: Arc::new(DashMap::new()),
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
auto_thread_reply,
}
}
@@ -76,14 +92,18 @@ impl SlackAdapter {
&self,
channel_id: &str,
text: &str,
thread_ts: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let chunks = split_message(text, SLACK_MSG_LIMIT);
for chunk in chunks {
let body = serde_json::json!({
let mut body = serde_json::json!({
"channel": channel_id,
"text": chunk,
});
if let Some(ts) = thread_ts {
body["thread_ts"] = serde_json::json!(ts);
}
let resp: serde_json::Value = self
.client
@@ -133,6 +153,30 @@ impl ChannelAdapter for SlackAdapter {
let allowed_channels = self.allowed_channels.clone();
let client = self.client.clone();
let mut shutdown = self.shutdown_rx.clone();
let active_threads = self.active_threads.clone();
let auto_thread_reply = self.auto_thread_reply;
// Spawn periodic cleanup of expired thread entries.
{
let active_threads = self.active_threads.clone();
let thread_ttl = self.thread_ttl;
let mut cleanup_shutdown = self.shutdown_rx.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(300));
loop {
tokio::select! {
_ = interval.tick() => {
active_threads.retain(|_, last| last.elapsed() < thread_ttl);
}
_ = cleanup_shutdown.changed() => {
if *cleanup_shutdown.borrow() {
return;
}
}
}
}
});
}
tokio::spawn(async move {
let mut backoff = INITIAL_BACKOFF;
@@ -241,7 +285,14 @@ impl ChannelAdapter for SlackAdapter {
// Extract the event
let event = &payload["payload"]["event"];
if let Some(msg) =
parse_slack_event(event, &bot_user_id, &allowed_channels).await
parse_slack_event(
event,
&bot_user_id,
&allowed_channels,
&active_threads,
auto_thread_reply,
)
.await
{
debug!(
"Slack message from {}: {:?}",
@@ -289,16 +340,40 @@ impl ChannelAdapter for SlackAdapter {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_message(channel_id, &text).await?;
self.api_send_message(channel_id, &text, None).await?;
}
_ => {
self.api_send_message(channel_id, "(Unsupported content type)")
self.api_send_message(channel_id, "(Unsupported content type)", None)
.await?;
}
}
Ok(())
}
async fn send_in_thread(
&self,
user: &ChannelUser,
content: ChannelContent,
thread_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_message(channel_id, &text, Some(thread_id))
.await?;
}
_ => {
self.api_send_message(
channel_id,
"(Unsupported content type)",
Some(thread_id),
)
.await?;
}
}
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
@@ -335,6 +410,8 @@ async fn parse_slack_event(
event: &serde_json::Value,
bot_user_id: &Arc<RwLock<Option<String>>>,
allowed_channels: &[String],
active_threads: &Arc<DashMap<String, Instant>>,
auto_thread_reply: bool,
) -> Option<ChannelMessage> {
let event_type = event["type"].as_str()?;
if event_type != "message" {
@@ -413,6 +490,47 @@ async fn parse_slack_event(
ChannelContent::Text(text.to_string())
};
// Extract thread_id: threaded replies have `thread_ts`, top-level messages
// use their own `ts` so the reply will start a thread under the original.
let thread_id = msg_data["thread_ts"]
.as_str()
.or_else(|| event["thread_ts"].as_str())
.map(|s| s.to_string())
.or_else(|| Some(ts.to_string()));
// Check if the bot was @-mentioned (for group_policy = "mention_only")
let mut metadata = HashMap::new();
// Determine the real thread_ts from the event (None for top-level messages).
let real_thread_ts = msg_data["thread_ts"]
.as_str()
.or_else(|| event["thread_ts"].as_str());
let mut explicitly_mentioned = false;
if let Some(ref bid) = *bot_user_id.read().await {
let mention_tag = format!("<@{bid}>");
if text.contains(&mention_tag) {
explicitly_mentioned = true;
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
// Track thread for auto-reply on subsequent messages.
if let Some(tts) = real_thread_ts {
active_threads.insert(tts.to_string(), Instant::now());
}
}
}
// Auto-reply to follow-up messages in tracked threads.
if !explicitly_mentioned && auto_thread_reply {
if let Some(tts) = real_thread_ts {
if let Some(mut entry) = active_threads.get_mut(tts) {
// Refresh TTL and mark as mentioned so dispatch proceeds.
*entry = Instant::now();
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
}
}
}
Some(ChannelMessage {
channel: ChannelType::Slack,
platform_message_id: ts.to_string(),
@@ -425,8 +543,8 @@ async fn parse_slack_event(
target_agent: None,
timestamp,
is_group: true,
thread_id: None,
metadata: HashMap::new(),
thread_id,
metadata,
})
}
@@ -445,7 +563,7 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap();
assert_eq!(msg.channel, ChannelType::Slack);
assert_eq!(msg.sender.platform_id, "C789");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello agent!"));
@@ -463,7 +581,7 @@ mod tests {
"bot_id": "B999"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -478,7 +596,7 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -495,11 +613,11 @@ mod tests {
// Not in allowed channels
let msg =
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()]).await;
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
// In allowed channels
let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()]).await;
let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_some());
}
@@ -516,7 +634,7 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -531,7 +649,7 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -556,7 +674,7 @@ mod tests {
"ts": "1700000001.000200"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap();
assert_eq!(msg.channel, ChannelType::Slack);
assert_eq!(msg.sender.platform_id, "C789");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message text"));
@@ -568,6 +686,8 @@ mod tests {
"xapp-test".to_string(),
"xoxb-test".to_string(),
vec!["C123".to_string()],
true,
24,
);
assert_eq!(adapter.name(), "slack");
assert_eq!(adapter.channel_type(), ChannelType::Slack);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -49,6 +49,13 @@ pub enum ChannelContent {
url: String,
filename: String,
},
/// Local file data (bytes read from disk). Used by the proactive `channel_send`
/// tool when `file_path` is provided instead of `file_url`.
FileData {
data: Vec<u8>,
filename: String,
mime_type: String,
},
Voice {
url: String,
duration_seconds: u32,
+234 -31
View File
@@ -520,6 +520,23 @@ enum WorkflowCommands {
/// Path to a JSON file describing the workflow.
file: PathBuf,
},
/// Get a workflow by ID.
Get {
/// Workflow ID (UUID).
workflow_id: String,
},
/// Update a workflow from a JSON file.
Update {
/// Workflow ID (UUID).
workflow_id: String,
/// Path to a JSON file with the updated workflow definition.
file: PathBuf,
},
/// Delete a workflow by ID.
Delete {
/// Workflow ID (UUID).
workflow_id: String,
},
/// Run a workflow by ID.
Run {
/// Workflow ID (UUID).
@@ -777,11 +794,36 @@ enum SystemCommands {
},
}
fn config_log_level() -> String {
let config_path = if let Ok(home) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(home).join("config.toml")
} else {
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
.join("config.toml")
};
if let Ok(content) = std::fs::read_to_string(config_path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("log_level") {
if let Some(val) = trimmed.split('=').nth(1) {
let level = val.trim().trim_matches('"').trim_matches('\'');
if !level.is_empty() {
return level.to_string();
}
}
}
}
}
"info".to_string()
}
fn init_tracing_stderr() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
)
.init();
}
@@ -807,7 +849,7 @@ fn init_tracing_file() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
)
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
@@ -893,6 +935,9 @@ fn main() {
Some(Commands::Workflow(sub)) => match sub {
WorkflowCommands::List => cmd_workflow_list(),
WorkflowCommands::Create { file } => cmd_workflow_create(file),
WorkflowCommands::Get { workflow_id } => cmd_workflow_get(&workflow_id),
WorkflowCommands::Update { workflow_id, file } => cmd_workflow_update(&workflow_id, file),
WorkflowCommands::Delete { workflow_id } => cmd_workflow_delete(&workflow_id),
WorkflowCommands::Run { workflow_id, input } => cmd_workflow_run(&workflow_id, &input),
},
Some(Commands::Trigger(sub)) => match sub {
@@ -1073,11 +1118,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,7 +1220,9 @@ fn cmd_init(quick: bool) {
if quick {
cmd_init_quick(&openfang_dir);
} else if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
} 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);
@@ -1288,8 +1347,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");
}
}
}
@@ -1337,7 +1405,7 @@ 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",
),
]
@@ -1453,27 +1521,37 @@ 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> {
// 1. Config file takes precedence
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()?;
if key.is_empty() {
None
} else {
Some(key.to_string())
if let Ok(text) = std::fs::read_to_string(config_path) {
if let Ok(table) = text.parse::<toml::Value>() {
if let Some(key) = table.get("api_key").and_then(|v| v.as_str()) {
let key = key.trim();
if !key.is_empty() {
return Some(key.to_string());
}
}
}
}
// 2. Fall back to OPENFANG_API_KEY env var
if let Ok(key) = std::env::var("OPENFANG_API_KEY") {
let key = key.trim().to_string();
if !key.is_empty() {
return Some(key);
}
}
None
}
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 {
@@ -2632,7 +2710,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");
}
@@ -2706,12 +2784,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!(
@@ -2954,10 +3038,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")))]
{
@@ -3062,6 +3167,100 @@ fn cmd_workflow_run(workflow_id: &str, input: &str) {
}
}
fn cmd_workflow_get(workflow_id: &str) {
let base = require_daemon("workflow get");
let client = daemon_client();
let body = daemon_json(client.get(format!("{base}/api/workflows/{workflow_id}")).send());
if body.get("error").is_some() {
eprintln!(
"Workflow not found: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
println!("Workflow: {}", body["name"].as_str().unwrap_or("?"));
println!(" ID: {}", body["id"].as_str().unwrap_or("?"));
println!(
" Description: {}",
body["description"].as_str().unwrap_or("")
);
println!(
" Created: {}",
body["created_at"].as_str().unwrap_or("?")
);
if let Some(steps) = body["steps"].as_array() {
println!(" Steps ({}):", steps.len());
for (i, s) in steps.iter().enumerate() {
let name = s["name"].as_str().unwrap_or("step");
let agent = s["agent"]
.get("name")
.or_else(|| s["agent"].get("id"))
.and_then(|v| v.as_str())
.unwrap_or("?");
println!(" #{}: {} -> {}", i + 1, name, agent);
}
}
}
fn cmd_workflow_update(workflow_id: &str, file: PathBuf) {
let base = require_daemon("workflow update");
if !file.exists() {
eprintln!("Workflow file not found: {}", file.display());
std::process::exit(1);
}
let contents = std::fs::read_to_string(&file).unwrap_or_else(|e| {
eprintln!("Error reading workflow file: {e}");
std::process::exit(1);
});
let json_body: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|e| {
eprintln!("Invalid JSON: {e}");
std::process::exit(1);
});
let client = daemon_client();
let body = daemon_json(
client
.put(format!("{base}/api/workflows/{workflow_id}"))
.json(&json_body)
.send(),
);
if body["status"].as_str() == Some("updated") {
println!("Workflow updated successfully!");
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
} else {
eprintln!(
"Failed to update workflow: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
}
fn cmd_workflow_delete(workflow_id: &str) {
let base = require_daemon("workflow delete");
let client = daemon_client();
let body = daemon_json(
client
.delete(format!("{base}/api/workflows/{workflow_id}"))
.send(),
);
if body["status"].as_str() == Some("removed") {
println!("Workflow deleted successfully!");
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
} else {
eprintln!(
"Failed to delete workflow: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
}
// ---------------------------------------------------------------------------
// Trigger commands
// ---------------------------------------------------------------------------
@@ -4517,6 +4716,8 @@ fn cmd_config_set(key: &str, value: &str) {
std::process::exit(1);
});
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
ui::error(&format!("Failed to write config: {e}"));
std::process::exit(1);
@@ -4583,6 +4784,8 @@ fn cmd_config_unset(key: &str) {
std::process::exit(1);
});
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
ui::error(&format!("Failed to write config: {e}"));
std::process::exit(1);
+12 -8
View File
@@ -230,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!({
@@ -242,7 +246,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
"version": env!("CARGO_PKG_VERSION")
}
});
Some(jsonrpc_response(id?, result))
Some(jsonrpc_response(rid, result))
}
"notifications/initialized" => None, // Notification, no response
@@ -274,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" => {
@@ -286,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}"),
));
@@ -302,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",
@@ -311,7 +315,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
}),
)),
Err(e) => Some(jsonrpc_response(
id?,
rid,
json!({
"content": [{
"type": "text",
@@ -324,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: "",
},
@@ -188,6 +188,14 @@ const PROVIDERS: &[ProviderInfo] = &[
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",
@@ -383,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);
}
@@ -430,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())
}
@@ -564,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();
@@ -1084,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
@@ -1093,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) {
@@ -1696,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()
@@ -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()
};
+4 -1
View File
@@ -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()
};
+29 -5
View File
@@ -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)
+26 -7
View File
@@ -6,6 +6,7 @@
use openfang_api::server::build_router;
use openfang_kernel::OpenFangKernel;
use std::net::{SocketAddr, TcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::watch;
use tracing::{error, info};
@@ -20,24 +21,40 @@ pub struct ServerHandle {
shutdown_tx: watch::Sender<bool>,
/// Join handle for the background server thread.
server_thread: Option<std::thread::JoinHandle<()>>,
/// Track whether shutdown has already been initiated to prevent double shutdown.
shutdown_initiated: Arc<AtomicBool>,
}
impl ServerHandle {
/// Signal the server to shut down and wait for the background thread.
pub fn shutdown(mut self) {
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
// Only proceed if shutdown hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
}
impl Drop for ServerHandle {
fn drop(&mut self) {
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
// Only send shutdown signal if it hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
}
}
}
@@ -61,6 +78,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let kernel_clone = kernel.clone();
let shutdown_initiated = Arc::new(AtomicBool::new(false));
let server_thread = std::thread::Builder::new()
.name("openfang-server".into())
@@ -83,6 +101,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
kernel,
shutdown_tx,
server_thread: Some(server_thread),
shutdown_initiated,
})
}
@@ -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"
+15 -16
View File
@@ -18,7 +18,7 @@ key = "python3"
label = "Python 3 must be installed"
requirement_type = "binary"
check_value = "python3"
description = "Python 3 is required to run Playwright, the browser automation library that powers this hand."
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,26 @@ 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"
optional = true
description = "A Chromium-based browser is recommended. Playwright can install its own bundled browser if none is found. Google Chrome, Chromium, or any Chromium derivative will also work. You can 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 ───────────────────────────────────────────────────
@@ -0,0 +1,740 @@
id = "trader"
name = "Trading Hand"
description = "Autonomous market intelligence and trading engine — multi-signal analysis, adversarial bull/bear reasoning, calibrated confidence scoring, strict risk management, and portfolio-level analytics"
category = "data"
icon = "\U0001F4C8"
tools = ["shell_exec", "file_read", "file_write", "file_list", "web_fetch", "web_search", "memory_store", "memory_recall", "schedule_create", "schedule_list", "schedule_delete", "knowledge_add_entity", "knowledge_add_relation", "knowledge_query", "event_publish"]
# ─── Configurable settings ───────────────────────────────────────────────────
[[settings]]
key = "trading_mode"
label = "Trading Mode"
description = "How the trading hand operates — analysis only, paper trading, or live trading"
setting_type = "select"
default = "paper"
[[settings.options]]
value = "analysis"
label = "Analysis Only — signals and reports, no trades"
[[settings.options]]
value = "paper"
label = "Paper Trading — simulated trades with virtual portfolio"
[[settings.options]]
value = "live"
label = "Live Trading — real trades via Alpaca (requires API keys)"
[[settings]]
key = "market_focus"
label = "Market Focus"
description = "Which markets to monitor and trade"
setting_type = "select"
default = "us_stocks"
[[settings.options]]
value = "us_stocks"
label = "US Stocks & ETFs"
[[settings.options]]
value = "crypto"
label = "Cryptocurrency"
[[settings.options]]
value = "multi_asset"
label = "Multi-Asset (stocks + crypto)"
[[settings]]
key = "strategy_style"
label = "Strategy Style"
description = "Trading timeframe and strategy approach"
setting_type = "select"
default = "swing"
[[settings.options]]
value = "scalping"
label = "Scalping (minutes to hours)"
[[settings.options]]
value = "day"
label = "Day Trading (intraday, close by EOD)"
[[settings.options]]
value = "swing"
label = "Swing Trading (days to weeks)"
[[settings.options]]
value = "position"
label = "Position Trading (weeks to months)"
[[settings]]
key = "risk_per_trade"
label = "Risk Per Trade"
description = "Maximum portfolio percentage risked on a single trade"
setting_type = "select"
default = "2"
[[settings.options]]
value = "1"
label = "Conservative (1% per trade)"
[[settings.options]]
value = "2"
label = "Moderate (2% per trade)"
[[settings.options]]
value = "3"
label = "Aggressive (3% per trade)"
[[settings.options]]
value = "5"
label = "High Risk (5% per trade)"
[[settings]]
key = "max_daily_loss"
label = "Max Daily Loss"
description = "Maximum portfolio percentage loss allowed per day before circuit breaker activates"
setting_type = "select"
default = "5"
[[settings.options]]
value = "2"
label = "Strict (2% daily max loss)"
[[settings.options]]
value = "5"
label = "Standard (5% daily max loss)"
[[settings.options]]
value = "10"
label = "Loose (10% daily max loss)"
[[settings]]
key = "analysis_depth"
label = "Analysis Depth"
description = "How many signals to collect and cross-reference per asset"
setting_type = "select"
default = "standard"
[[settings.options]]
value = "quick"
label = "Quick Scan (5-10 signals per asset)"
[[settings.options]]
value = "standard"
label = "Standard Analysis (15-25 signals per asset)"
[[settings.options]]
value = "deep"
label = "Deep Analysis (30+ signals, multi-source cross-reference)"
[[settings]]
key = "scan_schedule"
label = "Scan Schedule"
description = "How often to scan markets and update analysis"
setting_type = "select"
default = "4h"
[[settings.options]]
value = "15m"
label = "Every 15 minutes (scalping/day trading)"
[[settings.options]]
value = "1h"
label = "Every hour"
[[settings.options]]
value = "4h"
label = "Every 4 hours"
[[settings.options]]
value = "daily"
label = "Daily at market open"
[[settings]]
key = "watchlist"
label = "Watchlist"
description = "Comma-separated list of tickers to monitor (stocks: AAPL, crypto: BTC, ETFs: SPY)"
setting_type = "text"
default = "SPY,QQQ,AAPL,MSFT,NVDA,BTC,ETH"
[[settings]]
key = "initial_capital"
label = "Initial Capital"
description = "Starting portfolio value for paper trading or tracking (in USD)"
setting_type = "text"
default = "10000"
[[settings]]
key = "alpaca_api_key"
label = "Alpaca API Key"
description = "Alpaca API key for live/paper trading (get one free at alpaca.markets)"
setting_type = "text"
default = ""
env_var = "ALPACA_API_KEY"
[[settings]]
key = "alpaca_secret_key"
label = "Alpaca Secret Key"
description = "Alpaca API secret key"
setting_type = "text"
default = ""
env_var = "ALPACA_SECRET_KEY"
[[settings]]
key = "approval_mode"
label = "Approval Mode"
description = "Require explicit user approval before executing any live trade — STRONGLY recommended"
setting_type = "toggle"
default = "true"
# ─── Agent configuration ─────────────────────────────────────────────────────
[agent]
name = "trader-hand"
description = "AI market intelligence and trading engine — multi-signal analysis, adversarial reasoning, risk management, portfolio analytics"
module = "builtin:chat"
provider = "default"
model = "default"
max_tokens = 16384
temperature = 0.3
max_iterations = 80
system_prompt = """You are Trading Hand an autonomous market intelligence and trading engine that combines multi-signal analysis, adversarial reasoning, and strict risk management to generate high-conviction trade signals and manage a portfolio.
You are NOT a toy. You are built on the same principles used by the world's best quantitative hedge funds and superforecasters: multi-factor signal fusion, adversarial debate, calibrated confidence, and iron-clad risk management. You respect the market. You know you can be wrong. That humility makes you better.
## YOUR EDGE
Most trading bots are dumb they follow rules without understanding context. You THINK about markets:
- **Multi-Signal Fusion**: You combine technical, fundamental, sentiment, and macro signals never trading on a single indicator
- **Adversarial Reasoning**: For every trade, you build both the bull AND bear case, then synthesize eliminating confirmation bias
- **Calibrated Confidence**: You assign probabilities like a superforecaster tracked and scored over time
- **Strict Risk Management**: Your risk gate CANNOT be bypassed it's the difference between surviving and blowing up
- **Continuous Learning**: You track every prediction's accuracy and adjust your calibration over time
---
## Phase 0 — Platform Detection & State Recovery (ALWAYS DO THIS FIRST)
Detect the operating system:
```
python3 -c "import platform; print(platform.system())"
```
On Windows, try `python` if `python3` fails.
Then recover state:
1. memory_recall `trader_hand_state` load previous portfolio and config
2. Read **User Configuration** section for trading_mode, market_focus, risk settings, watchlist
3. file_read `portfolio.json` if it exists your portfolio ledger
4. file_read `trade_journal.json` if it exists your trade history
5. knowledge_query for existing market entities (companies, sectors, macro indicators)
6. Check circuit breaker status: if `trader_hand_circuit_breaker` is set and not expired, respect the cooldown
---
## Phase 1 — Portfolio & Market Setup
### First Run
1. Create scan schedule using schedule_create based on `scan_schedule` setting
2. Initialize portfolio ledger:
```json
{
"initial_capital": <from settings>,
"cash": <initial_capital>,
"positions": [],
"equity_curve": [{"date": "YYYY-MM-DD", "value": <initial_capital>}],
"daily_pnl": [],
"total_trades": 0,
"winning_trades": 0,
"losing_trades": 0,
"gross_profit": 0,
"gross_loss": 0,
"max_equity": <initial_capital>,
"max_drawdown_pct": 0,
"consecutive_losses": 0,
"circuit_breaker_until": null
}
```
3. Parse watchlist from settings (comma-separated tickers)
4. Determine market focus and adjust data sources accordingly
5. Initialize trade journal as empty array
### Subsequent Runs
1. Load portfolio from `portfolio.json`
2. Load trade journal from `trade_journal.json`
3. Update current prices for all open positions
4. Check if circuit breaker is active if so, skip to Phase 7 (reports only)
5. Check if max drawdown threshold exceeded if so, trigger emergency risk protocol
---
## Phase 2 — Market Intelligence Scan
Execute targeted searches for each watchlist asset. Adjust depth based on `analysis_depth` setting.
### For Each Asset in Watchlist:
**Price & Volume Data** (always):
- web_search "[TICKER] stock price today" or "[TICKER] crypto price"
- web_search "[TICKER] trading volume today"
- web_fetch financial data pages for current OHLCV data
**News & Events** (standard+):
- web_search "[TICKER] news today"
- web_search "[TICKER] earnings report" (if stock)
- web_search "[TICKER] SEC filing" (if stock)
- web_search "[TICKER] analyst upgrade downgrade"
**Sentiment** (standard+):
- web_search "[TICKER] sentiment analysis"
- web_search "[TICKER] reddit wallstreetbets" or "[TICKER] crypto twitter"
- web_search "[TICKER] institutional buyers sellers"
- web_search "[TICKER] short interest"
**Macro Context** (deep only):
- web_search "stock market outlook today"
- web_search "federal reserve interest rate decision"
- web_search "VIX fear greed index today"
- web_search "sector rotation [current month]"
- web_search "treasury yield curve today"
### Signal Tagging
For each piece of information, tag it:
- **Type**: price_action | volume | earnings | news | sentiment | macro | institutional | technical_pattern
- **Direction**: bullish | bearish | neutral
- **Strength**: strong | moderate | weak
- **Timeframe**: immediate (hours) | short (days) | medium (weeks) | long (months)
- **Credibility**: institutional (SEC, Fed, earnings) | media (Reuters, Bloomberg) | social (Reddit, Twitter) | unknown
Store in knowledge graph: `knowledge_add_entity` for each signal, `knowledge_add_relation` to link signal -> asset -> sector -> macro.
---
## Phase 3 — Multi-Factor Analysis Engine
For each asset in watchlist, compute a structured analysis:
### 3A — Technical Analysis Score
Using the price/volume data gathered, assess:
| Indicator | Method | Bullish | Bearish |
|-----------|--------|---------|---------|
| **Trend** | Price vs 50-day & 200-day MA | Above both | Below both |
| **Momentum** | RSI(14) | 30-50 (oversold bounce) | 70-90 (overbought) |
| **MACD** | MACD line vs Signal line | Bullish crossover | Bearish crossover |
| **Bollinger** | Price vs Bands(20,2) | Touch lower band + reversal | Touch upper band + reversal |
| **Volume** | Current vs 20-day average | Rising on up moves | Rising on down moves |
| **Support/Resistance** | Key price levels | Bouncing off support | Rejected at resistance |
| **ATR** | Average True Range(14) | Expanding (trending) | Contracting (ranging) |
**Technical Score**: -100 to +100 (sum of weighted indicator scores)
### 3B — Fundamental Analysis Score (stocks only)
| Factor | Bullish | Bearish |
|--------|---------|---------|
| **P/E vs Sector** | Below sector average | Way above sector average |
| **Revenue Growth** | Accelerating QoQ | Decelerating QoQ |
| **Earnings Surprise** | Beat estimates | Missed estimates |
| **Analyst Consensus** | Upgrades > downgrades | Downgrades > upgrades |
| **Insider Activity** | Net buying | Net selling |
| **Institutional Flow** | Increasing ownership | Decreasing ownership |
| **Debt/Equity** | Improving | Deteriorating |
**Fundamental Score**: -100 to +100
### 3C — Sentiment Analysis Score
| Factor | Bullish | Bearish |
|--------|---------|---------|
| **News Sentiment** | Mostly positive | Mostly negative |
| **Social Buzz** | Rising mentions + positive | Rising mentions + negative |
| **Fear & Greed** | Extreme fear (contrarian buy) | Extreme greed (contrarian sell) |
| **Put/Call Ratio** | High (contrarian bullish) | Low (contrarian bearish) |
| **Short Interest** | Declining | Increasing rapidly |
| **VIX Level** | Below 20 (calm) | Above 30 (panic) |
**Sentiment Score**: -100 to +100
### 3D — Macro Analysis Score
| Factor | Risk-On (Bullish) | Risk-Off (Bearish) |
|--------|-------------------|-------------------|
| **Fed Policy** | Dovish / cutting rates | Hawkish / raising rates |
| **Yield Curve** | Steepening | Inverting |
| **Dollar Strength** | Weakening USD | Strengthening USD |
| **Sector Rotation** | Into growth/tech | Into defensives/utilities |
| **Global Events** | Stability | Geopolitical tension |
**Macro Score**: -100 to +100
### Composite Signal Matrix
```
Asset: [TICKER]
Technical: [score] / 100 [............]
Fundamental: [score] / 100 [............]
Sentiment: [score] / 100 [............]
Macro: [score] / 100 [............]
---------------------------------------------
COMPOSITE: [weighted avg] / 100
```
Weight by strategy_style:
- Scalping: Technical 60%, Sentiment 25%, Macro 10%, Fundamental 5%
- Day Trading: Technical 50%, Sentiment 25%, Macro 15%, Fundamental 10%
- Swing: Technical 35%, Fundamental 25%, Sentiment 20%, Macro 20%
- Position: Fundamental 40%, Macro 25%, Technical 20%, Sentiment 15%
---
## Phase 4 — Signal Fusion: Adversarial Bull/Bear Debate
THIS IS YOUR MOST IMPORTANT PHASE. For each asset with composite score outside -20 to +20 range (i.e., actionable signal):
### Step 1: Build the BULL Case
Argue AS IF you are a senior analyst who is LONG this asset:
```
BULL THESIS for [TICKER]:
1. Technical: [strongest bullish technical signals]
2. Catalyst: [upcoming catalysts that could drive price up]
3. Sentiment: [positive sentiment indicators]
4. Macro: [favorable macro conditions]
5. Historical: [similar setups that played out bullishly]
BULL TARGET: $[price] (+X% from current)
BULL CONFIDENCE: X%
```
### Step 2: Build the BEAR Case
Now argue AS IF you are a senior analyst who is SHORT this asset:
```
BEAR THESIS for [TICKER]:
1. Technical: [strongest bearish technical signals]
2. Risk: [what could go wrong earnings miss, macro shock, etc.]
3. Sentiment: [negative sentiment indicators]
4. Macro: [unfavorable macro conditions]
5. Historical: [similar setups that played out bearishly]
BEAR TARGET: $[price] (-X% from current)
BEAR CONFIDENCE: X%
```
### Step 3: Cognitive Bias Check
Before synthesizing, explicitly check:
- [ ] Am I anchoring on the recent price move?
- [ ] Am I falling for narrative bias (compelling story != likely outcome)?
- [ ] Am I displaying overconfidence (> 80% confidence requires extraordinary evidence)?
- [ ] Am I neglecting the base rate? (Most individual stock picks underperform the index)
- [ ] What's my pre-mortem? If this trade fails, what was the most likely reason?
### Step 4: Synthesis & Final Signal
```
FINAL SIGNAL: [STRONG_BUY / BUY / HOLD / SELL / STRONG_SELL]
CONFIDENCE: X% (calibrated see Reference Knowledge for calibration guide)
ENTRY ZONE: $[low] - $[high]
STOP LOSS: $[price] (X% below entry based on ATR or support level)
TAKE PROFIT 1: $[price] (1.5:1 risk/reward take 50% off)
TAKE PROFIT 2: $[price] (3:1 risk/reward trailing stop for remainder)
RISK/REWARD: X:1
TIMEFRAME: [hours / days / weeks]
REASONING: [2-3 sentence synthesis of why bull > bear or vice versa]
```
---
## Phase 5 — Risk Management Gate (HARD LIMITS — CANNOT BE BYPASSED)
EVERY trade proposal MUST pass ALL checks below. NO exceptions. NO overrides.
### 5A — Position-Level Checks
1. **Position Size**: risk_per_trade% of portfolio / (entry_price - stop_loss_price) = max shares
- NEVER exceed this, even if the signal is strong
2. **Stop Loss**: MUST be set before entry no trade without a stop
3. **Risk/Reward**: Must be >= 1.5:1 reject trades with poor R:R
4. **Single Position Cap**: No position > 10% of total portfolio value
5. **Entry Quality**: Only enter at limit price within the entry zone no chasing
### 5B — Portfolio-Level Checks
1. **Cash Reserve**: Always maintain >= 20% cash (max 80% invested)
2. **Sector Concentration**: Max 3 positions in the same sector
3. **Correlation Risk**: If 2+ positions are highly correlated, reduce size by 50%
4. **Open Position Limit**: Max 10 simultaneous positions
### 5C — Circuit Breaker (Automatic Safety System)
| Trigger | Action |
|---------|--------|
| Daily loss > max_daily_loss setting | HALT all trading for 24 hours |
| 3 consecutive losing trades | Mandatory 24-hour cooldown |
| Max drawdown from peak > 15% | Reduce ALL positions by 50% |
| Max drawdown from peak > 25% | Close ALL positions, switch to analysis-only |
When circuit breaker activates:
1. Log the trigger and timestamp
2. memory_store `trader_hand_circuit_breaker` with expiry timestamp
3. event_publish alert to user: "Circuit breaker activated: [reason]"
4. Skip to Phase 7 for report generation
### 5D — Trade Rejection Log
If a trade fails any check, log it:
```
TRADE REJECTED: [TICKER] [BUY/SELL]
REASON: [which check failed]
DETAILS: [specific numbers that failed the check]
```
This helps identify if you're consistently generating signals that fail risk checks (recalibrate).
---
## Phase 6 — Trade Execution
Read trading_mode from User Configuration:
### Mode: "analysis" (Analysis Only)
- Generate signal report with all analysis from Phases 2-5
- Record what you WOULD have done in `shadow_trades.json`
- Track shadow P&L to validate strategy without risking capital
- This mode is perfect for building confidence before going live
### Mode: "paper" (Paper Trading)
- Execute simulated trades against `portfolio.json`
- Update positions, cash, equity curve, trade journal
- Use IDENTICAL logic to live mode same entries, stops, targets
- No approval required trades execute immediately in simulation
- This is the RECOMMENDED mode for new users
For each trade:
1. Deduct from cash, add to positions array
2. Set stop_loss and take_profit levels
3. Log in trade_journal.json with full reasoning
4. Update equity curve
For position management each cycle:
1. Check all open positions against current prices
2. If price hit stop_loss -> close position, record loss
3. If price hit take_profit_1 -> close 50%, move stop to breakeven
4. If price hit take_profit_2 -> close remaining
5. Trail stop-loss for profitable positions (50% of unrealized gain)
### Mode: "live" (Live Trading — requires Alpaca)
If approval_mode is enabled (STRONGLY recommended):
1. Build trade proposal summary:
```
============================================
TRADE PROPOSAL Requires Approval
============================================
Asset: [TICKER]
Direction: [BUY/SELL]
Quantity: [shares/units]
Entry: $[price] (limit order)
Stop Loss: $[price] (-X%)
Take Profit: $[price] (+X%)
Risk: $[amount] (X% of portfolio)
R:R Ratio: X:1
Confidence: X%
Bull Case: [1-line summary]
Bear Case: [1-line summary]
Reasoning: [1-line synthesis]
============================================
```
2. event_publish the proposal as an alert
3. STOP and wait for user response
4. On approval: execute via Alpaca API (see SKILL.md for API reference)
5. On rejection: log rejection, do not trade
If approval_mode is disabled:
1. Execute trade directly via Alpaca API using shell_exec with curl:
- POST to Alpaca orders endpoint
- Set stop_loss order simultaneously
- Verify order fill
2. Log everything with full reasoning chain
### Order Types (for live trading)
- Entry: LIMIT order at target price (never market orders in volatile markets)
- Stop Loss: STOP order (guaranteed execution)
- Take Profit: LIMIT order
- Trailing Stop: TRAILING_STOP order (percentage-based)
---
## Phase 7 — Analytics, Report Generation & State Persistence
### 7A — Portfolio Analytics Calculations
Calculate and update these metrics every cycle:
**Win Rate** = winning_trades / total_trades * 100
**Profit Factor** = gross_profit / abs(gross_loss) target > 1.5
**Sharpe Ratio** = mean(daily_returns) / stddev(daily_returns) * sqrt(252) target > 1.0
**Max Drawdown** = (peak_equity - trough_equity) / peak_equity * 100
**Average Win** = gross_profit / winning_trades
**Average Loss** = abs(gross_loss) / losing_trades
**Expectancy** = (win_rate * avg_win) - ((1 - win_rate) * avg_loss)
**Risk-Adjusted Return** = total_return / max_drawdown
### 7B — Generate Trading Report
```markdown
# Trading Report — YYYY-MM-DD HH:MM
## Portfolio Snapshot
| Metric | Value |
|--------|-------|
| Portfolio Value | $XX,XXX.XX |
| Cash | $XX,XXX.XX (XX%) |
| Invested | $XX,XXX.XX (XX%) |
| Daily P&L | +/-$X,XXX.XX (+/-X.XX%) |
| Total P&L | +/-$X,XXX.XX (+/-X.XX%) |
## Performance Metrics
| Metric | Value | Rating |
|--------|-------|--------|
| Win Rate | XX% | [Good >55%] |
| Profit Factor | X.XX | [Good >1.5] |
| Sharpe Ratio | X.XX | [Good >1.0] |
| Max Drawdown | X.XX% | [Caution >10%] |
| Expectancy | $XX.XX/trade | [Good >0] |
## Signal Dashboard
| Asset | Tech | Fund | Sent | Macro | Composite | Signal | Conf |
|-------|------|------|------|-------|-----------|--------|------|
| [Each watchlist asset with scores] |
## Active Positions
| Asset | Dir | Entry | Current | P&L | P&L% | Stop | Target | Days |
|-------|-----|-------|---------|-----|------|------|--------|------|
## New Trades This Cycle
[For each trade with bull/bear reasoning summary]
## Risk Dashboard
| Check | Status |
|-------|--------|
| Cash Reserve (>20%) | XX% |
| Max Position (<10%) | Largest: XX% |
| Sector Concentration (<3) | X sectors |
| Consecutive Losses | X (limit: 3) |
| Circuit Breaker | [Clear / ACTIVE until HH:MM] |
| Drawdown | X.XX% (limit: 15% / 25%) |
## Equity Curve Data
[JSON array for dashboard chart rendering]
## Trade Journal
[Detailed entry for each trade with full adversarial analysis]
```
Save to: `trading_report_YYYY-MM-DD.md`
### 7C — State Persistence
1. Save portfolio to `portfolio.json` (positions, cash, equity curve, all metrics)
2. Save trade journal to `trade_journal.json` (append new trades)
3. Update dashboard metrics via memory_store:
- `trader_hand_portfolio_value` current total portfolio value as formatted string "$XX,XXX.XX"
- `trader_hand_total_pnl` total P&L as formatted string "+$X,XXX.XX" or "-$X,XXX.XX"
- `trader_hand_win_rate` percentage number (e.g., 62.5)
- `trader_hand_sharpe_ratio` decimal number (e.g., 1.45)
- `trader_hand_max_drawdown` percentage number (e.g., 8.3)
- `trader_hand_trades_count` integer
- `trader_hand_active_positions` integer count of open positions
- `trader_hand_signals_generated` total signals analyzed this cycle
- `trader_hand_accuracy_pct` prediction accuracy percentage
- `trader_hand_last_scan` "YYYY-MM-DD HH:MM UTC"
4. Store rich dashboard data:
- `trader_hand_equity_curve` JSON: [{"date":"YYYY-MM-DD","value":10000}, ...]
- `trader_hand_daily_pnl` JSON: [{"date":"YYYY-MM-DD","pnl":125.50}, ...]
- `trader_hand_watchlist_heatmap` JSON: [{"ticker":"AAPL","change_pct":2.3,"signal":"BUY","confidence":72}, ...]
- `trader_hand_signal_radar` JSON: {"technical":65,"fundamental":40,"sentiment":72,"macro":55}
- `trader_hand_recent_trades` JSON: last 10 trades with ticker, direction, pnl, reasoning summary
5. memory_store `trader_hand_state` serialized state for recovery
---
## Guidelines
### Market Hours Awareness
- US Stocks: 9:30 AM - 4:00 PM ET (Mon-Fri). Pre-market 4:00 AM - 9:30 AM. After-hours 4:00 PM - 8:00 PM.
- Crypto: 24/7/365
- Respect market hours don't try to execute stock trades when market is closed (queue for next open)
### Data Quality Rules
- NEVER fabricate price data if you can't find current prices, say so
- Cross-reference prices from 2+ sources when possible
- If data is stale (> 15 minutes for day trading, > 1 hour for swing), note it
- Prefer financial data sites (Yahoo Finance, Google Finance, CoinGecko) over news articles for price data
### Trading Discipline
- NEVER average down on a losing position (adding to losers is how accounts blow up)
- NEVER remove or widen a stop loss after it's set
- NEVER risk more than the position sizing formula allows no matter how confident you are
- NEVER chase a missed entry wait for the next setup
- If a trade thesis is invalidated before entry, cancel the order
- Respect the circuit breaker it exists to protect the portfolio from emotional decisions
### Communication
- If the user messages you directly, pause autonomous operations and respond
- Explain your reasoning clearly the user should understand WHY you're making each decision
- Flag high-risk situations proactively (earnings approaching, Fed meeting, unusual volatility)
- When uncertain, default to HOLD no trade is better than a bad trade
### Accuracy Tracking
- Track every signal's outcome: did the predicted direction play out?
- Calculate rolling accuracy per signal type (technical accuracy, sentiment accuracy, etc.)
- Adjust signal weights over time based on what's actually working
- Be honest about failures log bad trades with the SAME detail as good ones
"""
# ─── Dashboard metrics ────────────────────────────────────────────────────────
[dashboard]
[[dashboard.metrics]]
label = "Portfolio Value"
memory_key = "trader_hand_portfolio_value"
format = "text"
[[dashboard.metrics]]
label = "Total P&L"
memory_key = "trader_hand_total_pnl"
format = "text"
[[dashboard.metrics]]
label = "Win Rate"
memory_key = "trader_hand_win_rate"
format = "percentage"
[[dashboard.metrics]]
label = "Sharpe Ratio"
memory_key = "trader_hand_sharpe_ratio"
format = "number"
[[dashboard.metrics]]
label = "Max Drawdown"
memory_key = "trader_hand_max_drawdown"
format = "percentage"
[[dashboard.metrics]]
label = "Trades Executed"
memory_key = "trader_hand_trades_count"
format = "number"
[[dashboard.metrics]]
label = "Active Positions"
memory_key = "trader_hand_active_positions"
format = "number"
[[dashboard.metrics]]
label = "Signals Analyzed"
memory_key = "trader_hand_signals_generated"
format = "number"
[[dashboard.metrics]]
label = "Accuracy"
memory_key = "trader_hand_accuracy_pct"
format = "percentage"
[[dashboard.metrics]]
label = "Last Scan"
memory_key = "trader_hand_last_scan"
format = "text"
@@ -0,0 +1,937 @@
---
name: trader-hand-skill
version: "1.0.0"
description: "Expert knowledge for autonomous market intelligence and trading — technical analysis, risk management, Alpaca API, financial data sources"
author: OpenFang
tags: [trading, finance, stocks, crypto, technical-analysis, risk-management]
tools: [shell_exec, file_read, file_write, web_fetch, web_search, memory_store]
runtime: prompt_only
---
# Trading Expert Knowledge
## Reference Knowledge
## 1. Technical Analysis Indicators Reference
### RSI (Relative Strength Index)
```
Formula: RSI = 100 - (100 / (1 + RS))
Where: RS = Average Gain / Average Loss over N periods (default N = 14)
Step-by-step calculation:
1. For each period, compute change = Close(t) - Close(t-1)
2. Gains = max(change, 0), Losses = abs(min(change, 0))
3. First average: simple mean of first 14 gains/losses
4. Subsequent: AvgGain = (PrevAvgGain * 13 + CurrentGain) / 14 (Wilder smoothing)
5. RS = AvgGain / AvgLoss
6. RSI = 100 - (100 / (1 + RS))
Worked example (14-period):
Avg Gain over 14 periods = 1.02
Avg Loss over 14 periods = 0.68
RS = 1.02 / 0.68 = 1.50
RSI = 100 - (100 / (1 + 1.50)) = 100 - 40 = 60.0
```
**Interpretation:**
- RSI < 30: Oversold territory (potential buy signal)
- RSI > 70: Overbought territory (potential sell signal)
- RSI = 50: Neutral — price momentum balanced
**Advanced RSI Signals:**
| Signal | Description | Strength |
|--------|-------------|----------|
| Bearish divergence | Price makes new high, RSI makes lower high | Strong reversal warning |
| Bullish divergence | Price makes new low, RSI makes higher low | Strong reversal warning |
| Bullish failure swing | RSI drops below 30, bounces, pulls back above 30, breaks prior RSI high | Very strong buy |
| Bearish failure swing | RSI rises above 70, drops, bounces below 70, breaks prior RSI low | Very strong sell |
| Range shift | RSI oscillates 40-80 in uptrend, 20-60 in downtrend | Trend confirmation |
**Best practices:** Never use RSI as a sole signal. Combine with trend direction (moving averages) and volume. In strong trends, RSI can stay overbought/oversold for extended periods.
---
### MACD (Moving Average Convergence Divergence)
```
MACD Line = EMA(12) - EMA(26)
Signal Line = EMA(9) of MACD Line
Histogram = MACD Line - Signal Line
EMA formula: EMA(t) = Price(t) * k + EMA(t-1) * (1 - k)
Where: k = 2 / (N + 1)
For EMA(12): k = 2/13 = 0.1538
For EMA(26): k = 2/27 = 0.0741
Worked example:
EMA(12) = 155.20
EMA(26) = 152.80
MACD Line = 155.20 - 152.80 = 2.40
Previous Signal Line = 1.80
Signal Line = 2.40 * (2/10) + 1.80 * (8/10) = 0.48 + 1.44 = 1.92
Histogram = 2.40 - 1.92 = 0.48 (positive = bullish momentum increasing)
```
**Interpretation:**
| Signal | Condition | Strength |
|--------|-----------|----------|
| Bullish crossover | MACD crosses above Signal Line | Moderate buy |
| Bearish crossover | MACD crosses below Signal Line | Moderate sell |
| Zero-line bullish cross | MACD crosses above zero | Trend change to bullish |
| Zero-line bearish cross | MACD crosses below zero | Trend change to bearish |
| Histogram expansion | Bars growing taller | Momentum accelerating |
| Histogram contraction | Bars shrinking | Momentum weakening, reversal may come |
| Bullish divergence | Price new low, MACD higher low | Strong reversal signal |
| Bearish divergence | Price new high, MACD lower high | Strong reversal signal |
---
### Bollinger Bands
```
Middle Band = SMA(20)
Upper Band = SMA(20) + 2 * StdDev(20)
Lower Band = SMA(20) - 2 * StdDev(20)
Bandwidth = (Upper - Lower) / Middle
%B = (Price - Lower) / (Upper - Lower)
Worked example:
SMA(20) = 150.00
StdDev(20) = 3.50
Upper = 150.00 + 2 * 3.50 = 157.00
Lower = 150.00 - 2 * 3.50 = 143.00
Bandwidth = (157.00 - 143.00) / 150.00 = 0.0933 (9.33%)
Current price = 155.00
%B = (155.00 - 143.00) / (157.00 - 143.00) = 12/14 = 0.857
Interpretation: Price is 85.7% of the way from lower to upper band — near upper band
```
**Key Bollinger Band Signals:**
| Signal | Condition | Meaning |
|--------|-----------|---------|
| Squeeze | Bandwidth at 6-month low | Volatility contraction, big move imminent |
| Squeeze breakout up | Price breaks above upper band after squeeze | Strong bullish breakout |
| Squeeze breakout down | Price breaks below lower band after squeeze | Strong bearish breakout |
| Walking the upper band | Price hugs upper band with middle band rising | Strong uptrend — do NOT short |
| Walking the lower band | Price hugs lower band with middle band falling | Strong downtrend — do NOT buy |
| Mean reversion touch | Price touches outer band, %B reverses | Potential reversion to middle band |
| W-bottom | Price hits lower band twice, second low has higher %B | Bullish reversal pattern |
| M-top | Price hits upper band twice, second high has lower %B | Bearish reversal pattern |
---
### VWAP (Volume Weighted Average Price)
```
VWAP = Cumulative(Typical Price * Volume) / Cumulative(Volume)
Typical Price = (High + Low + Close) / 3
Worked example (first 3 bars of the day):
Bar 1: TP = (101+99+100)/3 = 100.00, Vol = 10,000 -> cumTP*V = 1,000,000
Bar 2: TP = (102+100+101)/3 = 101.00, Vol = 15,000 -> cumTP*V = 2,515,000
Bar 3: TP = (103+101+102)/3 = 102.00, Vol = 8,000 -> cumTP*V = 3,331,000
Cumulative Volume = 33,000
VWAP = 3,331,000 / 33,000 = 100.94
```
**Usage:**
- **Institutional benchmark**: If price > VWAP, buyers dominate; price < VWAP, sellers dominate
- **Intraday S/R**: VWAP acts as dynamic support in uptrends, resistance in downtrends
- **Entry filter**: Buy only when price pulls back to VWAP (not chasing extended moves)
- **Standard deviations**: VWAP +1/-1 and +2/-2 StdDev bands serve as profit targets
- **Resets daily**: Do NOT carry VWAP across sessions — it is an intraday metric
---
### Moving Averages
```
SMA(N) = (Close_1 + Close_2 + ... + Close_N) / N
EMA(N) = Close * (2/(N+1)) + PrevEMA * (1 - 2/(N+1))
Key Moving Averages:
EMA(9) — very short-term trend (scalping, day trading)
EMA(20) — short-term trend
EMA(50) — medium-term trend
SMA(100) — intermediate trend
SMA(200) — long-term trend (institutional benchmark)
```
**Critical Cross Signals:**
| Cross | Name | Meaning | Reliability |
|-------|------|---------|-------------|
| 50 MA > 200 MA | Golden Cross | Bullish trend reversal | High (lag ~2 weeks) |
| 50 MA < 200 MA | Death Cross | Bearish trend reversal | High (lag ~2 weeks) |
| 9 EMA > 21 EMA | Fast bullish cross | Short-term momentum shift | Moderate |
| Price > 200 SMA | Above long-term trend | Bullish regime | Very High |
| Price < 200 SMA | Below long-term trend | Bearish regime | Very High |
**Moving Average Ribbon** (20/50/100/200 MAs all fanning out): Indicates a very strong trend. When all are stacked in order (20 > 50 > 100 > 200 for uptrend), the trend is highly reliable.
---
### ATR (Average True Range)
```
True Range = max(High - Low, |High - PrevClose|, |Low - PrevClose|)
ATR(14) = Simple or Wilder Moving Average of True Range over 14 periods
Worked example:
Today: High = 105, Low = 101, PrevClose = 102
TR = max(105-101, |105-102|, |101-102|) = max(4, 3, 1) = 4
If ATR(14) was 3.50 yesterday:
ATR(14) = (3.50 * 13 + 4) / 14 = (45.50 + 4) / 14 = 3.536
```
**Practical Applications:**
| Use Case | Formula | Example |
|----------|---------|---------|
| Stop-loss placement | Entry - 2 * ATR | Entry $100, ATR $2.50 -> Stop at $95.00 |
| Take-profit target | Entry + 3 * ATR | Entry $100, ATR $2.50 -> Target $107.50 |
| Position sizing | Risk$ / ATR | $200 risk / $2.50 ATR = 80 shares |
| Volatility filter | ATR > threshold | Only trade when ATR > daily average (avoid dead markets) |
| Trailing stop | Highest close - 3 * ATR | Locks in profit as price rises |
---
### Volume Analysis
```
OBV (On-Balance Volume):
If Close > PrevClose: OBV = PrevOBV + Volume
If Close < PrevClose: OBV = PrevOBV - Volume
If Close = PrevClose: OBV = PrevOBV
Volume Rate of Change: VROC = (Volume - Volume_N_ago) / Volume_N_ago * 100
```
**Volume Confirmation Rules:**
| Price Action | Volume | Interpretation |
|-------------|--------|----------------|
| Price up | Volume up | Strong bullish — legitimate move |
| Price up | Volume down | Weak rally — likely to reverse |
| Price down | Volume up | Strong bearish — capitulation or breakdown |
| Price down | Volume down | Weak decline — may be nearing bottom |
| Breakout | Volume > 150% of 20-day avg | Confirmed breakout — take the trade |
| Breakout | Volume < average | Failed breakout likely — wait or fade |
| Volume climax | Extreme volume spike (3x+ average) | Potential exhaustion/reversal point |
---
### Support & Resistance
**Fibonacci Retracement Levels:**
```
After a move from Low (L) to High (H):
23.6% level = H - (H - L) * 0.236
38.2% level = H - (H - L) * 0.382
50.0% level = H - (H - L) * 0.500
61.8% level = H - (H - L) * 0.618 (Golden Ratio — strongest level)
78.6% level = H - (H - L) * 0.786
Worked example (move from $80 to $120):
Range = $40
23.6% = 120 - 40 * 0.236 = 120 - 9.44 = $110.56
38.2% = 120 - 40 * 0.382 = 120 - 15.28 = $104.72
50.0% = 120 - 40 * 0.500 = 120 - 20.00 = $100.00
61.8% = 120 - 40 * 0.618 = 120 - 24.72 = $95.28 (most likely bounce)
78.6% = 120 - 40 * 0.786 = 120 - 31.44 = $88.56
```
**Pivot Points (Standard):**
```
PP = (High + Low + Close) / 3
S1 = 2 * PP - High
S2 = PP - (High - Low)
R1 = 2 * PP - Low
R2 = PP + (High - Low)
Worked example (prev day: High=155, Low=148, Close=152):
PP = (155 + 148 + 152) / 3 = 151.67
S1 = 2 * 151.67 - 155 = 148.33
S2 = 151.67 - (155 - 148) = 144.67
R1 = 2 * 151.67 - 148 = 155.33
R2 = 151.67 + (155 - 148) = 158.67
```
---
## 2. Candlestick Patterns
### Single-Candle Patterns
| Pattern | Signal | Body | Wicks | Context Required |
|---------|--------|------|-------|------------------|
| Doji | Indecision | Open = Close (or nearly) | Long both sides | At S/R level = reversal |
| Hammer | Bullish reversal | Small, at top of candle | Lower wick > 2x body | Must appear at bottom of downtrend |
| Inverted Hammer | Bullish reversal | Small, at bottom of candle | Upper wick > 2x body | At bottom of downtrend, needs confirmation |
| Shooting Star | Bearish reversal | Small, at bottom of candle | Upper wick > 2x body | Must appear at top of uptrend |
| Hanging Man | Bearish reversal | Small, at top of candle | Lower wick > 2x body | At top of uptrend (same shape as Hammer) |
| Marubozu (Bullish) | Strong continuation | Full green body, no wicks | None | Strong buying pressure |
| Marubozu (Bearish) | Strong continuation | Full red body, no wicks | None | Strong selling pressure |
| Spinning Top | Indecision | Small body centered | Equal wicks both sides | Trend may be losing steam |
| Dragonfly Doji | Bullish reversal | Open = Close = High | Long lower wick only | At support = strong reversal signal |
| Gravestone Doji | Bearish reversal | Open = Close = Low | Long upper wick only | At resistance = strong reversal signal |
### Multi-Candle Patterns
| Pattern | Signal | Description | Reliability |
|---------|--------|-------------|-------------|
| Bullish Engulfing | Reversal up | Large green candle fully engulfs prior red candle | High at support |
| Bearish Engulfing | Reversal down | Large red candle fully engulfs prior green candle | High at resistance |
| Morning Star | Bullish reversal | Red candle, small body/doji with gap, large green candle | Very High |
| Evening Star | Bearish reversal | Green candle, small body/doji with gap, large red candle | Very High |
| Three White Soldiers | Strong bullish | Three consecutive large green candles, each closing higher | Very High |
| Three Black Crows | Strong bearish | Three consecutive large red candles, each closing lower | Very High |
| Bullish Harami | Potential reversal | Large red, then small green contained within red's body | Moderate (needs confirmation) |
| Bearish Harami | Potential reversal | Large green, then small red contained within green's body | Moderate (needs confirmation) |
| Tweezer Bottom | Bullish reversal | Two candles with matching lows at support | High |
| Tweezer Top | Bearish reversal | Two candles with matching highs at resistance | High |
| Piercing Line | Bullish reversal | Red candle, then green opens below red's low and closes above 50% of red's body | Moderate-High |
| Dark Cloud Cover | Bearish reversal | Green candle, then red opens above green's high and closes below 50% of green's body | Moderate-High |
---
## 3. Risk Management Formulas
### Position Sizing (Fixed Fractional)
```
Position Size (shares) = Account Risk Amount / (Entry Price - Stop Loss Price)
Account Risk Amount = Portfolio Value * Risk Per Trade %
RULE: Never risk more than 1-2% of portfolio on a single trade.
Worked example:
Portfolio Value = $10,000
Risk Per Trade = 2% ($200)
Entry Price = $100.00
Stop Loss = $95.00 (based on 2x ATR below entry)
Risk per share = $100.00 - $95.00 = $5.00
Position Size = $200 / $5.00 = 40 shares
Position Value = 40 * $100 = $4,000 (40% of portfolio)
CONCENTRATION CHECK: If position value > 10% of portfolio, reduce size.
Adjusted: max position = $1,000 / $100 = 10 shares
Adjusted risk = 10 * $5.00 = $50 (only 0.5% of portfolio — acceptable)
```
### Kelly Criterion (Optimal Bet Size)
```
Kelly % = W - ((1 - W) / R)
Where:
W = win rate (decimal)
R = average win / average loss ratio (reward-to-risk)
Worked example:
Win rate: 60% (W = 0.60)
Average win: $300, Average loss: $200
R = 300 / 200 = 1.5
Kelly = 0.60 - (0.40 / 1.5) = 0.60 - 0.267 = 0.333 (33.3%)
Full Kelly is too aggressive for real trading. Use fractions:
Half-Kelly = 0.333 / 2 = 16.7% of portfolio per trade
Quarter-Kelly = 0.333 / 4 = 8.3% of portfolio per trade (recommended)
If Kelly is negative, the system has NEGATIVE expectancy — do not trade it.
```
### Value at Risk (VaR)
```
Parametric VaR = Portfolio Value * Portfolio Volatility * Z-score * sqrt(Time Horizon)
Z-scores: 90% confidence = 1.282
95% confidence = 1.645
99% confidence = 2.326
Worked example (daily VaR, 95% confidence):
Portfolio = $10,000
Daily volatility (stddev of daily returns) = 2.0%
VaR = $10,000 * 0.02 * 1.645 * sqrt(1) = $329.00
Meaning: 95% confident daily loss will not exceed $329.
Weekly VaR = $329 * sqrt(5) = $329 * 2.236 = $735.65
Monthly VaR = $329 * sqrt(21) = $329 * 4.583 = $1,507.81
```
### Sharpe Ratio
```
Sharpe = (Rp - Rf) / StdDev(Rp) * sqrt(252)
Where:
Rp = mean daily portfolio return
Rf = daily risk-free rate (Treasury yield / 252)
StdDev(Rp) = standard deviation of daily returns
252 = trading days per year (annualization factor)
Worked example:
Mean daily return = 0.10% (0.001)
Annual Treasury yield = 5.0% -> daily Rf = 0.05/252 = 0.000198
StdDev of daily returns = 0.80% (0.008)
Daily Sharpe = (0.001 - 0.000198) / 0.008 = 0.100
Annualized Sharpe = 0.100 * sqrt(252) = 0.100 * 15.875 = 1.59
Ratings:
< 0.5 = Poor (not compensated for risk)
0.5-1.0 = Acceptable
1.0-2.0 = Good
2.0-3.0 = Very Good
> 3.0 = Excellent (verify — may indicate overfitting)
```
### Sortino Ratio (Downside-Only Risk)
```
Sortino = (Rp - Rf) / DownsideDeviation * sqrt(252)
DownsideDeviation = sqrt(mean(min(Ri - Rf, 0)^2))
Better than Sharpe because it only penalizes downside volatility, not upside.
Sortino > 2.0 is considered very good.
```
### Maximum Drawdown
```
For each point t in equity curve:
Peak(t) = max(Equity[0..t])
Drawdown(t) = (Peak(t) - Equity(t)) / Peak(t) * 100%
MaxDrawdown = max(Drawdown(t)) for all t
Worked example:
Equity curve: $10,000 -> $12,000 -> $9,600 -> $11,500
Peak at $12,000
Drawdown at $9,600 = (12,000 - 9,600) / 12,000 = 20.0%
Max Drawdown = 20.0%
Recovery Factor = Total Net Profit / Max Drawdown
If total profit = $3,000, MaxDD = $2,400 -> RF = 3,000/2,400 = 1.25
Calmar Ratio = Annual Return / Max Drawdown
If annual return = 25%, MaxDD = 20% -> Calmar = 1.25 (target > 1.0)
```
### Profit Factor
```
Profit Factor = Gross Winning Trades / Gross Losing Trades
Worked example:
10 winning trades totaling $5,000
8 losing trades totaling $3,200
Profit Factor = 5,000 / 3,200 = 1.5625
Ratings: < 1.0 = losing system, 1.0-1.5 = marginal, 1.5-2.0 = good,
2.0-3.0 = very good, > 3.0 = excellent (verify with enough trades)
```
### Expectancy Per Trade
```
Expectancy = (Win% * AvgWin) - (Loss% * AvgLoss)
Worked example:
Win rate: 55%, Average win: $150, Average loss: $100
Expectancy = (0.55 * 150) - (0.45 * 100) = 82.50 - 45.00 = $37.50/trade
Over 100 trades: expected profit = $3,750
Minimum for a viable system: Expectancy > 0 with at least 30 sample trades.
```
### Risk/Reward Ratio
```
R:R = (Target Price - Entry Price) / (Entry Price - Stop Loss Price)
Worked example:
Entry = $100, Stop = $95, Target = $112
R:R = (112 - 100) / (100 - 95) = 12 / 5 = 2.4:1
Minimum acceptable R:R = 1.5:1
With 40% win rate and 2:1 R:R: Expectancy = 0.40*2 - 0.60*1 = +0.20 (profitable!)
With 40% win rate and 1:1 R:R: Expectancy = 0.40*1 - 0.60*1 = -0.20 (losing!)
```
---
## 4. Alpaca Trading API Reference
### Authentication
```bash
# Paper trading (ALWAYS start here)
BASE_URL="https://paper-api.alpaca.markets"
# Live trading (only after paper validation)
# BASE_URL="https://api.alpaca.markets"
# Data API (same for both paper and live)
DATA_URL="https://data.alpaca.markets"
# Auth headers (required on every request)
HEADERS="-H 'APCA-API-KEY-ID: $ALPACA_API_KEY' -H 'APCA-API-SECRET-KEY: $ALPACA_SECRET_KEY'"
```
### Account Information
```bash
# Get account details
curl -s "$BASE_URL/v2/account" $HEADERS
# Key fields: id, status, equity, cash, buying_power, portfolio_value,
# pattern_day_trader (bool), daytrade_count, last_equity
```
### Get Current Positions
```bash
# All positions
curl -s "$BASE_URL/v2/positions" $HEADERS
# Returns array: symbol, qty, side, avg_entry_price, current_price,
# unrealized_pl, unrealized_plpc, market_value, cost_basis
# Single position
curl -s "$BASE_URL/v2/positions/AAPL" $HEADERS
```
### Place Orders
```bash
# Market order (fills immediately at best available price)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{"symbol":"AAPL","qty":"10","side":"buy","type":"market","time_in_force":"day"}'
# Limit order (fills only at your price or better)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{"symbol":"AAPL","qty":"10","side":"buy","type":"limit","time_in_force":"gtc","limit_price":"150.00"}'
# Stop order (triggers market order when stop price hit)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{"symbol":"AAPL","qty":"10","side":"sell","type":"stop","time_in_force":"gtc","stop_price":"145.00"}'
# Stop-limit order (triggers limit order when stop price hit)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{"symbol":"AAPL","qty":"10","side":"sell","type":"stop_limit","time_in_force":"gtc","stop_price":"145.00","limit_price":"144.50"}'
# Trailing stop (dynamic stop that trails price by dollar or percent amount)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{"symbol":"AAPL","qty":"10","side":"sell","type":"trailing_stop","time_in_force":"gtc","trail_percent":"5"}'
# Bracket order (entry + stop loss + take profit as one atomic order)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{
"symbol": "AAPL",
"qty": "10",
"side": "buy",
"type": "limit",
"time_in_force": "day",
"limit_price": "150.00",
"order_class": "bracket",
"stop_loss": {"stop_price": "145.00"},
"take_profit": {"limit_price": "165.00"}
}'
# OCO order (one-cancels-other: stop loss OR take profit, whichever hits first)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{
"symbol": "AAPL",
"qty": "10",
"side": "sell",
"type": "limit",
"time_in_force": "gtc",
"limit_price": "165.00",
"order_class": "oco",
"stop_loss": {"stop_price": "145.00"}
}'
```
**Order parameters reference:**
| Parameter | Values | Notes |
|-----------|--------|-------|
| `side` | `buy`, `sell` | |
| `type` | `market`, `limit`, `stop`, `stop_limit`, `trailing_stop` | |
| `time_in_force` | `day`, `gtc`, `ioc`, `fok` | day = cancel at close, gtc = good til canceled |
| `order_class` | `simple`, `bracket`, `oco`, `oto` | bracket = entry + stop + target |
| `qty` | String number | Whole shares for stocks |
| `notional` | String dollar amount | Alternative to qty (fractional shares) |
### Manage Orders
```bash
# List open orders
curl -s "$BASE_URL/v2/orders?status=open" $HEADERS
# Get specific order
curl -s "$BASE_URL/v2/orders/{order_id}" $HEADERS
# Cancel specific order
curl -s -X DELETE "$BASE_URL/v2/orders/{order_id}" $HEADERS
# Cancel ALL open orders
curl -s -X DELETE "$BASE_URL/v2/orders" $HEADERS
```
### Close Positions
```bash
# Close entire position in a symbol
curl -s -X DELETE "$BASE_URL/v2/positions/AAPL" $HEADERS
# Partially close (sell 5 of 10 shares)
curl -s -X DELETE "$BASE_URL/v2/positions/AAPL?qty=5" $HEADERS
# EMERGENCY: Close ALL positions
curl -s -X DELETE "$BASE_URL/v2/positions" $HEADERS
```
### Market Data (free with Alpaca account)
```bash
# Latest quote (bid/ask)
curl -s "$DATA_URL/v2/stocks/AAPL/quotes/latest" $HEADERS
# Latest trade (last fill)
curl -s "$DATA_URL/v2/stocks/AAPL/trades/latest" $HEADERS
# Historical bars (OHLCV) — daily
curl -s "$DATA_URL/v2/stocks/AAPL/bars?timeframe=1Day&start=2024-01-01&limit=100" $HEADERS
# Intraday bars — 5-minute
curl -s "$DATA_URL/v2/stocks/AAPL/bars?timeframe=5Min&start=$(date -d 'today' +%Y-%m-%d)&limit=78" $HEADERS
# Multi-symbol snapshot
curl -s "$DATA_URL/v2/stocks/snapshots?symbols=AAPL,MSFT,GOOGL" $HEADERS
# Crypto bars
curl -s "$DATA_URL/v1beta3/crypto/us/bars?symbols=BTC/USD&timeframe=1Day&limit=30" $HEADERS
# Crypto latest quote
curl -s "$DATA_URL/v1beta3/crypto/us/latest/quotes?symbols=BTC/USD,ETH/USD" $HEADERS
```
### Market Clock & Calendar
```bash
# Is market open right now?
curl -s "$BASE_URL/v2/clock" $HEADERS
# Returns: timestamp, is_open (bool), next_open, next_close
# Upcoming market calendar
curl -s "$BASE_URL/v2/calendar?start=$(date +%Y-%m-%d)&end=$(date -d '+7 days' +%Y-%m-%d)" $HEADERS
```
### Crypto Trading Notes
- Symbols use slash format: `BTC/USD`, `ETH/USD`, `SOL/USD`, `DOGE/USD`
- 24/7 trading (no market hours restriction)
- Fractional quantities allowed (e.g., `"qty": "0.001"` for BTC)
- Paper trading works identically to live
- Use `notional` for dollar-based crypto orders: `"notional": "100.00"` buys $100 worth
### Account Activity & History
```bash
# Trade history
curl -s "$BASE_URL/v2/account/activities/FILL?after=2024-01-01" $HEADERS
# Portfolio history
curl -s "$BASE_URL/v2/account/portfolio/history?period=1M&timeframe=1D" $HEADERS
# Returns: timestamp[], equity[], profit_loss[], profit_loss_pct[]
```
---
## 5. Free Financial Data Sources
### Price Data (via web_search + web_fetch)
| Source | URL Pattern | Data Available |
|--------|-------------|----------------|
| Yahoo Finance | `finance.yahoo.com/quote/AAPL` | Realtime quotes, charts, financials, analyst ratings |
| Google Finance | `google.com/finance/quote/AAPL:NASDAQ` | Quotes, news, related stocks, earnings |
| CoinGecko | `coingecko.com/en/coins/bitcoin` | Crypto prices, market cap, volume, 24h change |
| CoinMarketCap | `coinmarketcap.com/currencies/bitcoin/` | Crypto prices, rankings, dominance, supply |
| MarketWatch | `marketwatch.com/investing/stock/AAPL` | Quotes, news, analysis, options data |
| Finviz | `finviz.com/quote.ashx?t=AAPL` | Technical + fundamental screener, charts |
| TradingView | `tradingview.com/symbols/NASDAQ-AAPL/` | Charts, technicals, community ideas |
### Fundamental Data
| Source | URL Pattern | Data Available |
|--------|-------------|----------------|
| Macrotrends | `macrotrends.net/stocks/charts/AAPL/apple/pe-ratio` | P/E, revenue, margins, historical |
| Simply Wall St | Web search: `"AAPL simply wall st"` | Visual fundamental analysis, fair value |
| SEC EDGAR | `sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=AAPL&type=10-K` | Official 10-K, 10-Q, 8-K filings |
| Earnings Whispers | `earningswhispers.com/stocks/AAPL` | Earnings estimates, surprise history, calendar |
| Stock Analysis | `stockanalysis.com/stocks/AAPL/financials/` | Clean financial statements, ratios |
| Wisesheets | Web search: `"AAPL income statement"` | Financial data in spreadsheet format |
### Sentiment & Alternative Data
| Source | URL | Data Available |
|--------|-----|----------------|
| CNN Fear & Greed | `money.cnn.com/data/fear-and-greed/` | Market sentiment index 0-100 (Extreme Fear to Extreme Greed) |
| CBOE VIX | Web search: `"VIX index today"` | Volatility index (>30 = fear, <15 = complacency) |
| Finviz Map | `finviz.com/map.ashx` | Market heatmap by sector/size |
| StockTwits | `stocktwits.com/symbol/AAPL` | Social sentiment (bullish/bearish ratio) |
| Put/Call Ratio | Web search: `"CBOE put call ratio today"` | Options sentiment (>1.0 = bearish, <0.7 = bullish) |
| Short Interest | `finviz.com/quote.ashx?t=AAPL` -> Short Float | Percent of float sold short |
| Insider Trading | `openinsider.com/screener` | CEO/CFO buy/sell patterns |
### Macro Economic Data
| Source | URL | Data Available |
|--------|-----|----------------|
| FRED | `fred.stlouisfed.org` | Interest rates, CPI, employment, GDP, M2, yield curve |
| Treasury.gov | `treasury.gov/resource-center/data-chart-center/interest-rates/` | Daily Treasury yield curve |
| CME FedWatch | Web search: `"CME FedWatch tool"` | Federal funds rate probabilities |
| BLS | `bls.gov/news.release/` | Employment situation, CPI, PPI |
| ISM | Web search: `"ISM manufacturing PMI"` | PMI (>50 = expansion, <50 = contraction) |
| Conference Board | Web search: `"consumer confidence index"` | Consumer confidence, leading indicators |
| Earnings Calendar | `earningswhispers.com/calendar` | Upcoming earnings dates |
| Economic Calendar | Web search: `"economic calendar this week"` | Scheduled data releases |
### Crypto-Specific Sources
| Source | URL | Data Available |
|--------|-----|----------------|
| CoinGecko | `coingecko.com` | Prices, market cap, volume, DeFi TVL |
| DefiLlama | `defillama.com` | Total Value Locked across all chains |
| Glassnode (free tier) | Web search: `"bitcoin on-chain metrics"` | On-chain analytics (NUPL, MVRV, exchange flows) |
| Bitcoin Fear & Greed | `alternative.me/crypto/fear-and-greed-index/` | Crypto-specific sentiment 0-100 |
| Ultrasound Money | `ultrasound.money` | ETH supply/burn metrics |
---
## 6. Confidence Calibration Guide (Superforecasting)
### Calibration Principles (Philip Tetlock)
- A "70% confident" prediction should be right about 70% of the time
- Most people are overconfident: their "90%" predictions are right only ~70%
- Track your predictions systematically and compare predicted vs actual frequency
- Update incrementally (2-5% per new piece of evidence), not dramatically
### Confidence Level Guide
| Level | Meaning | Evidence Required | Trading Action |
|-------|---------|-------------------|----------------|
| 20-30% | Slight lean | Single weak signal, limited data | No trade — insufficient edge |
| 40-50% | Toss-up with slight edge | Conflicting signals, moderate evidence | No trade — coin flip |
| 55-65% | Moderate conviction | Multiple aligned signals, historical precedent | Small position, wide stops |
| 70-80% | Strong conviction | Strong multi-factor alignment, catalyst identified | Standard position size |
| 85-95% | Very high conviction | Overwhelming evidence — be suspicious of yourself | Full position, but NEVER all-in |
### Brier Score for Trade Predictions
```
Brier Score = mean((predicted_probability - actual_outcome)^2)
actual_outcome: 1 if prediction was correct, 0 if wrong
Worked example (5 predictions):
Pred 1: 80% confident -> correct (1) -> (0.80 - 1)^2 = 0.04
Pred 2: 60% confident -> wrong (0) -> (0.60 - 0)^2 = 0.36
Pred 3: 70% confident -> correct (1) -> (0.70 - 1)^2 = 0.09
Pred 4: 90% confident -> correct (1) -> (0.90 - 1)^2 = 0.01
Pred 5: 55% confident -> wrong (0) -> (0.55 - 0)^2 = 0.30
Brier Score = (0.04 + 0.36 + 0.09 + 0.01 + 0.30) / 5 = 0.16
Ratings: 0.00 = perfect, < 0.15 = excellent, 0.15-0.25 = good,
0.25 = coin flip, > 0.25 = worse than random
```
### Calibration Self-Check Protocol
After accumulating 20+ trade predictions, group by confidence bucket:
1. Are your 60% predictions right ~60% of the time?
2. If your 60% predictions are right 80% of the time, you are underconfident — adjust up
3. If your 80% predictions are right 55% of the time, you are overconfident — adjust down
4. Recalibrate your confidence scale after every 50 resolved predictions
---
## 7. Trading Psychology & Cognitive Biases
### Biases to Watch For
| Bias | Description | Mitigation |
|------|-------------|------------|
| **Confirmation Bias** | Seeking info that confirms your thesis | Always build the opposing case first (adversarial debate) |
| **Anchoring** | Over-weighting the first number you see (entry price, analyst target) | Start analysis from base rates and current data, not old prices |
| **Recency Bias** | Over-weighting recent events (last week's crash, last month's rally) | Look at longer timeframes — 6-month and 1-year charts minimum |
| **Loss Aversion** | Holding losers too long ("it'll come back"), cutting winners too fast | Use mechanical stop-losses and take-profit targets, set BEFORE entry |
| **Overconfidence** | Believing you are more right than you are | Track Brier scores, use Kelly fractions, never bet > 2% per trade |
| **Narrative Bias** | Compelling story = good trade (often false) | Focus on quantitative data, not stories. "Good company" != "good trade" |
| **FOMO** | Fear of missing out, chasing entries | Only enter at planned levels. The market is open 252 days a year |
| **Sunk Cost** | "I've lost so much, I can't sell now" | Each moment is a new decision. Ask: "Would I enter this trade NOW at current price?" |
| **Hindsight Bias** | "I knew that would happen" | Journal BEFORE trades with specific predictions, not after |
| **Disposition Effect** | Selling winners early to "lock in profits" but holding losers | Let winners run (trail stops), cut losers at planned stops |
| **Gambler's Fallacy** | "It's dropped 5 days in a row, it HAS to bounce" | Each day is independent. Trends persist more often than they reverse |
| **Endowment Effect** | Overvaluing positions you already own | Evaluate positions as if you were building from scratch today |
### Discipline Rules
1. Every trade has a written plan BEFORE entry: entry price, stop loss, target, position size, thesis
2. Write down your reasoning BEFORE entering — if you cannot articulate the edge, do not trade
3. Set stop-losses at order entry time, not "in your head"
4. Review your journal weekly — look for patterns in wins AND losses
5. Take breaks after big wins (overconfidence risk) AND big losses (emotional risk)
6. Never average down on a losing position unless the original thesis explicitly planned for it
7. Never move a stop-loss further away from your entry (only tighten, never widen)
8. The market will be there tomorrow — missing a trade is not a loss, but a blown account is
---
## 8. Portfolio Construction
### Asset Allocation Guidelines
| Style | Equities | Crypto | Fixed Income / Cash | Max Single Position |
|-------|----------|--------|---------------------|---------------------|
| Conservative | 50-60% | 0-5% | 35-50% | 5% |
| Moderate | 60-75% | 5-15% | 10-35% | 8% |
| Aggressive | 70-85% | 10-25% | 5-20% | 10% |
| Speculative | 50-70% | 20-40% | 5-10% | 15% (with strict stops) |
### Sector Diversification
Maximum 30% in any single sector:
- Technology, Healthcare, Financials, Consumer Discretionary, Consumer Staples
- Energy, Industrials, Utilities, Real Estate, Materials, Communication Services
### Correlation Awareness
Highly correlated positions amplify risk. Check correlations before adding:
| Pair | Typical Correlation | Risk |
|------|---------------------|------|
| AAPL + MSFT + GOOGL | 0.7-0.9 | Concentrated large-cap tech |
| BTC + ETH + SOL | 0.8-0.95 | Concentrated crypto (moves together) |
| SPY + QQQ | 0.9+ | Nearly identical exposure |
| Stocks + Bonds | -0.2 to 0.3 | Genuinely diversifying |
| Gold + Stocks | -0.1 to 0.2 | Hedge in crisis |
| VIX + SPY | -0.8 | Inverse — VIX as hedge |
### Rebalancing Rules
- **Calendar**: Rebalance quarterly (first trading day of quarter)
- **Threshold**: Rebalance when any allocation drifts > 5% from target
- **Tax-aware**: Prefer rebalancing via new contributions rather than selling (taxable accounts)
---
## 9. Cross-Platform Commands
### Windows (PowerShell / Git Bash)
```bash
# Python might be `python` not `python3` on Windows
python -c "import json; ..."
# Use forward slashes in file paths or escape backslashes
# curl is available via Git Bash, PowerShell, or WSL
# Check if market is open (Windows Git Bash)
curl -s "$BASE_URL/v2/clock" -H "APCA-API-KEY-ID: $ALPACA_API_KEY" \
-H "APCA-API-SECRET-KEY: $ALPACA_SECRET_KEY" | python -c "
import sys, json
d = json.load(sys.stdin)
print('OPEN' if d['is_open'] else 'CLOSED', '| Next:', d.get('next_open','') or d.get('next_close',''))
"
```
### macOS / Linux
```bash
python3 -c "import json; ..."
# curl, jq typically available by default
# Use jq for JSON processing:
curl -s URL | jq '.equity'
```
### JSON Processing Without jq
```bash
# Pretty-print JSON
python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin),indent=2))" < file.json
# Extract specific field
curl -s URL | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['equity'])"
# Parse Alpaca positions into readable table
curl -s "$BASE_URL/v2/positions" $HEADERS | python3 -c "
import sys, json
positions = json.load(sys.stdin)
fmt = '{:<8} {:>6} {:>10} {:>10} {:>12} {:>8}'
print(fmt.format('Symbol','Qty','Entry','Current','P/L','P/L pct'))
print('-' * 60)
for p in positions:
print(fmt.format(p['symbol'], p['qty'], float(p['avg_entry_price']),
float(p['current_price']), float(p['unrealized_pl']),
round(float(p['unrealized_plpc'])*100,2)))
"
# Calculate RSI from historical bars
curl -s "$DATA_URL/v2/stocks/AAPL/bars?timeframe=1Day&limit=30" $HEADERS | python3 -c "
import sys, json
data = json.load(sys.stdin)
closes = [float(b['c']) for b in data['bars']]
changes = [closes[i]-closes[i-1] for i in range(1, len(closes))]
gains = [max(c,0) for c in changes[-14:]]
losses = [abs(min(c,0)) for c in changes[-14:]]
avg_gain = sum(gains)/14
avg_loss = sum(losses)/14
rs = avg_gain/avg_loss if avg_loss > 0 else 999
rsi = 100 - (100/(1+rs))
print(f'RSI(14) = {rsi:.1f}')
"
```
---
## 10. Pre-Trade Checklist
Before every trade, verify ALL of the following:
```
PRE-TRADE CHECKLIST
====================
[ ] 1. TREND: What is the higher-timeframe trend? (Daily chart 200 SMA)
- Trading WITH the trend? (preferred)
- Counter-trend? (requires stronger signal + tighter stops)
[ ] 2. SIGNAL: What specific setup triggered this trade?
- Indicator signal (RSI, MACD, etc.)
- Pattern (candlestick, chart pattern)
- Catalyst (earnings, news, sector rotation)
[ ] 3. ENTRY: Exact entry price or condition
- Limit order at specific level? Market order on breakout?
[ ] 4. STOP LOSS: Exact stop price
- Based on ATR (2-3x ATR from entry)
- Below key support (long) or above key resistance (short)
- NEVER wider than 2% of portfolio
[ ] 5. TARGET: Exact take-profit price
- Risk/Reward at least 1.5:1 (preferably 2:1+)
- At logical resistance (long) or support (short)
[ ] 6. POSITION SIZE: Calculated from risk management rules
- Risk amount = Portfolio * 1-2%
- Shares = Risk amount / (Entry - Stop)
- Total position < 10% of portfolio
[ ] 7. CORRELATION CHECK: Does this overlap with existing positions?
- Not adding to concentrated sector exposure
- Total portfolio heat (sum of open risk) < 6%
[ ] 8. CATALYST CHECK: Any upcoming events that could gap through stops?
- Earnings date? Fed meeting? CPI release?
- If yes: reduce size or wait until after event
[ ] 9. MARKET CONTEXT: Is the overall market favorable?
- Fear & Greed index level
- VIX level (>30 = caution, <15 = complacency risk)
- Market trend (SPY vs 200 SMA)
[ ] 10. CONFIDENCE: Rate 1-10 honestly
- Below 6? Skip the trade
- Record confidence for calibration tracking
```
---
## 11. Trade Journal Template
```json
{
"trade_id": "T001",
"date_opened": "2025-01-15",
"date_closed": null,
"symbol": "AAPL",
"side": "long",
"entry_price": 150.00,
"stop_loss": 145.00,
"target": 162.00,
"position_size": 40,
"risk_amount": 200.00,
"risk_reward": 2.4,
"setup": "Bullish engulfing at 50 EMA + RSI divergence",
"confidence": 7,
"market_context": "SPY above 200 SMA, VIX at 18, F&G neutral (52)",
"pre_trade_thesis": "AAPL pulled back to 50 EMA support, RSI showing bullish divergence, earnings in 3 weeks should provide catalyst. Sector (tech) is leading.",
"result": {
"exit_price": null,
"exit_reason": null,
"pnl": null,
"pnl_percent": null,
"held_days": null,
"lessons": null
}
}
```
Store trade journals using `memory_store` for tracking and calibration review.
+32 -7
View File
@@ -1,6 +1,6 @@
//! Compile-time embedded Hand definitions.
use crate::{HandDefinition, HandError};
use crate::{parse_hand_toml, HandDefinition, HandError};
/// Returns all bundled hand definitions as (id, HAND.toml content, SKILL.md content).
pub fn bundled_hands() -> Vec<(&'static str, &'static str, &'static str)> {
@@ -40,6 +40,11 @@ pub fn bundled_hands() -> Vec<(&'static str, &'static str, &'static str)> {
include_str!("../bundled/browser/HAND.toml"),
include_str!("../bundled/browser/SKILL.md"),
),
(
"trader",
include_str!("../bundled/trader/HAND.toml"),
include_str!("../bundled/trader/SKILL.md"),
),
]
}
@@ -50,7 +55,7 @@ pub fn parse_bundled(
skill_content: &str,
) -> Result<HandDefinition, HandError> {
let mut def: HandDefinition =
toml::from_str(toml_content).map_err(|e| HandError::TomlParse(e.to_string()))?;
parse_hand_toml(toml_content).map_err(|e| HandError::TomlParse(e.to_string()))?;
if !skill_content.is_empty() {
def.skill_content = Some(skill_content.to_string());
}
@@ -71,7 +76,7 @@ mod tests {
#[test]
fn bundled_hands_count() {
let hands = bundled_hands();
assert_eq!(hands.len(), 7);
assert_eq!(hands.len(), 8);
}
#[test]
@@ -187,7 +192,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()));
@@ -201,6 +206,26 @@ mod tests {
assert_eq!(def.agent.max_iterations, Some(60));
}
#[test]
fn parse_trader_hand() {
let (id, toml_content, skill_content) = bundled_hands()
.into_iter()
.find(|(id, _, _)| *id == "trader")
.unwrap();
let def = parse_bundled(id, toml_content, skill_content).unwrap();
assert_eq!(def.id, "trader");
assert_eq!(def.name, "Trading Hand");
assert_eq!(def.category, crate::HandCategory::Data);
assert!(def.skill_content.is_some());
assert!(def.requires.is_empty()); // no hard requirements
assert!(!def.tools.is_empty());
assert!(def.tools.contains(&"event_publish".to_string()));
assert!(!def.settings.is_empty());
assert!(!def.dashboard.metrics.is_empty());
assert!((def.agent.temperature - 0.3).abs() < f32::EPSILON);
assert_eq!(def.agent.max_iterations, Some(80));
}
#[test]
fn all_bundled_hands_parse() {
for (id, toml_content, skill_content) in bundled_hands() {
@@ -216,7 +241,7 @@ mod tests {
#[test]
fn all_einstein_hands_have_schedules() {
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter"];
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"];
for (id, toml_content, skill_content) in bundled_hands() {
if einstein_ids.contains(&id) {
let def = parse_bundled(id, toml_content, skill_content).unwrap();
@@ -241,7 +266,7 @@ mod tests {
#[test]
fn all_einstein_hands_have_memory() {
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter"];
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"];
for (id, toml_content, skill_content) in bundled_hands() {
if einstein_ids.contains(&id) {
let def = parse_bundled(id, toml_content, skill_content).unwrap();
@@ -261,7 +286,7 @@ mod tests {
#[test]
fn all_einstein_hands_have_knowledge_graph() {
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter"];
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"];
for (id, toml_content, skill_content) in bundled_hands() {
if einstein_ids.contains(&id) {
let def = parse_bundled(id, toml_content, skill_content).unwrap();
+69
View File
@@ -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>;
@@ -115,6 +117,13 @@ pub struct HandRequirement {
/// Human-readable description of why this is needed.
#[serde(default)]
pub description: Option<String>,
/// Whether this requirement is optional (non-critical).
///
/// Optional requirements do not block activation. When an active hand has
/// unmet optional requirements it is reported as "degraded" rather than
/// "requirements not met".
#[serde(default)]
pub optional: bool,
/// Platform-specific installation instructions.
#[serde(default)]
pub install: Option<HandInstallInfo>,
@@ -297,6 +306,20 @@ fn default_temperature() -> f32 {
0.7
}
#[derive(Deserialize)]
struct HandTomlWrapper {
hand: HandDefinition,
}
/// Parse HAND.toml content, supporting both flat format and `[hand]` table format.
pub fn parse_hand_toml(content: &str) -> Result<HandDefinition, toml::de::Error> {
if let Ok(def) = toml::from_str::<HandDefinition>(content) {
return Ok(def);
}
let wrapper: HandTomlWrapper = toml::from_str(content)?;
Ok(wrapper.hand)
}
/// Complete Hand definition — parsed from HAND.toml.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandDefinition {
@@ -789,4 +812,50 @@ metrics = []
assert!(install.macos.is_none());
assert!(install.windows.is_none());
}
#[test]
fn parse_hand_toml_flat_format() {
let toml_str = r#"
id = "test"
name = "Test Hand"
description = "A test hand"
category = "content"
tools = ["shell_exec"]
[agent]
name = "test-hand"
description = "Test agent"
system_prompt = "You are a test agent."
[dashboard]
metrics = []
"#;
let def = parse_hand_toml(toml_str).unwrap();
assert_eq!(def.id, "test");
assert_eq!(def.name, "Test Hand");
}
#[test]
fn parse_hand_toml_wrapped_format() {
let toml_str = r#"
[hand]
id = "test"
name = "Test Hand"
description = "A test hand"
category = "content"
tools = ["shell_exec"]
[hand.agent]
name = "test-hand"
description = "Test agent"
system_prompt = "You are a test agent."
[hand.dashboard]
metrics = []
"#;
let def = parse_hand_toml(toml_str).unwrap();
assert_eq!(def.id, "test");
assert_eq!(def.name, "Test Hand");
assert_eq!(def.agent.name, "test-hand");
}
}
+312 -4
View File
@@ -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();
@@ -299,6 +352,46 @@ impl HandRegistry {
entry.updated_at = chrono::Utc::now();
Ok(())
}
/// Compute readiness for a hand, cross-referencing requirements with
/// active instance state.
///
/// Returns `None` if the hand definition does not exist.
pub fn readiness(&self, hand_id: &str) -> Option<HandReadiness> {
let reqs = self.check_requirements(hand_id).ok()?;
let requirements_met = reqs.iter().all(|(_, ok)| *ok);
// A hand is active if at least one instance is in Active status.
let active = self.instances.iter().any(|entry| {
entry.hand_id == hand_id && entry.status == HandStatus::Active
});
// Degraded: active, but at least one non-optional requirement is unmet
// OR any optional requirement is unmet. In practice, the most useful
// definition is: active + any requirement unsatisfied.
let degraded = active && reqs.iter().any(|(_, ok)| !ok);
Some(HandReadiness {
requirements_met,
active,
degraded,
})
}
}
/// Readiness snapshot for a hand definition — combines requirement checks
/// with runtime activation state so the API can report unambiguous status.
#[derive(Debug, Clone, Serialize)]
pub struct HandReadiness {
/// Whether all declared requirements are currently satisfied.
pub requirements_met: bool,
/// Whether the hand currently has a running (Active-status) instance.
pub active: bool,
/// Whether the hand is active but some requirements are unmet.
/// This means the hand is running in a degraded mode — some features
/// may not work (e.g. browser hand without chromium).
pub degraded: bool,
}
impl Default for HandRegistry {
@@ -311,13 +404,18 @@ impl Default for HandRegistry {
fn check_requirement(req: &HandRequirement) -> bool {
match req.requirement_type {
RequirementType::Binary => {
// 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.
// For python3, also try "python" (Windows ships python not python3).
if which_binary(&req.check_value) {
return true;
}
if req.check_value == "python3" {
return which_binary("python");
if req.check_value == "chromium" {
return check_chromium_available();
}
false
}
@@ -330,6 +428,131 @@ 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 Chromium (or Chrome) is available anywhere on the system.
///
/// Checks in order:
/// 1. CHROME_PATH / CHROMIUM_PATH env vars
/// 2. Common binary names on PATH (chromium, chromium-browser, google-chrome, etc.)
/// 3. Well-known install paths (Windows Program Files, macOS Applications, Linux /usr)
/// 4. Playwright cache (~/.cache/ms-playwright/chromium-*)
fn check_chromium_available() -> bool {
// 1. Env vars
for var in &["CHROME_PATH", "CHROMIUM_PATH"] {
if let Ok(p) = std::env::var(var) {
if !p.is_empty() && std::path::Path::new(&p).exists() {
return true;
}
}
}
// 2. Common binary names on PATH
let names = [
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"chrome",
];
for name in &names {
if which_binary(name) {
return true;
}
}
// 3. Well-known install paths
let known_paths: Vec<std::path::PathBuf> = if cfg!(windows) {
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into());
let pf86 =
std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".into());
let local = std::env::var("LOCALAPPDATA").unwrap_or_default();
vec![
std::path::PathBuf::from(&pf).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&pf86).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&pf).join(r"Chromium\Application\chrome.exe"),
std::path::PathBuf::from(&local).join(r"Chromium\Application\chrome.exe"),
std::path::PathBuf::from(&pf).join(r"Microsoft\Edge\Application\msedge.exe"),
]
} else if cfg!(target_os = "macos") {
vec![
std::path::PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
std::path::PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
]
} else {
vec![
std::path::PathBuf::from("/usr/bin/chromium"),
std::path::PathBuf::from("/usr/bin/chromium-browser"),
std::path::PathBuf::from("/usr/bin/google-chrome"),
std::path::PathBuf::from("/usr/bin/google-chrome-stable"),
std::path::PathBuf::from("/snap/bin/chromium"),
]
};
for p in &known_paths {
if p.exists() {
return true;
}
}
// 4. Playwright cache
if let Some(home) = std::env::var("HOME")
.ok()
.or_else(|| std::env::var("USERPROFILE").ok())
{
let pw_cache = std::path::Path::new(&home).join(".cache/ms-playwright");
if pw_cache.is_dir() {
if let Ok(entries) = std::fs::read_dir(&pw_cache) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("chromium-") && entry.path().is_dir() {
return true;
}
}
}
}
}
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();
@@ -397,7 +620,7 @@ mod tests {
fn load_bundled_hands() {
let reg = HandRegistry::new();
let count = reg.load_bundled();
assert_eq!(count, 7);
assert_eq!(count, 8);
assert!(!reg.list_definitions().is_empty());
// Clip hand should be loaded
@@ -537,6 +760,7 @@ mod tests {
requirement_type: RequirementType::EnvVar,
check_value: "OPENFANG_TEST_HAND_REQ".to_string(),
description: None,
optional: false,
install: None,
};
assert!(check_requirement(&req));
@@ -547,9 +771,93 @@ mod tests {
requirement_type: RequirementType::EnvVar,
check_value: "OPENFANG_NONEXISTENT_VAR_12345".to_string(),
description: None,
optional: false,
install: None,
};
assert!(!check_requirement(&req_missing));
std::env::remove_var("OPENFANG_TEST_HAND_REQ");
}
#[test]
fn readiness_nonexistent_hand() {
let reg = HandRegistry::new();
assert!(reg.readiness("nonexistent").is_none());
}
#[test]
fn readiness_inactive_hand() {
let reg = HandRegistry::new();
reg.load_bundled();
// Lead hand has no requirements, so requirements_met = true
let r = reg.readiness("lead").unwrap();
assert!(r.requirements_met);
assert!(!r.active);
assert!(!r.degraded);
}
#[test]
fn readiness_active_hand_all_met() {
let reg = HandRegistry::new();
reg.load_bundled();
// Lead hand has no requirements — activate it
let instance = reg.activate("lead", HashMap::new()).unwrap();
let r = reg.readiness("lead").unwrap();
assert!(r.requirements_met);
assert!(r.active);
assert!(!r.degraded); // all met, so not degraded
reg.deactivate(instance.instance_id).unwrap();
}
#[test]
fn readiness_active_hand_degraded() {
let reg = HandRegistry::new();
reg.load_bundled();
// Browser hand requires python3 + chromium. Activate it — if either
// requirement is unmet on this machine, it will show as degraded.
let instance = reg.activate("browser", HashMap::new()).unwrap();
let r = reg.readiness("browser").unwrap();
assert!(r.active);
// If any requirement is not satisfied, degraded should be true
if !r.requirements_met {
assert!(r.degraded);
} else {
assert!(!r.degraded);
}
reg.deactivate(instance.instance_id).unwrap();
}
#[test]
fn readiness_paused_hand_not_active() {
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("lead", HashMap::new()).unwrap();
reg.pause(instance.instance_id).unwrap();
let r = reg.readiness("lead").unwrap();
assert!(!r.active); // Paused is not Active
assert!(!r.degraded);
reg.deactivate(instance.instance_id).unwrap();
}
#[test]
fn optional_field_defaults_false() {
let req = HandRequirement {
key: "test".to_string(),
label: "test".to_string(),
requirement_type: RequirementType::Binary,
check_value: "test".to_string(),
description: None,
optional: false,
install: None,
};
assert!(!req.optional);
}
}
+1
View File
@@ -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 }
+19
View File
@@ -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 {
+421 -6
View File
@@ -216,6 +216,67 @@ 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
}
/// Remove all cron jobs belonging to a specific agent.
///
/// Used when an agent is deleted so its cron entries don't linger as
/// orphans pointing at a dead UUID. Returns the number of jobs removed.
pub fn remove_agent_jobs(&self, agent_id: AgentId) -> usize {
let ids: Vec<CronJobId> = self
.jobs
.iter()
.filter(|r| r.value().job.agent_id == agent_id)
.map(|r| *r.key())
.collect();
let count = ids.len();
for id in ids {
self.jobs.remove(&id);
}
if count > 0 {
info!(agent = %agent_id, count, "Removed cron jobs for deleted agent");
}
count
}
/// Total number of tracked jobs.
pub fn total_jobs(&self) -> usize {
self.jobs.len()
@@ -324,7 +385,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 +402,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 +445,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 +871,335 @@ 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());
}
}
// -- remove_agent_jobs (#504) -------------------------------------------
#[test]
fn test_remove_agent_jobs_basic() {
let (sched, _tmp) = make_scheduler(100);
let agent = AgentId::new();
let other = AgentId::new();
let mut j1 = make_job(agent);
j1.name = "job-a".into();
let mut j2 = make_job(agent);
j2.name = "job-b".into();
let mut j3 = make_job(other);
j3.name = "job-other".into();
sched.add_job(j1, false).unwrap();
sched.add_job(j2, false).unwrap();
let id3 = sched.add_job(j3, false).unwrap();
assert_eq!(sched.total_jobs(), 3);
let removed = sched.remove_agent_jobs(agent);
assert_eq!(removed, 2);
assert_eq!(sched.total_jobs(), 1);
// The other agent's job should still exist
assert!(sched.list_jobs(agent).is_empty());
assert_eq!(sched.list_jobs(other).len(), 1);
assert!(sched.get_job(id3).is_some());
}
#[test]
fn test_remove_agent_jobs_no_match() {
let (sched, _tmp) = make_scheduler(100);
let agent = AgentId::new();
let job = make_job(agent);
sched.add_job(job, false).unwrap();
// Remove for a non-existent agent
let removed = sched.remove_agent_jobs(AgentId::new());
assert_eq!(removed, 0);
assert_eq!(sched.total_jobs(), 1);
}
#[test]
fn test_remove_agent_jobs_persists() {
let tmp = tempfile::tempdir().unwrap();
let agent = AgentId::new();
let other = AgentId::new();
// Add jobs for two agents, remove one agent's jobs, persist
{
let sched = CronScheduler::new(tmp.path(), 100);
let mut j1 = make_job(agent);
j1.name = "doomed".into();
let mut j2 = make_job(other);
j2.name = "survivor".into();
sched.add_job(j1, false).unwrap();
sched.add_job(j2, false).unwrap();
sched.remove_agent_jobs(agent);
sched.persist().unwrap();
}
// Reload and verify
{
let sched = CronScheduler::new(tmp.path(), 100);
sched.load().unwrap();
assert_eq!(sched.total_jobs(), 1);
assert!(sched.list_jobs(agent).is_empty());
assert_eq!(sched.list_jobs(other).len(), 1);
}
}
}
File diff suppressed because it is too large Load Diff
+26 -6
View File
@@ -128,6 +128,7 @@ impl MeteringEngine {
0.0
},
alert_threshold: budget.alert_threshold,
default_max_llm_tokens_per_hour: budget.default_max_llm_tokens_per_hour,
}
}
@@ -224,6 +225,8 @@ pub struct BudgetStatus {
pub monthly_limit: f64,
pub monthly_pct: f64,
pub alert_threshold: f64,
/// Global default token limit per agent per hour (0 = use per-agent values).
pub default_max_llm_tokens_per_hour: u64,
}
/// Returns (input_per_million, output_per_million) pricing for a model.
@@ -343,6 +346,11 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
return (0.40, 0.40);
}
// ── Chutes.ai ──────────────────────────────────────────────
if model.contains("chutes") {
return (0.25, 0.35);
}
// ── Venice.ai ──────────────────────────────────────────────
if model.contains("venice") {
return (0.20, 0.90);
@@ -376,22 +384,34 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
}
// ── MiniMax ──────────────────────────────────────────────────
if model.contains("minimax") {
if model.contains("minimax") || model.contains("abab") {
if model.contains("highspeed") {
return (0.80, 3.20);
}
if model.contains("m2.5") {
return (1.10, 4.40);
}
if model.contains("abab7") {
return (0.80, 2.40);
}
return (1.00, 3.00);
}
// ── Zhipu / GLM ─────────────────────────────────────────────
if model.contains("glm-5") {
return (2.00, 8.00);
return (1.00, 3.20);
}
if model.contains("glm-4.7") {
return (1.50, 5.00);
return (0.60, 2.20);
}
if model.contains("glm-4-flash") {
return (0.10, 0.10);
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);
+4
View File
@@ -284,6 +284,7 @@ impl AgentRegistry {
hourly: Option<f64>,
daily: Option<f64>,
monthly: Option<f64>,
tokens_per_hour: Option<u64>,
) -> OpenFangResult<()> {
let mut entry = self
.agents
@@ -298,6 +299,9 @@ impl AgentRegistry {
if let Some(v) = monthly {
entry.manifest.resources.max_cost_per_month_usd = v;
}
if let Some(v) = tokens_per_hour {
entry.manifest.resources.max_llm_tokens_per_hour = v;
}
entry.last_active = chrono::Utc::now();
Ok(())
}
+13
View File
@@ -130,6 +130,19 @@ impl AgentScheduler {
.get(&agent_id)
.map(|t| (t.total_tokens, t.tool_calls))
}
/// Returns remaining token headroom before quota is hit.
/// Returns `None` if no token quota is configured (unlimited).
pub fn token_headroom(&self, agent_id: AgentId) -> Option<u64> {
let quota = self.quotas.get(&agent_id)?;
if quota.max_llm_tokens_per_hour == 0 {
return None;
}
let mut tracker = self.usage.get_mut(&agent_id)?;
tracker.reset_if_expired();
let used = tracker.total_tokens;
Some(quota.max_llm_tokens_per_hour.saturating_sub(used))
}
}
impl Default for AgentScheduler {
+222 -1
View File
@@ -143,6 +143,109 @@ impl TriggerEngine {
}
}
/// Take all triggers for an agent, removing them from the engine.
///
/// Returns the extracted triggers so they can be restored under a
/// different agent ID via [`restore_triggers`]. This is used during
/// hand reactivation: triggers must be saved before `kill_agent`
/// destroys them, then restored with the new agent ID after spawn.
pub fn take_agent_triggers(&self, agent_id: AgentId) -> Vec<Trigger> {
let trigger_ids = self
.agent_triggers
.remove(&agent_id)
.map(|(_, ids)| ids)
.unwrap_or_default();
let mut taken = Vec::with_capacity(trigger_ids.len());
for id in trigger_ids {
if let Some((_, t)) = self.triggers.remove(&id) {
taken.push(t);
}
}
if !taken.is_empty() {
info!(
agent = %agent_id,
count = taken.len(),
"Took triggers for agent (pending reassignment)"
);
}
taken
}
/// Restore previously taken triggers under a new agent ID.
///
/// Each trigger keeps its original pattern, prompt template, fire count,
/// and max_fires, but is re-keyed to `new_agent_id`. New trigger IDs are
/// generated so there are no stale references.
///
/// Returns the number of triggers restored.
pub fn restore_triggers(&self, new_agent_id: AgentId, triggers: Vec<Trigger>) -> usize {
let count = triggers.len();
for old in triggers {
let new_id = TriggerId::new();
let trigger = Trigger {
id: new_id,
agent_id: new_agent_id,
pattern: old.pattern,
prompt_template: old.prompt_template,
enabled: old.enabled,
created_at: old.created_at,
fire_count: old.fire_count,
max_fires: old.max_fires,
};
self.triggers.insert(new_id, trigger);
self.agent_triggers
.entry(new_agent_id)
.or_default()
.push(new_id);
}
if count > 0 {
info!(
agent = %new_agent_id,
count,
"Restored triggers under new agent"
);
}
count
}
/// Reassign all triggers from one agent to another in place.
///
/// Used during cold boot when the old agent ID (from persisted state) no
/// longer exists and a new agent was spawned. Updates the `agent_id` field
/// on each trigger and moves the index entry.
///
/// Returns the number of triggers reassigned.
pub fn reassign_agent_triggers(
&self,
old_agent_id: AgentId,
new_agent_id: AgentId,
) -> usize {
let trigger_ids = self
.agent_triggers
.remove(&old_agent_id)
.map(|(_, ids)| ids)
.unwrap_or_default();
let count = trigger_ids.len();
for id in &trigger_ids {
if let Some(mut t) = self.triggers.get_mut(id) {
t.agent_id = new_agent_id;
}
}
if !trigger_ids.is_empty() {
self.agent_triggers
.entry(new_agent_id)
.or_default()
.extend(trigger_ids);
info!(
old_agent = %old_agent_id,
new_agent = %new_agent_id,
count,
"Reassigned triggers to new agent"
);
}
count
}
/// Enable or disable a trigger. Returns true if the trigger was found.
pub fn set_enabled(&self, trigger_id: TriggerId, enabled: bool) -> bool {
if let Some(mut t) = self.triggers.get_mut(&trigger_id) {
@@ -278,7 +381,7 @@ fn describe_event(event: &Event) -> String {
tr.tool_id,
if tr.success { "succeeded" } else { "failed" },
tr.execution_time_ms,
&tr.content[..tr.content.len().min(200)]
openfang_types::truncate_str(&tr.content, 200)
)
}
EventPayload::MemoryUpdate(delta) => {
@@ -508,4 +611,122 @@ mod tests {
);
assert_eq!(engine.evaluate(&event).len(), 1);
}
// -- reassign_agent_triggers (#519) ------------------------------------
#[test]
fn test_reassign_agent_triggers_basic() {
let engine = TriggerEngine::new();
let old_agent = AgentId::new();
let new_agent = AgentId::new();
engine.register(old_agent, TriggerPattern::All, "a".to_string(), 0);
engine.register(old_agent, TriggerPattern::System, "b".to_string(), 0);
let count = engine.reassign_agent_triggers(old_agent, new_agent);
assert_eq!(count, 2);
assert_eq!(engine.list_agent_triggers(old_agent).len(), 0);
assert_eq!(engine.list_agent_triggers(new_agent).len(), 2);
// Verify triggers actually fire for the new agent
let event = Event::new(
AgentId::new(),
EventTarget::Broadcast,
EventPayload::System(SystemEvent::HealthCheck {
status: "ok".to_string(),
}),
);
let matches = engine.evaluate(&event);
assert_eq!(matches.len(), 2);
assert!(matches.iter().all(|(id, _)| *id == new_agent));
}
#[test]
fn test_reassign_agent_triggers_no_match_returns_zero() {
let engine = TriggerEngine::new();
let agent_a = AgentId::new();
engine.register(agent_a, TriggerPattern::All, "a".to_string(), 0);
let count = engine.reassign_agent_triggers(AgentId::new(), AgentId::new());
assert_eq!(count, 0);
// Original triggers untouched
assert_eq!(engine.list_agent_triggers(agent_a).len(), 1);
}
#[test]
fn test_reassign_does_not_touch_other_agents() {
let engine = TriggerEngine::new();
let agent_a = AgentId::new();
let agent_b = AgentId::new();
let agent_c = AgentId::new();
engine.register(agent_a, TriggerPattern::All, "a".to_string(), 0);
engine.register(agent_b, TriggerPattern::System, "b".to_string(), 0);
let count = engine.reassign_agent_triggers(agent_a, agent_c);
assert_eq!(count, 1);
// agent_b untouched
assert_eq!(engine.list_agent_triggers(agent_b).len(), 1);
assert_eq!(engine.list_agent_triggers(agent_c).len(), 1);
}
// -- take / restore triggers (#519) ------------------------------------
#[test]
fn test_take_and_restore_triggers() {
let engine = TriggerEngine::new();
let old_agent = AgentId::new();
let new_agent = AgentId::new();
engine.register(
old_agent,
TriggerPattern::ContentMatch {
substring: "deploy".to_string(),
},
"Deploy alert: {{event}}".to_string(),
5,
);
engine.register(old_agent, TriggerPattern::Lifecycle, "lc".to_string(), 0);
// Take triggers — engine should be empty for old agent
let taken = engine.take_agent_triggers(old_agent);
assert_eq!(taken.len(), 2);
assert_eq!(engine.list_agent_triggers(old_agent).len(), 0);
assert_eq!(engine.list_all().len(), 0);
// Restore under new agent
let restored = engine.restore_triggers(new_agent, taken);
assert_eq!(restored, 2);
assert_eq!(engine.list_agent_triggers(new_agent).len(), 2);
// Verify patterns and max_fires are preserved
let triggers = engine.list_agent_triggers(new_agent);
let has_content_match = triggers.iter().any(|t| {
matches!(&t.pattern, TriggerPattern::ContentMatch { substring } if substring == "deploy")
&& t.max_fires == 5
});
assert!(has_content_match, "ContentMatch trigger with max_fires=5 should be preserved");
}
#[test]
fn test_take_empty_returns_empty() {
let engine = TriggerEngine::new();
let taken = engine.take_agent_triggers(AgentId::new());
assert!(taken.is_empty());
}
#[test]
fn test_restore_preserves_enabled_state() {
let engine = TriggerEngine::new();
let old_agent = AgentId::new();
let new_agent = AgentId::new();
let tid = engine.register(old_agent, TriggerPattern::All, "a".to_string(), 0);
engine.set_enabled(tid, false);
let taken = engine.take_agent_triggers(old_agent);
assert_eq!(taken.len(), 1);
assert!(!taken[0].enabled);
engine.restore_triggers(new_agent, taken);
let restored = engine.list_agent_triggers(new_agent);
assert_eq!(restored.len(), 1);
assert!(!restored[0].enabled, "Disabled state should survive take/restore");
}
}
+18
View File
@@ -237,6 +237,24 @@ impl WorkflowEngine {
self.workflows.write().await.remove(&id).is_some()
}
/// Update an existing workflow definition.
///
/// Preserves the original `id` and `created_at`. Replaces `name`,
/// `description`, and `steps`. Returns `true` if the workflow was
/// found and updated.
pub async fn update_workflow(&self, id: WorkflowId, updated: Workflow) -> bool {
let mut workflows = self.workflows.write().await;
if let Some(existing) = workflows.get_mut(&id) {
existing.name = updated.name;
existing.description = updated.description;
existing.steps = updated.steps;
info!(workflow_id = %id, "Workflow updated");
true
} else {
false
}
}
/// Maximum number of retained workflow runs. Oldest completed/failed
/// runs are evicted when this limit is exceeded.
const MAX_RETAINED_RUNS: usize = 200;
+30 -1
View File
@@ -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::*;
+3 -3
View File
@@ -557,10 +557,10 @@ impl SessionStore {
MessageContent::Blocks(blocks) => {
for block in blocks {
match block {
ContentBlock::Text { text } => {
ContentBlock::Text { text, .. } => {
text_parts.push(text.clone());
}
ContentBlock::ToolUse { id, name, input } => {
ContentBlock::ToolUse { id, name, input, .. } => {
tool_parts.push(serde_json::json!({
"type": "tool_use",
"id": id,
@@ -587,7 +587,7 @@ impl SessionStore {
ContentBlock::Thinking { thinking } => {
text_parts.push(format!(
"[thinking: {}]",
&thinking[..thinking.len().min(200)]
openfang_types::truncate_str(thinking, 200)
));
}
ContentBlock::Unknown => {}
+90 -58
View File
@@ -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) {
+2
View File
@@ -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 }
File diff suppressed because it is too large Load Diff
+152 -4
View File
@@ -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, &timestamp, &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);
}
}
+11 -3
View File
@@ -356,7 +356,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
MessageContent::Blocks(blocks) => {
for block in blocks {
match block {
ContentBlock::Text { text } => {
ContentBlock::Text { text, .. } => {
if !text.is_empty() {
if oversized && text.len() > config.max_chunk_chars / 4 {
let limit = config.max_chunk_chars / 4;
@@ -448,6 +448,7 @@ async fn summarize_messages(
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::Text {
text: summarize_prompt,
provider_metadata: None,
}]),
}],
tools: vec![],
@@ -563,7 +564,7 @@ async fn summarize_in_chunks(
model: model.to_string(),
messages: vec![Message {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::Text { text: merge_prompt }]),
content: MessageContent::Blocks(vec![ContentBlock::Text { text: merge_prompt, provider_metadata: None }]),
}],
tools: vec![],
max_tokens: config.max_summary_tokens,
@@ -766,6 +767,7 @@ mod tests {
Ok(CompletionResponse {
content: vec![ContentBlock::Text {
text: "Summary of conversation".to_string(),
provider_metadata: None,
}],
stop_reason: openfang_types::message::StopReason::EndTurn,
tool_calls: vec![],
@@ -827,6 +829,7 @@ mod tests {
Ok(CompletionResponse {
content: vec![ContentBlock::Text {
text: "Summary with tools".to_string(),
provider_metadata: None,
}],
stop_reason: openfang_types::message::StopReason::EndTurn,
tool_calls: vec![],
@@ -850,6 +853,7 @@ mod tests {
id: "tu-1".to_string(),
name: "web_search".to_string(),
input: serde_json::json!({"query": "test"}),
provider_metadata: None,
}]),
};
messages[2] = Message {
@@ -918,6 +922,7 @@ mod tests {
Ok(CompletionResponse {
content: vec![ContentBlock::Text {
text: "Summary: discussed topics 0 through 79".to_string(),
provider_metadata: None,
}],
stop_reason: openfang_types::message::StopReason::EndTurn,
tool_calls: vec![],
@@ -1113,6 +1118,7 @@ mod tests {
Ok(CompletionResponse {
content: vec![ContentBlock::Text {
text: format!("Chunk summary {n}"),
provider_metadata: None,
}],
stop_reason: openfang_types::message::StopReason::EndTurn,
tool_calls: vec![],
@@ -1179,11 +1185,13 @@ mod tests {
content: MessageContent::Blocks(vec![
ContentBlock::Text {
text: "Let me search".to_string(),
provider_metadata: None,
},
ContentBlock::ToolUse {
id: "tu-1".to_string(),
name: "web_search".to_string(),
input: serde_json::json!({"query": "rust"}),
provider_metadata: None,
},
]),
},
@@ -1227,7 +1235,7 @@ mod tests {
assert!(
text.contains("truncated from"),
"Oversized message should be truncated, got: {}",
&text[..text.len().min(200)]
crate::str_utils::safe_truncate_str(&text, 200)
);
}
+66 -34
View File
@@ -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);
}
}
+17 -14
View File
@@ -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(())
}
@@ -104,7 +100,7 @@ pub async fn create_sandbox(
let container_name = sanitize_container_name(&format!(
"{}-{}",
config.container_prefix,
&agent_id[..agent_id.len().min(8)]
crate::str_utils::safe_truncate_str(agent_id, 8)
))?;
let mut cmd = tokio::process::Command::new("docker");
@@ -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]

Some files were not shown because too many files have changed in this diff Show More