mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52bacf0946 | ||
|
|
d55e1b8545 | ||
|
|
0c059d1dc1 | ||
|
|
b6b8b4ebe1 | ||
|
|
14f4845170 | ||
|
|
be8a589986 | ||
|
|
951e8d0feb | ||
|
|
98f8d1ca79 | ||
|
|
24f5717ae9 | ||
|
|
f10eefdc0e | ||
|
|
edd0fed518 | ||
|
|
86b50070e8 | ||
|
|
c6aab08faa | ||
|
|
62ec09d0ae | ||
|
|
f6f9cf7e9f | ||
|
|
56aeb499c9 | ||
|
|
b4e6a693f5 | ||
|
|
48d5418c91 | ||
|
|
cc93ef4571 | ||
|
|
3e069798f9 | ||
|
|
ad10aa5e80 | ||
|
|
385aee8e56 | ||
|
|
a00327abe9 | ||
|
|
487555a5e5 | ||
|
|
9d51426cb4 | ||
|
|
6fab720843 | ||
|
|
4667f497ef | ||
|
|
eba9198827 | ||
|
|
f2413949bc | ||
|
|
9e230f423e | ||
|
|
8138b7e0e8 | ||
|
|
cfae867908 |
@@ -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
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -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"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
|
||||
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
|
||||
<img src="https://img.shields.io/badge/version-0.3.30-green?style=flat-square" alt="v0.3.30" />
|
||||
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
@@ -27,9 +27,9 @@
|
||||
|
||||
---
|
||||
|
||||
> **v0.1.0 — First Release (February 2026)**
|
||||
> **v0.3.30 — Security Hardening Release (March 2026)**
|
||||
>
|
||||
> OpenFang is feature-complete but this is the first public release. You may encounter instability, rough edges, or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
> OpenFang is feature-complete but still pre-1.0. You may encounter rough edges or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
|
||||
---
|
||||
|
||||
@@ -371,7 +371,7 @@ cargo fmt --all -- --check
|
||||
|
||||
## Stability Notice
|
||||
|
||||
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
OpenFang v0.3.30 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
|
||||
- **Breaking changes** may occur between minor versions until v1.0
|
||||
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
|
||||
@@ -382,6 +382,12 @@ We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
To report a security vulnerability, email **jaber@rightnowai.co**. We take all reports seriously and will respond within 48 hours.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — use it however you want.
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| 0.1.x | :white_check_mark: |
|
||||
| 0.3.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -14,7 +14,7 @@ If you discover a security vulnerability in OpenFang, please report it responsib
|
||||
|
||||
### How to Report
|
||||
|
||||
1. Email: **security@openfang.ai**
|
||||
1. Email: **jaber@rightnowai.co**
|
||||
2. Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
|
||||
@@ -33,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 }
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(¤t_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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -140,9 +140,23 @@ impl StreamChunker {
|
||||
}
|
||||
|
||||
/// Find the last occurrence of a pattern within a byte range.
|
||||
///
|
||||
/// Both `range.start` and `range.end` are clamped to the nearest valid UTF-8
|
||||
/// char boundary so that slicing never panics on multi-byte content.
|
||||
fn find_last_in_range(text: &str, pattern: &str, range: &std::ops::Range<usize>) -> Option<usize> {
|
||||
let search_text = &text[range.start..range.end.min(text.len())];
|
||||
search_text.rfind(pattern).map(|pos| range.start + pos)
|
||||
let len = text.len();
|
||||
// Clamp end to text length and walk back to a char boundary
|
||||
let mut end = range.end.min(len);
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
// Walk start forward to the nearest char boundary (never past end)
|
||||
let mut start = range.start.min(end);
|
||||
while start < end && !text.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
let search_text = &text[start..end];
|
||||
search_text.rfind(pattern).map(|pos| start + pos)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request to spawn an agent from a TOML manifest string.
|
||||
/// Request to spawn an agent from a TOML manifest string or a template name.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SpawnRequest {
|
||||
/// Agent manifest as TOML string.
|
||||
/// Agent manifest as TOML string (optional if `template` is provided).
|
||||
#[serde(default)]
|
||||
pub manifest_toml: String,
|
||||
/// Template name from `~/.openfang/agents/{template}/agent.toml`.
|
||||
/// When provided and `manifest_toml` is empty, the template is loaded automatically.
|
||||
#[serde(default)]
|
||||
pub template: Option<String>,
|
||||
/// Optional Ed25519 signed manifest envelope (JSON).
|
||||
/// When present, the signature is verified before spawning.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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">×</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)">×</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)">×</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() !== ''">✓ 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 →</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">📈</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()">×</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&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&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&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&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">📈</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 — no API key needed.
|
||||
</div>
|
||||
<div style="background:var(--bg);border-radius:4px;padding:10px 12px;margin-bottom:12px;font-size:12px;line-height:1.8">
|
||||
<div><span style="color:var(--accent)">1.</span> Install: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
|
||||
<div><span style="color:var(--accent)">2.</span> Authenticate: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">claude auth</code></div>
|
||||
<div><span style="color:var(--accent)">3.</span> Click <strong>Detect</strong> below to verify</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="detectClaudeCode()" :disabled="testingProvider">
|
||||
<span x-show="!testingProvider">Detect Claude Code</span>
|
||||
<span x-show="testingProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
||||
</button>
|
||||
<div x-show="testResult" class="mt-2">
|
||||
<div x-show="testResult && testResult.status === 'ok'" class="badge badge-success" style="padding:6px 12px">Claude Code detected<span x-show="testResult && testResult.latency_ms" x-text="' (' + (testResult ? testResult.latency_ms : '') + 'ms)'"></span></div>
|
||||
<div x-show="testResult && testResult.status !== 'ok'" class="badge badge-error" style="padding:6px 12px">Claude Code CLI not detected. Make sure you’ve run: <code>npm install -g @anthropic-ai/claude-code && claude auth</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider !== 'claude-code'">
|
||||
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
|
||||
<div class="card-header" x-text="'Configure ' + selectedProviderObj.display_name"></div>
|
||||
<div class="text-xs text-dim mb-2" x-show="selectedProviderObj.api_key_env">
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -149,7 +149,10 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut PeersState) {
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let id_short = if p.node_id.len() > 12 {
|
||||
format!("{}\u{2026}", &p.node_id[..12])
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&p.node_id, 12)
|
||||
)
|
||||
} else {
|
||||
p.node_id.clone()
|
||||
};
|
||||
|
||||
@@ -251,7 +251,10 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut SessionsState) {
|
||||
.map(|&idx| {
|
||||
let s = &state.sessions[idx];
|
||||
let id_short = if s.id.len() > 12 {
|
||||
format!("{}\u{2026}", &s.id[..12])
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&s.id, 12)
|
||||
)
|
||||
} else {
|
||||
s.id.clone()
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
ProviderInfo {
|
||||
name: "openrouter",
|
||||
env_var: "OPENROUTER_API_KEY",
|
||||
default_model: "anthropic/claude-sonnet-4-20250514",
|
||||
default_model: "google/gemini-2.5-flash",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
@@ -127,6 +127,12 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
default_model: "codegeex-4",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
env_var: "",
|
||||
default_model: "claude-code/sonnet",
|
||||
needs_key: false,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ollama",
|
||||
env_var: "OLLAMA_API_KEY",
|
||||
@@ -215,13 +221,23 @@ impl WizardState {
|
||||
self.provider_order.clear();
|
||||
// Detected providers first
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
if std::env::var(p.env_var).is_ok() {
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
|
||||
};
|
||||
if detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
// Then the rest
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
if std::env::var(p.env_var).is_err() {
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
|
||||
};
|
||||
if !detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
@@ -376,6 +392,8 @@ impl WizardState {
|
||||
|
||||
let api_key_line = if !self.api_key_input.is_empty() {
|
||||
format!("api_key = \"{}\"", self.api_key_input)
|
||||
} else if p.env_var.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("api_key_env = \"{}\"", p.env_var)
|
||||
};
|
||||
@@ -506,9 +524,15 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut WizardState) {
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let p = &PROVIDERS[idx];
|
||||
let hint = if !p.needs_key {
|
||||
let hint = if p.name == "claude-code" {
|
||||
if openfang_runtime::drivers::claude_code::claude_code_available() {
|
||||
"CLI detected".to_string()
|
||||
} else {
|
||||
"no API key needed".to_string()
|
||||
}
|
||||
} else if !p.needs_key {
|
||||
"local, no key needed".to_string()
|
||||
} else if std::env::var(p.env_var).is_ok() {
|
||||
} else if !p.env_var.is_empty() && std::env::var(p.env_var).is_ok() {
|
||||
format!("{} detected", p.env_var)
|
||||
} else {
|
||||
format!("requires {}", p.env_var)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ crossbeam = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
chrono-tz = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@@ -50,6 +50,25 @@ pub fn load_config(path: Option<&Path>) -> KernelConfig {
|
||||
tbl.remove("include");
|
||||
}
|
||||
|
||||
// Migrate misplaced api_key/api_listen from [api] section to root level.
|
||||
// The old config schema incorrectly grouped these under [api], so many
|
||||
// users have them in the wrong place. Move them up if not already at root.
|
||||
if let toml::Value::Table(ref mut tbl) = root_value {
|
||||
if let Some(toml::Value::Table(api_section)) = tbl.get("api").cloned() {
|
||||
for key in &["api_key", "api_listen", "log_level"] {
|
||||
if !tbl.contains_key(*key) {
|
||||
if let Some(val) = api_section.get(*key) {
|
||||
tracing::info!(
|
||||
key,
|
||||
"Migrating misplaced config field from [api] to root level"
|
||||
);
|
||||
tbl.insert(key.to_string(), val.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match root_value.try_into::<KernelConfig>() {
|
||||
Ok(config) => {
|
||||
info!(path = %config_path.display(), "Loaded configuration");
|
||||
|
||||
@@ -241,6 +241,10 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
|
||||
plan.hot_actions.push(HotAction::ReloadProviderUrls);
|
||||
}
|
||||
|
||||
if field_changed(&old.provider_api_keys, &new.provider_api_keys) {
|
||||
plan.noop_changes.push("provider_api_keys changed (takes effect on next driver init)".to_string());
|
||||
}
|
||||
|
||||
// ----- No-op fields -----
|
||||
|
||||
if old.log_level != new.log_level {
|
||||
|
||||
@@ -216,6 +216,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
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 => {}
|
||||
|
||||
@@ -64,11 +64,11 @@ struct OpenClawModels {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct OpenClawRootTools {
|
||||
#[allow(dead_code)]
|
||||
profile: Option<String>,
|
||||
profile: Option<serde_json::Value>,
|
||||
#[allow(dead_code)]
|
||||
allow: Option<Vec<String>>,
|
||||
allow: Option<serde_json::Value>,
|
||||
#[allow(dead_code)]
|
||||
deny: Option<Vec<String>>,
|
||||
deny: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -110,17 +110,36 @@ struct OpenClawAgentEntry {
|
||||
model: Option<OpenClawAgentModel>,
|
||||
tools: Option<OpenClawAgentTools>,
|
||||
workspace: Option<String>,
|
||||
skills: Option<Vec<String>>,
|
||||
skills: Option<serde_json::Value>,
|
||||
identity: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct OpenClawAgentTools {
|
||||
profile: Option<String>,
|
||||
allow: Option<Vec<String>>,
|
||||
deny: Option<Vec<String>>,
|
||||
also_allow: Option<Vec<String>>,
|
||||
profile: Option<serde_json::Value>,
|
||||
allow: Option<serde_json::Value>,
|
||||
deny: Option<serde_json::Value>,
|
||||
also_allow: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Extract a profile name from a Value (string or {name: "..."} object).
|
||||
fn extract_profile(val: &serde_json::Value) -> Option<String> {
|
||||
val.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| val.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
/// Extract a list of strings from a Value (array of strings, single string, or object keys).
|
||||
fn extract_string_list(val: &serde_json::Value) -> Vec<String> {
|
||||
match val {
|
||||
serde_json::Value::Array(arr) => {
|
||||
arr.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect()
|
||||
}
|
||||
serde_json::Value::String(s) => vec![s.clone()],
|
||||
serde_json::Value::Object(map) => map.keys().cloned().collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -149,7 +168,7 @@ struct OpenClawChannels {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct OpenClawTelegramConfig {
|
||||
bot_token: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
group_policy: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
@@ -162,7 +181,7 @@ struct OpenClawDiscordConfig {
|
||||
guilds: Option<serde_json::Value>,
|
||||
dm_policy: Option<String>,
|
||||
group_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -173,7 +192,7 @@ struct OpenClawSlackConfig {
|
||||
app_token: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
group_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -182,7 +201,7 @@ struct OpenClawSlackConfig {
|
||||
struct OpenClawWhatsAppConfig {
|
||||
auth_dir: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
group_policy: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
@@ -195,7 +214,7 @@ struct OpenClawSignalConfig {
|
||||
http_port: Option<u16>,
|
||||
account: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -205,9 +224,9 @@ struct OpenClawMatrixConfig {
|
||||
homeserver: Option<String>,
|
||||
user_id: Option<String>,
|
||||
access_token: Option<String>,
|
||||
rooms: Option<Vec<String>>,
|
||||
rooms: Option<serde_json::Value>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -228,7 +247,7 @@ struct OpenClawTeamsConfig {
|
||||
app_password: Option<String>,
|
||||
tenant_id: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -240,9 +259,9 @@ struct OpenClawIrcConfig {
|
||||
tls: Option<bool>,
|
||||
nick: Option<String>,
|
||||
password: Option<String>,
|
||||
channels: Option<Vec<String>>,
|
||||
channels: Option<serde_json::Value>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -252,7 +271,7 @@ struct OpenClawMattermostConfig {
|
||||
bot_token: Option<String>,
|
||||
base_url: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -272,7 +291,7 @@ struct OpenClawIMessageConfig {
|
||||
cli_path: Option<String>,
|
||||
db_path: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -282,7 +301,7 @@ struct OpenClawBlueBubblesConfig {
|
||||
server_url: Option<String>,
|
||||
password: Option<String>,
|
||||
dm_policy: Option<String>,
|
||||
allow_from: Option<Vec<String>>,
|
||||
allow_from: Option<serde_json::Value>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -488,16 +507,18 @@ fn build_channel_table(
|
||||
fields: Vec<(&str, toml::Value)>,
|
||||
dm_policy: Option<&str>,
|
||||
group_policy: Option<&str>,
|
||||
allow_from: Option<&[String]>,
|
||||
allow_from: Option<&serde_json::Value>,
|
||||
) -> toml::Value {
|
||||
let mut table = toml::map::Map::new();
|
||||
for (key, val) in fields {
|
||||
table.insert(key.to_string(), val);
|
||||
}
|
||||
|
||||
let allow_list = allow_from.map(extract_string_list).unwrap_or_default();
|
||||
|
||||
// Add overrides sub-table if any policy is set
|
||||
let has_overrides =
|
||||
dm_policy.is_some() || group_policy.is_some() || allow_from.is_some_and(|a| !a.is_empty());
|
||||
dm_policy.is_some() || group_policy.is_some() || !allow_list.is_empty();
|
||||
|
||||
if has_overrides {
|
||||
let mut overrides = toml::map::Map::new();
|
||||
@@ -515,14 +536,12 @@ fn build_channel_table(
|
||||
toml::Value::String(mapped.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(users) = allow_from {
|
||||
if !users.is_empty() {
|
||||
let arr: Vec<toml::Value> = users
|
||||
.iter()
|
||||
.map(|u| toml::Value::String(u.clone()))
|
||||
.collect();
|
||||
overrides.insert("allowed_users".to_string(), toml::Value::Array(arr));
|
||||
}
|
||||
if !allow_list.is_empty() {
|
||||
let arr: Vec<toml::Value> = allow_list
|
||||
.iter()
|
||||
.map(|u| toml::Value::String(u.clone()))
|
||||
.collect();
|
||||
overrides.insert("allowed_users".to_string(), toml::Value::Array(arr));
|
||||
}
|
||||
table.insert("overrides".to_string(), toml::Value::Table(overrides));
|
||||
}
|
||||
@@ -811,13 +830,14 @@ fn scan_from_json5(base: &Path, config_path: &Path, result: &mut ScanResult) {
|
||||
.tools
|
||||
.as_ref()
|
||||
.and_then(|t| t.allow.as_ref())
|
||||
.map(|a| a.len())
|
||||
.map(|a| extract_string_list(a).len())
|
||||
.or_else(|| {
|
||||
entry
|
||||
.tools
|
||||
.as_ref()
|
||||
.and_then(|t| t.profile.as_ref())
|
||||
.map(|p| tools_for_profile(p).len())
|
||||
.and_then(extract_profile)
|
||||
.map(|p| tools_for_profile(&p).len())
|
||||
})
|
||||
.unwrap_or(3);
|
||||
|
||||
@@ -1253,7 +1273,8 @@ fn migrate_channels_from_json(
|
||||
"bot_token_env",
|
||||
toml::Value::String("TELEGRAM_BOT_TOKEN".into()),
|
||||
)];
|
||||
if let Some(ref users) = tg.allow_from {
|
||||
if let Some(ref users_val) = tg.allow_from {
|
||||
let users = extract_string_list(users_val);
|
||||
if !users.is_empty() {
|
||||
let arr: Vec<toml::Value> = users
|
||||
.iter()
|
||||
@@ -1268,7 +1289,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
tg.dm_policy.as_deref(),
|
||||
tg.group_policy.as_deref(),
|
||||
tg.allow_from.as_deref(),
|
||||
tg.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1295,7 +1316,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
dc.dm_policy.as_deref(),
|
||||
dc.group_policy.as_deref(),
|
||||
dc.allow_from.as_deref(),
|
||||
dc.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1331,7 +1352,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
sl.dm_policy.as_deref(),
|
||||
sl.group_policy.as_deref(),
|
||||
sl.allow_from.as_deref(),
|
||||
sl.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1372,7 +1393,8 @@ fn migrate_channels_from_json(
|
||||
"access_token_env",
|
||||
toml::Value::String("WHATSAPP_ACCESS_TOKEN".into()),
|
||||
)];
|
||||
if let Some(ref users) = wa.allow_from {
|
||||
if let Some(ref users_val) = wa.allow_from {
|
||||
let users = extract_string_list(users_val);
|
||||
if !users.is_empty() {
|
||||
let arr: Vec<toml::Value> = users
|
||||
.iter()
|
||||
@@ -1387,7 +1409,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
wa.dm_policy.as_deref(),
|
||||
wa.group_policy.as_deref(),
|
||||
wa.allow_from.as_deref(),
|
||||
wa.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1418,7 +1440,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
sig.dm_policy.as_deref(),
|
||||
None,
|
||||
sig.allow_from.as_deref(),
|
||||
sig.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1445,7 +1467,8 @@ fn migrate_channels_from_json(
|
||||
if let Some(ref uid) = mx.user_id {
|
||||
fields.push(("user_id", toml::Value::String(uid.clone())));
|
||||
}
|
||||
if let Some(ref rooms) = mx.rooms {
|
||||
if let Some(ref rooms_val) = mx.rooms {
|
||||
let rooms = extract_string_list(rooms_val);
|
||||
if !rooms.is_empty() {
|
||||
let arr: Vec<toml::Value> = rooms
|
||||
.iter()
|
||||
@@ -1460,7 +1483,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
mx.dm_policy.as_deref(),
|
||||
None,
|
||||
mx.allow_from.as_deref(),
|
||||
mx.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1534,7 +1557,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
tm.dm_policy.as_deref(),
|
||||
None,
|
||||
tm.allow_from.as_deref(),
|
||||
tm.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1567,7 +1590,8 @@ fn migrate_channels_from_json(
|
||||
if irc.password.is_some() {
|
||||
fields.push(("password_env", toml::Value::String("IRC_PASSWORD".into())));
|
||||
}
|
||||
if let Some(ref chans) = irc.channels {
|
||||
if let Some(ref chans_val) = irc.channels {
|
||||
let chans = extract_string_list(chans_val);
|
||||
if !chans.is_empty() {
|
||||
let arr: Vec<toml::Value> = chans
|
||||
.iter()
|
||||
@@ -1582,7 +1606,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
irc.dm_policy.as_deref(),
|
||||
None,
|
||||
irc.allow_from.as_deref(),
|
||||
irc.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1612,7 +1636,7 @@ fn migrate_channels_from_json(
|
||||
fields,
|
||||
mm.dm_policy.as_deref(),
|
||||
None,
|
||||
mm.allow_from.as_deref(),
|
||||
mm.allow_from.as_ref(),
|
||||
),
|
||||
);
|
||||
report.imported.push(MigrateItem {
|
||||
@@ -1770,9 +1794,10 @@ fn convert_agent_from_json(
|
||||
// Resolve tools
|
||||
let mut unmapped_tools = Vec::new();
|
||||
let tools: Vec<String> = if let Some(ref agent_tools) = entry.tools {
|
||||
if let Some(ref allow) = agent_tools.allow {
|
||||
if let Some(ref allow_val) = agent_tools.allow {
|
||||
let allow = extract_string_list(allow_val);
|
||||
let mut mapped = Vec::new();
|
||||
for t in allow {
|
||||
for t in &allow {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
@@ -1782,8 +1807,9 @@ fn convert_agent_from_json(
|
||||
}
|
||||
}
|
||||
// also_allow
|
||||
if let Some(ref also) = agent_tools.also_allow {
|
||||
for t in also {
|
||||
if let Some(ref also_val) = agent_tools.also_allow {
|
||||
let also = extract_string_list(also_val);
|
||||
for t in &also {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
@@ -1794,8 +1820,9 @@ fn convert_agent_from_json(
|
||||
}
|
||||
}
|
||||
mapped
|
||||
} else if let Some(ref profile) = agent_tools.profile {
|
||||
tools_for_profile(profile)
|
||||
} else if let Some(ref profile_val) = agent_tools.profile {
|
||||
let profile_name = extract_profile(profile_val).unwrap_or_default();
|
||||
tools_for_profile(&profile_name)
|
||||
} else {
|
||||
resolve_default_tools(defaults)
|
||||
}
|
||||
@@ -1894,8 +1921,10 @@ fn convert_agent_from_json(
|
||||
|
||||
// Tool profile hint
|
||||
if let Some(ref agent_tools) = entry.tools {
|
||||
if let Some(ref profile) = agent_tools.profile {
|
||||
toml_str.push_str(&format!("\nprofile = \"{profile}\"\n"));
|
||||
if let Some(ref profile_val) = agent_tools.profile {
|
||||
if let Some(profile) = extract_profile(profile_val) {
|
||||
toml_str.push_str(&format!("\nprofile = \"{profile}\"\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1905,12 +1934,15 @@ fn convert_agent_from_json(
|
||||
fn resolve_default_tools(defaults: Option<&OpenClawAgentDefaults>) -> Vec<String> {
|
||||
if let Some(defs) = defaults {
|
||||
if let Some(ref tools) = defs.tools {
|
||||
if let Some(ref profile) = tools.profile {
|
||||
return tools_for_profile(profile);
|
||||
if let Some(ref profile_val) = tools.profile {
|
||||
if let Some(profile) = extract_profile(profile_val) {
|
||||
return tools_for_profile(&profile);
|
||||
}
|
||||
}
|
||||
if let Some(ref allow) = tools.allow {
|
||||
if let Some(ref allow_val) = tools.allow {
|
||||
let allow = extract_string_list(allow_val);
|
||||
let mut mapped = Vec::new();
|
||||
for t in allow {
|
||||
for t in &allow {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
|
||||
@@ -29,7 +29,9 @@ hex = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
tokio-tungstenite = "0.24"
|
||||
shlex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,15 @@
|
||||
//! Every auditable event is appended to an append-only log where each entry
|
||||
//! contains the SHA-256 hash of its own contents concatenated with the hash of
|
||||
//! the previous entry, forming a tamper-evident chain (similar to a blockchain).
|
||||
//!
|
||||
//! When a database connection is provided (`with_db`), entries are persisted to
|
||||
//! the `audit_entries` table (schema V8) so the trail survives daemon restarts.
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Categories of auditable actions within the agent runtime.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -77,26 +81,102 @@ fn compute_entry_hash(
|
||||
/// An append-only, tamper-evident audit log using a Merkle hash chain.
|
||||
///
|
||||
/// Thread-safe — all access is serialised through internal mutexes.
|
||||
/// Optionally backed by SQLite for persistence across daemon restarts.
|
||||
pub struct AuditLog {
|
||||
entries: Mutex<Vec<AuditEntry>>,
|
||||
tip: Mutex<String>,
|
||||
/// Optional database connection for persistent storage.
|
||||
db: Option<Arc<Mutex<Connection>>>,
|
||||
}
|
||||
|
||||
impl AuditLog {
|
||||
/// Creates a new empty audit log.
|
||||
/// Creates a new empty audit log (in-memory only, no persistence).
|
||||
///
|
||||
/// The initial tip hash is 64 zero characters (the "genesis" sentinel).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(Vec::new()),
|
||||
tip: Mutex::new("0".repeat(64)),
|
||||
db: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an audit log backed by a database connection.
|
||||
///
|
||||
/// On construction, loads all existing entries from the `audit_entries`
|
||||
/// table and verifies the Merkle chain integrity. New entries are written
|
||||
/// to both the in-memory chain and the database.
|
||||
pub fn with_db(conn: Arc<Mutex<Connection>>) -> Self {
|
||||
let mut entries = Vec::new();
|
||||
let mut tip = "0".repeat(64);
|
||||
|
||||
// Load existing entries from database
|
||||
if let Ok(db) = conn.lock() {
|
||||
let result = db.prepare(
|
||||
"SELECT seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash FROM audit_entries ORDER BY seq ASC",
|
||||
);
|
||||
if let Ok(mut stmt) = result {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let action_str: String = row.get(3)?;
|
||||
let action = match action_str.as_str() {
|
||||
"ToolInvoke" => AuditAction::ToolInvoke,
|
||||
"CapabilityCheck" => AuditAction::CapabilityCheck,
|
||||
"AgentSpawn" => AuditAction::AgentSpawn,
|
||||
"AgentKill" => AuditAction::AgentKill,
|
||||
"AgentMessage" => AuditAction::AgentMessage,
|
||||
"MemoryAccess" => AuditAction::MemoryAccess,
|
||||
"FileAccess" => AuditAction::FileAccess,
|
||||
"NetworkAccess" => AuditAction::NetworkAccess,
|
||||
"ShellExec" => AuditAction::ShellExec,
|
||||
"AuthAttempt" => AuditAction::AuthAttempt,
|
||||
"WireConnect" => AuditAction::WireConnect,
|
||||
"ConfigChange" => AuditAction::ConfigChange,
|
||||
_ => AuditAction::ToolInvoke, // fallback
|
||||
};
|
||||
Ok(AuditEntry {
|
||||
seq: row.get(0)?,
|
||||
timestamp: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
action,
|
||||
detail: row.get(4)?,
|
||||
outcome: row.get(5)?,
|
||||
prev_hash: row.get(6)?,
|
||||
hash: row.get(7)?,
|
||||
})
|
||||
});
|
||||
if let Ok(rows) = rows {
|
||||
for entry in rows.flatten() {
|
||||
tip = entry.hash.clone();
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let count = entries.len();
|
||||
let log = Self {
|
||||
entries: Mutex::new(entries),
|
||||
tip: Mutex::new(tip),
|
||||
db: Some(conn),
|
||||
};
|
||||
|
||||
// Verify chain integrity on load
|
||||
if count > 0 {
|
||||
if let Err(e) = log.verify_integrity() {
|
||||
tracing::error!("Audit trail integrity check FAILED on boot: {e}");
|
||||
} else {
|
||||
tracing::info!("Audit trail loaded: {count} entries, chain integrity OK");
|
||||
}
|
||||
}
|
||||
|
||||
log
|
||||
}
|
||||
|
||||
/// Records a new auditable event and returns the SHA-256 hash of the entry.
|
||||
///
|
||||
/// The entry is atomically appended to the chain with the current tip as
|
||||
/// its `prev_hash`, and the tip is advanced to the new hash.
|
||||
/// If a database connection is available, the entry is also persisted.
|
||||
pub fn record(
|
||||
&self,
|
||||
agent_id: impl Into<String>,
|
||||
@@ -119,7 +199,7 @@ impl AuditLog {
|
||||
seq, ×tamp, &agent_id, &action, &detail, &outcome, &prev_hash,
|
||||
);
|
||||
|
||||
entries.push(AuditEntry {
|
||||
let entry = AuditEntry {
|
||||
seq,
|
||||
timestamp,
|
||||
agent_id,
|
||||
@@ -128,8 +208,28 @@ impl AuditLog {
|
||||
outcome,
|
||||
prev_hash,
|
||||
hash: hash.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
// Persist to database if available
|
||||
if let Some(ref db) = self.db {
|
||||
if let Ok(conn) = db.lock() {
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO audit_entries (seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
rusqlite::params![
|
||||
entry.seq as i64,
|
||||
&entry.timestamp,
|
||||
&entry.agent_id,
|
||||
entry.action.to_string(),
|
||||
&entry.detail,
|
||||
&entry.outcome,
|
||||
&entry.prev_hash,
|
||||
&entry.hash,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
*tip = hash.clone();
|
||||
hash
|
||||
}
|
||||
@@ -271,4 +371,52 @@ mod tests {
|
||||
assert_eq!(log.tip_hash(), h2);
|
||||
assert_ne!(h2, h1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audit_persists_to_db() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE audit_entries (
|
||||
seq INTEGER PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let db = Arc::new(Mutex::new(conn));
|
||||
|
||||
// Record entries with DB
|
||||
let log = AuditLog::with_db(Arc::clone(&db));
|
||||
log.record("agent-1", AuditAction::AgentSpawn, "spawn test", "ok");
|
||||
log.record("agent-1", AuditAction::ShellExec, "ls", "ok");
|
||||
assert_eq!(log.len(), 2);
|
||||
|
||||
// Verify entries in database
|
||||
let db_conn = db.lock().unwrap();
|
||||
let count: i64 = db_conn
|
||||
.query_row("SELECT COUNT(*) FROM audit_entries", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
drop(db_conn);
|
||||
|
||||
// Simulate restart: create new AuditLog from same DB
|
||||
let log2 = AuditLog::with_db(Arc::clone(&db));
|
||||
assert_eq!(log2.len(), 2);
|
||||
assert!(log2.verify_integrity().is_ok());
|
||||
|
||||
// Chain continues correctly after restart
|
||||
log2.record("agent-2", AuditAction::ToolInvoke, "file_read", "ok");
|
||||
assert_eq!(log2.len(), 3);
|
||||
assert!(log2.verify_integrity().is_ok());
|
||||
|
||||
// Verify tip is correct
|
||||
let entries = log2.recent(3);
|
||||
assert_eq!(entries[2].prev_hash, entries[1].hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,22 +65,23 @@ pub fn truncate_tool_result_dynamic(content: &str, budget: &ContextBudget) -> St
|
||||
}
|
||||
|
||||
// Find last newline before the cap to break cleanly (char-boundary safe)
|
||||
let safe_cap = if content.is_char_boundary(cap) {
|
||||
cap
|
||||
} else {
|
||||
content[..cap].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
let search_start = safe_cap.saturating_sub(200);
|
||||
let break_point = content[search_start..safe_cap]
|
||||
let mut safe_cap = cap.min(content.len());
|
||||
while safe_cap > 0 && !content.is_char_boundary(safe_cap) {
|
||||
safe_cap -= 1;
|
||||
}
|
||||
let mut search_start = safe_cap.saturating_sub(200);
|
||||
// Ensure search_start is a valid char boundary
|
||||
while search_start > 0 && !content.is_char_boundary(search_start) {
|
||||
search_start -= 1;
|
||||
}
|
||||
let mut break_point = content[search_start..safe_cap]
|
||||
.rfind('\n')
|
||||
.map(|pos| search_start + pos)
|
||||
.unwrap_or(safe_cap.saturating_sub(100));
|
||||
// Ensure break_point is also a char boundary
|
||||
let break_point = if content.is_char_boundary(break_point) {
|
||||
break_point
|
||||
} else {
|
||||
content[..break_point].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
while break_point > 0 && !content.is_char_boundary(break_point) {
|
||||
break_point -= 1;
|
||||
}
|
||||
|
||||
format!(
|
||||
"{}\n\n[TRUNCATED: result was {} chars, showing first {} (budget: {}% of {}K context window)]",
|
||||
@@ -201,28 +202,16 @@ fn truncate_to(content: &str, max_chars: usize) -> String {
|
||||
if content.len() <= max_chars {
|
||||
return content.to_string();
|
||||
}
|
||||
let keep = max_chars.saturating_sub(80).min(content.len());
|
||||
// Ensure keep is a valid char boundary
|
||||
let keep = if content.is_char_boundary(keep) {
|
||||
keep
|
||||
} else {
|
||||
content[..keep]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let search_start = keep.saturating_sub(100);
|
||||
// Ensure search_start is a valid char boundary
|
||||
let search_start = if content.is_char_boundary(search_start) {
|
||||
search_start
|
||||
} else {
|
||||
content[..search_start]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let mut keep = max_chars.saturating_sub(80).min(content.len());
|
||||
// Walk back to a valid char boundary
|
||||
while keep > 0 && !content.is_char_boundary(keep) {
|
||||
keep -= 1;
|
||||
}
|
||||
let mut search_start = keep.saturating_sub(100);
|
||||
// Walk back to a valid char boundary
|
||||
while search_start > 0 && !content.is_char_boundary(search_start) {
|
||||
search_start -= 1;
|
||||
}
|
||||
// Try to break at newline
|
||||
let break_point = content[search_start..keep]
|
||||
.rfind('\n')
|
||||
@@ -319,4 +308,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_tool_result_multibyte_chinese() {
|
||||
// Tiny budget: cap = 30% of 100 * 2.0 = 60 bytes
|
||||
let budget = ContextBudget::new(100);
|
||||
// Each Chinese char is 3 bytes in UTF-8; 100 chars = 300 bytes
|
||||
let content: String = "\u{4f60}\u{597d}\u{4e16}\u{754c}".repeat(25);
|
||||
assert_eq!(content.len(), 300);
|
||||
// Must not panic on multi-byte content
|
||||
let result = truncate_tool_result_dynamic(&content, &budget);
|
||||
assert!(result.contains("[TRUNCATED:"));
|
||||
// The visible portion must be valid UTF-8 (implicit: no panic)
|
||||
assert!(result.is_char_boundary(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_to_multibyte_emoji() {
|
||||
// Each emoji is 4 bytes; 200 emojis = 800 bytes
|
||||
let content: String = "\u{1f600}".repeat(200);
|
||||
let result = truncate_to(&content, 100);
|
||||
assert!(result.contains("[COMPACTED:"));
|
||||
// Must not panic and must produce valid UTF-8
|
||||
assert!(result.is_char_boundary(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_guard_multibyte_tool_results() {
|
||||
let budget = ContextBudget::new(100);
|
||||
// Chinese text: 500 chars * 3 bytes = 1500 bytes
|
||||
let big_chinese: String = "\u{4e2d}\u{6587}\u{6d4b}\u{8bd5}\u{6570}\u{636e}".repeat(83);
|
||||
let mut messages = vec![Message {
|
||||
role: openfang_types::message::Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: big_chinese,
|
||||
is_error: false,
|
||||
}]),
|
||||
}];
|
||||
// Must not panic on multi-byte content
|
||||
let compacted = apply_context_guard(&mut messages, &budget, &[]);
|
||||
assert!(compacted > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,13 +102,11 @@ pub fn recover_from_overflow(
|
||||
for block in blocks.iter_mut() {
|
||||
if let ContentBlock::ToolResult { content, .. } = block {
|
||||
if content.len() > tool_truncation_limit {
|
||||
let keep = tool_truncation_limit.saturating_sub(80);
|
||||
// Find a valid char boundary at or before `keep`
|
||||
let safe_keep = if content.is_char_boundary(keep) {
|
||||
keep
|
||||
} else {
|
||||
content[..keep].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
let mut safe_keep = tool_truncation_limit.saturating_sub(80);
|
||||
// Walk back to a valid char boundary
|
||||
while safe_keep > 0 && !content.is_char_boundary(safe_keep) {
|
||||
safe_keep -= 1;
|
||||
}
|
||||
*content = format!(
|
||||
"{}\n\n[OVERFLOW RECOVERY: truncated from {} to {} chars]",
|
||||
&content[..safe_keep],
|
||||
@@ -244,4 +242,26 @@ mod tests {
|
||||
// we should cascade through stages
|
||||
assert_ne!(stage, RecoveryStage::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stage3_multibyte_tool_truncation() {
|
||||
// Chinese text (3 bytes per char) in tool results must not panic
|
||||
let chinese_result: String = "\u{4f60}\u{597d}\u{4e16}\u{754c}".repeat(1250); // 5000 chars, 15000 bytes
|
||||
let mut msgs = vec![
|
||||
Message::user("hi"),
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: chinese_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
},
|
||||
];
|
||||
// Tiny context window to force stage 3 tool truncation
|
||||
let stage = recover_from_overflow(&mut msgs, "system", &[], 500);
|
||||
// Must not panic — the truncation at byte boundaries could split a 3-byte char
|
||||
assert_ne!(stage, RecoveryStage::None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,19 +61,15 @@ fn validate_image_name(image: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// SECURITY: Sanitize command — reject dangerous shell metacharacters.
|
||||
/// Delegates to the comprehensive subprocess_sandbox check.
|
||||
fn validate_command(command: &str) -> Result<(), String> {
|
||||
if command.is_empty() {
|
||||
return Err("Command cannot be empty".into());
|
||||
}
|
||||
// Reject backticks and $() which could enable command injection
|
||||
let dangerous = ["`", "$(", "${"];
|
||||
for pattern in &dangerous {
|
||||
if command.contains(pattern) {
|
||||
return Err(format!(
|
||||
"Command contains disallowed pattern '{}' — potential injection",
|
||||
pattern
|
||||
));
|
||||
}
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
|
||||
return Err(format!(
|
||||
"Command blocked: contains {reason} — potential injection"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user