Compare commits

...
24 Commits
Author SHA1 Message Date
jaberjaber23 9d3136e512 bugfix batch 2026-03-05 14:39:43 +03:00
jaberjaber23 60566f22fb bugfix batch 2026-03-05 03:17:37 +03:00
jaberjaber23 50440e4047 bugfix batch 2026-03-05 02:13:48 +03:00
jaberjaber23 f45268aedc stress hardening 2026-03-05 01:21:32 +03:00
jaberjaber23 cc54e14114 bugfix batch 2026-03-05 00:25:25 +03:00
jaberjaber23 1037ef768d bugfix batch 2026-03-04 15:22:10 +03:00
jaberjaber23 c3dcf02e3c bugfix batch 2026-03-04 05:43:38 +03:00
jaberjaber23 b157e3c7e6 issue fixes 2026-03-04 04:11:07 +03:00
jaberjaber23 74ac992420 issue fixes 2026-03-04 03:34:12 +03:00
jaberjaber23 53e1b31777 version bump 2026-03-04 02:08:52 +03:00
jaberjaber23 603a94e560 issue fixes 2026-03-04 02:08:32 +03:00
jaberjaber23 fac4ad33e5 discord bugfixes 2026-03-04 01:17:37 +03:00
jaberjaber23 fe96cd1004 bugfixes batch 2026-03-03 21:26:06 +03:00
jaberjaber23 7c85308cf6 bugfixes batch 2026-03-03 20:28:46 +03:00
jaberjaber23 a4a83b1699 bugfixes batch 2026-03-03 16:54:30 +03:00
jaberjaber23 8942d8c2b6 bugfixes release 2026-03-03 05:20:05 +03:00
jaberjaber23 260dd7a125 version bump 2026-03-03 01:18:02 +03:00
jaberjaber23 294f0e7af8 batch fixes 2026-03-03 01:17:34 +03:00
jaberjaber23 444d82e4d6 batch fixes 2026-03-02 23:49:44 +03:00
jaberjaber23 62e6e0f088 batch fixes 2026-03-02 23:12:19 +03:00
jaberjaber23 d3385f2cdc batch fixes 2026-03-02 20:52:57 +03:00
jaberjaber23 516f163dfb batch fixes 2026-03-02 18:37:56 +03:00
jaberjaber23 a54bb1cd4f batch fixes 2026-03-02 15:14:58 +03:00
jaberjaber23 6f3c4e7778 batch fixes 2026-03-02 05:08:42 +03:00
101 changed files with 5255 additions and 1307 deletions
Generated
+62 -20
View File
@@ -2619,7 +2619,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.2",
"tokio",
"tower-service",
"tracing",
@@ -3128,7 +3128,7 @@ dependencies = [
"percent-encoding",
"quoted_printable",
"rustls",
"socket2",
"socket2 0.6.2",
"tokio",
"tokio-rustls",
"url",
@@ -3866,7 +3866,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"async-trait",
"axum",
@@ -3888,6 +3888,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"socket2 0.5.10",
"subtle",
"tempfile",
"tokio",
@@ -3902,7 +3903,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"async-trait",
"axum",
@@ -3933,7 +3934,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"clap",
"clap_complete",
@@ -3960,7 +3961,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"axum",
"open",
@@ -3986,7 +3987,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"aes-gcm",
"argon2",
@@ -4014,7 +4015,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"chrono",
"dashmap",
@@ -4031,7 +4032,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"async-trait",
"chrono",
@@ -4067,7 +4068,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"async-trait",
"chrono",
@@ -4086,7 +4087,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4105,7 +4106,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"anyhow",
"async-trait",
@@ -4128,6 +4129,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tokio-test",
"tokio-tungstenite 0.24.0",
"tracing",
"uuid",
"wasmtime",
@@ -4136,7 +4138,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"chrono",
"hex",
@@ -4154,11 +4156,12 @@ dependencies = [
"tracing",
"uuid",
"walkdir",
"zip 2.4.2",
]
[[package]]
name = "openfang-types"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"async-trait",
"chrono",
@@ -4177,7 +4180,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.2.5"
version = "0.3.17"
dependencies = [
"async-trait",
"chrono",
@@ -4868,7 +4871,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.6.2",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -4905,7 +4908,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
@@ -5965,6 +5968,16 @@ dependencies = [
"serde",
]
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.2"
@@ -6552,7 +6565,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
"zip 4.6.1",
]
[[package]]
@@ -6818,7 +6831,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.2",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -8789,7 +8802,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.2.5"
version = "0.3.17"
[[package]]
name = "yoke"
@@ -8969,6 +8982,23 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.13.0",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zip"
version = "4.6.1"
@@ -8987,6 +9017,18 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
+7 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.2.6"
version = "0.3.18"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -122,6 +122,12 @@ argon2 = "0.5"
# Lightweight regex
regex-lite = "0.1"
# Socket options (SO_REUSEADDR)
socket2 = "0.5"
# Zip archive extraction
zip = { version = "2", default-features = false, features = ["deflate"] }
# Email (SMTP + IMAP)
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
imap = "2"
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.4
@@ -34,8 +34,8 @@ OUTPUT FORMAT:
- Caveats and limitations"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["architecture", "design", "planning"]
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -31,8 +31,8 @@ Output format: Use clear headings, diagrams (ASCII), and structured reasoning.
When asked to review, be honest about weaknesses."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["general", "assistant", "default", "multipurpose", "conversation", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Assistant, a specialist agent in the OpenFang Agent OS. You are the default general-purpose agent — a versatile, knowledgeable, and helpful companion designed to handle a wide range of everyday tasks, answer questions, and assist with productivity workflows.
@@ -61,7 +61,7 @@ TOOLS AVAILABLE:
You are reliable, adaptable, and genuinely helpful. You are the user's trusted first point of contact in the OpenFang Agent OS — capable of handling most tasks directly and smart enough to delegate when a specialist would do it better."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["review", "code-quality", "best-practices"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -34,8 +34,8 @@ Rules:
- Focus on things that matter for production"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["coding", "implementation", "rust", "python"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -31,8 +31,8 @@ RESEARCH:
- Check official documentation before guessing at API usage."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["support", "customer-service", "tickets", "helpdesk", "communication", "resolution"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.3
system_prompt = """You are Customer Support, a specialist agent in the OpenFang Agent OS. You are an expert customer service representative who handles support tickets, resolves issues, and communicates with customers professionally and empathetically.
@@ -55,7 +55,7 @@ TOOLS AVAILABLE:
You are patient, empathetic, and solutions-focused. You turn frustrated customers into satisfied advocates."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -36,8 +36,8 @@ Output format:
- Caveats and limitations"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.2
@@ -37,8 +37,8 @@ OUTPUT FORMAT:
- Prevention: Test or pattern to prevent recurrence"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.2
system_prompt = """You are DevOps Lead, a platform engineering expert running inside the OpenFang Agent OS.
@@ -35,7 +35,7 @@ When designing pipelines:
5. Automated rollback on failure"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.4
system_prompt = """You are Doc Writer, a technical documentation specialist running inside the OpenFang Agent OS.
@@ -33,7 +33,7 @@ Style guide:
- Consistent formatting and structure"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["email", "communication", "triage", "drafting", "scheduling", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.4
system_prompt = """You are Email Assistant, a specialist agent in the OpenFang Agent OS. Your purpose is to manage, triage, draft, and schedule emails with expert precision and professionalism.
@@ -47,7 +47,7 @@ TOOLS AVAILABLE:
You are thorough, discreet, and efficient. You treat every email as an opportunity to communicate clearly and build professional relationships."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["health", "wellness", "fitness", "medication", "habits", "tracking"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.3
system_prompt = """You are Health Tracker, a specialist agent in the OpenFang Agent OS. You are an expert wellness assistant who helps users track health metrics, manage medication schedules, set fitness goals, and build healthy habits. You are NOT a medical professional and you always make this clear.
+2 -2
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.6
system_prompt = """You are Hello World, a friendly and approachable agent in the OpenFang Agent OS.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["smart-home", "iot", "automation", "devices", "monitoring", "home"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.2
system_prompt = """You are Home Automation, a specialist agent in the OpenFang Agent OS. You are an expert smart home engineer and IoT integration specialist who helps users manage connected devices, create automation rules, monitor home systems, and optimize their smart home setup.
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["legal", "contracts", "compliance", "research", "review", "documents"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 8192
temperature = 0.2
@@ -58,8 +58,8 @@ DISCLAIMER: You are an AI assistant providing legal information for educational
You are meticulous, cautious, and precise. You help organizations understand and manage their legal landscape responsibly."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["meetings", "notes", "action-items", "agenda", "follow-up", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Meeting Assistant, a specialist agent in the OpenFang Agent OS. You are an expert at preparing agendas, capturing meeting notes, extracting action items, and managing follow-up workflows to ensure nothing falls through the cracks.
@@ -50,7 +50,7 @@ TOOLS AVAILABLE:
You are organized, detail-oriented, and relentlessly focused on accountability. You turn chaotic meetings into clear outcomes."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+2 -2
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.1-8b-instant"
provider = "default"
model = "default"
max_tokens = 2048
temperature = 0.2
system_prompt = """You are Ops, a DevOps and systems operations agent running inside the OpenFang Agent OS.
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -45,8 +45,8 @@ Always explain your delegation strategy before executing it.
Be thorough but efficient — don't delegate trivially simple tasks."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[schedule]
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["finance", "budget", "expenses", "savings", "planning", "money"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.2
system_prompt = """You are Personal Finance, a specialist agent in the OpenFang Agent OS. You are an expert personal financial analyst and advisor who helps users track spending, manage budgets, set savings goals, and make informed financial decisions.
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Planner, a project planning specialist running inside the OpenFang Agent OS.
@@ -37,7 +37,7 @@ Output format:
### Open Questions"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["recruiting", "hiring", "resume", "outreach", "talent", "hr"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.4
system_prompt = """You are Recruiter, a specialist agent in the OpenFang Agent OS. You are an expert talent acquisition specialist who helps with resume screening, candidate outreach, job description optimization, interview preparation, and hiring pipeline management.
@@ -55,7 +55,7 @@ TOOLS AVAILABLE:
You are thorough, fair, and people-oriented. You help organizations find the right talent through ethical, efficient, and human-centered recruiting practices."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["research", "analysis", "web"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.5
@@ -36,8 +36,8 @@ OUTPUT:
Always cite your sources. Never present uncertain information as fact."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["sales", "crm", "outreach", "pipeline", "prospecting", "deals"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.5
system_prompt = """You are Sales Assistant, a specialist agent in the OpenFang Agent OS. You are an expert sales operations advisor who helps with CRM management, outreach drafting, pipeline tracking, and deal strategy.
@@ -54,7 +54,7 @@ TOOLS AVAILABLE:
You are strategic, persuasive, and detail-oriented. You help sales teams work smarter and close more deals."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["security", "audit", "vulnerability"]
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 4096
temperature = 0.2
@@ -37,8 +37,8 @@ Severity levels: CRITICAL / HIGH / MEDIUM / LOW / INFO
Report format: Finding → Impact → Evidence → Remediation"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[schedule]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["social-media", "content", "marketing", "engagement", "scheduling", "analytics"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.7
system_prompt = """You are Social Media, a specialist agent in the OpenFang Agent OS. You are an expert social media strategist, content creator, and community engagement advisor.
@@ -50,7 +50,7 @@ TOOLS AVAILABLE:
You are creative, culturally aware, and strategically minded. You balance creativity with data-driven decision-making."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["testing", "qa", "validation"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -39,8 +39,8 @@ When reviewing test coverage:
- Suggest mutation testing targets"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["translation", "languages", "localization", "multilingual", "communication", "i18n"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Translator, a specialist agent in the OpenFang Agent OS. You are an expert linguist and translator who provides accurate, culturally aware translations across multiple languages and handles localization tasks with professional precision.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["travel", "planning", "itinerary", "booking", "logistics", "vacation"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Travel Planner, a specialist agent in the OpenFang Agent OS. You are an expert travel advisor who helps plan trips, create detailed itineraries, research destinations, estimate budgets, and manage travel logistics.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["education", "teaching", "tutoring", "learning", "explanation", "knowledge"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Tutor, a specialist agent in the OpenFang Agent OS. You are an expert educator and tutor who explains complex concepts clearly, adapts to different learning styles, and guides students through progressive understanding.
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.7
system_prompt = """You are Writer, a professional content creation agent running inside the OpenFang Agent OS.
@@ -30,7 +30,7 @@ OUTPUT:
- Adapt formatting to the target platform when specified."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+1
View File
@@ -33,6 +33,7 @@ governor = { workspace = true }
tokio-stream = { workspace = true }
subtle = { workspace = true }
base64 = { workspace = true }
socket2 = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+33 -28
View File
@@ -934,30 +934,6 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
}
}
/// Resolve a default agent by name — find running or spawn from manifest.
async fn resolve_default_agent(
handle: &KernelBridgeAdapter,
name: &str,
router: &mut AgentRouter,
adapter_name: &str,
) {
match handle.find_agent_by_name(name).await {
Ok(Some(agent_id)) => {
router.set_default(agent_id);
info!("{adapter_name} default agent: {name} ({agent_id})");
}
_ => match handle.spawn_agent_by_name(name).await {
Ok(agent_id) => {
router.set_default(agent_id);
info!("{adapter_name}: spawned default agent {name} ({agent_id})");
}
Err(e) => {
warn!("{adapter_name}: could not find or spawn default agent '{name}': {e}");
}
},
}
}
/// 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) {
@@ -1065,6 +1041,7 @@ pub async fn start_channel_bridge_with_config(
let adapter = Arc::new(DiscordAdapter::new(
token,
dc_config.allowed_guilds.clone(),
dc_config.allowed_users.clone(),
dc_config.intents,
));
adapters.push((adapter, dc_config.default_agent.clone()));
@@ -1545,12 +1522,40 @@ pub async fn start_channel_bridge_with_config(
return (None, Vec::new());
}
// Resolve default agent from first adapter that has one configured
// Resolve per-channel default agents AND set the first one as system-wide fallback
let mut router = AgentRouter::new();
for (_, default_agent) in &adapters {
let mut system_default_set = false;
for (adapter, default_agent) in &adapters {
if let Some(ref name) = default_agent {
resolve_default_agent(&handle, name, &mut router, "Channel bridge").await;
break; // Only need one default
// Resolve agent name to ID
let agent_id = match handle.find_agent_by_name(name).await {
Ok(Some(id)) => Some(id),
_ => match handle.spawn_agent_by_name(name).await {
Ok(id) => Some(id),
Err(e) => {
warn!(
"{}: could not find or spawn default agent '{}': {e}",
adapter.name(),
name
);
None
}
},
};
if let Some(agent_id) = agent_id {
// Register per-channel default
let channel_key = format!("{:?}", adapter.channel_type());
info!(
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
adapter.name()
);
router.set_channel_default(channel_key, agent_id);
// First configured default also becomes system-wide fallback
if !system_default_set {
router.set_default(agent_id);
system_default_set = true;
}
}
}
}
+23 -22
View File
@@ -52,36 +52,30 @@ pub async fn auth(
request: Request<Body>,
next: Next,
) -> Response<Body> {
// If no API key configured, restrict to loopback addresses only.
// If no API key configured, skip authentication entirely (open access).
if api_key.is_empty() {
return next.run(request).await;
}
// Shutdown is loopback-only (CLI on same machine) — skip token auth
let path = request.uri().path();
if path == "/api/shutdown" {
let is_loopback = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false);
if !is_loopback {
tracing::warn!(
"Rejected non-localhost request: no API key configured. \
Set api_key in config.toml for remote access."
);
return Response::builder()
.status(StatusCode::FORBIDDEN)
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"error": "No API key configured. Remote access denied. Configure api_key in ~/.openfang/config.toml"
})
.to_string(),
))
.unwrap_or_default();
.unwrap_or(true); // default true for unix sockets / tests
if is_loopback {
return next.run(request).await;
}
return next.run(request).await;
}
// Public endpoints that don't require auth (dashboard needs these)
let path = request.uri().path();
if path == "/"
|| path == "/logo.png"
|| path == "/favicon.ico"
|| path == "/.well-known/agent.json"
|| path.starts_with("/a2a/")
|| path == "/api/health"
|| path == "/api/health/detail"
|| path == "/api/status"
@@ -119,15 +113,22 @@ pub async fn auth(
return next.run(request).await;
}
// Check Authorization: Bearer <token> header
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
let bearer_token = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
let api_token = bearer_token.or_else(|| {
request
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok())
});
// SECURITY: Use constant-time comparison to prevent timing attacks.
let header_auth = bearer_token.map(|token| {
let header_auth = api_token.map(|token| {
use subtle::ConstantTimeEq;
if token.len() != api_key.len() {
return false;
+2 -3
View File
@@ -179,9 +179,8 @@ fn resolve_agent(state: &AppState, model: &str) -> Option<(AgentId, String)> {
return Some((entry.id, entry.name.clone()));
}
// 4. Fallback → first registered agent
let agents = state.kernel.registry.list();
agents.first().map(|e| (e.id, e.name.clone()))
// No match — return None so the caller returns a proper 404
None
}
// ── Message conversion ──────────────────────────────────────────────────────
+478 -117
View File
@@ -33,6 +33,9 @@ pub struct AppState {
pub channels_config: tokio::sync::RwLock<openfang_types::config::ChannelsConfig>,
/// Notify handle to trigger graceful HTTP server shutdown from the API.
pub shutdown_notify: Arc<tokio::sync::Notify>,
/// 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)>,
}
/// POST /api/agents — Spawn a new agent.
@@ -112,20 +115,63 @@ pub async fn spawn_agent(
/// GET /api/agents — List all agents.
pub async fn list_agents(State(state): State<Arc<AppState>>) -> impl IntoResponse {
// Snapshot catalog once for enrichment
let catalog = state.kernel.model_catalog.read().ok();
let dm = &state.kernel.config.default_model;
let agents: Vec<serde_json::Value> = state
.kernel
.registry
.list()
.into_iter()
.map(|e| {
// Resolve "default" provider/model to actual kernel defaults
let provider = if e.manifest.model.provider.is_empty()
|| e.manifest.model.provider == "default"
{
dm.provider.as_str()
} else {
e.manifest.model.provider.as_str()
};
let model = if e.manifest.model.model.is_empty()
|| e.manifest.model.model == "default"
{
dm.model.as_str()
} else {
e.manifest.model.model.as_str()
};
// Enrich from catalog
let (tier, auth_status) = catalog
.as_ref()
.map(|cat| {
let tier = cat
.find_model(model)
.map(|m| format!("{:?}", m.tier).to_lowercase())
.unwrap_or_else(|| "unknown".to_string());
let auth = cat
.get_provider(provider)
.map(|p| format!("{:?}", p.auth_status).to_lowercase())
.unwrap_or_else(|| "unknown".to_string());
(tier, auth)
})
.unwrap_or(("unknown".to_string(), "unknown".to_string()));
let ready = matches!(e.state, openfang_types::agent::AgentState::Running)
&& auth_status != "missing";
serde_json::json!({
"id": e.id.to_string(),
"name": e.name,
"state": format!("{:?}", e.state),
"mode": e.mode,
"created_at": e.created_at.to_rfc3339(),
"model_provider": e.manifest.model.provider,
"model_name": e.manifest.model.model,
"last_active": e.last_active.to_rfc3339(),
"model_provider": provider,
"model_name": model,
"model_tier": tier,
"auth_status": auth_status,
"ready": ready,
"profile": e.manifest.profile,
"identity": {
"emoji": e.identity.emoji,
@@ -252,6 +298,14 @@ pub async fn send_message(
);
}
// Check agent exists before processing
if state.kernel.registry.get(agent_id).is_none() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found"})),
);
}
// Resolve file attachments into image content blocks
if !req.attachments.is_empty() {
let image_blocks = resolve_attachments(&req.attachments);
@@ -291,8 +345,15 @@ pub async fn send_message(
}
Err(e) => {
tracing::warn!("send_message failed for agent {id}: {e}");
let status = if format!("{e}").contains("Agent not found") {
StatusCode::NOT_FOUND
} else if format!("{e}").contains("quota") || format!("{e}").contains("Quota") {
StatusCode::TOO_MANY_REQUESTS
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(
StatusCode::INTERNAL_SERVER_ERROR,
status,
Json(serde_json::json!({"error": format!("Message delivery failed: {e}")})),
)
}
@@ -480,6 +541,10 @@ pub async fn status(State(state): State<Arc<AppState>>) -> impl IntoResponse {
"default_provider": state.kernel.config.default_model.provider,
"default_model": state.kernel.config.default_model.model,
"uptime_seconds": uptime,
"api_listen": state.kernel.config.api_listen,
"home_dir": state.kernel.config.home_dir.display().to_string(),
"log_level": state.kernel.config.log_level,
"network_enabled": state.kernel.config.network_enabled,
"agents": agents,
}))
}
@@ -1043,7 +1108,7 @@ pub async fn send_message_stream(
// ---------------------------------------------------------------------------
/// Field type for the channel configuration form.
#[derive(Clone, Copy)]
#[derive(Clone, Copy, PartialEq)]
enum FieldType {
Secret,
Text,
@@ -1119,8 +1184,9 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[
fields: &[
ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("DISCORD_BOT_TOKEN"), required: true, placeholder: "MTIz...", advanced: false },
ChannelField { key: "allowed_guilds", label: "Allowed Guild IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true },
ChannelField { key: "allowed_users", label: "Allowed User IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true },
ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true },
ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "33280", advanced: true },
ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "37376", advanced: true },
],
setup_steps: &["Go to discord.com/developers/applications", "Create a bot and copy the token", "Paste it below"],
config_template: "[channels.discord]\nbot_token_env = \"DISCORD_BOT_TOKEN\"",
@@ -1756,12 +1822,16 @@ fn is_channel_configured(config: &openfang_types::config::ChannelsConfig, name:
}
/// Build a JSON field descriptor, checking env var presence but never exposing secrets.
fn build_field_json(f: &ChannelField) -> serde_json::Value {
/// For non-secret fields, includes the actual config value from `config_values` if available.
fn build_field_json(
f: &ChannelField,
config_values: Option<&serde_json::Value>,
) -> serde_json::Value {
let has_value = f
.env_var
.map(|ev| std::env::var(ev).map(|v| !v.is_empty()).unwrap_or(false))
.unwrap_or(false);
serde_json::json!({
let mut field = serde_json::json!({
"key": f.key,
"label": f.label,
"type": f.field_type.as_str(),
@@ -1770,7 +1840,41 @@ fn build_field_json(f: &ChannelField) -> serde_json::Value {
"has_value": has_value,
"placeholder": f.placeholder,
"advanced": f.advanced,
})
});
// For non-secret fields, include the actual saved config value so the
// dashboard can pre-populate forms when editing existing configs.
if f.env_var.is_none() {
if let Some(obj) = config_values.and_then(|v| v.as_object()) {
if let Some(val) = obj.get(f.key) {
// Convert arrays to comma-separated string for list fields
let display_val = if f.field_type == FieldType::List {
if let Some(arr) = val.as_array() {
serde_json::Value::String(
arr.iter()
.filter_map(|v| {
v.as_str()
.map(|s| s.to_string())
.or_else(|| Some(v.to_string()))
})
.collect::<Vec<_>>()
.join(", "),
)
} else {
val.clone()
}
} else {
val.clone()
};
field["value"] = display_val;
if !val.is_null()
&& val.as_str().map(|s| !s.is_empty()).unwrap_or(true)
{
field["has_value"] = serde_json::Value::Bool(true);
}
}
}
}
field
}
/// Find a channel definition by name.
@@ -1778,6 +1882,56 @@ fn find_channel_meta(name: &str) -> Option<&'static ChannelMeta> {
CHANNEL_REGISTRY.iter().find(|c| c.name == name)
}
/// Serialize a channel's config to a JSON Value for pre-populating dashboard forms.
fn channel_config_values(
config: &openfang_types::config::ChannelsConfig,
name: &str,
) -> Option<serde_json::Value> {
match name {
"telegram" => config.telegram.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"discord" => config.discord.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"slack" => config.slack.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"whatsapp" => config.whatsapp.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"signal" => config.signal.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"matrix" => config.matrix.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"email" => config.email.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"teams" => config.teams.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"mattermost" => config.mattermost.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"irc" => config.irc.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"google_chat" => config.google_chat.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"twitch" => config.twitch.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"rocketchat" => config.rocketchat.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"zulip" => config.zulip.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"xmpp" => config.xmpp.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"line" => config.line.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"viber" => config.viber.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"messenger" => config.messenger.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"reddit" => config.reddit.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"mastodon" => config.mastodon.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"bluesky" => config.bluesky.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"feishu" => config.feishu.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"revolt" => config.revolt.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"nextcloud" => config.nextcloud.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"guilded" => config.guilded.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"keybase" => config.keybase.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"threema" => config.threema.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"nostr" => config.nostr.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"webex" => config.webex.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"pumble" => config.pumble.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"flock" => config.flock.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"twist" => config.twist.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"mumble" => config.mumble.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"dingtalk" => config.dingtalk.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"discourse" => config.discourse.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"gitter" => config.gitter.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"ntfy" => config.ntfy.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"gotify" => config.gotify.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"webhook" => config.webhook.as_ref().and_then(|c| serde_json::to_value(c).ok()),
"linkedin" => config.linkedin.as_ref().and_then(|c| serde_json::to_value(c).ok()),
_ => None,
}
}
/// GET /api/channels — List all 40 channel adapters with status and field metadata.
pub async fn list_channels(State(state): State<Arc<AppState>>) -> impl IntoResponse {
// Read the live channels config (updated on every hot-reload) instead of the
@@ -1803,7 +1957,12 @@ pub async fn list_channels(State(state): State<Arc<AppState>>) -> impl IntoRespo
.unwrap_or(true)
});
let fields: Vec<serde_json::Value> = meta.fields.iter().map(build_field_json).collect();
let config_vals = channel_config_values(&live_channels, meta.name);
let fields: Vec<serde_json::Value> = meta
.fields
.iter()
.map(|f| build_field_json(f, config_vals.as_ref()))
.collect();
channels.push(serde_json::json!({
"name": meta.name,
@@ -1859,7 +2018,7 @@ pub async fn configure_channel(
let home = openfang_kernel::config::openfang_home();
let secrets_path = home.join("secrets.env");
let config_path = home.join("config.toml");
let mut config_fields: HashMap<String, String> = HashMap::new();
let mut config_fields: HashMap<String, (String, FieldType)> = HashMap::new();
for field_def in meta.fields {
let value = fields
@@ -1882,9 +2041,15 @@ pub async fn configure_channel(
unsafe {
std::env::set_var(env_var, value);
}
// Also write the env var NAME to config.toml so the channel section
// is not empty and the kernel knows which env var to read.
config_fields.insert(
field_def.key.to_string(),
(env_var.to_string(), FieldType::Text),
);
} else {
// Config field — collect for TOML write
config_fields.insert(field_def.key.to_string(), value.to_string());
// Config field — collect for TOML write with type info
config_fields.insert(field_def.key.to_string(), (value.to_string(), field_def.field_type));
}
}
@@ -2355,19 +2520,14 @@ pub async fn get_template(Path(name): Path<String>) -> impl IntoResponse {
// ---------------------------------------------------------------------------
/// GET /api/memory/agents/:id/kv — List KV pairs for an agent.
///
/// Note: memory_store tool writes to a shared namespace, so we read from that
/// same namespace regardless of which agent ID is in the URL.
pub async fn get_agent_kv(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Path(_id): Path<String>,
) -> impl IntoResponse {
let agent_id: AgentId = match id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid agent ID"})),
);
}
};
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
match state.kernel.memory.list_kv(agent_id) {
Ok(pairs) => {
@@ -2378,7 +2538,7 @@ pub async fn get_agent_kv(
(StatusCode::OK, Json(serde_json::json!({"kv_pairs": kv})))
}
Err(e) => {
tracing::warn!("Memory list_kv failed for agent {id}: {e}");
tracing::warn!("Memory list_kv failed: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Memory operation failed"})),
@@ -2390,17 +2550,9 @@ pub async fn get_agent_kv(
/// GET /api/memory/agents/:id/kv/:key — Get a specific KV value.
pub async fn get_agent_kv_key(
State(state): State<Arc<AppState>>,
Path((id, key)): Path<(String, String)>,
Path((_id, key)): Path<(String, String)>,
) -> impl IntoResponse {
let agent_id: AgentId = match id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid agent ID"})),
);
}
};
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
match state.kernel.memory.structured_get(agent_id, &key) {
Ok(Some(val)) => (
@@ -2412,7 +2564,7 @@ pub async fn get_agent_kv_key(
Json(serde_json::json!({"error": "Key not found"})),
),
Err(e) => {
tracing::warn!("Memory get failed for agent {id}, key '{key}': {e}");
tracing::warn!("Memory get failed for key '{key}': {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Memory operation failed"})),
@@ -2424,18 +2576,10 @@ pub async fn get_agent_kv_key(
/// PUT /api/memory/agents/:id/kv/:key — Set a KV value.
pub async fn set_agent_kv_key(
State(state): State<Arc<AppState>>,
Path((id, key)): Path<(String, String)>,
Path((_id, key)): Path<(String, String)>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let agent_id: AgentId = match id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid agent ID"})),
);
}
};
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
let value = body.get("value").cloned().unwrap_or(body);
@@ -2445,7 +2589,7 @@ pub async fn set_agent_kv_key(
Json(serde_json::json!({"status": "stored", "key": key})),
),
Err(e) => {
tracing::warn!("Memory set failed for agent {id}, key '{key}': {e}");
tracing::warn!("Memory set failed for key '{key}': {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Memory operation failed"})),
@@ -2457,17 +2601,9 @@ pub async fn set_agent_kv_key(
/// DELETE /api/memory/agents/:id/kv/:key — Delete a KV value.
pub async fn delete_agent_kv_key(
State(state): State<Arc<AppState>>,
Path((id, key)): Path<(String, String)>,
Path((_id, key)): Path<(String, String)>,
) -> impl IntoResponse {
let agent_id: AgentId = match id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid agent ID"})),
);
}
};
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
match state.kernel.memory.structured_delete(agent_id, &key) {
Ok(()) => (
@@ -2475,7 +2611,7 @@ pub async fn delete_agent_kv_key(
Json(serde_json::json!({"status": "deleted", "key": key})),
),
Err(e) => {
tracing::warn!("Memory delete failed for agent {id}, key '{key}': {e}");
tracing::warn!("Memory delete failed for key '{key}': {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Memory operation failed"})),
@@ -2782,6 +2918,14 @@ pub async fn clawhub_search(
.and_then(|v| v.parse().ok())
.unwrap_or(20);
// Check cache (120s TTL)
let cache_key = format!("search:{}:{}", query, limit);
if let Some(entry) = state.clawhub_cache.get(&cache_key) {
if entry.0.elapsed().as_secs() < 120 {
return (StatusCode::OK, Json(entry.1.clone()));
}
}
let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub");
let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir);
@@ -2801,20 +2945,26 @@ pub async fn clawhub_search(
})
})
.collect();
(
StatusCode::OK,
Json(serde_json::json!({
"items": items,
"next_cursor": null,
})),
)
let resp = serde_json::json!({
"items": items,
"next_cursor": null,
});
state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone()));
(StatusCode::OK, Json(resp))
}
Err(e) => {
tracing::warn!("ClawHub search failed: {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") {
StatusCode::TOO_MANY_REQUESTS
} else {
StatusCode::OK
};
(
StatusCode::OK,
status,
Json(
serde_json::json!({"items": [], "next_cursor": null, "error": format!("{e}")}),
serde_json::json!({"items": [], "next_cursor": null, "error": msg}),
),
)
}
@@ -2846,6 +2996,14 @@ pub async fn clawhub_browse(
let cursor = params.get("cursor").map(|s| s.as_str());
// Check cache (120s TTL)
let cache_key = format!("browse:{:?}:{}:{}", sort, limit, cursor.unwrap_or(""));
if let Some(entry) = state.clawhub_cache.get(&cache_key) {
if entry.0.elapsed().as_secs() < 120 {
return (StatusCode::OK, Json(entry.1.clone()));
}
}
let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub");
let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir);
@@ -2856,20 +3014,25 @@ pub async fn clawhub_browse(
.iter()
.map(clawhub_browse_entry_to_json)
.collect();
(
StatusCode::OK,
Json(serde_json::json!({
"items": items,
"next_cursor": results.next_cursor,
})),
)
let resp = serde_json::json!({
"items": items,
"next_cursor": results.next_cursor,
});
state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone()));
(StatusCode::OK, Json(resp))
}
Err(e) => {
tracing::warn!("ClawHub browse failed: {e}");
let msg = format!("{e}");
tracing::warn!("ClawHub browse failed: {msg}");
let status = if msg.contains("429") || msg.contains("rate limit") {
StatusCode::TOO_MANY_REQUESTS
} else {
StatusCode::OK
};
(
StatusCode::OK,
status,
Json(
serde_json::json!({"items": [], "next_cursor": null, "error": format!("{e}")}),
serde_json::json!({"items": [], "next_cursor": null, "error": msg}),
),
)
}
@@ -2936,6 +3099,46 @@ pub async fn clawhub_skill_detail(
}
}
/// GET /api/clawhub/skill/{slug}/code — Fetch the source code (SKILL.md) of a ClawHub skill.
pub async fn clawhub_skill_code(
State(state): State<Arc<AppState>>,
Path(slug): Path<String>,
) -> impl IntoResponse {
let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub");
let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir);
// Try to fetch SKILL.md first, then fallback to package.json
let mut code = String::new();
let mut filename = String::new();
if let Ok(content) = client.get_file(&slug, "SKILL.md").await {
code = content;
filename = "SKILL.md".to_string();
} else if let Ok(content) = client.get_file(&slug, "package.json").await {
code = content;
filename = "package.json".to_string();
} else if let Ok(content) = client.get_file(&slug, "skill.toml").await {
code = content;
filename = "skill.toml".to_string();
}
if code.is_empty() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "No source code found for this skill"})),
);
}
(
StatusCode::OK,
Json(serde_json::json!({
"slug": slug,
"filename": filename,
"code": code,
})),
)
}
/// POST /api/clawhub/install — Install a skill from ClawHub.
///
/// Runs the full security pipeline: SHA256 verification, format detection,
@@ -2992,13 +3195,18 @@ pub async fn clawhub_install(
)
}
Err(e) => {
let status = if e.to_string().contains("SecurityBlocked") {
let msg = format!("{e}");
let status = if msg.contains("SecurityBlocked") {
StatusCode::FORBIDDEN
} else if msg.contains("429") || msg.contains("rate limit") {
StatusCode::TOO_MANY_REQUESTS
} else if msg.contains("Network error") || msg.contains("returned 4") || msg.contains("returned 5") {
StatusCode::BAD_GATEWAY
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
tracing::warn!("ClawHub install failed: {e}");
(status, Json(serde_json::json!({"error": format!("{e}")})))
tracing::warn!("ClawHub install failed: {msg}");
(status, Json(serde_json::json!({"error": msg})))
}
}
}
@@ -3442,6 +3650,42 @@ pub async fn install_hand_deps(
)
}
/// POST /api/hands/install — Install a hand from TOML content.
pub async fn install_hand(
State(state): State<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let toml_content = body["toml_content"].as_str().unwrap_or("");
let skill_content = body["skill_content"].as_str().unwrap_or("");
if toml_content.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Missing toml_content field"})),
);
}
match state
.kernel
.hand_registry
.install_from_content(toml_content, skill_content)
{
Ok(def) => (
StatusCode::OK,
Json(serde_json::json!({
"id": def.id,
"name": def.name,
"description": def.description,
"category": format!("{:?}", def.category),
})),
),
Err(e) => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("{e}")})),
),
}
}
/// POST /api/hands/{hand_id}/activate — Activate a hand (spawns agent).
pub async fn activate_hand(
State(state): State<Arc<AppState>>,
@@ -3451,17 +3695,36 @@ pub async fn activate_hand(
let config = body.map(|b| b.0.config).unwrap_or_default();
match state.kernel.activate_hand(&hand_id, config) {
Ok(instance) => (
StatusCode::OK,
Json(serde_json::json!({
"instance_id": instance.instance_id,
"hand_id": instance.hand_id,
"status": format!("{}", instance.status),
"agent_id": instance.agent_id.map(|a| a.to_string()),
"agent_name": instance.agent_name,
"activated_at": instance.activated_at.to_rfc3339(),
})),
),
Ok(instance) => {
// If the hand agent has a non-reactive schedule (autonomous hands),
// start its background loop so it begins running immediately.
if let Some(agent_id) = instance.agent_id {
let entry = state.kernel.registry.list().into_iter().find(|e| e.id == agent_id);
if let Some(entry) = entry {
if !matches!(
entry.manifest.schedule,
openfang_types::agent::ScheduleMode::Reactive
) {
state.kernel.start_background_for_agent(
agent_id,
&entry.name,
&entry.manifest.schedule,
);
}
}
}
(
StatusCode::OK,
Json(serde_json::json!({
"instance_id": instance.instance_id,
"hand_id": instance.hand_id,
"status": format!("{}", instance.status),
"agent_id": instance.agent_id.map(|a| a.to_string()),
"agent_name": instance.agent_name,
"activated_at": instance.activated_at.to_rfc3339(),
})),
)
}
Err(e) => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("{e}")})),
@@ -4852,10 +5115,11 @@ pub async fn list_models(
true
})
.map(|m| {
// Custom models from unknown providers are assumed available
let available = catalog
.get_provider(&m.provider)
.map(|p| p.auth_status != openfang_types::model_catalog::AuthStatus::Missing)
.unwrap_or(false);
.unwrap_or(m.tier == openfang_types::model_catalog::ModelTier::Custom);
serde_json::json!({
"id": m.id,
"display_name": m.display_name,
@@ -4929,7 +5193,7 @@ pub async fn get_model(
let available = catalog
.get_provider(&m.provider)
.map(|p| p.auth_status != openfang_types::model_catalog::AuthStatus::Missing)
.unwrap_or(false);
.unwrap_or(m.tier == openfang_types::model_catalog::ModelTier::Custom);
(
StatusCode::OK,
Json(serde_json::json!({
@@ -4991,6 +5255,10 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
entry["latency_ms"] = serde_json::json!(probe.latency_ms);
if !probe.discovered_models.is_empty() {
entry["discovered_models"] = serde_json::json!(probe.discovered_models);
// Merge discovered models into the catalog so agents can use them
if let Ok(mut catalog) = state.kernel.model_catalog.write() {
catalog.merge_discovered_models(&p.id, &probe.discovered_models);
}
}
if let Some(err) = &probe.error {
entry["error"] = serde_json::json!(err);
@@ -6195,14 +6463,25 @@ pub async fn test_provider(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> impl IntoResponse {
let (env_var, base_url, key_required) = {
let (env_var, base_url, key_required, default_model) = {
let catalog = state
.kernel
.model_catalog
.read()
.unwrap_or_else(|e| e.into_inner());
match catalog.get_provider(&name) {
Some(p) => (p.api_key_env.clone(), p.base_url.clone(), p.key_required),
Some(p) => {
// Find a default model for this provider to use in the test request
let model_id = catalog
.default_model_for_provider(&name)
.unwrap_or_default();
(
p.api_key_env.clone(),
p.base_url.clone(),
p.key_required,
model_id,
)
}
None => {
return (
StatusCode::NOT_FOUND,
@@ -6237,7 +6516,7 @@ pub async fn test_provider(
Ok(driver) => {
// Send a minimal completion request to test connectivity
let test_req = openfang_runtime::llm_driver::CompletionRequest {
model: String::new(), // Driver will use default
model: default_model.clone(),
messages: vec![openfang_types::message::Message::user("Hi")],
tools: vec![],
max_tokens: 1,
@@ -6341,16 +6620,25 @@ pub async fn set_provider_url(
let probe =
openfang_runtime::provider_health::probe_provider(&name, &base_url).await;
(
StatusCode::OK,
Json(serde_json::json!({
"status": "saved",
"provider": name,
"base_url": base_url,
"reachable": probe.reachable,
"latency_ms": probe.latency_ms,
})),
)
// Merge discovered models into catalog
if !probe.discovered_models.is_empty() {
if let Ok(mut catalog) = state.kernel.model_catalog.write() {
catalog.merge_discovered_models(&name, &probe.discovered_models);
}
}
let mut resp = serde_json::json!({
"status": "saved",
"provider": name,
"base_url": base_url,
"reachable": probe.reachable,
"latency_ms": probe.latency_ms,
});
if !probe.discovered_models.is_empty() {
resp["discovered_models"] = serde_json::json!(probe.discovered_models);
}
(StatusCode::OK, Json(resp))
}
/// Upsert a provider URL in the `[provider_urls]` section of config.toml.
@@ -6537,7 +6825,7 @@ fn remove_secret_env(path: &std::path::Path, key: &str) -> Result<(), std::io::E
fn upsert_channel_config(
config_path: &std::path::Path,
channel_name: &str,
fields: &HashMap<String, String>,
fields: &HashMap<String, (String, FieldType)>,
) -> Result<(), Box<dyn std::error::Error>> {
let content = if config_path.exists() {
std::fs::read_to_string(config_path)?
@@ -6565,10 +6853,33 @@ fn upsert_channel_config(
.and_then(|v| v.as_table_mut())
.ok_or("channels is not a table")?;
// Build channel sub-table
// Build channel sub-table with correct TOML types
let mut ch_table = toml::map::Map::new();
for (k, v) in fields {
ch_table.insert(k.clone(), toml::Value::String(v.clone()));
for (k, (v, ft)) in fields {
let toml_val = match ft {
FieldType::Number => {
if let Ok(n) = v.parse::<i64>() {
toml::Value::Integer(n)
} else {
toml::Value::String(v.clone())
}
}
FieldType::List => {
let items: Vec<toml::Value> = v
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<i64>()
.map(toml::Value::Integer)
.unwrap_or_else(|_| toml::Value::String(s.to_string()))
})
.collect();
toml::Value::Array(items)
}
_ => toml::Value::String(v.clone()),
};
ch_table.insert(k.clone(), toml_val);
}
channels_table.insert(channel_name.to_string(), toml::Value::Table(ch_table));
@@ -6946,6 +7257,29 @@ pub async fn create_schedule(
}
let agent_id_str = req["agent_id"].as_str().unwrap_or("").to_string();
if agent_id_str.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Missing required field: agent_id"})),
);
}
// Validate agent exists (UUID or name lookup)
let agent_exists = if let Ok(aid) = agent_id_str.parse::<AgentId>() {
state.kernel.registry.get(aid).is_some()
} else {
state
.kernel
.registry
.list()
.iter()
.any(|a| a.name == agent_id_str)
};
if !agent_exists {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("Agent not found: {agent_id_str}")})),
);
}
let message = req["message"].as_str().unwrap_or("").to_string();
let enabled = req.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
@@ -7119,10 +7453,14 @@ pub async fn run_schedule(
.unwrap_or("Scheduled task triggered manually.");
let name = schedule["name"].as_str().unwrap_or("(unnamed)");
// Find the target agent
// Find the target agent — require explicit agent_id, no silent fallback
let target_agent = if !agent_id_str.is_empty() {
if let Ok(aid) = agent_id_str.parse::<AgentId>() {
Some(aid)
if state.kernel.registry.get(aid).is_some() {
Some(aid)
} else {
None
}
} else {
state
.kernel
@@ -7133,7 +7471,7 @@ pub async fn run_schedule(
.map(|a| a.id)
}
} else {
state.kernel.registry.list().first().map(|a| a.id)
None
};
let target_agent = match target_agent {
@@ -7174,7 +7512,8 @@ pub async fn run_schedule(
serde_json::Value::Array(schedules_updated),
);
match state.kernel.send_message(target_agent, &run_message).await {
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
match state.kernel.send_message_with_handle(target_agent, &run_message, Some(kernel_handle)).await {
Ok(result) => (
StatusCode::OK,
Json(serde_json::json!({
@@ -7494,6 +7833,13 @@ pub async fn patch_agent_config(
}
}
// Persist updated manifest to database so changes survive restart
if let Some(entry) = state.kernel.registry.get(agent_id) {
if let Err(e) = state.kernel.memory.save_agent(&entry) {
tracing::warn!("Failed to persist agent config update: {e}");
}
}
(
StatusCode::OK,
Json(serde_json::json!({"status": "ok", "agent_id": id})),
@@ -8307,7 +8653,19 @@ pub async fn config_reload(State(state): State<Arc<AppState>>) -> impl IntoRespo
// ---------------------------------------------------------------------------
/// GET /api/config/schema — Return a simplified JSON description of the config structure.
pub async fn config_schema() -> impl IntoResponse {
pub async fn config_schema(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
// Build provider/model options from model catalog for dropdowns
let catalog = state.kernel.model_catalog.read().unwrap_or_else(|e| e.into_inner());
let provider_options: Vec<String> = catalog.list_providers().iter().map(|p| p.id.clone()).collect();
let model_options: Vec<serde_json::Value> = catalog
.list_models()
.iter()
.map(|m| serde_json::json!({"id": m.id, "name": m.display_name, "provider": m.provider}))
.collect();
drop(catalog);
Json(serde_json::json!({
"sections": {
"api": {
@@ -8318,9 +8676,10 @@ pub async fn config_schema() -> impl IntoResponse {
}
},
"default_model": {
"hot_reloadable": true,
"fields": {
"provider": "string",
"model": "string",
"provider": { "type": "select", "options": provider_options },
"model": { "type": "select", "options": model_options },
"api_key_env": "string",
"base_url": "string"
}
@@ -8515,7 +8874,9 @@ fn json_to_toml_value(value: &serde_json::Value) -> toml::Value {
match value {
serde_json::Value::String(s) => toml::Value::String(s.clone()),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
if let Some(i) = n.as_u64() {
toml::Value::Integer(i as i64)
} else if let Some(i) = n.as_i64() {
toml::Value::Integer(i)
} else if let Some(f) = n.as_f64() {
toml::Value::Float(f)
+52 -3
View File
@@ -49,6 +49,7 @@ pub async fn build_router(
bridge_manager: tokio::sync::Mutex::new(bridge),
channels_config: tokio::sync::RwLock::new(channels_config),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
// CORS: allow localhost origins by default. If API key is set, the API
@@ -320,12 +321,20 @@ pub async fn build_router(
"/api/clawhub/skill/{slug}",
axum::routing::get(routes::clawhub_skill_detail),
)
.route(
"/api/clawhub/skill/{slug}/code",
axum::routing::get(routes::clawhub_skill_code),
)
.route(
"/api/clawhub/install",
axum::routing::post(routes::clawhub_install),
)
// Hands endpoints
.route("/api/hands", axum::routing::get(routes::list_hands))
.route(
"/api/hands/install",
axum::routing::post(routes::install_hand),
)
.route(
"/api/hands/active",
axum::routing::get(routes::list_active_hands),
@@ -731,7 +740,8 @@ pub async fn run_daemon(
if info_path.exists() {
if let Ok(existing) = std::fs::read_to_string(info_path) {
if let Ok(info) = serde_json::from_str::<DaemonInfo>(&existing) {
if is_process_alive(info.pid) {
// PID alive AND the health endpoint responds → truly running
if is_process_alive(info.pid) && is_daemon_responding(&info.listen_addr) {
return Err(format!(
"Another daemon (PID {}) is already running at {}",
info.pid, info.listen_addr
@@ -740,7 +750,8 @@ pub async fn run_daemon(
}
}
}
// Stale PID file, remove it
// Stale PID file (process dead or different process reused PID), remove it
info!("Removing stale daemon info file");
let _ = std::fs::remove_file(info_path);
}
@@ -762,7 +773,22 @@ pub async fn run_daemon(
info!("WebChat UI available at http://{addr}/",);
info!("WebSocket endpoint: ws://{addr}/api/agents/{{id}}/ws",);
let listener = tokio::net::TcpListener::bind(addr).await?;
// Use SO_REUSEADDR to allow binding immediately after reboot (avoids TIME_WAIT).
let socket = socket2::Socket::new(
if addr.is_ipv4() {
socket2::Domain::IPV4
} else {
socket2::Domain::IPV6
},
socket2::Type::STREAM,
None,
)?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(1024)?;
let listener =
tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
// Run server with graceful shutdown.
// SECURITY: `into_make_service_with_connect_info` injects the peer
@@ -882,3 +908,26 @@ fn is_process_alive(pid: u32) -> bool {
false
}
}
/// Check if an OpenFang daemon is actually responding at the given address.
/// This avoids false positives where a different process reused the same PID
/// after a system reboot.
fn is_daemon_responding(addr: &str) -> bool {
// Quick TCP connect check — don't make a full HTTP request to avoid delays
let addr_only = addr
.strip_prefix("http://")
.or_else(|| addr.strip_prefix("https://"))
.unwrap_or(addr);
if let Ok(sock_addr) = addr_only.parse::<std::net::SocketAddr>() {
std::net::TcpStream::connect_timeout(
&sock_addr,
std::time::Duration::from_millis(500),
)
.is_ok()
} else {
// Fallback: try connecting to hostname
std::net::TcpStream::connect(addr_only)
.map(|_| true)
.unwrap_or(false)
}
}
+2
View File
@@ -125,6 +125,8 @@ const WEBCHAT_HTML: &str = concat!(
include_str!("../static/js/pages/approvals.js"),
"\n",
include_str!("../static/js/pages/comms.js"),
"\n",
include_str!("../static/js/pages/runtime.js"),
"\n</script>\n",
// Alpine.js MUST be last — it processes x-data and fires alpine:init
"<script>\n",
+11 -2
View File
@@ -796,7 +796,12 @@ async fn handle_command(
} else {
match state.kernel.set_agent_model(agent_id, args) {
Ok(()) => {
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
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)
} else {
format!("Model switched to: {args}")
};
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
}
Err(e) => {
serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")})
@@ -1109,7 +1114,11 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
}
llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(),
llm_errors::LlmErrorCategory::ModelNotFound => {
"Model unavailable. Use /model to see options.".to_string()
if inner.contains("localhost:11434") || inner.contains("ollama") {
"Model not found on Ollama. Run `ollama pull <model>` to download it, then try again. Use /model to see options.".to_string()
} else {
"Model unavailable. Use /model to see options or check your provider configuration.".to_string()
}
}
llm_errors::LlmErrorCategory::Format => {
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
@@ -898,6 +898,121 @@ mark.search-highlight {
.slash-menu-item:last-child { border-bottom: none; }
.slash-menu-item:hover, .slash-menu-item.slash-active { background: var(--surface2); }
/* Model switcher dropdown */
.model-switcher-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
max-width: 200px;
transition: all 0.15s;
white-space: nowrap;
}
.model-switcher-btn:hover { border-color: var(--accent); color: var(--text); }
.model-switcher-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.model-switcher-btn:disabled:hover { border-color: var(--border); color: var(--text-dim); }
.model-switcher-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150px;
}
.model-switcher-chevron {
transition: transform 0.2s;
flex-shrink: 0;
opacity: 0.5;
}
.model-switcher-chevron.open { transform: rotate(180deg); }
.model-switcher-dropdown {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
width: 340px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow: hidden;
}
.model-switcher-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.model-switcher-search input {
flex: 1;
background: none;
border: none;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
}
.model-switcher-list {
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain;
}
.model-switcher-group-header {
position: sticky;
top: 0;
z-index: 1;
padding: 6px 12px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
background: var(--surface2);
border-bottom: 1px solid var(--border);
}
.model-switcher-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s;
}
.model-switcher-item:hover { background: var(--surface2); }
.model-switcher-item.active {
background: var(--accent-subtle, rgba(255,92,0,0.06));
cursor: default;
}
.model-switcher-item-name {
font-size: 12px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-switcher-tier {
display: inline-block;
padding: 1px 5px;
border-radius: 8px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
flex-shrink: 0;
}
.model-switcher-tier.tier-frontier { background: rgba(168,85,247,0.15); color: #a855f7; }
.model-switcher-tier.tier-smart { background: rgba(59,130,246,0.15); color: #3b82f6; }
.model-switcher-tier.tier-balanced { background: rgba(34,197,94,0.15); color: #22c55e; }
.model-switcher-tier.tier-fast { background: rgba(245,158,11,0.15); color: #f59e0b; }
.model-switcher-tier.tier-local { background: rgba(148,163,184,0.12); color: var(--text-dim); }
/* Sidebar footer */
.sidebar-footer {
padding: 8px 0;
+153 -62
View File
@@ -156,6 +156,10 @@
</div>
<template x-if="!collapsed">
<div x-transition>
<a class="nav-item" :class="{ active: page === 'runtime' }" @click="navigate('runtime')" :aria-current="page === 'runtime' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></span>
<span class="nav-label">Runtime</span>
</a>
<a class="nav-item" :class="{ active: page === 'settings' }" @click="navigate('settings')" :aria-current="page === 'settings' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg></span>
<span class="nav-label">Settings</span>
@@ -585,6 +589,14 @@
<!-- Messages area -->
<div class="messages" id="messages" @dragover.prevent="dragOver = true" @dragleave="dragOver = false" @drop.prevent="handleDrop($event); dragOver = false">
<!-- Empty state: no agent selected -->
<template x-if="!currentAgent">
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;padding:32px;opacity:0.8">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:16px;opacity:0.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to start chatting</h3>
<p class="text-dim" style="font-size:13px;max-width:320px">Choose an agent from the sidebar or go to the Agents tab to create a new one.</p>
</div>
</template>
<!-- Message list -->
<template x-if="currentAgent">
<div>
@@ -742,9 +754,55 @@
</button>
</template>
</div>
<!-- Footer: tokens + queue + tips -->
<!-- Footer: model switcher + tokens + queue + tips -->
<div class="input-footer">
<div class="flex items-center gap-2">
<!-- Model Switcher -->
<div style="position:relative" x-show="currentAgent" @click.outside="showModelSwitcher = false" @keydown.escape.window="showModelSwitcher = false">
<button class="model-switcher-btn" @click="toggleModelSwitcher()" :disabled="sending" title="Switch model (Ctrl+M)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
<span class="model-switcher-label" x-text="modelDisplayName || 'Model'"></span>
<svg class="model-switcher-chevron" :class="{'open': showModelSwitcher}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<!-- Dropdown -->
<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])">
</div>
<div x-show="modelSwitching" style="display:flex;align-items:center;justify-content:center;padding:12px;gap:8px">
<div class="tool-card-spinner"></div>
<span class="text-xs text-dim">Switching...</span>
</div>
<div class="model-switcher-list" x-show="!modelSwitching">
<template x-if="groupedSwitcherModels.length === 0">
<div style="padding:16px;text-align:center" class="text-xs text-dim">No models found</div>
</template>
<template x-for="group in groupedSwitcherModels" :key="group.provider">
<div>
<div class="model-switcher-group-header" x-text="group.provider"></div>
<template x-for="(m, mi) in group.models" :key="m.id">
<div class="model-switcher-item" :class="{'active': currentAgent && m.id === currentAgent.model_name}" @click="switchModel(m)" @mouseenter="modelSwitcherIdx = filteredSwitcherModels.indexOf(m)">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px">
<span class="model-switcher-item-name" x-text="m.display_name || m.id"></span>
<span class="model-switcher-tier" :class="'tier-' + (m.tier || 'balanced').toLowerCase()" x-text="m.tier || 'Balanced'"></span>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
<span class="text-xs text-dim" x-text="m.id" style="font-family:var(--font-mono)"></span>
<span class="text-xs text-dim" x-show="m.context_window" x-text="m.context_window >= 1000000 ? (m.context_window/1000000).toFixed(1)+'M' : Math.round(m.context_window/1000)+'K'"></span>
<svg x-show="m.supports_vision" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Vision"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg x-show="m.supports_tools" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Tools"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</div>
</div>
<svg x-show="currentAgent && m.id === currentAgent.model_name" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
<span class="text-xs text-dim" x-text="tokenCount > 0 ? '~' + tokenCount + ' tokens' : (attachments.length ? attachments.length + ' file(s)' : '')"></span>
<span class="queue-badge" x-show="messageQueue.length > 0" x-text="messageQueue.length + ' queued'"></span>
</div>
@@ -1349,63 +1407,8 @@
</defs>
<rect x="-2000" y="-2000" width="6000" height="6000" fill="url(#wf-grid)"/>
<!-- Connections -->
<template x-for="conn in connections" :key="conn.id">
<g>
<path :d="getConnectionPath(conn)" fill="none" stroke="var(--text-dim)" stroke-width="2"
style="cursor:pointer" @click.stop="selectedConnection = conn"
:stroke="selectedConnection && selectedConnection.id === conn.id ? 'var(--accent)' : 'var(--text-dim)'"
:stroke-width="selectedConnection && selectedConnection.id === conn.id ? 3 : 2"/>
<!-- Arrow at midpoint -->
</g>
</template>
<!-- Connection preview line -->
<template x-if="connecting && connectPreview">
<path :d="getPreviewPath()" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="6,3"/>
</template>
<!-- Nodes -->
<template x-for="node in nodes" :key="node.id">
<g class="wf-node" :transform="'translate(' + node.x + ',' + node.y + ')'"
@mousedown="onNodeMouseDown(node, $event)" @dblclick="editNode(node)">
<!-- Node body -->
<rect x="0" y="0" :width="node.width" :height="node.height" rx="8" ry="8"
:fill="selectedNode && selectedNode.id === node.id ? 'var(--card-bg)' : 'var(--bg-secondary)'"
:stroke="selectedNode && selectedNode.id === node.id ? node.color : 'var(--border)'"
stroke-width="2" style="cursor:grab"/>
<!-- Color accent bar -->
<rect x="0" y="0" width="6" :height="node.height" rx="3" ry="0" :fill="node.color"/>
<!-- Icon -->
<circle cx="28" :cy="node.height/2" r="14" :fill="node.color" opacity="0.15"/>
<text x="28" :y="node.height/2 + 4" text-anchor="middle" :fill="node.color"
style="font-size:12px;font-weight:700;pointer-events:none" x-text="node.icon"></text>
<!-- Label -->
<text x="50" :y="node.height/2 - 4" fill="var(--text)" style="font-size:12px;font-weight:600;pointer-events:none"
x-text="node.label"></text>
<!-- Sub-label (config hint) -->
<text x="50" :y="node.height/2 + 12" fill="var(--text-dim)" style="font-size:10px;pointer-events:none"
x-text="node.type === 'agent' ? (node.config.agent_name || 'No agent') :
node.type === 'condition' ? (node.config.expression || 'No condition') :
node.type === 'loop' ? ('max ' + (node.config.max_iterations || 5) + ' iters') :
node.type === 'parallel' ? (node.config.fan_count + ' branches') :
node.type === 'collect' ? node.config.strategy : ''"></text>
<!-- Input ports -->
<template x-for="pi in Array.from({length: node.ports.in}, function(_,i){return i})" :key="'in-'+pi">
<circle class="wf-port wf-port-in" :cx="node.width / (node.ports.in + 1) * (pi + 1)" cy="0" r="6"
fill="var(--bg-secondary)" stroke="var(--text-dim)" stroke-width="2"
@mouseup.stop="endConnect(node.id, pi, $event)"/>
</template>
<!-- Output ports -->
<template x-for="po in Array.from({length: node.ports.out}, function(_,i){return i})" :key="'out-'+po">
<circle class="wf-port wf-port-out" :cx="node.width / (node.ports.out + 1) * (po + 1)" :cy="node.height" r="6"
fill="var(--bg-secondary)" :stroke="node.color" stroke-width="2"
@mousedown.stop="startConnect(node.id, po, $event)"/>
</template>
</g>
</template>
<!-- Manually rendered nodes & connections (Alpine x-for breaks inside SVG) -->
<g id="wf-render-group" x-effect="nodes.length; connections.length; selectedNode; selectedConnection; connecting; connectPreview; scheduleRender()"></g>
</g>
</svg>
@@ -2360,7 +2363,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</template>
</div>
<div class="flex gap-2">
<button class="btn btn-primary btn-block" @click="installFromClawHub(skillDetail.slug)" :disabled="installingSlug === skillDetail.slug || skillDetail.installed || isSkillInstalled(skillDetail.slug)" x-text="skillDetail.installed || isSkillInstalled(skillDetail.slug) ? 'Already Installed' : installingSlug === skillDetail.slug ? 'Installing...' : 'Install from ClawHub'"></button>
<button class="btn btn-primary" style="flex:1" @click="installFromClawHub(skillDetail.slug)" :disabled="installingSlug === skillDetail.slug || skillDetail.installed || isSkillInstalled(skillDetail.slug)" x-text="skillDetail.installed || isSkillInstalled(skillDetail.slug) ? 'Already Installed' : installingSlug === skillDetail.slug ? 'Installing...' : 'Install from ClawHub'"></button>
<button class="btn btn-ghost" @click="viewSkillCode(skillDetail.slug)" :disabled="skillCodeLoading" x-text="skillCodeLoading ? 'Loading...' : showSkillCode ? 'Hide Code' : 'View Code'"></button>
</div>
<div x-show="showSkillCode && skillCode" x-transition class="mt-3" style="max-height:300px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-inset)">
<div class="flex justify-between items-center" style="padding:6px 12px;border-bottom:1px solid var(--border)">
<span class="text-xs text-dim" x-text="skillCodeFilename"></span>
</div>
<pre style="margin:0;padding:12px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all" x-text="skillCode"></pre>
</div>
<div class="text-xs text-dim mt-2 text-center">Skills are security-scanned before installation. Prompt injection and malware patterns are blocked.</div>
</div>
@@ -2462,7 +2472,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<span style="font-size:1.4rem" x-text="getHandIcon(inst.hand_id)"></span>
<div class="card-header" style="margin:0" x-text="inst.agent_name || inst.hand_id"></div>
</div>
<span class="badge" :class="inst.status === 'Active' ? 'badge-success' : inst.status === 'Paused' ? 'badge-dim' : 'badge-info'" x-text="inst.status"></span>
<span class="badge" :class="{ 'badge-success': inst.status === 'Active', 'badge-dim': inst.status === 'Paused', 'badge-warn': inst.status && inst.status.startsWith('Error'), 'badge-info': inst.status === 'Inactive' }" x-text="inst.status"></span>
</div>
<div class="text-xs text-dim" x-text="'Activated: ' + new Date(inst.activated_at).toLocaleString()"></div>
<div class="text-xs text-dim" x-show="inst.agent_id" x-text="'Agent: ' + inst.agent_id"></div>
@@ -2489,6 +2499,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<template x-if="inst.status === 'Paused'">
<button class="btn btn-ghost btn-sm" @click="resumeHand(inst)">Resume</button>
</template>
<template x-if="inst.status && inst.status.startsWith('Error')">
<span class="text-xs text-dim">Error — deactivate and reactivate</span>
</template>
<button class="btn btn-danger btn-sm" @click="deactivate(inst)">Deactivate</button>
</div>
</div>
@@ -2774,7 +2787,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Text type -->
<template x-if="setting.setting_type === 'text'">
<input type="text" class="input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
<input type="text" class="form-input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
</template>
</div>
</template>
@@ -3046,9 +3059,15 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
</div>
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
<div x-show="!filteredModels.length && !settingsLoading" style="text-align:center;padding:32px 16px">
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">&#x1F916;</div>
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
<button class="btn btn-ghost btn-sm mt-2" x-show="models.length && (modelSearch || modelProviderFilter)" @click="modelSearch=''; modelProviderFilter=''">Clear Filters</button>
</div>
<div class="table-wrap" x-show="filteredModels.length">
<table>
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th></tr></thead>
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th><th></th></tr></thead>
<tbody>
<template x-for="m in filteredModels" :key="m.id">
<tr>
@@ -3059,6 +3078,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<td class="text-xs" x-text="formatCost(m.input_cost_per_m)"></td>
<td class="text-xs" x-text="formatCost(m.output_cost_per_m)"></td>
<td><span class="badge" :class="m.available ? 'badge-success' : 'badge-muted'" x-text="m.available ? 'Available' : 'Needs Key'"></span></td>
<td><button x-show="m.tier === 'custom'" class="btn btn-ghost btn-sm" @click="deleteCustomModel(m.id)" title="Delete custom model" style="padding:2px 6px;font-size:11px;color:var(--text-muted)">&#x2715;</button></td>
</tr>
</template>
</tbody>
@@ -3073,6 +3093,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<input placeholder="Search tools..." x-model="toolSearch">
</div>
<div class="text-xs text-dim mb-2" x-text="filteredTools.length + ' of ' + tools.length + ' tools'"></div>
<div x-show="!filteredTools.length && !settingsLoading" style="text-align:center;padding:32px 16px">
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">&#x1F527;</div>
<h3 style="margin:0 0 4px;font-size:14px" x-text="tools.length ? 'No tools match your search' : 'No tools available'"></h3>
<p class="text-xs text-dim" x-text="tools.length ? 'Try a different search term.' : 'Tools will appear once agents are configured.'"></p>
<button class="btn btn-ghost btn-sm mt-2" x-show="tools.length && toolSearch" @click="toolSearch=''">Clear Search</button>
</div>
<div class="table-wrap" x-show="filteredTools.length">
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
@@ -4615,6 +4641,71 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</template>
<!-- ─── Runtime page ─── -->
<template x-if="page === 'runtime'">
<div x-data="runtimePage">
<div class="page-header"><h2>Runtime</h2></div>
<div class="page-body" x-init="loadData()">
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading runtime info...</span></div>
<div x-show="!loading">
<div class="grid grid-cols-4" style="gap:16px;margin-bottom:24px">
<div class="card stat-card">
<div class="stat-label">Uptime</div>
<div class="stat-value" x-text="uptime"></div>
</div>
<div class="card stat-card">
<div class="stat-label">Agents</div>
<div class="stat-value" x-text="agentCount"></div>
</div>
<div class="card stat-card">
<div class="stat-label">Version</div>
<div class="stat-value" x-text="version"></div>
</div>
<div class="card stat-card">
<div class="stat-label">Default Model</div>
<div class="stat-value" style="font-size:13px" x-text="defaultModel"></div>
</div>
</div>
<div class="card" style="margin-bottom:16px">
<div class="card-header">System</div>
<table class="table" style="margin-top:8px">
<tbody>
<tr><td style="width:180px;font-weight:500">Platform</td><td x-text="platform"></td></tr>
<tr><td style="font-weight:500">Architecture</td><td x-text="arch"></td></tr>
<tr><td style="font-weight:500">API Listen</td><td x-text="apiListen"></td></tr>
<tr><td style="font-weight:500">Home Directory</td><td x-text="homeDir"></td></tr>
<tr><td style="font-weight:500">Log Level</td><td x-text="logLevel"></td></tr>
<tr><td style="font-weight:500">Network</td><td x-text="networkEnabled ? 'Enabled' : 'Disabled'"></td></tr>
</tbody>
</table>
</div>
<div class="card" style="margin-bottom:16px">
<div class="card-header">Providers</div>
<table class="table" style="margin-top:8px">
<thead><tr><th>Provider</th><th>Status</th><th>Models</th><th>Latency</th></tr></thead>
<tbody>
<template x-for="p in providers" :key="p.id">
<tr>
<td x-text="p.display_name || p.id"></td>
<td><span class="badge" :class="p.reachable ? 'badge-success' : (p.auth_status === 'Configured' ? 'badge-success' : 'badge-dim')" x-text="p.reachable ? 'Online' : (p.auth_status === 'Configured' ? 'Ready' : 'Not configured')"></span></td>
<td x-text="p.model_count"></td>
<td x-text="p.latency_ms ? p.latency_ms + 'ms' : '-'"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="flex gap-2">
<button class="btn btn-ghost btn-sm" @click="loadData()">Refresh</button>
</div>
</div>
</div>
</div>
</template>
</main>
</div>
+1 -1
View File
@@ -218,7 +218,7 @@ function app() {
});
// Hash routing
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','runtime','settings','wizard'];
var pageRedirects = {
'chat': 'agents',
'templates': 'agents',
@@ -141,7 +141,16 @@ function channelsPage() {
openSetup(ch) {
this.setupModal = ch;
this.formValues = {};
// Pre-populate form values from saved config (non-secret fields).
var vals = {};
if (ch.fields) {
ch.fields.forEach(function(f) {
if (f.value !== undefined && f.value !== null && f.type !== 'secret') {
vals[f.key] = String(f.value);
}
});
}
this.formValues = vals;
this.showAdvanced = false;
this.showBusinessApi = false;
this.setupStep = ch.configured ? 3 : 1;
@@ -34,6 +34,13 @@ function chatPage() {
modelPickerList: [],
modelPickerFilter: '',
modelPickerIdx: 0,
// Model switcher dropdown
showModelSwitcher: false,
modelSwitcherFilter: '',
modelSwitcherIdx: 0,
modelSwitching: false,
_modelCache: null,
_modelCacheTime: 0,
slashCommands: [
{ cmd: '/help', desc: 'Show available commands' },
{ cmd: '/agents', desc: 'Switch to Agents page' },
@@ -85,6 +92,36 @@ function chatPage() {
}
},
get modelDisplayName() {
if (!this.currentAgent) return '';
var name = this.currentAgent.model_name || '';
var short = name.replace(/-\d{8}$/, '');
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
},
get filteredSwitcherModels() {
var models = this._modelCache || [];
if (!this.modelSwitcherFilter) return models;
var f = this.modelSwitcherFilter.toLowerCase();
return models.filter(function(m) {
return m.id.toLowerCase().indexOf(f) !== -1 ||
(m.display_name || '').toLowerCase().indexOf(f) !== -1 ||
m.provider.toLowerCase().indexOf(f) !== -1;
});
},
get groupedSwitcherModels() {
var filtered = this.filteredSwitcherModels;
var groups = {}, order = [];
filtered.forEach(function(m) {
if (!groups[m.provider]) { groups[m.provider] = []; order.push(m.provider); }
groups[m.provider].push(m);
});
return order.map(function(p) {
return { provider: p.charAt(0).toUpperCase() + p.slice(1), models: groups[p] };
});
},
init() {
var self = this;
@@ -101,6 +138,11 @@ function chatPage() {
var input = document.getElementById('msg-input');
if (input) { input.focus(); self.inputText = '/'; }
}
// Ctrl+M for model switcher
if ((e.ctrlKey || e.metaKey) && e.key === 'm' && self.currentAgent) {
e.preventDefault();
self.toggleModelSwitcher();
}
// Ctrl+F for chat search
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && self.currentAgent) {
e.preventDefault();
@@ -172,6 +214,54 @@ function chatPage() {
this.sendMessage();
},
toggleModelSwitcher() {
if (this.showModelSwitcher) { this.showModelSwitcher = false; return; }
var self = this;
var now = Date.now();
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
this.modelSwitcherFilter = '';
this.modelSwitcherIdx = 0;
this.showModelSwitcher = true;
this.$nextTick(function() {
var el = document.getElementById('model-switcher-search');
if (el) el.focus();
});
return;
}
OpenFangAPI.get('/api/models').then(function(data) {
var models = (data.models || []).filter(function(m) { return m.available; });
self._modelCache = models;
self._modelCacheTime = Date.now();
self.modelPickerList = models;
self.modelSwitcherFilter = '';
self.modelSwitcherIdx = 0;
self.showModelSwitcher = true;
self.$nextTick(function() {
var el = document.getElementById('model-switcher-search');
if (el) el.focus();
});
}).catch(function(e) {
OpenFangToast.error('Failed to load models: ' + e.message);
});
},
switchModel(model) {
if (!this.currentAgent) return;
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;
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
self.showModelSwitcher = false;
self.modelSwitching = false;
}).catch(function(e) {
OpenFangToast.error('Switch failed: ' + e.message);
self.modelSwitching = false;
});
},
// Fetch dynamic slash commands from server
fetchCommands: function() {
var self = this;
@@ -0,0 +1,59 @@
// Runtime page — system overview and provider status
document.addEventListener('alpine:init', function() {
Alpine.data('runtimePage', function() {
return {
loading: true,
uptime: '-',
agentCount: 0,
version: '-',
defaultModel: '-',
platform: '-',
arch: '-',
apiListen: '-',
homeDir: '-',
logLevel: '-',
networkEnabled: false,
providers: [],
async loadData() {
this.loading = true;
try {
var results = await Promise.all([
OpenFangAPI.get('/api/status'),
OpenFangAPI.get('/api/version'),
OpenFangAPI.get('/api/providers'),
OpenFangAPI.get('/api/agents')
]);
var status = results[0];
var ver = results[1];
var prov = results[2];
var agents = results[3];
this.version = ver.version || '-';
this.platform = ver.platform || '-';
this.arch = ver.arch || '-';
this.agentCount = Array.isArray(agents) ? agents.length : 0;
this.defaultModel = status.default_model || '-';
this.apiListen = status.api_listen || status.listen || '-';
this.homeDir = status.home_dir || '-';
this.logLevel = status.log_level || '-';
this.networkEnabled = !!status.network_enabled;
// Compute uptime from uptime_seconds
var diff = status.uptime_seconds || 0;
if (diff < 60) this.uptime = diff + 's';
else if (diff < 3600) this.uptime = Math.floor(diff / 60) + 'm ' + (diff % 60) + 's';
else if (diff < 86400) this.uptime = Math.floor(diff / 3600) + 'h ' + Math.floor((diff % 3600) / 60) + 'm';
else this.uptime = Math.floor(diff / 86400) + 'd ' + Math.floor((diff % 86400) / 3600) + 'h';
this.providers = (prov.providers || []).filter(function(p) {
return p.auth_status === 'Configured' || p.reachable || p.is_local;
});
} catch(e) {
console.error('Runtime load error:', e);
}
this.loading = false;
}
};
});
});
@@ -258,6 +258,17 @@ function settingsPage() {
}
},
async deleteCustomModel(modelId) {
if (!confirm('Delete custom model "' + modelId + '"?')) return;
try {
await OpenFangAPI.del('/api/models/custom/' + encodeURIComponent(modelId));
OpenFangToast.success('Model deleted');
await this.loadModels();
} catch(e) {
OpenFangToast.error('Failed to delete: ' + (e.message || 'Unknown error'));
}
},
async loadConfigSchema() {
try {
var results = await Promise.all([
+35 -1
View File
@@ -19,10 +19,16 @@ function skillsPage() {
installingSlug: null,
installResult: null,
_searchTimer: null,
_browseCache: {}, // { key: { ts, data } } client-side 60s cache
_searchCache: {},
// Skill detail modal
skillDetail: null,
detailLoading: false,
showSkillCode: false,
skillCode: '',
skillCodeFilename: '',
skillCodeLoading: false,
// MCP servers
mcpServers: [],
@@ -146,9 +152,16 @@ function skillsPage() {
if (this._searchTimer) clearTimeout(this._searchTimer);
},
// ClawHub browse by sort
// ClawHub browse by sort (with 60s client-side cache)
async browseClawHub(sort) {
this.clawhubSort = sort || 'trending';
var ckey = 'browse:' + this.clawhubSort;
var cached = this._browseCache[ckey];
if (cached && (Date.now() - cached.ts) < 60000) {
this.clawhubBrowseResults = cached.data.items || [];
this.clawhubNextCursor = cached.data.next_cursor || null;
return;
}
this.clawhubLoading = true;
this.clawhubError = '';
this.clawhubNextCursor = null;
@@ -157,6 +170,7 @@ function skillsPage() {
this.clawhubBrowseResults = data.items || [];
this.clawhubNextCursor = data.next_cursor || null;
if (data.error) this.clawhubError = data.error;
this._browseCache[ckey] = { ts: Date.now(), data: data };
} catch(e) {
this.clawhubBrowseResults = [];
this.clawhubError = e.message || 'Browse failed';
@@ -195,6 +209,26 @@ function skillsPage() {
closeDetail() {
this.skillDetail = null;
this.installResult = null;
this.showSkillCode = false;
this.skillCode = '';
this.skillCodeFilename = '';
},
async viewSkillCode(slug) {
if (this.showSkillCode) {
this.showSkillCode = false;
return;
}
this.skillCodeLoading = true;
try {
var data = await OpenFangAPI.get('/api/clawhub/skill/' + encodeURIComponent(slug) + '/code');
this.skillCode = data.code || '';
this.skillCodeFilename = data.filename || 'source';
this.showSkillCode = true;
} catch(e) {
OpenFangToast.error('Could not load skill source code');
}
this.skillCodeLoading = false;
},
// Install from ClawHub
@@ -441,8 +441,8 @@ function wizardPage() {
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
toml += 'profile = "' + tpl.profile + '"\n\n';
toml += '[model]\nprovider = "' + provider + '"\n';
toml += 'name = "' + model + '"\n\n';
toml += '[prompt]\nsystem = """\n' + tpl.system_prompt + '\n"""\n';
toml += 'model = "' + model + '"\n';
toml += 'system_prompt = """\n' + tpl.system_prompt + '\n"""\n';
this.creatingAgent = true;
try {
@@ -37,6 +37,8 @@ function workflowBuilder() {
{ type: 'end', label: 'End', color: '#ef4444', icon: 'E', ports: { in: 1, out: 0 } }
],
_renderScheduled: false,
async init() {
var self = this;
// Load agents for the agent step dropdown
@@ -50,6 +52,157 @@ function workflowBuilder() {
self.addNode('start', 60, 200);
},
// ── SVG Manual Rendering ────────────────────────────
// Alpine.js x-for inside <svg> breaks because document.importNode
// doesn't handle SVG namespace correctly. We render nodes/connections
// manually via createElementNS and schedule re-renders reactively.
scheduleRender: function() {
if (this._renderScheduled) return;
this._renderScheduled = true;
var self = this;
requestAnimationFrame(function() {
self._renderScheduled = false;
self.renderCanvas();
});
},
renderCanvas: function() {
var container = document.getElementById('wf-render-group');
if (!container) return;
var SVG_NS = 'http://www.w3.org/2000/svg';
var self = this;
// Clear previous rendered content
while (container.firstChild) container.removeChild(container.firstChild);
// ── Connections ──
for (var ci = 0; ci < this.connections.length; ci++) {
var conn = this.connections[ci];
var d = this.getConnectionPath(conn);
if (!d) continue;
var path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', (this.selectedConnection && this.selectedConnection.id === conn.id) ? 'var(--accent)' : 'var(--text-dim)');
path.setAttribute('stroke-width', (this.selectedConnection && this.selectedConnection.id === conn.id) ? '3' : '2');
path.style.cursor = 'pointer';
(function(c) {
path.addEventListener('click', function(e) { e.stopPropagation(); self.selectedConnection = c; self.scheduleRender(); });
})(conn);
container.appendChild(path);
}
// ── Connection preview ──
if (this.connecting && this.connectPreview) {
var pd = this.getPreviewPath();
if (pd) {
var preview = document.createElementNS(SVG_NS, 'path');
preview.setAttribute('d', pd);
preview.setAttribute('fill', 'none');
preview.setAttribute('stroke', 'var(--accent)');
preview.setAttribute('stroke-width', '2');
preview.setAttribute('stroke-dasharray', '6,3');
container.appendChild(preview);
}
}
// ── Nodes ──
for (var ni = 0; ni < this.nodes.length; ni++) {
var node = this.nodes[ni];
var g = document.createElementNS(SVG_NS, 'g');
g.classList.add('wf-node');
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
(function(n) {
g.addEventListener('mousedown', function(e) { self.onNodeMouseDown(n, e); });
g.addEventListener('dblclick', function() { self.editNode(n); });
})(node);
// Node body rect
var rect = document.createElementNS(SVG_NS, 'rect');
rect.setAttribute('x', '0'); rect.setAttribute('y', '0');
rect.setAttribute('width', node.width); rect.setAttribute('height', node.height);
rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8');
rect.setAttribute('fill', (self.selectedNode && self.selectedNode.id === node.id) ? 'var(--card-bg)' : 'var(--bg-secondary)');
rect.setAttribute('stroke', (self.selectedNode && self.selectedNode.id === node.id) ? node.color : 'var(--border)');
rect.setAttribute('stroke-width', '2');
rect.style.cursor = 'grab';
g.appendChild(rect);
// Color accent bar
var bar = document.createElementNS(SVG_NS, 'rect');
bar.setAttribute('x', '0'); bar.setAttribute('y', '0');
bar.setAttribute('width', '6'); bar.setAttribute('height', node.height);
bar.setAttribute('rx', '3'); bar.setAttribute('ry', '0');
bar.setAttribute('fill', node.color);
g.appendChild(bar);
// Icon circle + text
var circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', '28'); circle.setAttribute('cy', node.height / 2);
circle.setAttribute('r', '14'); circle.setAttribute('fill', node.color);
circle.setAttribute('opacity', '0.15');
g.appendChild(circle);
var iconText = document.createElementNS(SVG_NS, 'text');
iconText.setAttribute('x', '28'); iconText.setAttribute('y', node.height / 2 + 4);
iconText.setAttribute('text-anchor', 'middle'); iconText.setAttribute('fill', node.color);
iconText.setAttribute('style', 'font-size:12px;font-weight:700;pointer-events:none');
iconText.textContent = node.icon;
g.appendChild(iconText);
// Label
var label = document.createElementNS(SVG_NS, 'text');
label.setAttribute('x', '50'); label.setAttribute('y', node.height / 2 - 4);
label.setAttribute('fill', 'var(--text)');
label.setAttribute('style', 'font-size:12px;font-weight:600;pointer-events:none');
label.textContent = node.label;
g.appendChild(label);
// Sub-label
var subLabel = document.createElementNS(SVG_NS, 'text');
subLabel.setAttribute('x', '50'); subLabel.setAttribute('y', node.height / 2 + 12);
subLabel.setAttribute('fill', 'var(--text-dim)');
subLabel.setAttribute('style', 'font-size:10px;pointer-events:none');
if (node.type === 'agent') subLabel.textContent = node.config.agent_name || 'No agent';
else if (node.type === 'condition') subLabel.textContent = node.config.expression || 'No condition';
else if (node.type === 'loop') subLabel.textContent = 'max ' + (node.config.max_iterations || 5) + ' iters';
else if (node.type === 'parallel') subLabel.textContent = (node.config.fan_count || 3) + ' branches';
else if (node.type === 'collect') subLabel.textContent = node.config.strategy || 'all';
g.appendChild(subLabel);
// Input ports
for (var pi = 0; pi < node.ports.in; pi++) {
var inp = document.createElementNS(SVG_NS, 'circle');
inp.classList.add('wf-port', 'wf-port-in');
inp.setAttribute('cx', node.width / (node.ports.in + 1) * (pi + 1));
inp.setAttribute('cy', '0'); inp.setAttribute('r', '6');
inp.setAttribute('fill', 'var(--bg-secondary)');
inp.setAttribute('stroke', 'var(--text-dim)'); inp.setAttribute('stroke-width', '2');
(function(nid, idx) {
inp.addEventListener('mouseup', function(e) { e.stopPropagation(); self.endConnect(nid, idx, e); });
})(node.id, pi);
g.appendChild(inp);
}
// Output ports
for (var po = 0; po < node.ports.out; po++) {
var outp = document.createElementNS(SVG_NS, 'circle');
outp.classList.add('wf-port', 'wf-port-out');
outp.setAttribute('cx', node.width / (node.ports.out + 1) * (po + 1));
outp.setAttribute('cy', node.height); outp.setAttribute('r', '6');
outp.setAttribute('fill', 'var(--bg-secondary)');
outp.setAttribute('stroke', node.color); outp.setAttribute('stroke-width', '2');
(function(nid, idx) {
outp.addEventListener('mousedown', function(e) { e.stopPropagation(); self.startConnect(nid, idx, e); });
})(node.id, po);
g.appendChild(outp);
}
container.appendChild(g);
}
},
// ── Node Management ──────────────────────────────────
addNode: function(type, x, y) {
@@ -83,6 +236,7 @@ function workflowBuilder() {
node.config = { strategy: 'all' };
}
this.nodes.push(node);
this.scheduleRender();
return node;
},
@@ -95,6 +249,7 @@ function workflowBuilder() {
this.selectedNode = null;
this.showNodeEditor = false;
}
this.scheduleRender();
},
duplicateNode: function(node) {
@@ -166,11 +321,13 @@ function workflowBuilder() {
}
this.connecting = null;
this.connectPreview = null;
this.scheduleRender();
},
deleteConnection: function(connId) {
this.connections = this.connections.filter(function(c) { return c.id !== connId; });
this.selectedConnection = null;
this.scheduleRender();
},
// ── Drag Handling ────────────────────────────────────
@@ -205,11 +362,13 @@ function workflowBuilder() {
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
node.y = Math.max(0, (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - this.dragOffset.y);
}
this.scheduleRender();
} else if (this.connecting) {
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.canvasOffset = {
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
@@ -223,6 +382,7 @@ function workflowBuilder() {
this.connecting = null;
this.connectPreview = null;
this.canvasDragging = false;
this.scheduleRender();
},
onCanvasWheel: function(e) {
@@ -386,7 +546,7 @@ function workflowBuilder() {
var rect = this._getCanvasRect();
var x = (e.clientX - rect.left) / this.zoom - this.canvasOffset.x;
var y = (e.clientY - rect.top) / this.zoom - this.canvasOffset.y;
this.addNode(type, x - 90, y - 35);
this.addNode(type, x - 90, y - 35); // addNode already calls scheduleRender
},
onCanvasDragOver: function(e) {
@@ -405,6 +565,7 @@ function workflowBuilder() {
this.nodes[i].y = y;
y += 120;
}
this.scheduleRender();
},
// ── Clear ────────────────────────────────────────────
@@ -414,7 +575,7 @@ function workflowBuilder() {
this.connections = [];
this.selectedNode = null;
this.nextId = 1;
this.addNode('start', 60, 200);
this.addNode('start', 60, 200); // addNode already calls scheduleRender
},
// ── Zoom controls ────────────────────────────────────
@@ -76,6 +76,7 @@ async fn start_test_server_with_provider(
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
let app = Router::new()
@@ -221,10 +222,10 @@ async fn test_status_endpoint() {
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "running");
assert_eq!(body["agent_count"], 0);
assert_eq!(body["agent_count"], 1); // default assistant auto-spawned
assert!(body["uptime_seconds"].is_number());
assert_eq!(body["default_provider"], "ollama");
assert_eq!(body["agents"].as_array().unwrap().len(), 0);
assert_eq!(body["agents"].as_array().unwrap().len(), 1);
}
#[tokio::test]
@@ -246,7 +247,7 @@ async fn test_spawn_list_kill_agent() {
let agent_id = body["agent_id"].as_str().unwrap().to_string();
assert!(!agent_id.is_empty());
// --- List (1 agent) ---
// --- List (2 agents: default assistant + test-agent) ---
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
@@ -254,10 +255,10 @@ async fn test_spawn_list_kill_agent() {
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0]["name"], "test-agent");
assert_eq!(agents[0]["id"], agent_id);
assert_eq!(agents[0]["model_provider"], "ollama");
assert_eq!(agents.len(), 2);
let test_agent = agents.iter().find(|a| a["name"] == "test-agent").unwrap();
assert_eq!(test_agent["id"], agent_id);
assert_eq!(test_agent["model_provider"], "ollama");
// --- Kill ---
let resp = client
@@ -269,7 +270,7 @@ async fn test_spawn_list_kill_agent() {
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "killed");
// --- List (empty) ---
// --- List (only default assistant remains) ---
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
@@ -277,7 +278,8 @@ async fn test_spawn_list_kill_agent() {
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 0);
assert_eq!(agents.len(), 1);
assert_eq!(agents[0]["name"], "assistant");
}
#[tokio::test]
@@ -616,14 +618,14 @@ memory_write = ["self.*"]
ids.push(body["agent_id"].as_str().unwrap().to_string());
}
// List should show 3
// List should show 4 (3 spawned + default assistant)
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 3);
assert_eq!(agents.len(), 4);
// Status should agree
let resp = client
@@ -632,7 +634,7 @@ memory_write = ["self.*"]
.await
.unwrap();
let status: serde_json::Value = resp.json().await.unwrap();
assert_eq!(status["agent_count"], 3);
assert_eq!(status["agent_count"], 4);
// Kill one
let resp = client
@@ -642,14 +644,14 @@ memory_write = ["self.*"]
.unwrap();
assert_eq!(resp.status(), 200);
// List should show 2
// List should show 3 (2 spawned + default assistant)
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 2);
assert_eq!(agents.len(), 3);
// Kill the rest
for id in [&ids[0], &ids[2]] {
@@ -660,14 +662,14 @@ memory_write = ["self.*"]
.unwrap();
}
// List should be empty
// List should have only default assistant
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 0);
assert_eq!(agents.len(), 1);
}
// ---------------------------------------------------------------------------
@@ -702,6 +704,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
let api_key_state = state.kernel.config.api_key.clone();
@@ -113,6 +113,7 @@ async fn test_full_daemon_lifecycle() {
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
let app = Router::new()
@@ -236,6 +237,7 @@ async fn test_server_immediate_responsiveness() {
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
let app = Router::new()
+2 -1
View File
@@ -57,6 +57,7 @@ async fn start_test_server() -> TestServer {
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
let app = Router::new()
@@ -540,7 +541,7 @@ async fn load_spawn_kill_cycle() {
.await
.unwrap();
let remaining = agents.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(remaining, 0, "All agents should be killed");
assert_eq!(remaining, 1, "Only default assistant should remain");
}
/// Test: Prometheus metrics endpoint under sustained load.
+42 -11
View File
@@ -373,10 +373,15 @@ async fn dispatch_message(
// Fetch per-channel overrides (if configured)
let overrides = handle.channel_overrides(ct_str).await;
let channel_default_format = match ct_str {
"telegram" => OutputFormat::TelegramHtml,
"slack" => OutputFormat::SlackMrkdwn,
_ => OutputFormat::Markdown,
};
let output_format = overrides
.as_ref()
.and_then(|o| o.output_format)
.unwrap_or(OutputFormat::Markdown);
.unwrap_or(channel_default_format);
let threading_enabled = overrides.as_ref().map(|o| o.threading).unwrap_or(false);
let thread_id = if threading_enabled {
message.thread_id.as_deref()
@@ -402,8 +407,15 @@ async fn dispatch_message(
}
}
GroupPolicy::MentionOnly => {
// Pass through — adapters should only forward mentioned messages.
// This is a hint for adapters, not enforced here.
// Only allow messages where the bot was @mentioned or commands.
let was_mentioned = message.metadata.get("was_mentioned")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_command = matches!(&message.content, ChannelContent::Command { .. });
if !was_mentioned && !is_command {
debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)");
return;
}
}
GroupPolicy::All => {}
}
@@ -577,14 +589,33 @@ async fn dispatch_message(
let agent_id = match agent_id {
Some(id) => id,
None => {
send_response(
adapter,
&message.sender,
"No agent assigned. Use /agents to list available agents, then /agent <name> to select one.".to_string(),
thread_id,
output_format,
).await;
return;
// Fallback: try "assistant" agent, then first available agent
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) => {
// Auto-set this as the user's default so future messages route directly
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;
}
}
}
};
+144 -18
View File
@@ -38,7 +38,8 @@ pub struct DiscordAdapter {
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
token: Zeroizing<String>,
client: reqwest::Client,
allowed_guilds: Vec<u64>,
allowed_guilds: Vec<String>,
allowed_users: Vec<String>,
intents: u64,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
@@ -51,12 +52,18 @@ pub struct DiscordAdapter {
}
impl DiscordAdapter {
pub fn new(token: String, allowed_guilds: Vec<u64>, intents: u64) -> Self {
pub fn new(
token: String,
allowed_guilds: Vec<String>,
allowed_users: Vec<String>,
intents: u64,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
token: Zeroizing::new(token),
client: reqwest::Client::new(),
allowed_guilds,
allowed_users,
intents,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
@@ -147,6 +154,7 @@ impl ChannelAdapter for DiscordAdapter {
let token = self.token.clone();
let intents = self.intents;
let allowed_guilds = self.allowed_guilds.clone();
let allowed_users = self.allowed_users.clone();
let bot_user_id = self.bot_user_id.clone();
let session_id_store = self.session_id.clone();
let resume_url_store = self.resume_gateway_url.clone();
@@ -307,7 +315,7 @@ impl ChannelAdapter for DiscordAdapter {
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
if let Some(msg) =
parse_discord_message(d, &bot_user_id, &allowed_guilds)
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users)
.await
{
debug!(
@@ -422,7 +430,8 @@ impl ChannelAdapter for DiscordAdapter {
async fn parse_discord_message(
d: &serde_json::Value,
bot_user_id: &Arc<RwLock<Option<String>>>,
allowed_guilds: &[u64],
allowed_guilds: &[String],
allowed_users: &[String],
) -> Option<ChannelMessage> {
let author = d.get("author")?;
let author_id = author["id"].as_str()?;
@@ -439,11 +448,16 @@ async fn parse_discord_message(
return None;
}
// Filter by allowed users
if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == author_id) {
debug!("Discord: ignoring message from unlisted user {author_id}");
return None;
}
// Filter by allowed guilds
if !allowed_guilds.is_empty() {
if let Some(guild_id) = d["guild_id"].as_str() {
let gid: u64 = guild_id.parse().unwrap_or(0);
if !allowed_guilds.contains(&gid) {
if !allowed_guilds.iter().any(|g| g == guild_id) {
return None;
}
}
@@ -487,6 +501,29 @@ async fn parse_discord_message(
ChannelContent::Text(content_text.to_string())
};
// Determine if this is a group message (guild_id present = server channel)
let is_group = d["guild_id"].as_str().is_some();
// Check if bot was @mentioned (for MentionOnly policy enforcement)
let was_mentioned = if let Some(ref bid) = *bot_user_id.read().await {
// Check Discord mentions array
let mentioned_in_array = d["mentions"]
.as_array()
.map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str())))
.unwrap_or(false);
// Also check content for <@bot_id> or <@!bot_id> patterns
let mentioned_in_content =
content_text.contains(&format!("<@{bid}>")) || content_text.contains(&format!("<@!{bid}>"));
mentioned_in_array || mentioned_in_content
} else {
false
};
let mut metadata = HashMap::new();
if was_mentioned {
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
}
Some(ChannelMessage {
channel: ChannelType::Discord,
platform_message_id: message_id.to_string(),
@@ -498,9 +535,9 @@ async fn parse_discord_message(
content,
target_agent: None,
timestamp,
is_group: true,
is_group,
thread_id: None,
metadata: HashMap::new(),
metadata,
})
}
@@ -524,7 +561,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, &[], &[]).await.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert_eq!(msg.sender.display_name, "alice");
assert_eq!(msg.sender.platform_id, "ch1");
@@ -546,7 +583,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, &[], &[]).await;
assert!(msg.is_none());
}
@@ -566,7 +603,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, &[], &[]).await;
assert!(msg.is_none());
}
@@ -587,11 +624,11 @@ mod tests {
});
// Not in allowed guilds
let msg = parse_discord_message(&d, &bot_id, &[111, 222]).await;
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[]).await;
assert!(msg.is_none());
// In allowed guilds
let msg = parse_discord_message(&d, &bot_id, &[999]).await;
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[]).await;
assert!(msg.is_some());
}
@@ -610,7 +647,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, &[], &[]).await.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -635,7 +672,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, &[], &[]).await;
assert!(msg.is_none());
}
@@ -654,7 +691,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, &[], &[]).await.unwrap();
assert_eq!(msg.sender.display_name, "alice#1234");
}
@@ -676,16 +713,105 @@ mod tests {
});
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert!(
matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content")
);
}
#[tokio::test]
async fn test_parse_discord_allowed_users_filter() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"content": "Hello",
"author": {
"id": "user999",
"username": "bob",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
// Not in allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()]).await;
assert!(msg.is_none());
// In allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()]).await;
assert!(msg.is_some());
// Empty allowed_users = allow all
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
assert!(msg.is_some());
}
#[tokio::test]
async fn test_parse_discord_mention_detection() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
// Message with bot mentioned in mentions array
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"guild_id": "guild1",
"content": "Hey <@bot123> help me",
"mentions": [{"id": "bot123", "username": "openfang"}],
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
assert!(msg.is_group);
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
// Message without mention in group
let d2 = serde_json::json!({
"id": "msg2",
"channel_id": "ch1",
"guild_id": "guild1",
"content": "Just chatting",
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[]).await.unwrap();
assert!(msg2.is_group);
assert!(!msg2.metadata.contains_key("was_mentioned"));
}
#[tokio::test]
async fn test_parse_discord_dm_not_group() {
let bot_id = Arc::new(RwLock::new(None));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "dm-ch1",
"content": "Hello",
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
assert!(!msg.is_group);
}
#[test]
fn test_discord_adapter_creation() {
let adapter = DiscordAdapter::new("test-token".to_string(), vec![123, 456], 33280);
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], 37376);
assert_eq!(adapter.name(), "discord");
assert_eq!(adapter.channel_type(), ChannelType::Discord);
}
+6 -1
View File
@@ -21,7 +21,12 @@ pub fn format_for_channel(text: &str, format: OutputFormat) -> String {
///
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`.
fn markdown_to_telegram_html(text: &str) -> String {
let mut result = text.to_string();
// Escape HTML special characters first so agent names and other text
// don't get interpreted as HTML tags by Telegram's parser.
let mut result = text
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
// Bold: **text** → <b>text</b>
while let Some(start) = result.find("**") {
+43 -3
View File
@@ -32,6 +32,8 @@ pub struct AgentRouter {
direct_routes: DashMap<(String, String), AgentId>,
/// System-wide default agent.
default_agent: Option<AgentId>,
/// Per-channel-type default agent (e.g., Telegram -> agent_a, Discord -> agent_b).
channel_defaults: DashMap<String, AgentId>,
/// Sorted bindings (most specific first). Uses Mutex for runtime updates via Arc.
bindings: Mutex<Vec<(AgentBinding, String)>>,
/// Broadcast configuration. Uses Mutex for runtime updates via Arc.
@@ -47,6 +49,7 @@ impl AgentRouter {
user_defaults: DashMap::new(),
direct_routes: DashMap::new(),
default_agent: None,
channel_defaults: DashMap::new(),
bindings: Mutex::new(Vec::new()),
broadcast: Mutex::new(BroadcastConfig::default()),
agent_name_cache: DashMap::new(),
@@ -58,6 +61,11 @@ impl AgentRouter {
self.default_agent = Some(agent_id);
}
/// Set a per-channel-type default agent (e.g., "Telegram" -> agent_id).
pub fn set_channel_default(&self, channel_key: String, agent_id: AgentId) {
self.channel_defaults.insert(channel_key, agent_id);
}
/// Set a user's default agent.
pub fn set_user_default(&self, user_key: String, agent_id: AgentId) {
self.user_defaults.insert(user_key, agent_id);
@@ -125,7 +133,7 @@ impl AgentRouter {
// 1. Check direct routes
if let Some(agent) = self
.direct_routes
.get(&(channel_key, platform_user_id.to_string()))
.get(&(channel_key.clone(), platform_user_id.to_string()))
{
return Some(*agent);
}
@@ -141,7 +149,12 @@ impl AgentRouter {
return Some(*agent);
}
// 3. System default
// 3. Per-channel-type default
if let Some(agent) = self.channel_defaults.get(&channel_key) {
return Some(*agent);
}
// 4. System default
self.default_agent
}
@@ -161,7 +174,7 @@ impl AgentRouter {
let channel_key = format!("{channel_type:?}");
if let Some(agent) = self
.direct_routes
.get(&(channel_key, platform_user_id.to_string()))
.get(&(channel_key.clone(), platform_user_id.to_string()))
{
return Some(*agent);
}
@@ -173,6 +186,9 @@ impl AgentRouter {
if let Some(agent) = self.user_defaults.get(platform_user_id) {
return Some(*agent);
}
if let Some(agent) = self.channel_defaults.get(&channel_key) {
return Some(*agent);
}
self.default_agent
}
@@ -501,6 +517,30 @@ mod tests {
assert_eq!(targets[1].1, Some(id2));
}
#[test]
fn test_channel_default_routing() {
let mut router = AgentRouter::new();
let system_default = AgentId::new();
let telegram_default = AgentId::new();
let discord_default = AgentId::new();
router.set_default(system_default);
router.set_channel_default("Telegram".to_string(), telegram_default);
router.set_channel_default("Discord".to_string(), discord_default);
// Telegram should use Telegram-specific default
let resolved = router.resolve(&ChannelType::Telegram, "user1", None);
assert_eq!(resolved, Some(telegram_default));
// Discord should use Discord-specific default
let resolved = router.resolve(&ChannelType::Discord, "user1", None);
assert_eq!(resolved, Some(discord_default));
// WhatsApp has no channel default — falls to system default
let resolved = router.resolve(&ChannelType::WhatsApp, "user1", None);
assert_eq!(resolved, Some(system_default));
}
#[test]
fn test_empty_bindings_legacy_behavior() {
let mut router = AgentRouter::new();
+196 -3
View File
@@ -79,12 +79,18 @@ impl TelegramAdapter {
self.token.as_str()
);
// Sanitize: strip unsupported HTML tags so Telegram doesn't reject with 400.
// Telegram only allows: b, i, u, s, tg-spoiler, a, code, pre, blockquote.
// Any other tag (e.g. <name>, <thinking>) causes a 400 Bad Request.
let sanitized = sanitize_telegram_html(text);
// Telegram has a 4096 character limit per message — split if needed
let chunks = split_message(text, 4096);
let chunks = split_message(&sanitized, 4096);
for chunk in chunks {
let body = serde_json::json!({
"chat_id": chat_id,
"text": chunk,
"parse_mode": "HTML",
});
let resp = self.client.post(&url).json(&body).send().await?;
@@ -97,6 +103,103 @@ impl TelegramAdapter {
Ok(())
}
/// Call `sendPhoto` on the Telegram API.
async fn api_send_photo(
&self,
chat_id: i64,
photo_url: &str,
caption: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://api.telegram.org/bot{}/sendPhoto",
self.token.as_str()
);
let mut body = serde_json::json!({
"chat_id": chat_id,
"photo": photo_url,
});
if let Some(cap) = caption {
body["caption"] = serde_json::Value::String(cap.to_string());
body["parse_mode"] = serde_json::Value::String("HTML".to_string());
}
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendPhoto failed: {body_text}");
}
Ok(())
}
/// Call `sendDocument` on the Telegram API.
async fn api_send_document(
&self,
chat_id: i64,
document_url: &str,
filename: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://api.telegram.org/bot{}/sendDocument",
self.token.as_str()
);
let body = serde_json::json!({
"chat_id": chat_id,
"document": document_url,
"caption": filename,
});
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendDocument failed: {body_text}");
}
Ok(())
}
/// Call `sendVoice` on the Telegram API.
async fn api_send_voice(
&self,
chat_id: i64,
voice_url: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://api.telegram.org/bot{}/sendVoice",
self.token.as_str()
);
let body = serde_json::json!({
"chat_id": chat_id,
"voice": voice_url,
});
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendVoice failed: {body_text}");
}
Ok(())
}
/// Call `sendLocation` on the Telegram API.
async fn api_send_location(
&self,
chat_id: i64,
lat: f64,
lon: f64,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://api.telegram.org/bot{}/sendLocation",
self.token.as_str()
);
let body = serde_json::json!({
"chat_id": chat_id,
"latitude": lat,
"longitude": lon,
});
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendLocation failed: {body_text}");
}
Ok(())
}
/// Call `sendChatAction` to show "typing..." indicator.
async fn api_send_typing(&self, chat_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
@@ -130,6 +233,26 @@ impl ChannelAdapter for TelegramAdapter {
let bot_name = self.validate_token().await?;
info!("Telegram bot @{bot_name} connected");
// Clear any existing webhook to avoid 409 Conflict during getUpdates polling.
// This is necessary when the daemon restarts — the old polling session may
// still be active on Telegram's side for ~30s, causing 409 errors.
{
let delete_url = format!(
"https://api.telegram.org/bot{}/deleteWebhook",
self.token.as_str()
);
match self
.client
.post(&delete_url)
.json(&serde_json::json!({"drop_pending_updates": true}))
.send()
.await
{
Ok(_) => info!("Telegram: cleared webhook, polling mode active"),
Err(e) => tracing::warn!("Telegram: deleteWebhook failed (non-fatal): {e}"),
}
}
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let token = self.token.clone();
@@ -285,10 +408,23 @@ impl ChannelAdapter for TelegramAdapter {
ChannelContent::Text(text) => {
self.api_send_message(chat_id, &text).await?;
}
_ => {
self.api_send_message(chat_id, "(Unsupported content type)")
ChannelContent::Image { url, caption } => {
self.api_send_photo(chat_id, &url, caption.as_deref())
.await?;
}
ChannelContent::File { url, filename } => {
self.api_send_document(chat_id, &url, &filename).await?;
}
ChannelContent::Voice { url, .. } => {
self.api_send_voice(chat_id, &url).await?;
}
ChannelContent::Location { lat, lon } => {
self.api_send_location(chat_id, lat, lon).await?;
}
ChannelContent::Command { name, args } => {
let text = format!("/{name} {}", args.join(" "));
self.api_send_message(chat_id, text.trim()).await?;
}
}
Ok(())
}
@@ -393,6 +529,63 @@ pub fn calculate_backoff(current: Duration) -> Duration {
(current * 2).min(MAX_BACKOFF)
}
/// Sanitize text for Telegram HTML parse mode.
///
/// Escapes angle brackets that are NOT part of Telegram-allowed HTML tags.
/// Allowed tags: b, i, u, s, tg-spoiler, a, code, pre, blockquote.
/// Everything else (e.g. `<name>`, `<thinking>`) gets escaped to `&lt;...&gt;`.
fn sanitize_telegram_html(text: &str) -> String {
const ALLOWED: &[&str] = &[
"b", "i", "u", "s", "em", "strong", "a", "code", "pre", "blockquote", "tg-spoiler",
"tg-emoji",
];
let mut result = String::with_capacity(text.len());
let mut chars = text.char_indices().peekable();
while let Some(&(i, ch)) = chars.peek() {
if ch == '<' {
// Try to parse an HTML tag
if let Some(end_offset) = text[i..].find('>') {
let tag_end = i + end_offset;
let tag_content = &text[i + 1..tag_end]; // content between < and >
let tag_name = tag_content
.trim_start_matches('/')
.split(|c: char| c.is_whitespace() || c == '/' || c == '>')
.next()
.unwrap_or("")
.to_lowercase();
if !tag_name.is_empty() && ALLOWED.contains(&tag_name.as_str()) {
// Allowed tag — keep as-is
result.push_str(&text[i..tag_end + 1]);
} else {
// Unknown tag — escape both brackets
result.push_str("&lt;");
result.push_str(tag_content);
result.push_str("&gt;");
}
// Advance past the whole tag
while let Some(&(j, _)) = chars.peek() {
chars.next();
if j >= tag_end {
break;
}
}
} else {
// No closing > — escape the lone <
result.push_str("&lt;");
chars.next();
}
} else {
result.push(ch);
chars.next();
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
@@ -379,8 +379,8 @@ async fn test_bridge_dispatch_no_agent_assigned() {
let sent = adapter_ref.get_sent();
assert_eq!(sent.len(), 1);
assert!(
sent[0].1.contains("No agent assigned"),
"Expected 'No agent assigned' message, got: {}",
sent[0].1.contains("No agents available"),
"Expected 'No agents available' message, got: {}",
sent[0].1
);
+10 -2
View File
@@ -6,9 +6,17 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
/// Get the OpenFang home directory, respecting OPENFANG_HOME env var.
fn dotenv_openfang_home() -> Option<PathBuf> {
if let Ok(home) = std::env::var("OPENFANG_HOME") {
return Some(PathBuf::from(home));
}
dirs::home_dir().map(|h| h.join(".openfang"))
}
/// Return the path to `~/.openfang/.env`.
pub fn env_file_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".openfang").join(".env"))
dotenv_openfang_home().map(|h| h.join(".env"))
}
/// Load `~/.openfang/.env` and `~/.openfang/secrets.env` into `std::env`.
@@ -25,7 +33,7 @@ pub fn load_dotenv() {
/// Return the path to `~/.openfang/secrets.env`.
pub fn secrets_env_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".openfang").join("secrets.env"))
dotenv_openfang_home().map(|h| h.join("secrets.env"))
}
fn load_env_file(path: Option<PathBuf>) {
+8 -4
View File
@@ -39,11 +39,15 @@ fn detect_provider() -> Option<(&'static str, &'static str)> {
}
fn is_first_run() -> bool {
let home = match dirs::home_dir() {
Some(h) => h,
None => return true,
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(h)
} else {
match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => return true,
}
};
!home.join(".openfang").join("config.toml").exists()
!of_home.join("config.toml").exists()
}
fn has_openclaw() -> bool {
+339 -66
View File
@@ -390,6 +390,11 @@ enum HandCommands {
List,
/// Show currently active hand instances.
Active,
/// Install a hand from a local directory containing HAND.toml.
Install {
/// Path to the hand directory (must contain HAND.toml).
path: String,
},
/// Activate a hand by ID.
Activate {
/// Hand ID (e.g. "clip", "lead", "researcher").
@@ -405,6 +410,26 @@ enum HandCommands {
/// Hand ID.
id: String,
},
/// Check dependency status for a hand.
CheckDeps {
/// Hand ID.
id: String,
},
/// Install missing dependencies for a hand.
InstallDeps {
/// Hand ID.
id: String,
},
/// Pause a running hand instance.
Pause {
/// Instance ID (from `hand active`).
id: String,
},
/// Resume a paused hand instance.
Resume {
/// Instance ID (from `hand active`).
id: String,
},
}
#[derive(Subcommand)]
@@ -612,6 +637,9 @@ enum CronCommands {
spec: String,
/// Prompt to send when the job fires.
prompt: String,
/// Optional job name (auto-generated if omitted).
#[arg(long)]
name: Option<String>,
},
/// Delete a scheduled job.
Delete {
@@ -758,11 +786,19 @@ fn init_tracing_stderr() {
.init();
}
/// Get the OpenFang home directory, respecting OPENFANG_HOME env var.
fn cli_openfang_home() -> std::path::PathBuf {
if let Ok(home) = std::env::var("OPENFANG_HOME") {
return std::path::PathBuf::from(home);
}
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
}
/// Redirect tracing to a log file so it doesn't corrupt the ratatui TUI.
fn init_tracing_file() {
let log_dir = dirs::home_dir()
.map(|h| h.join(".openfang"))
.unwrap_or_else(|| std::path::PathBuf::from("."));
let log_dir = cli_openfang_home();
let _ = std::fs::create_dir_all(&log_dir);
let log_path = log_dir.join("tui.log");
@@ -887,9 +923,14 @@ fn main() {
Some(Commands::Hand(sub)) => match sub {
HandCommands::List => cmd_hand_list(),
HandCommands::Active => cmd_hand_active(),
HandCommands::Install { path } => cmd_hand_install(&path),
HandCommands::Activate { id } => cmd_hand_activate(&id),
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
HandCommands::Info { id } => cmd_hand_info(&id),
HandCommands::CheckDeps { id } => cmd_hand_check_deps(&id),
HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id),
HandCommands::Pause { id } => cmd_hand_pause(&id),
HandCommands::Resume { id } => cmd_hand_resume(&id),
},
Some(Commands::Config(sub)) => match sub {
ConfigCommands::Show => cmd_config_show(),
@@ -940,7 +981,8 @@ fn main() {
agent,
spec,
prompt,
} => cmd_cron_create(&agent, &spec, &prompt),
name,
} => cmd_cron_create(&agent, &spec, &prompt, name.as_deref()),
CronCommands::Delete { id } => cmd_cron_delete(&id),
CronCommands::Enable { id } => cmd_cron_toggle(&id, true),
CronCommands::Disable { id } => cmd_cron_toggle(&id, false),
@@ -1009,7 +1051,7 @@ pub(crate) fn restrict_dir_permissions(path: &std::path::Path) {
pub(crate) fn restrict_dir_permissions(_path: &std::path::Path) {}
pub(crate) fn find_daemon() -> Option<String> {
let home_dir = dirs::home_dir()?.join(".openfang");
let home_dir = cli_openfang_home();
let info = read_daemon_info(&home_dir)?;
// Normalize listen address: replace 0.0.0.0 with 127.0.0.1 to avoid
@@ -1091,7 +1133,7 @@ fn cmd_init(quick: bool) {
}
};
let openfang_dir = home.join(".openfang");
let openfang_dir = cli_openfang_home();
// --- Ensure directories exist ---
if !openfang_dir.exists() {
@@ -1264,8 +1306,14 @@ fn detect_best_provider() -> (&'static str, &'static str, &'static str) {
ui::success("Detected Gemini (GOOGLE_API_KEY)");
return ("gemini", "GOOGLE_API_KEY", "gemini-2.5-flash");
}
// Check if Ollama is running locally (no API key needed)
if check_ollama_available() {
ui::success("Detected Ollama running locally (no API key needed)");
return ("ollama", "OLLAMA_API_KEY", "llama3.2");
}
ui::hint("No LLM provider API keys found");
ui::hint("Groq offers a free tier: https://console.groq.com");
ui::hint("Or install Ollama for local models: https://ollama.com");
("groq", "GROQ_API_KEY", "llama-3.3-70b-versatile")
}
@@ -1291,6 +1339,15 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
]
}
/// Quick probe to check if Ollama is running on localhost.
fn check_ollama_available() -> bool {
std::net::TcpStream::connect_timeout(
&std::net::SocketAddr::from(([127, 0, 0, 1], 11434)),
std::time::Duration::from_millis(500),
)
.is_ok()
}
/// Write config.toml if it doesn't already exist.
fn write_config_if_missing(
openfang_dir: &std::path::Path,
@@ -1391,11 +1448,28 @@ fn cmd_start(config: Option<PathBuf>) {
});
}
/// Read the api_key from ~/.openfang/config.toml (if any).
fn read_api_key() -> Option<String> {
let config_path = cli_openfang_home().join("config.toml");
let text = std::fs::read_to_string(config_path).ok()?;
let table: toml::Value = text.parse().ok()?;
let key = table.get("api_key")?.as_str()?;
if key.is_empty() {
None
} else {
Some(key.to_string())
}
}
fn cmd_stop() {
match find_daemon() {
Some(base) => {
let client = daemon_client();
match client.post(format!("{base}/api/shutdown")).send() {
let mut req = client.post(format!("{base}/api/shutdown"));
if let Some(key) = read_api_key() {
req = req.bearer_auth(key);
}
match req.send() {
Ok(r) if r.status().is_success() => {
// Wait for daemon to actually stop (up to 5 seconds)
for _ in 0..10 {
@@ -1406,8 +1480,8 @@ fn cmd_stop() {
}
}
// Still alive — force kill via PID
if let Some(home) = dirs::home_dir() {
let of_dir = home.join(".openfang");
{
let of_dir = cli_openfang_home();
if let Some(info) = read_daemon_info(&of_dir) {
force_kill_pid(info.pid);
let _ = std::fs::remove_file(of_dir.join("daemon.json"));
@@ -1882,8 +1956,8 @@ fn cmd_doctor(json: bool, repair: bool) {
}
let home = dirs::home_dir();
if let Some(h) = &home {
let openfang_dir = h.join(".openfang");
if let Some(_h) = &home {
let openfang_dir = cli_openfang_home();
// --- Check 1: OpenFang directory ---
if openfang_dir.exists() {
@@ -2040,7 +2114,20 @@ decay_rate = 0.05
all_ok = false;
}
// --- Check 4: Port 4200 availability ---
// --- Check 4: Port availability ---
// Read api_listen from config (default: 127.0.0.1:4200)
let api_listen = {
let cfg_path = openfang_dir.join("config.toml");
if cfg_path.exists() {
std::fs::read_to_string(&cfg_path)
.ok()
.and_then(|s| toml::from_str::<openfang_types::config::KernelConfig>(&s).ok())
.map(|c| c.api_listen)
.unwrap_or_else(|| "127.0.0.1:4200".to_string())
} else {
"127.0.0.1:4200".to_string()
}
};
if !json {
println!();
}
@@ -2056,19 +2143,24 @@ decay_rate = 0.05
}
checks.push(serde_json::json!({"check": "daemon", "status": "warn"}));
// Check if port 4200 is available
match std::net::TcpListener::bind("127.0.0.1:4200") {
// Check if the configured port is available
let bind_addr = if api_listen.starts_with("0.0.0.0") {
api_listen.replacen("0.0.0.0", "127.0.0.1", 1)
} else {
api_listen.clone()
};
match std::net::TcpListener::bind(&bind_addr) {
Ok(_) => {
if !json {
ui::check_ok("Port 4200 is available");
ui::check_ok(&format!("Port {api_listen} is available"));
}
checks.push(serde_json::json!({"check": "port_4200", "status": "ok"}));
checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen}));
}
Err(_) => {
if !json {
ui::check_warn("Port 4200 is in use by another process");
ui::check_warn(&format!("Port {api_listen} is in use by another process"));
}
checks.push(serde_json::json!({"check": "port_4200", "status": "warn"}));
checks.push(serde_json::json!({"check": "port", "status": "warn", "address": api_listen}));
}
}
}
@@ -2288,8 +2380,8 @@ decay_rate = 0.05
}
// --- Check 11: .env keys vs config api_key_env consistency ---
if let Some(ref h) = home {
let openfang_dir = h.join(".openfang");
{
let openfang_dir = cli_openfang_home();
let config_path = openfang_dir.join("config.toml");
if config_path.exists() {
let config_str = std::fs::read_to_string(&config_path).unwrap_or_default();
@@ -2314,8 +2406,8 @@ decay_rate = 0.05
}
// --- Check 12: Config deserialization into KernelConfig ---
if let Some(ref h) = home {
let openfang_dir = h.join(".openfang");
{
let openfang_dir = cli_openfang_home();
let config_path = openfang_dir.join("config.toml");
if config_path.exists() {
if !json {
@@ -2419,10 +2511,7 @@ decay_rate = 0.05
if !json {
println!("\n Skills:");
}
let skills_dir = home
.as_ref()
.map(|h| h.join(".openfang").join("skills"))
.unwrap_or_else(|| std::path::PathBuf::from("skills"));
let skills_dir = cli_openfang_home().join("skills");
let mut skill_reg = openfang_skills::registry::SkillRegistry::new(skills_dir.clone());
skill_reg.load_bundled();
let bundled_count = skill_reg.count();
@@ -2456,12 +2545,20 @@ decay_rate = 0.05
}
// Check for prompt injection issues in skill definitions
// Only flag Critical-severity warnings (Warning-level hits are expected
// in bundled skills that mention shell commands in educational context).
let skills = skill_reg.list();
let mut injection_warnings = 0;
for skill in &skills {
if let Some(ref prompt) = skill.manifest.prompt_context {
let warnings = openfang_skills::verify::SkillVerifier::scan_prompt_content(prompt);
if !warnings.is_empty() {
let has_critical = warnings.iter().any(|w| {
matches!(
w.severity,
openfang_skills::verify::WarningSeverity::Critical
)
});
if has_critical {
injection_warnings += 1;
if !json {
ui::check_warn(&format!(
@@ -2483,11 +2580,11 @@ decay_rate = 0.05
}
// --- Check 14: Extension registry health ---
if let Some(ref h) = home {
{
if !json {
println!("\n Extensions:");
}
let openfang_dir = h.join(".openfang");
let openfang_dir = cli_openfang_home();
let mut ext_registry =
openfang_extensions::registry::IntegrationRegistry::new(&openfang_dir);
ext_registry.load_bundled();
@@ -3098,12 +3195,7 @@ fn cmd_migrate(args: MigrateArgs) {
}
});
let target_dir = dirs::home_dir()
.unwrap_or_else(|| {
eprintln!("Error: Could not determine home directory");
std::process::exit(1);
})
.join(".openfang");
let target_dir = cli_openfang_home();
println!("Migrating from {} ({})...", source, source_dir.display());
if args.dry_run {
@@ -3794,6 +3886,52 @@ fn cmd_channel_toggle(channel: &str, enable: bool) {
// Hand commands
// ---------------------------------------------------------------------------
fn cmd_hand_install(path: &str) {
let base = require_daemon("hand install");
let dir = std::path::Path::new(path);
let toml_path = dir.join("HAND.toml");
let skill_path = dir.join("SKILL.md");
if !toml_path.exists() {
eprintln!(
"Error: No HAND.toml found in {}",
dir.canonicalize()
.unwrap_or_else(|_| dir.to_path_buf())
.display()
);
std::process::exit(1);
}
let toml_content = std::fs::read_to_string(&toml_path).unwrap_or_else(|e| {
eprintln!("Error reading {}: {e}", toml_path.display());
std::process::exit(1);
});
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
let client = daemon_client();
let body = daemon_json(
client
.post(format!("{base}/api/hands/install"))
.json(&serde_json::json!({
"toml_content": toml_content,
"skill_content": skill_content,
}))
.send(),
);
if let Some(err) = body.get("error").and_then(|v| v.as_str()) {
eprintln!("Error: {err}");
std::process::exit(1);
}
println!(
"Installed hand: {} ({})",
body["name"].as_str().unwrap_or("?"),
body["id"].as_str().unwrap_or("?"),
);
println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?"));
}
fn cmd_hand_list() {
let base = require_daemon("hand list");
let client = daemon_client();
@@ -3952,6 +4090,87 @@ fn cmd_hand_info(id: &str) {
);
}
fn cmd_hand_check_deps(id: &str) {
let base = require_daemon("hand check-deps");
let client = daemon_client();
let body = daemon_json(
client
.post(format!("{base}/api/hands/{id}/check-deps"))
.send(),
);
if body.get("error").is_some() {
ui::error(&format!(
"Failed: {}",
body["error"].as_str().unwrap_or("?")
));
} else {
println!(
"{}",
serde_json::to_string_pretty(&body).unwrap_or_default()
);
}
}
fn cmd_hand_install_deps(id: &str) {
let base = require_daemon("hand install-deps");
let client = daemon_client();
let body = daemon_json(
client
.post(format!("{base}/api/hands/{id}/install-deps"))
.send(),
);
if body.get("error").is_some() {
ui::error(&format!(
"Failed: {}",
body["error"].as_str().unwrap_or("?")
));
} else {
ui::success(&format!("Dependencies installed for hand '{id}'."));
if let Some(results) = body.get("results") {
println!(
"{}",
serde_json::to_string_pretty(results).unwrap_or_default()
);
}
}
}
fn cmd_hand_pause(id: &str) {
let base = require_daemon("hand pause");
let client = daemon_client();
let body = daemon_json(
client
.post(format!("{base}/api/hands/instances/{id}/pause"))
.send(),
);
if body.get("error").is_some() {
ui::error(&format!(
"Failed: {}",
body["error"].as_str().unwrap_or("?")
));
} else {
ui::success(&format!("Hand instance '{id}' paused."));
}
}
fn cmd_hand_resume(id: &str) {
let base = require_daemon("hand resume");
let client = daemon_client();
let body = daemon_json(
client
.post(format!("{base}/api/hands/instances/{id}/resume"))
.send(),
);
if body.get("error").is_some() {
ui::error(&format!(
"Failed: {}",
body["error"].as_str().unwrap_or("?")
));
} else {
ui::success(&format!("Hand instance '{id}' resumed."));
}
}
// ---------------------------------------------------------------------------
// Provider / API key helpers
// ---------------------------------------------------------------------------
@@ -4220,6 +4439,31 @@ fn cmd_config_set(key: &str, value: &str) {
}
let last_key = parts[parts.len() - 1];
// Validate: single-part keys must be known scalar fields, not sections.
// Writing a section name as a scalar silently breaks config deserialization.
if parts.len() == 1 {
let known_scalars = [
"home_dir",
"data_dir",
"log_level",
"api_listen",
"network_enabled",
"api_key",
"language",
"max_cron_jobs",
"usage_footer",
"workspaces_dir",
];
if !known_scalars.contains(&last_key) {
ui::error_with_fix(
&format!("'{last_key}' is a section, not a scalar"),
&format!("Use dotted notation: {last_key}.field_name"),
);
std::process::exit(1);
}
}
let tbl = current.as_table_mut().unwrap_or_else(|| {
ui::error(&format!("Parent of '{key}' is not a table"));
std::process::exit(1);
@@ -4229,8 +4473,9 @@ fn cmd_config_set(key: &str, value: &str) {
let new_value = if let Some(existing) = tbl.get(last_key) {
match existing {
toml::Value::Integer(_) => value
.parse::<i64>()
.map(toml::Value::Integer)
.parse::<u64>()
.map(|v| toml::Value::Integer(v as i64))
.or_else(|_| value.parse::<i64>().map(toml::Value::Integer))
.unwrap_or_else(|_| toml::Value::String(value.to_string())),
toml::Value::Float(_) => value
.parse::<f64>()
@@ -4243,7 +4488,18 @@ fn cmd_config_set(key: &str, value: &str) {
_ => toml::Value::String(value.to_string()),
}
} else {
toml::Value::String(value.to_string())
// No existing value — infer type from the string content
if let Ok(b) = value.parse::<bool>() {
toml::Value::Boolean(b)
} else if let Ok(i) = value.parse::<u64>() {
toml::Value::Integer(i as i64)
} else if let Ok(i) = value.parse::<i64>() {
toml::Value::Integer(i)
} else if let Ok(f) = value.parse::<f64>() {
toml::Value::Float(f)
} else {
toml::Value::String(value.to_string())
}
};
tbl.insert(last_key.to_string(), new_value);
@@ -4402,6 +4658,9 @@ fn cmd_quick_chat(config: Option<PathBuf>, agent: Option<String>) {
// ---------------------------------------------------------------------------
pub(crate) fn openfang_home() -> PathBuf {
if let Ok(home) = std::env::var("OPENFANG_HOME") {
return PathBuf::from(home);
}
dirs::home_dir()
.unwrap_or_else(|| {
eprintln!("Error: Could not determine home directory");
@@ -5124,16 +5383,40 @@ fn cmd_cron_list(json: bool) {
}
}
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str) {
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<&str>) {
let base = require_daemon("cron create");
let client = daemon_client();
// Use explicit name if provided, otherwise derive from agent + prompt
let name = if let Some(n) = explicit_name {
n.to_string()
} else {
let short_prompt: String = prompt
.split_whitespace()
.take(4)
.collect::<Vec<_>>()
.join("-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.take(64)
.collect();
format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt })
};
let body = daemon_json(
client
.post(format!("{base}/api/cron/jobs"))
.json(&serde_json::json!({
"agent_id": agent,
"cron_expr": spec,
"prompt": prompt,
"name": name,
"schedule": {
"kind": "cron",
"expr": spec
},
"action": {
"kind": "agent_turn",
"message": prompt
}
}))
.send(),
);
@@ -5220,9 +5503,7 @@ fn cmd_sessions(agent: Option<&str>, json: bool) {
}
fn cmd_logs(lines: usize, follow: bool) {
let log_path = dirs::home_dir()
.map(|h| h.join(".openfang").join("tui.log"))
.unwrap_or_else(|| PathBuf::from("tui.log"));
let log_path = cli_openfang_home().join("tui.log");
if !log_path.exists() {
ui::error_with_fix(
@@ -5752,13 +6033,7 @@ fn cmd_system_version(json: bool) {
}
fn cmd_reset(confirm: bool) {
let openfang_dir = match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => {
ui::error("Could not determine home directory");
std::process::exit(1);
}
};
let openfang_dir = cli_openfang_home();
if !openfang_dir.exists() {
println!(
@@ -5793,14 +6068,7 @@ fn cmd_reset(confirm: bool) {
// ---------------------------------------------------------------------------
fn cmd_uninstall(confirm: bool, keep_config: bool) {
let home = match dirs::home_dir() {
Some(h) => h,
None => {
ui::error("Could not determine home directory");
std::process::exit(1);
}
};
let openfang_dir = home.join(".openfang");
let openfang_dir = cli_openfang_home();
let exe_path = std::env::current_exe().ok();
// Step 1: Show what will be removed
@@ -5826,11 +6094,15 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
println!(" • Remove binary: {}", exe.display());
}
// Check cargo bin path
let cargo_bin = home.join(".cargo").join("bin").join(if cfg!(windows) {
"openfang.exe"
} else {
"openfang"
});
let cargo_bin = dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".cargo")
.join("bin")
.join(if cfg!(windows) {
"openfang.exe"
} else {
"openfang"
});
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
println!(" • Remove cargo binary: {}", cargo_bin.display());
}
@@ -5864,12 +6136,13 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
}
// Step 4: Remove auto-start entries
remove_autostart_entries(&home);
let user_home = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
remove_autostart_entries(&user_home);
// Step 5: Clean PATH from shell configs
if let Some(ref exe) = exe_path {
if let Some(bin_dir) = exe.parent() {
clean_path_entries(&home, &bin_dir.to_string_lossy());
clean_path_entries(&user_home, &bin_dir.to_string_lossy());
}
}
+10 -3
View File
@@ -35,9 +35,16 @@ pub fn discover_template_dirs() -> Vec<PathBuf> {
}
}
// Installed templates
if let Some(home) = dirs::home_dir() {
let agents = home.join(".openfang").join("agents");
// Installed templates (respects OPENFANG_HOME)
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
PathBuf::from(h)
} else if let Some(home) = dirs::home_dir() {
home.join(".openfang")
} else {
std::env::temp_dir().join(".openfang")
};
{
let agents = of_home.join("agents");
if agents.is_dir() && !dirs.contains(&agents) {
dirs.push(agents);
}
+171 -8
View File
@@ -220,6 +220,8 @@ impl StandaloneChat {
}
ChatAction::SendMessage(msg) => self.send_message(msg),
ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
ChatAction::OpenModelPicker => self.open_model_picker(),
ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
}
}
@@ -267,12 +269,13 @@ impl StandaloneChat {
self.chat.push_message(
Role::System,
[
"/help \u{2014} show this help",
"/status \u{2014} connection & agent info",
"/model \u{2014} show current model",
"/clear \u{2014} clear chat history",
"/kill \u{2014} kill the current agent & quit",
"/exit \u{2014} end chat session",
"/help \u{2014} show this help",
"/model \u{2014} open model picker (Ctrl+M)",
"/model <name> \u{2014} switch to model directly",
"/status \u{2014} connection & agent info",
"/clear \u{2014} clear chat history",
"/kill \u{2014} kill the current agent & quit",
"/exit \u{2014} end chat session",
]
.join("\n"),
);
@@ -294,8 +297,14 @@ impl StandaloneChat {
self.chat.push_message(Role::System, s.join("\n"));
}
"/model" => {
self.chat
.push_message(Role::System, format!("Model: {}", self.chat.model_label));
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
if args.is_empty() {
// No argument: open the model picker
self.open_model_picker();
} else {
// With argument: switch directly
self.switch_model(args);
}
}
"/clear" => {
let name = self.chat.agent_name.clone();
@@ -364,6 +373,160 @@ impl StandaloneChat {
}
}
// ── Model picker helpers ──────────────────────────────────────────────────
fn open_model_picker(&mut self) {
use super::screens::chat::ModelEntry;
let models = match &self.backend {
Backend::Daemon { base_url } => {
let client = crate::daemon_client();
match client.get(format!("{base_url}/api/models")).send() {
Ok(resp) => match resp.json::<serde_json::Value>() {
Ok(body) => body["models"]
.as_array()
.map(|arr| {
arr.iter()
.filter(|m| m["available"].as_bool().unwrap_or(false))
.map(|m| ModelEntry {
id: m["id"].as_str().unwrap_or("").to_string(),
display_name: m["display_name"]
.as_str()
.unwrap_or("")
.to_string(),
provider: m["provider"]
.as_str()
.unwrap_or("")
.to_string(),
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
})
.collect()
})
.unwrap_or_default(),
Err(_) => Vec::new(),
},
Err(_) => Vec::new(),
}
}
Backend::InProcess { kernel } => {
let catalog = kernel.model_catalog.read().unwrap();
catalog
.available_models()
.into_iter()
.map(|e| ModelEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
provider: e.provider.clone(),
tier: format!("{:?}", e.tier),
})
.collect()
}
Backend::None => Vec::new(),
};
if models.is_empty() {
self.chat
.push_message(Role::System, "No models available.".to_string());
return;
}
self.chat.model_picker_models = models;
self.chat.model_picker_filter.clear();
self.chat.model_picker_idx = 0;
self.chat.show_model_picker = true;
}
fn switch_model(&mut self, model_id: &str) {
// Skip if already on this model
if self.chat.model_label.ends_with(model_id) {
return;
}
match &self.backend {
Backend::Daemon { base_url } => {
if let Some(ref agent_id) = self.agent_id_daemon {
let client = crate::daemon_client();
let url = format!("{base_url}/api/agents/{agent_id}/model");
match client
.put(&url)
.json(&serde_json::json!({"model": model_id}))
.send()
{
Ok(r) if r.status().is_success() => {
// Re-fetch agent to get updated provider/model
if let Ok(resp) = client
.get(format!("{base_url}/api/agents/{agent_id}"))
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
let provider =
body["model_provider"].as_str().unwrap_or("?");
let model = body["model_name"].as_str().unwrap_or("?");
self.chat.model_label = format!("{provider}/{model}");
}
}
self.chat.push_message(
Role::System,
format!("Switched to {model_id}"),
);
}
_ => {
self.chat.push_message(
Role::System,
format!("Failed to switch to {model_id}"),
);
}
}
}
}
Backend::InProcess { kernel } => {
if let Some(id) = self.agent_id_inprocess {
let provider = kernel
.model_catalog
.read()
.unwrap()
.find_model(model_id)
.map(|e| e.provider.clone());
let result = if let Some(ref prov) = provider {
kernel.registry.update_model_and_provider(
id,
model_id.to_string(),
prov.clone(),
)
} else {
kernel.registry.update_model(id, model_id.to_string())
};
match result {
Ok(()) => {
let prov_label = provider.unwrap_or_else(|| {
kernel
.registry
.get(id)
.map(|e| e.manifest.model.provider.clone())
.unwrap_or_else(|| "?".to_string())
});
self.chat.model_label = format!("{prov_label}/{model_id}");
self.chat.push_message(
Role::System,
format!("Switched to {model_id}"),
);
}
Err(e) => {
self.chat.push_message(
Role::System,
format!("Switch failed: {e}"),
);
}
}
}
}
Backend::None => {
self.chat
.push_message(Role::System, "No backend connected.".to_string());
}
}
}
// ── Agent resolution helpers ─────────────────────────────────────────────
fn enter_chat_daemon(&mut self, id: String, name: String) {
+169 -11
View File
@@ -1364,6 +1364,8 @@ impl App {
}
chat::ChatAction::SendMessage(msg) => self.send_message(msg),
chat::ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
chat::ChatAction::OpenModelPicker => self.open_model_picker(),
chat::ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
}
}
@@ -1848,6 +1850,159 @@ impl App {
}
}
// ─── Model picker ────────────────────────────────────────────────────────
fn open_model_picker(&mut self) {
let models = match &self.backend {
Backend::Daemon { base_url } => {
let client = crate::daemon_client();
match client.get(format!("{base_url}/api/models")).send() {
Ok(resp) => match resp.json::<serde_json::Value>() {
Ok(body) => body["models"]
.as_array()
.map(|arr| {
arr.iter()
.filter(|m| m["available"].as_bool().unwrap_or(false))
.map(|m| chat::ModelEntry {
id: m["id"].as_str().unwrap_or("").to_string(),
display_name: m["display_name"]
.as_str()
.unwrap_or("")
.to_string(),
provider: m["provider"]
.as_str()
.unwrap_or("")
.to_string(),
tier: m["tier"]
.as_str()
.unwrap_or("Balanced")
.to_string(),
})
.collect()
})
.unwrap_or_default(),
Err(_) => Vec::new(),
},
Err(_) => Vec::new(),
}
}
Backend::InProcess { kernel } => {
let catalog = kernel.model_catalog.read().unwrap();
catalog
.available_models()
.into_iter()
.map(|e| chat::ModelEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
provider: e.provider.clone(),
tier: format!("{:?}", e.tier),
})
.collect()
}
Backend::None => Vec::new(),
};
if models.is_empty() {
self.chat
.push_message(chat::Role::System, "No models available.".to_string());
return;
}
self.chat.model_picker_models = models;
self.chat.model_picker_filter.clear();
self.chat.model_picker_idx = 0;
self.chat.show_model_picker = true;
}
fn switch_model(&mut self, model_id: &str) {
if self.chat.model_label.ends_with(model_id) {
return;
}
match (&self.backend, &self.chat_target) {
(Backend::Daemon { base_url }, Some(target)) => {
if let Some(ref agent_id) = target.agent_id_daemon {
let client = crate::daemon_client();
let url = format!("{base_url}/api/agents/{agent_id}/model");
match client
.put(&url)
.json(&serde_json::json!({"model": model_id}))
.send()
{
Ok(r) if r.status().is_success() => {
if let Ok(resp) = client
.get(format!("{base_url}/api/agents/{agent_id}"))
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
let provider =
body["model_provider"].as_str().unwrap_or("?");
let model = body["model_name"].as_str().unwrap_or("?");
self.chat.model_label = format!("{provider}/{model}");
}
}
self.chat.push_message(
chat::Role::System,
format!("Switched to {model_id}"),
);
}
_ => {
self.chat.push_message(
chat::Role::System,
format!("Failed to switch to {model_id}"),
);
}
}
}
}
(Backend::InProcess { kernel }, Some(target)) => {
if let Some(id) = target.agent_id_inprocess {
let provider = kernel
.model_catalog
.read()
.unwrap()
.find_model(model_id)
.map(|e| e.provider.clone());
let result = if let Some(ref prov) = provider {
kernel.registry.update_model_and_provider(
id,
model_id.to_string(),
prov.clone(),
)
} else {
kernel.registry.update_model(id, model_id.to_string())
};
match result {
Ok(()) => {
let prov_label = provider.unwrap_or_else(|| {
kernel
.registry
.get(id)
.map(|e| e.manifest.model.provider.clone())
.unwrap_or_else(|| "?".to_string())
});
self.chat.model_label = format!("{prov_label}/{model_id}");
self.chat.push_message(
chat::Role::System,
format!("Switched to {model_id}"),
);
}
Err(e) => {
self.chat.push_message(
chat::Role::System,
format!("Switch failed: {e}"),
);
}
}
}
}
_ => {
self.chat
.push_message(chat::Role::System, "No backend connected.".to_string());
}
}
}
// ─── Slash commands ──────────────────────────────────────────────────────
fn handle_slash_command(&mut self, cmd: &str) {
@@ -1858,13 +2013,14 @@ impl App {
self.chat.push_message(
chat::Role::System,
[
"/help \u{2014} show this help",
"/status \u{2014} connection & agent info",
"/agents \u{2014} list running agents",
"/model \u{2014} show current model",
"/clear \u{2014} clear chat history",
"/kill \u{2014} kill the current agent",
"/exit \u{2014} end chat session",
"/help \u{2014} show this help",
"/model \u{2014} open model picker (Ctrl+M)",
"/model <name> \u{2014} switch to model directly",
"/status \u{2014} connection & agent info",
"/agents \u{2014} list running agents",
"/clear \u{2014} clear chat history",
"/kill \u{2014} kill the current agent",
"/exit \u{2014} end chat session",
]
.join("\n"),
);
@@ -1989,10 +2145,12 @@ impl App {
}
}
"/model" => {
self.chat.push_message(
chat::Role::System,
format!("Model: {}", self.chat.model_label),
);
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
if args.is_empty() {
self.open_model_picker();
} else {
self.switch_model(args);
}
}
"/hands" => match &self.backend {
Backend::InProcess { kernel } => {
+224 -3
View File
@@ -5,9 +5,18 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};
use ratatui::Frame;
/// Model entry for the picker.
#[derive(Clone)]
pub struct ModelEntry {
pub id: String,
pub display_name: String,
pub provider: String,
pub tier: String,
}
/// Tool call metadata for rich rendering.
#[derive(Clone)]
pub struct ToolInfo {
@@ -68,6 +77,14 @@ pub struct ChatState {
pub staged_messages: Vec<String>,
/// Accumulates ToolInputDelta text for the current tool call.
pub tool_input_buf: String,
/// Model picker overlay state.
pub show_model_picker: bool,
/// Available models for the picker.
pub model_picker_models: Vec<ModelEntry>,
/// Filter text for model search.
pub model_picker_filter: String,
/// Selected index in the filtered model list.
pub model_picker_idx: usize,
}
pub enum ChatAction {
@@ -75,6 +92,10 @@ pub enum ChatAction {
SendMessage(String),
Back,
SlashCommand(String),
/// Open the model picker (fetch models first).
OpenModelPicker,
/// Switch to a specific model by id.
SwitchModel(String),
}
impl ChatState {
@@ -97,6 +118,10 @@ impl ChatState {
status_msg: None,
staged_messages: Vec::new(),
tool_input_buf: String::new(),
show_model_picker: false,
model_picker_models: Vec::new(),
model_picker_filter: String::new(),
model_picker_idx: 0,
}
}
@@ -115,6 +140,9 @@ impl ChatState {
self.status_msg = None;
self.staged_messages.clear();
self.tool_input_buf.clear();
self.show_model_picker = false;
self.model_picker_filter.clear();
self.model_picker_idx = 0;
}
/// Push a completed message into history.
@@ -205,11 +233,81 @@ impl ChatState {
}
}
/// Return filtered models based on the current picker filter.
pub fn filtered_models(&self) -> Vec<&ModelEntry> {
if self.model_picker_filter.is_empty() {
return self.model_picker_models.iter().collect();
}
let f = self.model_picker_filter.to_lowercase();
self.model_picker_models
.iter()
.filter(|m| {
m.id.to_lowercase().contains(&f)
|| m.display_name.to_lowercase().contains(&f)
|| m.provider.to_lowercase().contains(&f)
})
.collect()
}
pub fn handle_key(&mut self, key: KeyEvent) -> ChatAction {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
if self.show_model_picker {
self.show_model_picker = false;
return ChatAction::Continue;
}
return ChatAction::Back;
}
// Ctrl+M: toggle model picker
if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
if self.is_streaming {
return ChatAction::Continue;
}
if self.show_model_picker {
self.show_model_picker = false;
return ChatAction::Continue;
}
return ChatAction::OpenModelPicker;
}
// Model picker mode: intercept all keys
if self.show_model_picker {
match key.code {
KeyCode::Esc => {
self.show_model_picker = false;
}
KeyCode::Up => {
self.model_picker_idx = self.model_picker_idx.saturating_sub(1);
}
KeyCode::Down => {
let max = self.filtered_models().len().saturating_sub(1);
if self.model_picker_idx < max {
self.model_picker_idx += 1;
}
}
KeyCode::Enter => {
let filtered = self.filtered_models();
if let Some(entry) = filtered.get(self.model_picker_idx) {
let model_id = entry.id.clone();
self.show_model_picker = false;
self.model_picker_filter.clear();
self.model_picker_idx = 0;
return ChatAction::SwitchModel(model_id);
}
}
KeyCode::Backspace => {
self.model_picker_filter.pop();
self.model_picker_idx = 0;
}
KeyCode::Char(c) => {
self.model_picker_filter.push(c);
self.model_picker_idx = 0;
}
_ => {}
}
return ChatAction::Continue;
}
// When streaming, allow typing + staging messages, scrolling, and Esc
if self.is_streaming {
match key.code {
@@ -361,13 +459,136 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut ChatState) {
f.render_widget(Paragraph::new(input_line), chunks[2]);
// ── Hints ────────────────────────────────────────────────────────────────
let hints = if state.is_streaming {
let hints = if state.show_model_picker {
" [\u{2191}\u{2193}] Navigate [Enter] Select [Esc] Close [type] Filter"
} else if state.is_streaming {
" [Enter] Stage [\u{2191}\u{2193}] Scroll [Esc] Stop"
} else {
" [Enter] Send [\u{2191}\u{2193}/PgUp/PgDn] Scroll [Esc] Back"
" [Enter] Send [Ctrl+M] Models [\u{2191}\u{2193}] Scroll [Esc] Back"
};
let hints = Paragraph::new(Line::from(vec![Span::styled(hints, theme::hint_style())]));
f.render_widget(hints, chunks[3]);
// ── Model picker overlay ────────────────────────────────────────────────
if state.show_model_picker {
draw_model_picker(f, inner, state);
}
}
fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
let filtered = state.filtered_models();
// Center a popup — width ~50 cols, height capped at area
if area.height < 6 || area.width < 20 {
return; // Too small to show picker
}
let popup_w = area.width.clamp(30, 54);
let popup_h = (filtered.len() as u16 + 4)
.clamp(5, area.height.saturating_sub(2));
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
// Clear background
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(Line::from(vec![Span::styled(
" Switch Model ",
theme::title_style(),
)]))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::ACCENT))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
if inner.height < 2 || inner.width < 10 {
return;
}
// Layout: search bar | model list
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
// Search bar
let search_line = Line::from(vec![
Span::styled("/ ", theme::dim_style()),
Span::raw(&state.model_picker_filter),
Span::styled(
"\u{2588}",
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::SLOW_BLINK),
),
]);
f.render_widget(Paragraph::new(search_line), chunks[0]);
// Model list
let visible_h = chunks[1].height as usize;
let total = filtered.len();
if total == 0 {
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" No models match",
theme::dim_style(),
)])),
chunks[1],
);
return;
}
// Scroll window: keep selected item visible
let scroll_start = if state.model_picker_idx >= visible_h {
state.model_picker_idx - visible_h + 1
} else {
0
};
let mut lines: Vec<Line> = Vec::new();
let max_name = (chunks[1].width as usize).saturating_sub(14);
for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) {
let selected = i == state.model_picker_idx;
let indicator = if selected { "\u{25b6} " } else { " " };
let name = if entry.display_name.is_empty() {
&entry.id
} else {
&entry.display_name
};
let name_display = if name.len() > max_name && max_name > 1 {
let truncated = openfang_types::truncate_str(name, max_name.saturating_sub(1));
format!("{truncated}\u{2026}")
} else {
name.to_string()
};
let tier_style = match entry.tier.to_lowercase().as_str() {
"frontier" => Style::default().fg(theme::PURPLE),
"smart" => Style::default().fg(theme::BLUE),
"balanced" => Style::default().fg(theme::GREEN),
"fast" => Style::default().fg(theme::YELLOW),
_ => theme::dim_style(),
};
let bg = if selected {
Style::default()
.fg(theme::TEXT_PRIMARY)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::TEXT_SECONDARY)
};
lines.push(Line::from(vec![
Span::styled(indicator, Style::default().fg(theme::ACCENT)),
Span::styled(name_display, bg),
Span::raw(" "),
Span::styled(entry.tier.to_lowercase(), tier_style),
]));
}
f.render_widget(Paragraph::new(lines), chunks[1]);
}
fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
@@ -825,8 +825,11 @@ fn handle_migration_key(
if yes {
state.migration_phase = MigrationPhase::Running;
let source_dir = state.openclaw_path.clone().unwrap_or_default();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let target_dir = home.join(".openfang");
let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
PathBuf::from(h)
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang")
};
let tx = migrate_tx.clone();
std::thread::spawn(move || {
let options = openfang_migrate::MigrateOptions {
@@ -945,15 +948,17 @@ fn save_config(state: &mut State) {
}
};
let home = match dirs::home_dir() {
Some(h) => h,
None => {
state.save_error = "Could not determine home directory".to_string();
return;
let openfang_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
PathBuf::from(h)
} else {
match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => {
state.save_error = "Could not determine home directory".to_string();
return;
}
}
};
let openfang_dir = home.join(".openfang");
let _ = std::fs::create_dir_all(openfang_dir.join("agents"));
let _ = std::fs::create_dir_all(openfang_dir.join("data"));
crate::restrict_dir_permissions(&openfang_dir);
+78 -12
View File
@@ -67,6 +67,66 @@ const PROVIDERS: &[ProviderInfo] = &[
default_model: "accounts/fireworks/models/llama-v3p3-70b-instruct",
needs_key: true,
},
ProviderInfo {
name: "gemini",
env_var: "GEMINI_API_KEY",
default_model: "gemini-2.5-flash",
needs_key: true,
},
ProviderInfo {
name: "xai",
env_var: "XAI_API_KEY",
default_model: "grok-4-0709",
needs_key: true,
},
ProviderInfo {
name: "qwen",
env_var: "DASHSCOPE_API_KEY",
default_model: "qwen-plus",
needs_key: true,
},
ProviderInfo {
name: "perplexity",
env_var: "PERPLEXITY_API_KEY",
default_model: "sonar-pro",
needs_key: true,
},
ProviderInfo {
name: "cohere",
env_var: "CO_API_KEY",
default_model: "command-a",
needs_key: true,
},
ProviderInfo {
name: "cerebras",
env_var: "CEREBRAS_API_KEY",
default_model: "llama-3.3-70b",
needs_key: true,
},
ProviderInfo {
name: "sambanova",
env_var: "SAMBANOVA_API_KEY",
default_model: "Meta-Llama-3.3-70B-Instruct",
needs_key: true,
},
ProviderInfo {
name: "moonshot",
env_var: "MOONSHOT_API_KEY",
default_model: "moonshot-v1-128k",
needs_key: true,
},
ProviderInfo {
name: "zhipu",
env_var: "ZHIPU_API_KEY",
default_model: "glm-4-plus",
needs_key: true,
},
ProviderInfo {
name: "zhipu_coding",
env_var: "ZHIPU_API_KEY",
default_model: "codegeex-4",
needs_key: true,
},
ProviderInfo {
name: "ollama",
env_var: "OLLAMA_API_KEY",
@@ -89,11 +149,15 @@ const PROVIDERS: &[ProviderInfo] = &[
/// Check if first-run setup is needed.
pub fn needs_setup() -> bool {
let home = match dirs::home_dir() {
Some(h) => h,
None => return true,
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(h)
} else {
match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => return true,
}
};
!home.join(".openfang").join("config.toml").exists()
!of_home.join("config.toml").exists()
}
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -294,16 +358,18 @@ impl WizardState {
}
};
let home = match dirs::home_dir() {
Some(h) => h,
None => {
self.status_msg = "Could not determine home directory".to_string();
self.step = WizardStep::Done;
return;
let openfang_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(h)
} else {
match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => {
self.status_msg = "Could not determine home directory".to_string();
self.step = WizardStep::Done;
return;
}
}
};
let openfang_dir = home.join(".openfang");
let _ = std::fs::create_dir_all(openfang_dir.join("agents"));
let _ = std::fs::create_dir_all(openfang_dir.join("data"));
crate::restrict_dir_permissions(&openfang_dir);
@@ -113,6 +113,14 @@ value = "elevenlabs"
label = "ElevenLabs"
provider_env = "ELEVENLABS_API_KEY"
[[settings]]
key = "elevenlabs_api_key"
label = "ElevenLabs API Key"
description = "API key from elevenlabs.io for high-quality text-to-speech. Required when ElevenLabs TTS is selected."
setting_type = "text"
env_var = "ELEVENLABS_API_KEY"
default = ""
# ─── Publishing settings ────────────────────────────────────────────────────
[[settings]]
@@ -28,6 +28,13 @@ steps = [
# ─── Configurable settings ───────────────────────────────────────────────────
[[settings]]
key = "twitter_bearer_token"
label = "Twitter Bearer Token"
description = "Bearer Token from the Twitter/X Developer Portal. Required for all Twitter API operations."
setting_type = "text"
default = ""
[[settings]]
key = "twitter_style"
label = "Content Style"
+11
View File
@@ -172,6 +172,10 @@ pub struct HandSetting {
pub default: String,
#[serde(default)]
pub options: Vec<HandSettingOption>,
/// Env var name to expose when a text-type setting has a value
/// (e.g. `ELEVENLABS_API_KEY` for an API key text field).
#[serde(default)]
pub env_var: Option<String>,
}
/// Result of resolving user-chosen settings against the schema.
@@ -227,6 +231,9 @@ pub fn resolve_settings(
HandSettingType::Text => {
if !chosen_value.is_empty() {
lines.push(format!("- {}: {}", setting.label, chosen_value));
if let Some(ref env) = setting.env_var {
env_vars.push(env.clone());
}
}
}
}
@@ -550,6 +557,7 @@ metrics = []
binary: None,
},
],
env_var: None,
}];
// User picks groq
@@ -583,6 +591,7 @@ metrics = []
binary: None,
},
],
env_var: None,
}];
// Empty config → uses default "auto"
@@ -604,6 +613,7 @@ metrics = []
setting_type: HandSettingType::Toggle,
default: "false".to_string(),
options: vec![],
env_var: None,
},
HandSetting {
key: "custom_model".to_string(),
@@ -612,6 +622,7 @@ metrics = []
setting_type: HandSettingType::Text,
default: String::new(),
options: vec![],
env_var: None,
},
];
+58 -14
View File
@@ -38,7 +38,7 @@ pub struct SettingStatus {
/// The Hand registry — stores definitions and tracks active instances.
pub struct HandRegistry {
/// All known hand definitions, keyed by hand_id.
definitions: HashMap<String, HandDefinition>,
definitions: DashMap<String, HandDefinition>,
/// Active hand instances, keyed by instance UUID.
instances: DashMap<Uuid, HandInstance>,
}
@@ -47,13 +47,13 @@ impl HandRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self {
definitions: HashMap::new(),
definitions: DashMap::new(),
instances: DashMap::new(),
}
}
/// Load all bundled hand definitions. Returns count of definitions loaded.
pub fn load_bundled(&mut self) -> usize {
pub fn load_bundled(&self) -> usize {
let bundled = bundled::bundled_hands();
let mut count = 0;
for (id, toml_content, skill_content) in bundled {
@@ -71,16 +71,60 @@ impl HandRegistry {
count
}
/// Install a hand from a directory containing HAND.toml (and optional SKILL.md).
pub fn install_from_path(&self, path: &std::path::Path) -> HandResult<HandDefinition> {
let toml_path = path.join("HAND.toml");
let skill_path = path.join("SKILL.md");
let toml_content = std::fs::read_to_string(&toml_path).map_err(|e| {
HandError::NotFound(format!("Cannot read {}: {e}", toml_path.display()))
})?;
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
let def = bundled::parse_bundled("custom", &toml_content, &skill_content)?;
if self.definitions.contains_key(&def.id) {
return Err(HandError::AlreadyActive(format!(
"Hand '{}' already registered",
def.id
)));
}
info!(hand = %def.id, name = %def.name, path = %path.display(), "Installed hand from path");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
/// Install a hand from raw TOML + skill content (for API-based installs).
pub fn install_from_content(
&self,
toml_content: &str,
skill_content: &str,
) -> HandResult<HandDefinition> {
let def = bundled::parse_bundled("custom", toml_content, skill_content)?;
if self.definitions.contains_key(&def.id) {
return Err(HandError::AlreadyActive(format!(
"Hand '{}' already registered",
def.id
)));
}
info!(hand = %def.id, name = %def.name, "Installed hand from content");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
/// List all known hand definitions.
pub fn list_definitions(&self) -> Vec<&HandDefinition> {
let mut defs: Vec<&HandDefinition> = self.definitions.values().collect();
defs.sort_by_key(|d| &d.name);
pub fn list_definitions(&self) -> Vec<HandDefinition> {
let mut defs: Vec<HandDefinition> = self.definitions.iter().map(|r| r.value().clone()).collect();
defs.sort_by(|a, b| a.name.cmp(&b.name));
defs
}
/// Get a specific hand definition by ID.
pub fn get_definition(&self, hand_id: &str) -> Option<&HandDefinition> {
self.definitions.get(hand_id)
pub fn get_definition(&self, hand_id: &str) -> Option<HandDefinition> {
self.definitions.get(hand_id).map(|r| r.value().clone())
}
/// Activate a hand — creates an instance (agent spawning is done by kernel).
@@ -344,7 +388,7 @@ mod tests {
#[test]
fn load_bundled_hands() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
let count = reg.load_bundled();
assert_eq!(count, 7);
assert!(!reg.list_definitions().is_empty());
@@ -368,7 +412,7 @@ mod tests {
#[test]
fn activate_and_deactivate() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
@@ -390,7 +434,7 @@ mod tests {
#[test]
fn pause_and_resume() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
@@ -409,7 +453,7 @@ mod tests {
#[test]
fn set_agent() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
@@ -427,7 +471,7 @@ mod tests {
#[test]
fn check_requirements() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
reg.load_bundled();
let results = reg.check_requirements("clip").unwrap();
@@ -452,7 +496,7 @@ mod tests {
#[test]
fn set_error_status() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
+9 -5
View File
@@ -224,15 +224,19 @@ pub fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
}
/// Get the default config file path.
///
/// Respects `OPENFANG_HOME` env var (e.g. `OPENFANG_HOME=/opt/openfang`).
pub fn default_config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
.join("config.toml")
openfang_home().join("config.toml")
}
/// Get the default OpenFang home directory.
/// Get the OpenFang home directory.
///
/// Priority: `OPENFANG_HOME` env var > `~/.openfang`.
pub fn openfang_home() -> PathBuf {
if let Ok(home) = std::env::var("OPENFANG_HOME") {
return PathBuf::from(home);
}
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
+8 -11
View File
@@ -5,7 +5,7 @@
//!
//! **No-op** (informational only): log_level, language, mode.
//!
//! **Restart required**: api_listen, api_key, network, memory, default_model.
//! **Restart required**: api_listen, api_key, network, memory.
use openfang_types::config::{KernelConfig, ReloadMode};
use tracing::{info, warn};
@@ -43,6 +43,8 @@ pub enum HotAction {
ReloadFallbackProviders,
/// Provider base URL overrides changed.
ReloadProviderUrls,
/// Default model changed — update in-place without restart.
UpdateDefaultModel,
}
// ---------------------------------------------------------------------------
@@ -161,11 +163,9 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
.push("memory config changed".to_string());
}
// Default model (driver needs recreation)
// Default model — hot-reloadable (just swap config fields, new agents pick it up)
if field_changed(&old.default_model, &new.default_model) {
plan.restart_required = true;
plan.restart_reasons
.push("default_model changed".to_string());
plan.hot_actions.push(HotAction::UpdateDefaultModel);
}
// Home/data directory changes
@@ -406,16 +406,13 @@ mod tests {
}
#[test]
fn test_default_model_requires_restart() {
fn test_default_model_hot_reloadable() {
let a = default_cfg();
let mut b = default_cfg();
b.default_model.model = "gpt-4".to_string();
let plan = build_reload_plan(&a, &b);
assert!(plan.restart_required);
assert!(plan
.restart_reasons
.iter()
.any(|r| r.contains("default_model")));
assert!(!plan.restart_required, "default_model should be hot-reloadable");
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
}
// -----------------------------------------------------------------------
+381 -98
View File
@@ -20,7 +20,9 @@ use openfang_runtime::agent_loop::{
use openfang_runtime::audit::AuditLog;
use openfang_runtime::drivers;
use openfang_runtime::kernel_handle::{self, KernelHandle};
use openfang_runtime::llm_driver::{CompletionRequest, DriverConfig, LlmDriver, StreamEvent};
use openfang_runtime::llm_driver::{
CompletionRequest, CompletionResponse, DriverConfig, LlmDriver, LlmError, StreamEvent,
};
use openfang_runtime::python_runtime::{self, PythonConfig};
use openfang_runtime::routing::ModelRouter;
use openfang_runtime::sandbox::{SandboxConfig, WasmSandbox};
@@ -39,6 +41,22 @@ use std::sync::{Arc, OnceLock, Weak};
use tracing::{debug, info, warn};
/// The main OpenFang kernel — coordinates all subsystems.
/// Stub LLM driver used when no providers are configured.
/// Returns a helpful error so the dashboard still boots and users can configure providers.
struct StubDriver;
#[async_trait]
impl LlmDriver for StubDriver {
async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
Err(LlmError::MissingApiKey(
"No LLM provider configured. Set an API key (e.g. GROQ_API_KEY) and restart, \
configure a provider via the dashboard, \
or use Ollama for local models (no API key needed)."
.to_string(),
))
}
}
pub struct OpenFangKernel {
/// Kernel configuration.
pub config: KernelConfig,
@@ -131,6 +149,8 @@ pub struct OpenFangKernel {
pub whatsapp_gateway_pid: Arc<std::sync::Mutex<Option<u32>>>,
/// Channel adapters registered at bridge startup (for proactive `channel_send` tool).
pub channel_adapters: dashmap::DashMap<String, Arc<dyn openfang_channels::types::ChannelAdapter>>,
/// Hot-reloadable default model override (set via config hot-reload, read at agent spawn).
pub default_model_override: std::sync::RwLock<Option<openfang_types::config::DefaultModelConfig>>,
/// Weak self-reference for trigger dispatch (set after Arc wrapping).
self_handle: OnceLock<Weak<OpenFangKernel>>,
}
@@ -530,51 +550,73 @@ impl OpenFangKernel {
let driver_config = DriverConfig {
provider: config.default_model.provider.clone(),
api_key: std::env::var(&config.default_model.api_key_env).ok(),
base_url: config.default_model.base_url.clone(),
base_url: config
.default_model
.base_url
.clone()
.or_else(|| config.provider_urls.get(&config.default_model.provider).cloned()),
};
let primary_driver = drivers::create_driver(&driver_config)
.map_err(|e| KernelError::BootFailed(format!("LLM driver init failed: {e}")))?;
// Primary driver failure is non-fatal: the dashboard should remain accessible
// even if the LLM provider is misconfigured. Users can fix config via dashboard.
let primary_result = drivers::create_driver(&driver_config);
let mut driver_chain: Vec<Arc<dyn LlmDriver>> = Vec::new();
// If fallback providers are configured, wrap the primary driver in a FallbackDriver
let driver: Arc<dyn LlmDriver> = if !config.fallback_providers.is_empty() {
let mut chain: Vec<Arc<dyn LlmDriver>> = vec![primary_driver.clone()];
for fb in &config.fallback_providers {
let fb_config = DriverConfig {
provider: fb.provider.clone(),
api_key: if fb.api_key_env.is_empty() {
None
} else {
std::env::var(&fb.api_key_env).ok()
},
base_url: fb.base_url.clone(),
};
match drivers::create_driver(&fb_config) {
Ok(d) => {
info!(
provider = %fb.provider,
model = %fb.model,
"Fallback provider configured"
);
chain.push(d);
}
Err(e) => {
warn!(
provider = %fb.provider,
error = %e,
"Fallback provider init failed — skipped"
);
}
match &primary_result {
Ok(d) => driver_chain.push(d.clone()),
Err(e) => {
warn!(
provider = %config.default_model.provider,
error = %e,
"Primary LLM driver init failed — dashboard will still be accessible"
);
}
}
// Add fallback providers to the chain
for fb in &config.fallback_providers {
let fb_config = DriverConfig {
provider: fb.provider.clone(),
api_key: if fb.api_key_env.is_empty() {
None
} else {
std::env::var(&fb.api_key_env).ok()
},
base_url: fb
.base_url
.clone()
.or_else(|| config.provider_urls.get(&fb.provider).cloned()),
};
match drivers::create_driver(&fb_config) {
Ok(d) => {
info!(
provider = %fb.provider,
model = %fb.model,
"Fallback provider configured"
);
driver_chain.push(d);
}
Err(e) => {
warn!(
provider = %fb.provider,
error = %e,
"Fallback provider init failed — skipped"
);
}
}
if chain.len() > 1 {
Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::new(
chain,
))
} else {
primary_driver
}
}
// Use the chain, or create a stub driver if everything failed
let driver: Arc<dyn LlmDriver> = if driver_chain.len() > 1 {
Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::new(
driver_chain,
))
} else if let Some(single) = driver_chain.into_iter().next() {
single
} else {
primary_driver
// All drivers failed — use a stub that returns a helpful error.
// The kernel boots, dashboard is accessible, users can fix their config.
warn!("No LLM drivers available — agents will return errors until a provider is configured");
Arc::new(StubDriver) as Arc<dyn LlmDriver>
};
// Initialize metering engine (shares the same SQLite connection as the memory substrate)
@@ -646,7 +688,7 @@ impl OpenFangKernel {
}
// Initialize hand registry (curated autonomous packages)
let mut hand_registry = openfang_hands::registry::HandRegistry::new();
let hand_registry = openfang_hands::registry::HandRegistry::new();
let hand_count = hand_registry.load_bundled();
if hand_count > 0 {
info!("Loaded {hand_count} bundled hand(s)");
@@ -713,12 +755,13 @@ impl OpenFangKernel {
Arc<dyn openfang_runtime::embedding::EmbeddingDriver + Send + Sync>,
> = {
use openfang_runtime::embedding::create_embedding_driver;
let configured_model = &config.memory.embedding_model;
if let Some(ref provider) = config.memory.embedding_provider {
// Explicit config takes priority
// Explicit config takes priority — use the configured embedding model
let api_key_env = config.memory.embedding_api_key_env.as_deref().unwrap_or("");
match create_embedding_driver(provider, "text-embedding-3-small", api_key_env) {
match create_embedding_driver(provider, configured_model, api_key_env) {
Ok(d) => {
info!(provider = %provider, "Embedding driver configured from memory config");
info!(provider = %provider, model = %configured_model, "Embedding driver configured from memory config");
Some(Arc::from(d))
}
Err(e) => {
@@ -727,8 +770,12 @@ impl OpenFangKernel {
}
}
} else if std::env::var("OPENAI_API_KEY").is_ok() {
match create_embedding_driver("openai", "text-embedding-3-small", "OPENAI_API_KEY")
{
let model = if configured_model == "all-MiniLM-L6-v2" {
"text-embedding-3-small"
} else {
configured_model.as_str()
};
match create_embedding_driver("openai", model, "OPENAI_API_KEY") {
Ok(d) => {
info!("Embedding driver auto-detected: OpenAI");
Some(Arc::from(d))
@@ -740,7 +787,12 @@ impl OpenFangKernel {
}
} else {
// Try Ollama (local, no key needed)
match create_embedding_driver("ollama", "nomic-embed-text", "") {
let model = if configured_model == "all-MiniLM-L6-v2" {
"nomic-embed-text"
} else {
configured_model.as_str()
};
match create_embedding_driver("ollama", model, "") {
Ok(d) => {
info!("Embedding driver auto-detected: Ollama (local)");
Some(Arc::from(d))
@@ -883,6 +935,7 @@ impl OpenFangKernel {
booted_at: std::time::Instant::now(),
whatsapp_gateway_pid: Arc::new(std::sync::Mutex::new(None)),
channel_adapters: dashmap::DashMap::new(),
default_model_override: std::sync::RwLock::new(None),
self_handle: OnceLock::new(),
};
@@ -894,6 +947,61 @@ impl OpenFangKernel {
let agent_id = entry.id;
let name = entry.name.clone();
// Check if TOML on disk is newer/different — if so, update from file
let mut entry = entry;
let toml_path = kernel
.config
.home_dir
.join("agents")
.join(&name)
.join("agent.toml");
if toml_path.exists() {
match std::fs::read_to_string(&toml_path) {
Ok(toml_str) => {
match toml::from_str::<openfang_types::agent::AgentManifest>(
&toml_str,
) {
Ok(disk_manifest) => {
// Compare key fields to detect changes
let changed = disk_manifest.name != entry.manifest.name
|| disk_manifest.description != entry.manifest.description
|| disk_manifest.model.system_prompt != entry.manifest.model.system_prompt
|| disk_manifest.model.provider != entry.manifest.model.provider
|| disk_manifest.model.model != entry.manifest.model.model
|| disk_manifest.capabilities.tools != entry.manifest.capabilities.tools;
if changed {
info!(
agent = %name,
"Agent TOML on disk differs from DB, updating"
);
entry.manifest = disk_manifest;
// Persist the update back to DB
if let Err(e) = kernel.memory.save_agent(&entry) {
warn!(
agent = %name,
"Failed to persist TOML update: {e}"
);
}
}
}
Err(e) => {
warn!(
agent = %name,
path = %toml_path.display(),
"Invalid agent TOML on disk, using DB version: {e}"
);
}
}
}
Err(e) => {
warn!(
agent = %name,
"Failed to read agent TOML: {e}"
);
}
}
}
// Re-grant capabilities
let caps = manifest_to_capabilities(&entry.manifest);
kernel.capabilities.grant(agent_id, caps);
@@ -920,18 +1028,25 @@ impl OpenFangKernel {
);
// Apply default_model to restored agents (same logic as spawn)
if restored_entry.manifest.model.api_key_env.is_none()
&& restored_entry.manifest.model.base_url.is_none()
{
let dm = &kernel.config.default_model;
if !dm.provider.is_empty() {
restored_entry.manifest.model.provider = dm.provider.clone();
}
if !dm.model.is_empty() {
restored_entry.manifest.model.model = dm.model.clone();
}
if dm.base_url.is_some() {
restored_entry.manifest.model.base_url = dm.base_url.clone();
let is_default_provider = restored_entry.manifest.model.provider.is_empty()
|| restored_entry.manifest.model.provider == "default";
let is_default_model = restored_entry.manifest.model.model.is_empty()
|| restored_entry.manifest.model.model == "default";
if is_default_provider && is_default_model {
let dm = &kernel.config.default_model;
if !dm.provider.is_empty() {
restored_entry.manifest.model.provider = dm.provider.clone();
}
if !dm.model.is_empty() {
restored_entry.manifest.model.model = dm.model.clone();
}
if !dm.api_key_env.is_empty() && restored_entry.manifest.model.api_key_env.is_none() {
restored_entry.manifest.model.api_key_env = Some(dm.api_key_env.clone());
}
if dm.base_url.is_some() && restored_entry.manifest.model.base_url.is_none() {
restored_entry.manifest.model.base_url.clone_from(&dm.base_url);
}
}
}
@@ -950,6 +1065,33 @@ impl OpenFangKernel {
}
}
// If no agents exist (fresh install), spawn a default assistant
if kernel.registry.list().is_empty() {
info!("No agents found — spawning default assistant");
let dm = &kernel.config.default_model;
let manifest = AgentManifest {
name: "assistant".to_string(),
description: "General-purpose assistant".to_string(),
model: openfang_types::agent::ModelConfig {
provider: dm.provider.clone(),
model: dm.model.clone(),
system_prompt: "You are a helpful AI assistant.".to_string(),
api_key_env: if dm.api_key_env.is_empty() {
None
} else {
Some(dm.api_key_env.clone())
},
base_url: dm.base_url.clone(),
..Default::default()
},
..Default::default()
};
match kernel.spawn_agent(manifest) {
Ok(id) => info!(id = %id, "Default assistant spawned"),
Err(e) => warn!("Failed to spawn default assistant: {e}"),
}
}
// Validate routing configs against model catalog
for entry in kernel.registry.list() {
if let Some(ref routing_config) = entry.manifest.routing {
@@ -996,19 +1138,39 @@ impl OpenFangKernel {
if manifest.exec_policy.is_none() {
manifest.exec_policy = Some(self.config.exec_policy.clone());
}
info!(agent = %name, id = %agent_id, exec_mode = ?manifest.exec_policy.as_ref().map(|p| &p.mode), "Agent exec_policy resolved");
// Overlay kernel default_model onto agent if no custom key/url is set.
// This ensures agents respect the user's configured provider from `openfang init`.
if manifest.model.api_key_env.is_none() && manifest.model.base_url.is_none() {
let dm = &self.config.default_model;
if !dm.provider.is_empty() {
manifest.model.provider = dm.provider.clone();
}
if !dm.model.is_empty() {
manifest.model.model = dm.model.clone();
}
if dm.base_url.is_some() {
manifest.model.base_url = dm.base_url.clone();
// Overlay kernel default_model onto agent if agent didn't explicitly choose.
// Treat empty or "default" as "use the kernel's configured default_model".
// This allows bundled agents to defer to the user's configured provider/model,
// even if the agent manifest specifies an api_key_env (which is just a hint
// about which env var to check, not a hard lock on provider/model).
{
let is_default_provider =
manifest.model.provider.is_empty() || manifest.model.provider == "default";
let is_default_model =
manifest.model.model.is_empty() || manifest.model.model == "default";
if is_default_provider && is_default_model {
// Check hot-reloaded override first, fall back to boot-time config
let override_guard = self
.default_model_override
.read()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
let dm = override_guard
.as_ref()
.unwrap_or(&self.config.default_model);
if !dm.provider.is_empty() {
manifest.model.provider = dm.provider.clone();
}
if !dm.model.is_empty() {
manifest.model.model = dm.model.clone();
}
if !dm.api_key_env.is_empty() && manifest.model.api_key_env.is_none() {
manifest.model.api_key_env = Some(dm.api_key_env.clone());
}
if dm.base_url.is_some() && manifest.model.base_url.is_none() {
manifest.model.base_url.clone_from(&dm.base_url);
}
}
}
@@ -1447,6 +1609,7 @@ impl OpenFangKernel {
None
},
peer_agents,
current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()),
};
manifest.model.system_prompt =
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
@@ -1915,6 +2078,7 @@ impl OpenFangKernel {
None
},
peer_agents,
current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()),
};
manifest.model.system_prompt =
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
@@ -1963,7 +2127,16 @@ impl OpenFangKernel {
routed_model = %routed_model,
"Model routing applied"
);
manifest.model.model = routed_model;
manifest.model.model = routed_model.clone();
// Also update provider if the routed model belongs to a different provider
if let Ok(cat) = self.model_catalog.read() {
if let Some(entry) = cat.find_model(&routed_model) {
if entry.provider != manifest.model.provider {
info!(old = %manifest.model.provider, new = %entry.provider, "Model routing changed provider");
manifest.model.provider = entry.provider.clone();
}
}
}
}
let driver = self.resolve_driver(&manifest)?;
@@ -2736,6 +2909,15 @@ impl OpenFangKernel {
max_iterations: max_iter,
..Default::default()
}),
// Autonomous hands must run in Continuous mode so the background loop picks them up.
// Reactive (default) only fires on incoming messages, so autonomous hands would be inert.
schedule: if def.agent.max_iterations.is_some() {
ScheduleMode::Continuous {
check_interval_secs: 60,
}
} else {
ScheduleMode::default()
},
skills: def.skills.clone(),
mcp_servers: def.mcp_servers.clone(),
// Hands are curated packages — if they declare shell_exec, grant full exec access
@@ -2760,10 +2942,23 @@ impl OpenFangKernel {
manifest.model.system_prompt, resolved.prompt_block
);
}
if !resolved.env_vars.is_empty() {
// Collect env vars from settings + from requires (api_key/env_var requirements)
let mut allowed_env = resolved.env_vars;
for req in &def.requires {
match req.requirement_type {
openfang_hands::RequirementType::ApiKey
| openfang_hands::RequirementType::EnvVar => {
if !req.check_value.is_empty() && !allowed_env.contains(&req.check_value) {
allowed_env.push(req.check_value.clone());
}
}
_ => {}
}
}
if !allowed_env.is_empty() {
manifest.metadata.insert(
"hand_allowed_env".to_string(),
serde_json::to_value(&resolved.env_vars).unwrap_or_default(),
serde_json::to_value(&allowed_env).unwrap_or_default(),
);
}
@@ -2775,6 +2970,13 @@ impl OpenFangKernel {
);
}
// If an agent with this hand's name already exists, remove it first
let existing = self.registry.list().into_iter().find(|e| e.name == def.agent.name);
if let Some(old) = existing {
info!(agent = %old.name, id = %old.id, "Removing existing hand agent for reactivation");
let _ = self.kill_agent(old.id);
}
// Spawn the agent
let agent_id = self.spawn_agent(manifest)?;
@@ -2808,6 +3010,18 @@ impl OpenFangKernel {
if let Err(e) = self.kill_agent(agent_id) {
warn!(agent = %agent_id, error = %e, "Failed to kill hand agent (may already be dead)");
}
} else {
// Fallback: if agent_id was never set (incomplete activation), search by hand tag
let hand_tag = format!("hand:{}", instance.hand_id);
for entry in self.registry.list() {
if entry.tags.contains(&hand_tag) {
if let Err(e) = self.kill_agent(entry.id) {
warn!(agent = %entry.id, error = %e, "Failed to kill orphaned hand agent");
} else {
info!(agent_id = %entry.id, hand_id = %instance.hand_id, "Cleaned up orphaned hand agent");
}
}
}
}
Ok(())
}
@@ -2924,6 +3138,17 @@ impl OpenFangKernel {
.unwrap_or_else(|e| e.into_inner());
catalog.apply_url_overrides(&new_config.provider_urls);
}
HotAction::UpdateDefaultModel => {
info!(
"Hot-reload: updating default model to {}/{}",
new_config.default_model.provider, new_config.default_model.model
);
let mut guard = self
.default_model_override
.write()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
*guard = Some(new_config.default_model.clone());
}
_ => {
// Other hot actions (channels, web, browser, extensions, etc.)
// are logged but not applied here — they require subsystem-specific
@@ -3078,18 +3303,30 @@ impl OpenFangKernel {
/// `Continuous`, `Periodic`, or `Proactive` schedules.
pub fn start_background_agents(self: &Arc<Self>) {
let agents = self.registry.list();
let mut started = 0u32;
let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> =
Vec::new();
for entry in &agents {
if matches!(entry.manifest.schedule, ScheduleMode::Reactive) {
continue;
}
self.start_background_for_agent(entry.id, &entry.name, &entry.manifest.schedule);
started += 1;
bg_agents.push((entry.id, entry.name.clone(), entry.manifest.schedule.clone()));
}
if started > 0 {
info!("Started {started} background agent loop(s)");
if !bg_agents.is_empty() {
let count = bg_agents.len();
let kernel = Arc::clone(self);
// Stagger agent startup to prevent rate-limit storm on shared providers.
// Each agent gets a 500ms delay before the next one starts.
tokio::spawn(async move {
for (i, (id, name, schedule)) in bg_agents.into_iter().enumerate() {
kernel.start_background_for_agent(id, &name, &schedule);
if i > 0 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
info!("Started {count} background agent loop(s) (staggered)");
});
}
// Start heartbeat monitor for agent health checking
@@ -3280,9 +3517,10 @@ impl OpenFangKernel {
let timeout_s = timeout_secs.unwrap_or(120);
let timeout = std::time::Duration::from_secs(timeout_s);
let delivery = job.delivery.clone();
let kh: std::sync::Arc<dyn openfang_runtime::kernel_handle::KernelHandle> = kernel.clone();
match tokio::time::timeout(
timeout,
kernel.send_message(agent_id, message),
kernel.send_message_with_handle(agent_id, message, Some(kh)),
)
.await
{
@@ -3516,7 +3754,7 @@ impl OpenFangKernel {
}
/// Start the background loop / register triggers for a single agent.
fn start_background_for_agent(
pub fn start_background_for_agent(
self: &Arc<Self>,
agent_id: AgentId,
name: &str,
@@ -3650,10 +3888,14 @@ impl OpenFangKernel {
let base_url = if has_custom_url {
manifest.model.base_url.clone()
} else if agent_provider == default_provider {
self.config.default_model.base_url.clone()
self.config
.default_model
.base_url
.clone()
.or_else(|| self.config.provider_urls.get(agent_provider.as_str()).cloned())
} else {
// Let create_driver() use the target provider's default base URL
None
// Check provider_urls before falling back to hardcoded defaults
self.config.provider_urls.get(agent_provider.as_str()).cloned()
};
let driver_config = DriverConfig {
@@ -3669,7 +3911,9 @@ impl OpenFangKernel {
// If fallback models are configured, wrap in FallbackDriver
if !manifest.fallback_models.is_empty() {
let mut chain = vec![primary.clone()];
// Primary driver uses the agent's own model name (already set in request)
let mut chain: Vec<(std::sync::Arc<dyn openfang_runtime::llm_driver::LlmDriver>, String)> =
vec![(primary.clone(), String::new())];
for fb in &manifest.fallback_models {
let config = DriverConfig {
provider: fb.provider.clone(),
@@ -3677,10 +3921,13 @@ impl OpenFangKernel {
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok()),
base_url: fb.base_url.clone(),
base_url: fb
.base_url
.clone()
.or_else(|| self.config.provider_urls.get(&fb.provider).cloned()),
};
match drivers::create_driver(&config) {
Ok(d) => chain.push(d),
Ok(d) => chain.push((d, fb.model.clone())),
Err(e) => {
warn!("Fallback driver '{}' failed to init: {e}", fb.provider);
}
@@ -3688,7 +3935,7 @@ impl OpenFangKernel {
}
if chain.len() > 1 {
return Ok(Arc::new(
openfang_runtime::drivers::fallback::FallbackDriver::new(chain),
openfang_runtime::drivers::fallback::FallbackDriver::with_models(chain),
));
}
}
@@ -4101,6 +4348,18 @@ impl OpenFangKernel {
all_tools.retain(|t| !tool_blocklist.iter().any(|b| b == &t.name));
}
// Remove shell_exec from tool list if exec_policy won't allow it,
// so the LLM doesn't try to call a tool that will be blocked.
let exec_blocks_shell = entry.as_ref().is_some_and(|e| {
e.manifest
.exec_policy
.as_ref()
.is_some_and(|p| p.mode == openfang_types::config::ExecSecurityMode::Deny)
});
if exec_blocks_shell {
all_tools.retain(|t| t.name != "shell_exec");
}
let caps = self.capabilities.list(agent_id);
// If agent has ToolAll, return all tools
@@ -4403,17 +4662,23 @@ fn apply_budget_defaults(
/// This is a defense-in-depth fallback — models should ideally be in the catalog.
fn infer_provider_from_model(model: &str) -> Option<String> {
let lower = model.to_lowercase();
// Check for explicit provider prefix (e.g., "minimax/MiniMax-M2.5")
if let Some(prefix) = lower.split('/').next() {
// Check for explicit provider prefix with / or : delimiter
// (e.g., "minimax/MiniMax-M2.5" or "qwen:qwen-plus")
let (prefix, has_delim) = if let Some(idx) = lower.find('/') {
(&lower[..idx], true)
} else if let Some(idx) = lower.find(':') {
(&lower[..idx], true)
} else {
(lower.as_str(), false)
};
if has_delim {
match prefix {
"minimax" | "gemini" | "anthropic" | "openai" | "groq" | "deepseek" | "mistral"
| "cohere" | "xai" | "ollama" | "together" | "fireworks" | "perplexity"
| "cerebras" | "sambanova" | "replicate" | "huggingface" | "ai21" | "codex"
| "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "moonshot"
| "openrouter" => {
if model.contains('/') {
return Some(prefix.to_string());
}
| "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "zai" | "moonshot"
| "openrouter" | "volcengine" | "doubao" | "dashscope" => {
return Some(prefix.to_string());
}
_ => {}
}
@@ -4455,7 +4720,7 @@ fn infer_provider_from_model(model: &str) -> Option<String> {
/// A well-known agent ID used for shared memory operations across agents.
/// This is a fixed UUID so all agents read/write to the same namespace.
fn shared_memory_agent_id() -> AgentId {
pub fn shared_memory_agent_id() -> AgentId {
AgentId(uuid::Uuid::from_bytes([
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01,
@@ -4845,6 +5110,24 @@ impl KernelHandle for OpenFangKernel {
Ok(result)
}
async fn hand_install(
&self,
toml_content: &str,
skill_content: &str,
) -> Result<serde_json::Value, String> {
let def = self
.hand_registry
.install_from_content(toml_content, skill_content)
.map_err(|e| format!("{e}"))?;
Ok(serde_json::json!({
"id": def.id,
"name": def.name,
"description": def.description,
"category": format!("{:?}", def.category),
}))
}
async fn hand_activate(
&self,
hand_id: &str,
@@ -4871,8 +5154,8 @@ impl KernelHandle for OpenFangKernel {
.ok_or_else(|| format!("No active instance found for hand '{hand_id}'"))?;
let def = self.hand_registry.get_definition(hand_id);
let def_name = def.map(|d| d.name.clone()).unwrap_or_default();
let def_icon = def.map(|d| d.icon.clone()).unwrap_or_default();
let def_name = def.as_ref().map(|d| d.name.clone()).unwrap_or_default();
let def_icon = def.as_ref().map(|d| d.icon.clone()).unwrap_or_default();
Ok(serde_json::json!({
"hand_id": hand_id,
+21 -1
View File
@@ -376,6 +376,12 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
}
// ── Zhipu / GLM ─────────────────────────────────────────────
if model.contains("glm-5") {
return (2.00, 8.00);
}
if model.contains("glm-4.7") {
return (1.50, 5.00);
}
if model.contains("glm-4-flash") {
return (0.10, 0.10);
}
@@ -391,6 +397,20 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
return (0.80, 0.80);
}
// ── Volcano Engine / Doubao ────────────────────────────────
if model.contains("doubao-seed-code") {
return (0.50, 1.00);
}
if model.contains("doubao") && model.contains("mini") {
return (0.10, 0.10);
}
if model.contains("doubao") && model.contains("lite") {
return (0.30, 0.60);
}
if model.contains("doubao") {
return (0.80, 2.00);
}
// ── Baidu ERNIE ─────────────────────────────────────────────
if model.contains("ernie") {
return (2.00, 6.00);
@@ -429,7 +449,7 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
}
// ── xAI / Grok ──────────────────────────────────────────────
if model.contains("grok-4.1") {
if model.contains("grok-4-1") {
return (0.20, 0.50);
}
if model.contains("grok-4") {
@@ -349,9 +349,9 @@ async fn test_multiple_wasm_agents() {
assert_eq!(hello_result.response, "hello from wasm");
assert!(echo_result.response.contains("test data"));
// Verify agent list shows both
// Verify agent list shows both + default assistant
let agents = kernel.registry.list();
assert_eq!(agents.len(), 2);
assert_eq!(agents.len(), 3);
kernel.shutdown();
}
@@ -391,9 +391,9 @@ memory_write = ["self.*"]
let llm_manifest: AgentManifest = toml::from_str(llm_toml).unwrap();
let llm_id = kernel.spawn_agent(llm_manifest).unwrap();
// Verify both agents exist
// Verify both agents exist + default assistant
let agents = kernel.registry.list();
assert_eq!(agents.len(), 2);
assert_eq!(agents.len(), 3);
// WASM agent should work
let result = kernel.send_message(wasm_id, "hello").await.unwrap();
@@ -404,7 +404,7 @@ memory_write = ["self.*"]
// Kill WASM agent
kernel.kill_agent(wasm_id).unwrap();
assert_eq!(kernel.registry.list().len(), 1);
assert_eq!(kernel.registry.list().len(), 2);
kernel.shutdown();
}
+10 -5
View File
@@ -91,16 +91,21 @@ impl StructuredStore {
let rows = stmt
.query_map(rusqlite::params![agent_id.0.to_string()], |row| {
let key: String = row.get(0)?;
let val_str: String = row.get(1)?;
Ok((key, val_str))
let blob: Vec<u8> = row.get(1)?;
Ok((key, blob))
})
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
let mut pairs = Vec::new();
for row in rows {
let (key, val_str) = row.map_err(|e| OpenFangError::Memory(e.to_string()))?;
let value: serde_json::Value =
serde_json::from_str(&val_str).unwrap_or(serde_json::Value::String(val_str));
let (key, blob) = row.map_err(|e| OpenFangError::Memory(e.to_string()))?;
let value: serde_json::Value = serde_json::from_slice(&blob)
.unwrap_or_else(|_| {
// Fallback: try as UTF-8 string
String::from_utf8(blob)
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null)
});
pairs.push((key, value));
}
Ok(pairs)
+1
View File
@@ -29,6 +29,7 @@ hex = { workspace = true }
zeroize = { workspace = true }
dashmap = { workspace = true }
regex-lite = { workspace = true }
tokio-tungstenite = "0.24"
[dev-dependencies]
tokio-test = { workspace = true }
+12 -3
View File
@@ -57,9 +57,12 @@ const MAX_HISTORY_MESSAGES: usize = 20;
/// but the upstream API expects just `org/model`. This also handles special routers
/// like `openrouter/auto` → `auto`.
pub fn strip_provider_prefix(model: &str, provider: &str) -> String {
let prefix = format!("{}/", provider);
if model.starts_with(&prefix) {
model[prefix.len()..].to_string()
let slash_prefix = format!("{}/", provider);
let colon_prefix = format!("{}:", provider);
if model.starts_with(&slash_prefix) {
model[slash_prefix.len()..].to_string()
} else if model.starts_with(&colon_prefix) {
model[colon_prefix.len()..].to_string()
} else {
model.to_string()
}
@@ -292,6 +295,12 @@ pub async fn run_agent_loop(
warn!("Context overflow unrecoverable — suggest /reset or /compact");
}
// Re-validate tool_call/tool_result pairing after overflow drains
// which may have broken assistant→tool ordering invariants.
if recovery != RecoveryStage::None {
messages = crate::session_repair::validate_and_repair(&messages);
}
// Context guard: compact oversized tool results before LLM call
apply_context_guard(&mut messages, &context_budget, available_tools);
File diff suppressed because it is too large Load Diff
@@ -1,188 +0,0 @@
#!/usr/bin/env python3
"""OpenFang Browser Bridge — Playwright automation over JSON-line stdio protocol.
Reads JSON commands from stdin (one per line), executes browser actions via
Playwright, and writes JSON responses to stdout (one per line).
Usage:
python browser_bridge.py [--headless] [--width 1280] [--height 720] [--timeout 30]
"""
import argparse
import base64
import json
import re
import sys
import traceback
def main():
parser = argparse.ArgumentParser(description="OpenFang Browser Bridge")
parser.add_argument("--headless", action="store_true", default=True)
parser.add_argument("--no-headless", dest="headless", action="store_false")
parser.add_argument("--width", type=int, default=1280)
parser.add_argument("--height", type=int, default=720)
parser.add_argument("--timeout", type=int, default=30)
args = parser.parse_args()
timeout_ms = args.timeout * 1000
try:
from playwright.sync_api import sync_playwright
except ImportError:
respond({"success": False, "error": "playwright not installed. Run: pip install playwright && playwright install chromium"})
return
pw = sync_playwright().start()
browser = pw.chromium.launch(headless=args.headless)
context = browser.new_context(
viewport={"width": args.width, "height": args.height},
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
page = context.new_page()
page.set_default_timeout(timeout_ms)
page.set_default_navigation_timeout(timeout_ms)
# Signal ready
respond({"success": True, "data": {"status": "ready"}})
for line in sys.stdin:
line = line.strip()
if not line:
continue
action = None
try:
cmd = json.loads(line)
action = cmd.get("action", "")
result = handle_command(page, context, action, cmd, timeout_ms)
respond(result)
except Exception as e:
respond({"success": False, "error": f"{type(e).__name__}: {e}"})
if action == "Close":
break
# Cleanup
try:
context.close()
browser.close()
pw.stop()
except Exception:
pass
def handle_command(page, context, action, cmd, timeout_ms):
if action == "Navigate":
url = cmd.get("url", "")
if not url:
return {"success": False, "error": "Missing 'url' parameter"}
page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
title = page.title()
content = extract_readable(page)
return {"success": True, "data": {"title": title, "url": page.url, "content": content}}
elif action == "Click":
selector = cmd.get("selector", "")
if not selector:
return {"success": False, "error": "Missing 'selector' parameter"}
# Try CSS selector first, then text content
try:
page.click(selector, timeout=timeout_ms)
except Exception:
# Fallback: try as text
page.get_by_text(selector, exact=False).first.click(timeout=timeout_ms)
page.wait_for_load_state("domcontentloaded", timeout=timeout_ms)
title = page.title()
return {"success": True, "data": {"clicked": selector, "title": title, "url": page.url}}
elif action == "Type":
selector = cmd.get("selector", "")
text = cmd.get("text", "")
if not selector:
return {"success": False, "error": "Missing 'selector' parameter"}
if not text:
return {"success": False, "error": "Missing 'text' parameter"}
page.fill(selector, text, timeout=timeout_ms)
return {"success": True, "data": {"typed": text, "selector": selector}}
elif action == "Screenshot":
screenshot_bytes = page.screenshot(full_page=False)
b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
return {"success": True, "data": {"image_base64": b64, "format": "png", "url": page.url}}
elif action == "ReadPage":
title = page.title()
content = extract_readable(page)
return {"success": True, "data": {"title": title, "url": page.url, "content": content}}
elif action == "Close":
return {"success": True, "data": {"status": "closed"}}
else:
return {"success": False, "error": f"Unknown action: {action}"}
def extract_readable(page):
"""Extract readable text content from the page, stripping nav/footer/script noise."""
try:
# Remove script, style, nav, footer, header elements
content = page.evaluate("""() => {
const clone = document.body.cloneNode(true);
const remove = ['script', 'style', 'nav', 'footer', 'header', 'aside',
'iframe', 'noscript', 'svg', 'canvas'];
remove.forEach(tag => {
clone.querySelectorAll(tag).forEach(el => el.remove());
});
// Try to find main content area
const main = clone.querySelector('main, article, [role="main"], .content, #content');
const source = main || clone;
// Extract text with basic structure
const lines = [];
const walk = (node) => {
if (node.nodeType === 3) {
const text = node.textContent.trim();
if (text) lines.push(text);
} else if (node.nodeType === 1) {
const tag = node.tagName.toLowerCase();
if (['h1','h2','h3','h4','h5','h6'].includes(tag)) {
lines.push('\\n## ' + node.textContent.trim());
} else if (tag === 'li') {
lines.push('- ' + node.textContent.trim());
} else if (tag === 'a' && node.href) {
lines.push('[' + node.textContent.trim() + '](' + node.href + ')');
} else if (['p', 'div', 'section', 'td', 'th'].includes(tag)) {
for (const child of node.childNodes) walk(child);
lines.push('');
} else {
for (const child of node.childNodes) walk(child);
}
}
};
walk(source);
return lines.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trim();
}""")
# Truncate to prevent huge payloads
max_chars = 50000
if len(content) > max_chars:
content = content[:max_chars] + f"\n\n[Truncated — {len(content)} total chars]"
return content
except Exception:
# Fallback: plain innerText
try:
text = page.inner_text("body")
if len(text) > 50000:
text = text[:50000] + f"\n\n[Truncated — {len(text)} total chars]"
return text
except Exception:
return "(could not extract page content)"
def respond(data):
"""Write a JSON response line to stdout."""
sys.stdout.write(json.dumps(data) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()
+37 -3
View File
@@ -145,7 +145,14 @@ pub fn apply_context_guard(
let mut compacted = 0;
for loc in &locations {
if loc.char_len > single_max {
// Bounds check: indices may be stale if messages were modified concurrently
if loc.msg_idx >= messages.len() {
continue;
}
if let MessageContent::Blocks(blocks) = &mut messages[loc.msg_idx].content {
if loc.block_idx >= blocks.len() {
continue;
}
if let ContentBlock::ToolResult { content, .. } = &mut blocks[loc.block_idx] {
let old_len = content.len();
*content = truncate_to(content, single_max);
@@ -167,7 +174,13 @@ pub fn apply_context_guard(
if loc.char_len <= compact_target {
continue;
}
if loc.msg_idx >= messages.len() {
continue;
}
if let MessageContent::Blocks(blocks) = &mut messages[loc.msg_idx].content {
if loc.block_idx >= blocks.len() {
continue;
}
if let ContentBlock::ToolResult { content, .. } = &mut blocks[loc.block_idx] {
if content.len() > compact_target {
let old_len = content.len();
@@ -188,11 +201,32 @@ 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);
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)
};
// Try to break at newline
let break_point = content[keep.saturating_sub(100)..keep]
let break_point = content[search_start..keep]
.rfind('\n')
.map(|pos| keep.saturating_sub(100) + pos)
.map(|pos| search_start + pos)
.unwrap_or(keep);
format!(
"{}\n\n[COMPACTED: {} → {} chars by context guard]",
@@ -365,10 +365,10 @@ impl LlmDriver for AnthropicDriver {
let mut event_type = String::new();
let mut data = String::new();
for line in event_text.lines() {
if let Some(et) = line.strip_prefix("event: ") {
event_type = et.to_string();
} else if let Some(d) = line.strip_prefix("data: ") {
data = d.to_string();
if let Some(et) = line.strip_prefix("event:") {
event_type = et.trim_start().to_string();
} else if let Some(d) = line.strip_prefix("data:") {
data = d.trim_start().to_string();
}
}
@@ -415,12 +415,13 @@ impl LlmDriver for AnthropicDriver {
}
}
"content_block_delta" => {
let block_idx = json["index"].as_u64().unwrap_or(0) as usize;
let delta = &json["delta"];
match delta["type"].as_str().unwrap_or("") {
"text_delta" => {
if let Some(text) = delta["text"].as_str() {
if let Some(ContentBlockAccum::Text(ref mut t)) =
blocks.last_mut()
blocks.get_mut(block_idx)
{
t.push_str(text);
}
@@ -436,7 +437,7 @@ impl LlmDriver for AnthropicDriver {
if let Some(ContentBlockAccum::ToolUse {
ref mut input_json,
..
}) = blocks.last_mut()
}) = blocks.get_mut(block_idx)
{
input_json.push_str(partial);
}
@@ -450,7 +451,7 @@ impl LlmDriver for AnthropicDriver {
"thinking_delta" => {
if let Some(thinking) = delta["thinking"].as_str() {
if let Some(ContentBlockAccum::Thinking(ref mut t)) =
blocks.last_mut()
blocks.get_mut(block_idx)
{
t.push_str(thinking);
}
@@ -460,11 +461,12 @@ impl LlmDriver for AnthropicDriver {
}
}
"content_block_stop" => {
let block_idx = json["index"].as_u64().unwrap_or(0) as usize;
if let Some(ContentBlockAccum::ToolUse {
id,
name,
input_json,
}) = blocks.last()
}) = blocks.get(block_idx)
{
let input: serde_json::Value =
serde_json::from_str(input_json).unwrap_or_default();
@@ -83,10 +83,17 @@ impl ClaudeCodeDriver {
}
/// JSON output from `claude -p --output-format json`.
///
/// The CLI may return the response text in different fields depending on
/// version: `result`, `content`, or `text`. We try all three.
#[derive(Debug, Deserialize)]
struct ClaudeJsonOutput {
result: Option<String>,
#[serde(default)]
content: Option<String>,
#[serde(default)]
text: Option<String>,
#[serde(default)]
usage: Option<ClaudeUsage>,
#[serde(default)]
#[allow(dead_code)]
@@ -127,6 +134,7 @@ impl LlmDriver for ClaudeCodeDriver {
let mut cmd = tokio::process::Command::new(&self.cli_path);
cmd.arg("-p")
.arg(&prompt)
.arg("--dangerously-skip-permissions")
.arg("--output-format")
.arg("json");
@@ -157,7 +165,10 @@ impl LlmDriver for ClaudeCodeDriver {
// Try JSON parse first
if let Ok(parsed) = serde_json::from_str::<ClaudeJsonOutput>(&stdout) {
let text = parsed.result.unwrap_or_default();
let text = parsed.result
.or(parsed.content)
.or(parsed.text)
.unwrap_or_default();
let usage = parsed.usage.unwrap_or_default();
return Ok(CompletionResponse {
content: vec![ContentBlock::Text { text: text.clone() }],
@@ -194,8 +205,10 @@ impl LlmDriver for ClaudeCodeDriver {
let mut cmd = tokio::process::Command::new(&self.cli_path);
cmd.arg("-p")
.arg(&prompt)
.arg("--dangerously-skip-permissions")
.arg("--output-format")
.arg("stream-json");
.arg("stream-json")
.arg("--verbose");
if let Some(ref model) = model_flag {
cmd.arg("--model").arg(model);
@@ -232,7 +245,7 @@ impl LlmDriver for ClaudeCodeDriver {
match serde_json::from_str::<ClaudeStreamEvent>(&line) {
Ok(event) => {
match event.r#type.as_str() {
"content" | "text" => {
"content" | "text" | "assistant" | "content_block_delta" => {
if let Some(ref content) = event.content {
full_text.push_str(content);
let _ = tx
@@ -205,6 +205,11 @@ impl CopilotDriver {
token.base_url.clone()
};
super::openai::OpenAIDriver::new(token.token.to_string(), base_url)
.with_extra_headers(vec![
("Editor-Version".to_string(), "vscode/1.96.0".to_string()),
("Editor-Plugin-Version".to_string(), "copilot/1.250.0".to_string()),
("Copilot-Integration-Id".to_string(), "vscode-chat".to_string()),
])
}
}
@@ -12,15 +12,23 @@ use tracing::warn;
///
/// On failure (including rate-limit and overload), moves to the next driver.
/// Only returns an error when ALL drivers in the chain are exhausted.
/// Each driver is paired with the model name it should use.
pub struct FallbackDriver {
drivers: Vec<Arc<dyn LlmDriver>>,
drivers: Vec<(Arc<dyn LlmDriver>, String)>,
}
impl FallbackDriver {
/// Create a new fallback driver from an ordered chain of drivers.
/// Create a new fallback driver from an ordered chain of (driver, model_name) pairs.
///
/// The first driver is the primary; subsequent are fallbacks.
/// The first entry is the primary; subsequent are fallbacks.
pub fn new(drivers: Vec<Arc<dyn LlmDriver>>) -> Self {
Self {
drivers: drivers.into_iter().map(|d| (d, String::new())).collect(),
}
}
/// Create a new fallback driver with explicit model names for each driver.
pub fn with_models(drivers: Vec<(Arc<dyn LlmDriver>, String)>) -> Self {
Self { drivers }
}
}
@@ -30,12 +38,17 @@ impl LlmDriver for FallbackDriver {
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let mut last_error = None;
for (i, driver) in self.drivers.iter().enumerate() {
match driver.complete(request.clone()).await {
for (i, (driver, model_name)) in self.drivers.iter().enumerate() {
let mut req = request.clone();
if !model_name.is_empty() {
req.model = model_name.clone();
}
match driver.complete(req).await {
Ok(response) => return Ok(response),
Err(e @ LlmError::RateLimited { .. }) | Err(e @ LlmError::Overloaded { .. }) => {
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Driver rate-limited/overloaded, trying next fallback"
);
@@ -44,6 +57,7 @@ impl LlmDriver for FallbackDriver {
Err(e) => {
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Fallback driver failed, trying next"
);
@@ -65,12 +79,17 @@ impl LlmDriver for FallbackDriver {
) -> Result<CompletionResponse, LlmError> {
let mut last_error = None;
for (i, driver) in self.drivers.iter().enumerate() {
match driver.stream(request.clone(), tx.clone()).await {
for (i, (driver, model_name)) in self.drivers.iter().enumerate() {
let mut req = request.clone();
if !model_name.is_empty() {
req.model = model_name.clone();
}
match driver.stream(req, tx.clone()).await {
Ok(response) => return Ok(response),
Err(e @ LlmError::RateLimited { .. }) | Err(e @ LlmError::Overloaded { .. }) => {
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Driver rate-limited/overloaded (stream), trying next fallback"
);
@@ -79,6 +98,7 @@ impl LlmDriver for FallbackDriver {
Err(e) => {
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Fallback driver (stream) failed, trying next"
);
+36 -24
View File
@@ -305,32 +305,44 @@ fn convert_response(resp: GeminiResponse) -> Result<CompletionResponse, LlmError
let mut content = Vec::new();
let mut tool_calls = Vec::new();
if let Some(gemini_content) = candidate.content {
for part in gemini_content.parts {
match part {
GeminiPart::Text { text } => {
if !text.is_empty() {
content.push(ContentBlock::Text { text });
match candidate.content {
Some(gemini_content) => {
for part in gemini_content.parts {
match part {
GeminiPart::Text { text } => {
if !text.is_empty() {
content.push(ContentBlock::Text { text });
}
}
GeminiPart::FunctionCall { function_call } => {
let id = format!("call_{}", uuid::Uuid::new_v4().simple());
content.push(ContentBlock::ToolUse {
id: id.clone(),
name: function_call.name.clone(),
input: function_call.args.clone(),
});
tool_calls.push(ToolCall {
id,
name: function_call.name,
input: function_call.args,
});
}
GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => {
// Shouldn't normally appear in responses, ignore
}
}
GeminiPart::FunctionCall { function_call } => {
let id = format!("call_{}", uuid::Uuid::new_v4().simple());
content.push(ContentBlock::ToolUse {
id: id.clone(),
name: function_call.name.clone(),
input: function_call.args.clone(),
});
tool_calls.push(ToolCall {
id,
name: function_call.name,
input: function_call.args,
});
}
GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => {
// Shouldn't normally appear in responses, ignore
}
}
}
None => {
let reason = candidate
.finish_reason
.as_deref()
.unwrap_or("unknown");
warn!(finish_reason = %reason, "Gemini returned candidate with no content");
return Err(LlmError::Parse(format!(
"Gemini returned empty response (finish_reason: {reason})"
)));
}
}
// Gemini uses "STOP" for both end-of-turn and function calls,
@@ -525,10 +537,10 @@ impl LlmDriver for GeminiDriver {
let event_text = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
// Extract the data line
// Extract the data line (handle both "data: " and "data:" formats)
let data = event_text
.lines()
.find_map(|line| line.strip_prefix("data: "))
.find_map(|line| line.strip_prefix("data:").map(|d| d.trim_start()))
.unwrap_or("");
if data.is_empty() {
+26 -3
View File
@@ -17,8 +17,9 @@ use openfang_types::model_catalog::{
FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, XAI_BASE_URL,
ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL,
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
use std::sync::Arc;
@@ -168,11 +169,31 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
api_key_env: "ZHIPU_API_KEY",
key_required: true,
}),
"zai" => Some(ProviderDefaults {
base_url: ZAI_BASE_URL,
api_key_env: "ZHIPU_API_KEY",
key_required: true,
}),
"zai_coding" => Some(ProviderDefaults {
base_url: ZAI_CODING_BASE_URL,
api_key_env: "ZHIPU_API_KEY",
key_required: true,
}),
"qianfan" | "baidu" => Some(ProviderDefaults {
base_url: QIANFAN_BASE_URL,
api_key_env: "QIANFAN_API_KEY",
key_required: true,
}),
"volcengine" | "doubao" => Some(ProviderDefaults {
base_url: VOLCENGINE_BASE_URL,
api_key_env: "VOLCENGINE_API_KEY",
key_required: true,
}),
"volcengine_coding" => Some(ProviderDefaults {
base_url: VOLCENGINE_CODING_BASE_URL,
api_key_env: "VOLCENGINE_API_KEY",
key_required: true,
}),
_ => None,
}
}
@@ -360,6 +381,7 @@ pub fn known_providers() -> &'static [&'static str] {
"zhipu",
"zhipu_coding",
"qianfan",
"volcengine",
"codex",
"claude-code",
]
@@ -455,9 +477,10 @@ mod tests {
assert!(providers.contains(&"zhipu"));
assert!(providers.contains(&"zhipu_coding"));
assert!(providers.contains(&"qianfan"));
assert!(providers.contains(&"volcengine"));
assert!(providers.contains(&"codex"));
assert!(providers.contains(&"claude-code"));
assert_eq!(providers.len(), 29);
assert_eq!(providers.len(), 30);
}
#[test]
+92 -20
View File
@@ -16,6 +16,7 @@ pub struct OpenAIDriver {
api_key: Zeroizing<String>,
base_url: String,
client: reqwest::Client,
extra_headers: Vec<(String, String)>,
}
impl OpenAIDriver {
@@ -25,15 +26,27 @@ impl OpenAIDriver {
api_key: Zeroizing::new(api_key),
base_url,
client: reqwest::Client::new(),
extra_headers: Vec::new(),
}
}
/// Create a driver with additional HTTP headers (e.g. for Copilot IDE auth).
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
}
#[derive(Debug, Serialize)]
struct OaiRequest {
model: String,
messages: Vec<OaiMessage>,
max_tokens: u32,
/// Classic token limit field (used by most models).
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
/// New token limit field required by GPT-5 and o-series reasoning models.
#[serde(skip_serializing_if = "Option::is_none")]
max_completion_tokens: Option<u32>,
temperature: f32,
#[serde(skip_serializing_if = "Vec::is_empty")]
tools: Vec<OaiTool>,
@@ -43,6 +56,16 @@ struct OaiRequest {
stream: bool,
}
/// Returns true if a model uses `max_completion_tokens` instead of `max_tokens`.
fn uses_completion_tokens(model: &str) -> bool {
let m = model.to_lowercase();
m.starts_with("gpt-5")
|| m.starts_with("gpt5")
|| m.starts_with("o1")
|| m.starts_with("o3")
|| m.starts_with("o4")
}
#[derive(Debug, Serialize)]
struct OaiMessage {
role: String,
@@ -276,10 +299,16 @@ impl LlmDriver for OpenAIDriver {
Some(serde_json::json!("auto"))
};
let (mt, mct) = if uses_completion_tokens(&request.model) {
(None, Some(request.max_tokens))
} else {
(Some(request.max_tokens), None)
};
let mut oai_request = OaiRequest {
model: request.model.clone(),
messages: oai_messages,
max_tokens: request.max_tokens,
max_tokens: mt,
max_completion_tokens: mct,
temperature: request.temperature,
tools: oai_tools,
tool_choice,
@@ -301,6 +330,9 @@ impl LlmDriver for OpenAIDriver {
req_builder = req_builder
.header("authorization", format!("Bearer {}", self.api_key.as_str()));
}
for (k, v) in &self.extra_headers {
req_builder = req_builder.header(k, v);
}
let resp = req_builder
.send()
@@ -339,16 +371,31 @@ impl LlmDriver for OpenAIDriver {
}
}
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
if status == 400
&& body.contains("max_tokens")
&& (body.contains("unsupported_parameter")
|| body.contains("max_completion_tokens"))
&& oai_request.max_tokens.is_some()
&& attempt < max_retries
{
let val = oai_request.max_tokens.unwrap();
warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model");
oai_request.max_tokens = None;
oai_request.max_completion_tokens = Some(val);
continue;
}
// Auto-cap max_tokens when model rejects our value (e.g. Groq Maverick limit 8192)
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
// Extract the limit from error: "must be less than or equal to `8192`"
let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2);
warn!(
old = oai_request.max_tokens,
new = cap,
"Auto-capping max_tokens to model limit"
);
oai_request.max_tokens = cap;
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
warn!(old = current, new = cap, "Auto-capping max_tokens to model limit");
if oai_request.max_completion_tokens.is_some() {
oai_request.max_completion_tokens = Some(cap);
} else {
oai_request.max_tokens = Some(cap);
}
continue;
}
@@ -555,10 +602,16 @@ impl LlmDriver for OpenAIDriver {
Some(serde_json::json!("auto"))
};
let (mt, mct) = if uses_completion_tokens(&request.model) {
(None, Some(request.max_tokens))
} else {
(Some(request.max_tokens), None)
};
let mut oai_request = OaiRequest {
model: request.model.clone(),
messages: oai_messages,
max_tokens: request.max_tokens,
max_tokens: mt,
max_completion_tokens: mct,
temperature: request.temperature,
tools: oai_tools,
tool_choice,
@@ -581,6 +634,9 @@ impl LlmDriver for OpenAIDriver {
req_builder = req_builder
.header("authorization", format!("Bearer {}", self.api_key.as_str()));
}
for (k, v) in &self.extra_headers {
req_builder = req_builder.header(k, v);
}
let resp = req_builder
.send()
@@ -620,15 +676,31 @@ impl LlmDriver for OpenAIDriver {
}
}
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
if status == 400
&& body.contains("max_tokens")
&& (body.contains("unsupported_parameter")
|| body.contains("max_completion_tokens"))
&& oai_request.max_tokens.is_some()
&& attempt < max_retries
{
let val = oai_request.max_tokens.unwrap();
warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model (stream)");
oai_request.max_tokens = None;
oai_request.max_completion_tokens = Some(val);
continue;
}
// Auto-cap max_tokens when model rejects our value
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2);
warn!(
old = oai_request.max_tokens,
new = cap,
"Auto-capping max_tokens (stream)"
);
oai_request.max_tokens = cap;
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
warn!(old = current, new = cap, "Auto-capping max_tokens (stream)");
if oai_request.max_completion_tokens.is_some() {
oai_request.max_completion_tokens = Some(cap);
} else {
oai_request.max_tokens = Some(cap);
}
continue;
}
@@ -660,8 +732,8 @@ impl LlmDriver for OpenAIDriver {
continue;
}
let data = match line.strip_prefix("data: ") {
Some(d) => d,
let data = match line.strip_prefix("data:") {
Some(d) => d.trim_start(),
None => continue,
};
@@ -139,6 +139,16 @@ pub trait KernelHandle: Send + Sync {
Err("Hands system not available".to_string())
}
/// Install a Hand from TOML content.
async fn hand_install(
&self,
toml_content: &str,
skill_content: &str,
) -> Result<serde_json::Value, String> {
let _ = (toml_content, skill_content);
Err("Hands system not available".to_string())
}
/// Activate a Hand — spawns a specialized autonomous agent.
async fn hand_activate(
&self,
+58 -3
View File
@@ -32,7 +32,7 @@ pub struct McpServerConfig {
}
fn default_timeout() -> u64 {
30
60
}
/// Transport type for MCP server connections.
@@ -393,7 +393,31 @@ impl McpConnection {
return Err("MCP command path contains '..': rejected".to_string());
}
let mut cmd = tokio::process::Command::new(command);
// On Windows, npm/npx install as .cmd batch wrappers. Detect and adapt.
let resolved_command: String = if cfg!(windows) {
// If the user already specified .cmd/.bat, use as-is
if command.ends_with(".cmd") || command.ends_with(".bat") {
command.to_string()
} else {
// Check if the .cmd variant exists on PATH
let cmd_variant = format!("{command}.cmd");
let has_cmd = std::env::var("PATH")
.unwrap_or_default()
.split(';')
.any(|dir| {
std::path::Path::new(dir).join(&cmd_variant).exists()
});
if has_cmd {
cmd_variant
} else {
command.to_string()
}
}
} else {
command.to_string()
};
let mut cmd = tokio::process::Command::new(&resolved_command);
cmd.args(args);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
@@ -410,10 +434,41 @@ impl McpConnection {
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
// On Windows, npm/node need APPDATA, USERPROFILE, LOCALAPPDATA, and SystemRoot
if cfg!(windows) {
for var in &[
"APPDATA",
"LOCALAPPDATA",
"USERPROFILE",
"SystemRoot",
"TEMP",
"TMP",
"HOME",
"HOMEDRIVE",
"HOMEPATH",
] {
if let Ok(val) = std::env::var(var) {
cmd.env(var, val);
}
}
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn MCP server '{command}': {e}"))?;
.map_err(|e| format!("Failed to spawn MCP server '{resolved_command}': {e}"))?;
// Log stderr in background for debugging MCP server issues
if let Some(stderr) = child.stderr.take() {
let cmd_name = resolved_command.clone();
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let reader = tokio::io::BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(mcp_server = %cmd_name, "stderr: {line}");
}
});
}
let stdin = child
.stdin
+227 -34
View File
@@ -9,8 +9,9 @@ use openfang_types::model_catalog::{
GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL,
LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL,
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, XAI_BASE_URL,
ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL,
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
use std::collections::HashMap;
@@ -115,6 +116,19 @@ impl ModelCatalog {
.collect()
}
/// Return the default model ID for a provider (first model in catalog order).
pub fn default_model_for_provider(&self, provider: &str) -> Option<String> {
// Check aliases first — e.g. "minimax" alias resolves to "MiniMax-M2.5"
if let Some(model_id) = self.aliases.get(provider) {
return Some(model_id.clone());
}
// Fall back to the first model registered for this provider
self.models
.iter()
.find(|m| m.provider == provider)
.map(|m| m.id.clone())
}
/// List models that are available (from configured providers only).
pub fn available_models(&self) -> Vec<&ModelCatalogEntry> {
let configured: Vec<&str> = self
@@ -156,9 +170,18 @@ impl ModelCatalog {
///
/// Each entry maps a provider ID to a custom base URL.
/// Unknown providers are silently skipped.
/// Providers with explicit URL overrides are marked as configured since
/// the user intentionally set them up (e.g. local proxies, custom endpoints).
pub fn apply_url_overrides(&mut self, overrides: &HashMap<String, String>) {
for (provider, url) in overrides {
self.set_provider_url(provider, url);
if self.set_provider_url(provider, url) {
// Mark as configured so models from this provider show as available
if let Some(p) = self.providers.iter_mut().find(|p| p.id == *provider) {
if p.auth_status == AuthStatus::Missing {
p.auth_status = AuthStatus::Configured;
}
}
}
}
}
@@ -570,6 +593,24 @@ fn builtin_providers() -> Vec<ProviderInfo> {
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "zai".into(),
display_name: "Z.AI".into(),
api_key_env: "ZHIPU_API_KEY".into(),
base_url: ZAI_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "zai_coding".into(),
display_name: "Z.AI Coding".into(),
api_key_env: "ZHIPU_API_KEY".into(),
base_url: ZAI_CODING_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "moonshot".into(),
display_name: "Moonshot (Kimi)".into(),
@@ -588,6 +629,25 @@ fn builtin_providers() -> Vec<ProviderInfo> {
auth_status: AuthStatus::Missing,
model_count: 0,
},
// ── Volcano Engine (Doubao) ──────────────────────────────────
ProviderInfo {
id: "volcengine".into(),
display_name: "Volcano Engine (Doubao)".into(),
api_key_env: "VOLCENGINE_API_KEY".into(),
base_url: VOLCENGINE_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "volcengine_coding".into(),
display_name: "Volcano Engine Coding Plan".into(),
api_key_env: "VOLCENGINE_API_KEY".into(),
base_url: VOLCENGINE_CODING_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
// ── AWS Bedrock ──────────────────────────────────────────────
ProviderInfo {
id: "bedrock".into(),
@@ -634,9 +694,9 @@ fn builtin_aliases() -> HashMap<String, String> {
("gpt4-mini", "gpt-4o-mini"),
("gpt5", "gpt-5.2"),
("gpt5-mini", "gpt-5-mini"),
("flash", "gemini-3-flash"),
("gemini-flash", "gemini-3-flash"),
("gemini-pro", "gemini-3.1-pro"),
("flash", "gemini-2.5-flash"),
("gemini-pro", "gemini-3.1-pro-preview"),
("gemini-flash", "gemini-3-flash-preview"),
("deepseek", "deepseek-chat"),
("llama", "llama-3.3-70b-versatile"),
("llama-70b", "llama-3.3-70b-versatile"),
@@ -650,10 +710,11 @@ fn builtin_aliases() -> HashMap<String, String> {
("mistral-nemo", "open-mistral-nemo"),
("pixtral", "pixtral-large-latest"),
// xAI aliases
("grok", "grok-4"),
("grok", "grok-4-0709"),
("grok-4", "grok-4-0709"),
("grok-mini", "grok-2-mini"),
("grok3", "grok-3"),
("grok-fast", "grok-4.1-fast"),
("grok-fast", "grok-4-1-fast-reasoning"),
// Perplexity alias
("sonar", "sonar-pro"),
// AI21 aliases
@@ -669,7 +730,7 @@ fn builtin_aliases() -> HashMap<String, String> {
("copilot-gpt4", "copilot/gpt-4"),
// Chinese model aliases
("qwen", "qwen-plus"),
("glm", "glm-4-plus"),
("glm", "glm-5-20250605"),
("ernie", "ernie-4.5-8k"),
("kimi", "moonshot-v1-128k"),
("minimax", "MiniMax-M2.5"),
@@ -1026,8 +1087,8 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
// Google Gemini (10)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "gemini-3.1-pro".into(),
display_name: "Gemini 3.1 Pro".into(),
id: "gemini-3.1-pro-preview".into(),
display_name: "Gemini 3.1 Pro Preview".into(),
provider: "gemini".into(),
tier: ModelTier::Frontier,
context_window: 1_048_576,
@@ -1040,28 +1101,28 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec!["gemini-pro".into()],
},
ModelCatalogEntry {
id: "gemini-3-flash".into(),
display_name: "Gemini 3 Flash".into(),
id: "gemini-3-flash-preview".into(),
display_name: "Gemini 3 Flash Preview".into(),
provider: "gemini".into(),
tier: ModelTier::Smart,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 0.50,
output_cost_per_m: 3.0,
input_cost_per_m: 0.15,
output_cost_per_m: 0.60,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["flash".into(), "gemini-flash".into()],
aliases: vec!["gemini-flash".into()],
},
ModelCatalogEntry {
id: "gemini-3-deep-think".into(),
display_name: "Gemini 3 Deep Think".into(),
id: "gemini-3.1-flash-lite-preview".into(),
display_name: "Gemini 3.1 Flash Lite Preview".into(),
provider: "gemini".into(),
tier: ModelTier::Frontier,
tier: ModelTier::Fast,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 2.50,
output_cost_per_m: 15.0,
max_output_tokens: 8_192,
input_cost_per_m: 0.04,
output_cost_per_m: 0.15,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
@@ -2174,10 +2235,10 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// xAI (6)
// xAI (9)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "grok-4".into(),
id: "grok-4-0709".into(),
display_name: "Grok 4".into(),
provider: "xai".into(),
tier: ModelTier::Frontier,
@@ -2188,11 +2249,39 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["grok".into()],
aliases: vec!["grok".into(), "grok-4".into()],
},
ModelCatalogEntry {
id: "grok-4.1-fast".into(),
display_name: "Grok 4.1 Fast".into(),
id: "grok-4-fast-reasoning".into(),
display_name: "Grok 4 Fast Reasoning".into(),
provider: "xai".into(),
tier: ModelTier::Smart,
context_window: 256_000,
max_output_tokens: 32_768,
input_cost_per_m: 1.0,
output_cost_per_m: 5.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "grok-4-fast-non-reasoning".into(),
display_name: "Grok 4 Fast Non-Reasoning".into(),
provider: "xai".into(),
tier: ModelTier::Smart,
context_window: 256_000,
max_output_tokens: 32_768,
input_cost_per_m: 1.0,
output_cost_per_m: 5.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "grok-4-1-fast-reasoning".into(),
display_name: "Grok 4.1 Fast Reasoning".into(),
provider: "xai".into(),
tier: ModelTier::Fast,
context_window: 2_000_000,
@@ -2204,6 +2293,20 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec!["grok-fast".into()],
},
ModelCatalogEntry {
id: "grok-4-1-fast-non-reasoning".into(),
display_name: "Grok 4.1 Fast Non-Reasoning".into(),
provider: "xai".into(),
tier: ModelTier::Fast,
context_window: 2_000_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.20,
output_cost_per_m: 0.50,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "grok-3".into(),
display_name: "Grok 3".into(),
@@ -2528,7 +2631,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Zhipu AI / GLM (4)
// Zhipu AI / GLM (6)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "glm-4-plus".into(),
@@ -2586,6 +2689,34 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "glm-5-20250605".into(),
display_name: "GLM-5".into(),
provider: "zhipu".into(),
tier: ModelTier::Frontier,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 2.00,
output_cost_per_m: 8.00,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["glm-5".into()],
},
ModelCatalogEntry {
id: "glm-4.7".into(),
display_name: "GLM-4.7".into(),
provider: "zhipu".into(),
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 1.50,
output_cost_per_m: 5.00,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Zhipu Coding / CodeGeeX (1)
// ══════════════════════════════════════════════════════════════
@@ -2694,6 +2825,65 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Volcano Engine / Doubao (4)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "doubao-seed-1-6-251015".into(),
display_name: "Doubao Seed 1.6 Pro".into(),
provider: "volcengine".into(),
tier: ModelTier::Smart,
context_window: 262_144,
max_output_tokens: 16_384,
input_cost_per_m: 0.80,
output_cost_per_m: 2.00,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["doubao".into(), "doubao-pro".into()],
},
ModelCatalogEntry {
id: "doubao-seed-2-0-lite".into(),
display_name: "Doubao Seed 2.0 Lite".into(),
provider: "volcengine".into(),
tier: ModelTier::Balanced,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 0.30,
output_cost_per_m: 0.60,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["doubao-lite".into()],
},
ModelCatalogEntry {
id: "doubao-seed-2-0-mini".into(),
display_name: "Doubao Seed 2.0 Mini".into(),
provider: "volcengine".into(),
tier: ModelTier::Fast,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 0.10,
output_cost_per_m: 0.10,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["doubao-mini".into()],
},
ModelCatalogEntry {
id: "doubao-seed-code".into(),
display_name: "Doubao Seed Code".into(),
provider: "volcengine".into(),
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 0.50,
output_cost_per_m: 1.00,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["doubao-code".into()],
},
// ══════════════════════════════════════════════════════════════
// AWS Bedrock (8)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
@@ -2900,7 +3090,7 @@ mod tests {
#[test]
fn test_catalog_has_providers() {
let catalog = ModelCatalog::new();
assert_eq!(catalog.list_providers().len(), 30);
assert_eq!(catalog.list_providers().len(), 34);
}
#[test]
@@ -3020,7 +3210,7 @@ mod tests {
assert!(aliases.len() >= 20);
assert_eq!(aliases.get("sonnet").unwrap(), "claude-sonnet-4-6");
// New aliases
assert_eq!(aliases.get("grok").unwrap(), "grok-4");
assert_eq!(aliases.get("grok").unwrap(), "grok-4-0709");
assert_eq!(aliases.get("jamba").unwrap(), "jamba-1.5-large");
}
@@ -3028,7 +3218,7 @@ mod tests {
fn test_find_grok_by_alias() {
let catalog = ModelCatalog::new();
let entry = catalog.find_model("grok").unwrap();
assert_eq!(entry.id, "grok-4");
assert_eq!(entry.id, "grok-4-0709");
assert_eq!(entry.provider, "xai");
}
@@ -3049,9 +3239,12 @@ mod tests {
fn test_xai_models() {
let catalog = ModelCatalog::new();
let xai = catalog.models_by_provider("xai");
assert_eq!(xai.len(), 6);
assert!(xai.iter().any(|m| m.id == "grok-4"));
assert!(xai.iter().any(|m| m.id == "grok-4.1-fast"));
assert_eq!(xai.len(), 9);
assert!(xai.iter().any(|m| m.id == "grok-4-0709"));
assert!(xai.iter().any(|m| m.id == "grok-4-fast-reasoning"));
assert!(xai.iter().any(|m| m.id == "grok-4-fast-non-reasoning"));
assert!(xai.iter().any(|m| m.id == "grok-4-1-fast-reasoning"));
assert!(xai.iter().any(|m| m.id == "grok-4-1-fast-non-reasoning"));
assert!(xai.iter().any(|m| m.id == "grok-3"));
assert!(xai.iter().any(|m| m.id == "grok-3-mini"));
assert!(xai.iter().any(|m| m.id == "grok-2"));
@@ -53,6 +53,8 @@ pub struct PromptContext {
pub heartbeat_md: Option<String>,
/// Peer agents visible to this agent: (name, state, model).
pub peer_agents: Vec<(String, String, String)>,
/// Current date/time string for temporal awareness.
pub current_date: Option<String>,
}
/// Build the complete system prompt from a `PromptContext`.
@@ -66,6 +68,11 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String {
// Section 1 — Agent Identity (always present)
sections.push(build_identity_section(ctx));
// Section 1.5 — Current Date/Time (always present when set)
if let Some(ref date) = ctx.current_date {
sections.push(format!("## Current Date\nToday is {date}."));
}
// Section 2 — Tool Call Behavior (skip for subagents)
if !ctx.is_subagent {
sections.push(TOOL_CALL_BEHAVIOR.to_string());
@@ -208,6 +215,9 @@ const TOOL_CALL_BEHAVIOR: &str = "\
- Prefer action over narration. If you can answer by using a tool, do it.
- When executing multiple sequential tool calls, batch them don't output reasoning between each call.
- If a tool returns useful results, present the KEY information, not the raw output.
- When web_fetch or web_search returns content, you MUST include the relevant data in your response. \
Quote specific facts, numbers, or passages from the fetched content. Never say you fetched something \
without sharing what you found.
- Start with the answer, not meta-commentary about how you'll help.
- IMPORTANT: If your instructions or persona mention a shell command, script path, or code snippet, \
execute it via the appropriate tool call (shell_exec, file_write, etc.). Never output commands as \
+98 -14
View File
@@ -41,7 +41,7 @@ fn check_taint_shell_exec(command: &str) -> Option<String> {
labels.insert(TaintLabel::ExternalNetwork);
let tainted = TaintedValue::new(command, labels, "llm_tool_call");
if let Err(violation) = tainted.check_sink(&TaintSink::shell_exec()) {
warn!(command = &command[..command.len().min(80)], %violation, "Shell taint check failed");
warn!(command = crate::str_utils::safe_truncate_str(command, 80), %violation, "Shell taint check failed");
return Some(violation.to_string());
}
}
@@ -68,7 +68,7 @@ fn check_taint_net_fetch(url: &str) -> Option<String> {
labels.insert(TaintLabel::Secret);
let tainted = TaintedValue::new(url, labels, "llm_tool_call");
if let Err(violation) = tainted.check_sink(&TaintSink::net_fetch()) {
warn!(url = &url[..url.len().min(80)], %violation, "Net fetch taint check failed");
warn!(url = crate::str_utils::safe_truncate_str(url, 80), %violation, "Net fetch taint check failed");
return Some(violation.to_string());
}
}
@@ -187,8 +187,11 @@ pub async fn execute_tool(
is_error: true,
};
}
let method = input["method"].as_str().unwrap_or("GET");
let headers = input.get("headers").and_then(|v| v.as_object());
let body = input["body"].as_str();
if let Some(ctx) = web_ctx {
ctx.fetch.fetch(url).await
ctx.fetch.fetch_with_options(url, method, headers, body).await
} else {
tool_web_fetch_legacy(input).await
}
@@ -213,7 +216,11 @@ pub async fn execute_tool(
{
return ToolResult {
tool_use_id: tool_use_id.to_string(),
content: format!("Exec policy denied: {reason}"),
content: format!(
"shell_exec blocked: {reason}. Current exec_policy.mode = '{:?}'. \
To allow shell commands, set exec_policy.mode = 'full' in the agent manifest or config.toml.",
policy.mode
),
is_error: true,
};
}
@@ -330,7 +337,7 @@ pub async fn execute_tool(
crate::browser::tool_browser_navigate(input, mgr, aid).await
}
None => Err(
"Browser tools not available. Ensure Python and playwright are installed."
"Browser tools not available. Ensure Chrome/Chromium is installed."
.to_string(),
),
}
@@ -340,35 +347,63 @@ pub async fn execute_tool(
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_click(input, mgr, aid).await
}
None => Err("Browser tools not available.".to_string()),
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_type" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_type(input, mgr, aid).await
}
None => Err("Browser tools not available.".to_string()),
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_screenshot" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_screenshot(input, mgr, aid).await
}
None => Err("Browser tools not available.".to_string()),
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_read_page" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_read_page(input, mgr, aid).await
}
None => Err("Browser tools not available.".to_string()),
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_close" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_close(input, mgr, aid).await
}
None => Err("Browser tools not available.".to_string()),
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_scroll" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_scroll(input, mgr, aid).await
}
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_wait" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_wait(input, mgr, aid).await
}
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_run_js" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_run_js(input, mgr, aid).await
}
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_back" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_back(input, mgr, aid).await
}
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
// Canvas / A2UI tool
@@ -501,11 +536,14 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
// --- Web tools ---
ToolDefinition {
name: "web_fetch".to_string(),
description: "Fetch a web page and extract its content as Markdown. Includes SSRF protection and result caching.".to_string(),
description: "Fetch a URL with SSRF protection. Supports GET/POST/PUT/PATCH/DELETE. For GET, HTML is converted to Markdown. For other methods, returns raw response body.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string", "description": "The URL to fetch (http/https only)" }
"url": { "type": "string", "description": "The URL to fetch (http/https only)" },
"method": { "type": "string", "enum": ["GET","POST","PUT","PATCH","DELETE"], "description": "HTTP method (default: GET)" },
"headers": { "type": "object", "description": "Custom HTTP headers as key-value pairs" },
"body": { "type": "string", "description": "Request body for POST/PUT/PATCH" }
},
"required": ["url"]
}),
@@ -589,7 +627,7 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"type": "object",
"properties": {
"key": { "type": "string", "description": "The storage key" },
"value": { "description": "The JSON value to store (any type)" }
"value": { "type": "string", "description": "The value to store (JSON-encode objects/arrays, or pass a plain string)" }
},
"required": ["key", "value"]
}),
@@ -667,7 +705,7 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"type": "object",
"properties": {
"event_type": { "type": "string", "description": "Type identifier for the event (e.g., 'code_review_requested')" },
"payload": { "description": "JSON payload data for the event" }
"payload": { "type": "object", "description": "JSON payload data for the event" }
},
"required": ["event_type"]
}),
@@ -828,6 +866,48 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"properties": {}
}),
},
ToolDefinition {
name: "browser_scroll".to_string(),
description: "Scroll the browser page. Use this to see content below the fold or navigate long pages.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"direction": { "type": "string", "description": "Scroll direction: 'up', 'down', 'left', 'right' (default: 'down')" },
"amount": { "type": "integer", "description": "Pixels to scroll (default: 600)" }
}
}),
},
ToolDefinition {
name: "browser_wait".to_string(),
description: "Wait for a CSS selector to appear on the page. Useful for dynamic content that loads asynchronously.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"selector": { "type": "string", "description": "CSS selector to wait for" },
"timeout_ms": { "type": "integer", "description": "Max wait time in milliseconds (default: 5000, max: 30000)" }
},
"required": ["selector"]
}),
},
ToolDefinition {
name: "browser_run_js".to_string(),
description: "Run JavaScript on the current browser page and return the result. For advanced interactions that other browser tools cannot handle.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"expression": { "type": "string", "description": "JavaScript expression to run in the page context" }
},
"required": ["expression"]
}),
},
ToolDefinition {
name: "browser_back".to_string(),
description: "Go back to the previous page in browser history.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
// --- Media understanding tools ---
ToolDefinition {
name: "media_describe".to_string(),
@@ -3018,6 +3098,10 @@ mod tests {
assert!(names.contains(&"browser_screenshot"));
assert!(names.contains(&"browser_read_page"));
assert!(names.contains(&"browser_close"));
assert!(names.contains(&"browser_scroll"));
assert!(names.contains(&"browser_wait"));
assert!(names.contains(&"browser_run_js"));
assert!(names.contains(&"browser_back"));
// 3 media/image generation tools
assert!(names.contains(&"media_describe"));
assert!(names.contains(&"media_transcribe"));
+78 -24
View File
@@ -6,6 +6,31 @@
use sha2::{Digest, Sha256};
// ---------------------------------------------------------------------------
// ASCII case-insensitive find — byte offsets always valid on original string
// ---------------------------------------------------------------------------
/// Find `needle` in `haystack` starting at byte offset `from`, comparing
/// ASCII characters case-insensitively. Since HTML tags are ASCII, this
/// avoids the byte-length mismatch caused by `str::to_lowercase()` on
/// multi-byte Unicode (e.g. `İ` 2 bytes → `i̇` 4 bytes).
fn find_ci(haystack: &str, needle: &str, from: usize) -> Option<usize> {
let h = haystack.as_bytes();
let n = needle.as_bytes();
if n.is_empty() || from + n.len() > h.len() {
return None;
}
'outer: for i in from..=(h.len() - n.len()) {
for j in 0..n.len() {
if !h[i + j].eq_ignore_ascii_case(&n[j]) {
continue 'outer;
}
}
return Some(i);
}
None
}
// ---------------------------------------------------------------------------
// External content markers
// ---------------------------------------------------------------------------
@@ -79,19 +104,17 @@ fn remove_non_content_blocks(html: &str) -> String {
/// Remove all occurrences of a specific tag and its contents (case-insensitive).
fn remove_tag_blocks(html: &str, tag: &str) -> String {
let mut result = String::with_capacity(html.len());
let lower = html.to_lowercase();
let open_tag = format!("<{}", tag);
let close_tag = format!("</{}>", tag);
let mut pos = 0;
while pos < html.len() {
if let Some(start) = lower[pos..].find(&open_tag) {
let abs_start = pos + start;
if let Some(abs_start) = find_ci(html, &open_tag, pos) {
result.push_str(&html[pos..abs_start]);
// Find the matching close tag
if let Some(end) = lower[abs_start..].find(&close_tag) {
pos = abs_start + end + close_tag.len();
if let Some(end) = find_ci(html, &close_tag, abs_start) {
pos = end + close_tag.len();
} else {
// No close tag — remove to end of self-closing or skip the open tag
if let Some(gt) = html[abs_start..].find('>') {
@@ -110,16 +133,15 @@ fn remove_tag_blocks(html: &str, tag: &str) -> String {
/// Extract the content from <main>, <article>, or <body> (in priority order).
fn extract_main_content(html: &str) -> String {
let lower = html.to_lowercase();
for tag in &["main", "article", "body"] {
let open = format!("<{}", tag);
let close = format!("</{}>", tag);
if let Some(start) = lower.find(&open) {
if let Some(start) = find_ci(html, &open, 0) {
// Skip past the opening tag's >
if let Some(gt) = html[start..].find('>') {
let content_start = start + gt + 1;
if let Some(end) = lower[content_start..].find(&close) {
return html[content_start..content_start + end].to_string();
if let Some(end) = find_ci(html, &close, content_start) {
return html[content_start..end].to_string();
}
}
}
@@ -193,23 +215,21 @@ fn convert_inline_tag(
md_close: &str,
) -> String {
let mut result = String::with_capacity(html.len());
let lower = html.to_lowercase();
let mut pos = 0;
while pos < html.len() {
if let Some(start) = lower[pos..].find(open_prefix) {
let abs_start = pos + start;
if let Some(abs_start) = find_ci(html, open_prefix, pos) {
result.push_str(&html[pos..abs_start]);
// Find the end of the opening tag
if let Some(gt) = html[abs_start..].find('>') {
let content_start = abs_start + gt + 1;
// Find the close tag
if let Some(end) = lower[content_start..].find(close) {
if let Some(end) = find_ci(html, close, content_start) {
result.push_str(md_open);
result.push_str(&html[content_start..content_start + end]);
result.push_str(&html[content_start..end]);
result.push_str(md_close);
pos = content_start + end + close.len();
pos = end + close.len();
} else {
// No close tag, just skip the open tag
result.push_str(md_open);
@@ -230,12 +250,10 @@ fn convert_inline_tag(
/// Convert <a href="url">text</a> to [text](url).
fn convert_links(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let lower = html.to_lowercase();
let mut pos = 0;
while pos < html.len() {
if let Some(start) = lower[pos..].find("<a ") {
let abs_start = pos + start;
if let Some(abs_start) = find_ci(html, "<a ", pos) {
result.push_str(&html[pos..abs_start]);
// Extract href
@@ -244,14 +262,14 @@ fn convert_links(html: &str) -> String {
if let Some(gt) = tag_content.find('>') {
let text_start = abs_start + gt + 1;
if let Some(end) = lower[text_start..].find("</a>") {
let link_text = strip_all_tags(&html[text_start..text_start + end]);
if let Some(end) = find_ci(html, "</a>", text_start) {
let link_text = strip_all_tags(&html[text_start..end]);
if let Some(url) = href {
result.push_str(&format!("[{}]({})", link_text.trim(), url));
} else {
result.push_str(link_text.trim());
}
pos = text_start + end + 4; // skip </a>
pos = end + 4; // skip </a>
} else {
pos = text_start;
}
@@ -269,9 +287,8 @@ fn convert_links(html: &str) -> String {
/// Extract an attribute value from an HTML tag.
fn extract_attribute(tag: &str, attr: &str) -> Option<String> {
let lower = tag.to_lowercase();
let pattern = format!("{}=\"", attr);
if let Some(start) = lower.find(&pattern) {
if let Some(start) = find_ci(tag, &pattern, 0) {
let val_start = start + pattern.len();
if let Some(end) = tag[val_start..].find('"') {
return Some(tag[val_start..val_start + end].to_string());
@@ -279,7 +296,7 @@ fn extract_attribute(tag: &str, attr: &str) -> Option<String> {
}
// Try single quotes
let pattern_sq = format!("{}='", attr);
if let Some(start) = lower.find(&pattern_sq) {
if let Some(start) = find_ci(tag, &pattern_sq, 0) {
let val_start = start + pattern_sq.len();
if let Some(end) = tag[val_start..].find('\'') {
return Some(tag[val_start..val_start + end].to_string());
@@ -389,4 +406,41 @@ mod tests {
assert!(result.contains("Keep"));
assert!(result.contains("this"));
}
#[test]
fn test_find_ci_basic() {
assert_eq!(find_ci("Hello World", "hello", 0), Some(0));
assert_eq!(find_ci("Hello World", "WORLD", 0), Some(6));
assert_eq!(find_ci("Hello World", "xyz", 0), None);
assert_eq!(find_ci("Hello World", "world", 6), Some(6));
assert_eq!(find_ci("Hello World", "hello", 1), None);
}
#[test]
fn test_unicode_no_panic() {
// Turkish dotted I: İ is 2 bytes, but lowercase i̇ is 4 bytes.
// German sharp S: ẞ is 3 bytes, lowercase ß is 2 bytes.
// This used to panic because to_lowercase() changed byte lengths.
let html = "<body>İstanbul ẞtraße <B>bold</B> text</body>";
let md = html_to_markdown(html);
assert!(md.contains("**bold**"), "Expected bold, got: {md}");
assert!(md.contains("İstanbul"), "Expected unicode preserved, got: {md}");
}
#[test]
fn test_unicode_in_script_removal() {
let html = "<div>Ünïcödé <SCRIPT>İstanbul</SCRIPT> keep</div>";
let result = remove_non_content_blocks(html);
assert!(!result.contains("İstanbul"));
assert!(result.contains("Ünïcödé"));
assert!(result.contains("keep"));
}
#[test]
fn test_mixed_case_tags() {
let html = "<HTML><BODY><H1>Title</H1><P>Hello <STRONG>world</STRONG>.</P></BODY></HTML>";
let md = html_to_markdown(html);
assert!(md.contains("# Title"), "Expected heading, got: {md}");
assert!(md.contains("**world**"), "Expected bold, got: {md}");
}
}
+64 -20
View File
@@ -32,23 +32,62 @@ impl WebFetchEngine {
}
}
/// Fetch a URL with full security pipeline.
/// Fetch a URL with full security pipeline (GET only, for backwards compat).
pub async fn fetch(&self, url: &str) -> Result<String, String> {
self.fetch_with_options(url, "GET", None, None).await
}
/// Fetch a URL with configurable HTTP method, headers, and body.
pub async fn fetch_with_options(
&self,
url: &str,
method: &str,
headers: Option<&serde_json::Map<String, serde_json::Value>>,
body: Option<&str>,
) -> Result<String, String> {
let method_upper = method.to_uppercase();
// Step 1: SSRF protection — BEFORE any network I/O
check_ssrf(url)?;
// Step 2: Cache lookup
let cache_key = format!("fetch:{}", url);
if let Some(cached) = self.cache.get(&cache_key) {
debug!(url, "Fetch cache hit");
return Ok(cached);
// Step 2: Cache lookup (only for GET)
let cache_key = format!("fetch:{}:{}", method_upper, url);
if method_upper == "GET" {
if let Some(cached) = self.cache.get(&cache_key) {
debug!(url, "Fetch cache hit");
return Ok(cached);
}
}
// Step 3: HTTP GET
let resp = self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)")
// Step 3: Build request with configured method
let mut req = match method_upper.as_str() {
"POST" => self.client.post(url),
"PUT" => self.client.put(url),
"PATCH" => self.client.patch(url),
"DELETE" => self.client.delete(url),
_ => self.client.get(url),
};
req = req.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)");
// Add custom headers
if let Some(hdrs) = headers {
for (k, v) in hdrs {
if let Some(val) = v.as_str() {
req = req.header(k.as_str(), val);
}
}
}
// Add body for non-GET methods
if let Some(b) = body {
// Auto-detect JSON body
if b.trim_start().starts_with('{') || b.trim_start().starts_with('[') {
req = req.header("Content-Type", "application/json");
}
req = req.body(b.to_string());
}
let resp = req
.send()
.await
.map_err(|e| format!("HTTP request failed: {e}"))?;
@@ -72,22 +111,25 @@ impl WebFetchEngine {
.unwrap_or("")
.to_string();
let body = resp
let resp_body = resp
.text()
.await
.map_err(|e| format!("Failed to read response body: {e}"))?;
// Step 4: Detect HTML and optionally convert to Markdown
let processed = if self.config.readability && is_html(&content_type, &body) {
let markdown = html_to_markdown(&body);
// Step 4: For GET requests, detect HTML and convert to Markdown.
// For non-GET (API calls), return raw body — don't mangle JSON/XML responses.
let processed = if method_upper == "GET"
&& self.config.readability
&& is_html(&content_type, &resp_body)
{
let markdown = html_to_markdown(&resp_body);
if markdown.trim().is_empty() {
// Fallback to raw text if extraction produced nothing
body
resp_body
} else {
markdown
}
} else {
body
resp_body
};
// Step 5: Truncate
@@ -107,8 +149,10 @@ impl WebFetchEngine {
wrap_external_content(url, &truncated)
);
// Step 7: Cache
self.cache.put(cache_key, result.clone());
// Step 7: Cache (only GET responses)
if method_upper == "GET" {
self.cache.put(cache_key, result.clone());
}
Ok(result)
}
+1
View File
@@ -20,6 +20,7 @@ reqwest = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
serde_yaml = { workspace = true }
zip = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
+39 -17
View File
@@ -17,7 +17,7 @@ use crate::SkillError;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use tracing::info;
use tracing::{info, warn};
// ---------------------------------------------------------------------------
// API response types (matching actual ClawHub v1 API — verified Feb 2026)
@@ -123,7 +123,7 @@ pub struct ClawHubSearchEntry {
#[serde(default)]
pub summary: String,
#[serde(default)]
pub version: String,
pub version: Option<String>,
/// Unix ms timestamp.
#[serde(default)]
pub updated_at: i64,
@@ -459,27 +459,49 @@ impl ClawHubClient {
let skill_dir = target_dir.join(slug);
std::fs::create_dir_all(&skill_dir)?;
// Extract: ClawHub delivers as zip bundles. For now, detect content type
// and write accordingly. A full zip extraction would use the `zip` crate.
// Detect content type and extract accordingly
let content_str = String::from_utf8_lossy(&bytes);
let is_skillmd = content_str.trim_start().starts_with("---");
if is_skillmd {
std::fs::write(skill_dir.join("SKILL.md"), &*bytes)?;
} else {
// Try to detect if it's a zip by magic bytes (PK\x03\x04)
if bytes.len() >= 4 && bytes[0] == 0x50 && bytes[1] == 0x4b {
// It's a zip — write as raw zip for future extraction
std::fs::write(skill_dir.join("skill.zip"), &*bytes)?;
// Try to fetch the SKILL.md file directly as fallback
if let Ok(skillmd_content) = self.get_file(slug, "SKILL.md").await {
std::fs::write(skill_dir.join("SKILL.md"), &skillmd_content)?;
} else {
std::fs::write(skill_dir.join("package.json"), &*bytes)?;
} else if bytes.len() >= 4 && bytes[0] == 0x50 && bytes[1] == 0x4b {
// Zip archive — extract all files
let cursor = std::io::Cursor::new(&*bytes);
match zip::ZipArchive::new(cursor) {
Ok(mut archive) => {
for i in 0..archive.len() {
let mut file = match archive.by_index(i) {
Ok(f) => f,
Err(e) => {
warn!(index = i, error = %e, "Skipping zip entry");
continue;
}
};
let Some(enclosed_name) = file.enclosed_name() else {
warn!("Skipping zip entry with unsafe path");
continue;
};
let out_path = skill_dir.join(enclosed_name);
if file.is_dir() {
std::fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out_file = std::fs::File::create(&out_path)?;
std::io::copy(&mut file, &mut out_file)?;
}
}
info!(slug, entries = archive.len(), "Extracted skill zip");
}
Err(e) => {
warn!(slug, error = %e, "Failed to read zip, saving raw");
std::fs::write(skill_dir.join("skill.zip"), &*bytes)?;
}
} else {
std::fs::write(skill_dir.join("package.json"), &*bytes)?;
}
} else {
std::fs::write(skill_dir.join("package.json"), &*bytes)?;
}
// Step 2-3: Detect format and convert
@@ -676,7 +698,7 @@ mod tests {
assert_eq!(entry.slug, "github");
assert_eq!(entry.display_name, "Github");
assert!(entry.score > 3.0);
assert_eq!(entry.version, "1.0.0");
assert_eq!(entry.version.as_deref(), Some("1.0.0"));
assert_eq!(entry.updated_at, 1771777539580);
}
+26 -15
View File
@@ -281,8 +281,8 @@ pub struct BrowserConfig {
pub idle_timeout_secs: u64,
/// Maximum concurrent browser sessions.
pub max_sessions: usize,
/// Python executable path (e.g., "python3" on Unix, "python" on Windows).
pub python_path: String,
/// Path to Chromium/Chrome binary. Auto-detected if None.
pub chromium_path: Option<String>,
}
impl Default for BrowserConfig {
@@ -294,11 +294,7 @@ impl Default for BrowserConfig {
timeout_secs: 30,
idle_timeout_secs: 300,
max_sessions: 5,
python_path: if cfg!(windows) {
"python".to_string()
} else {
"python3".to_string()
},
chromium_path: None,
}
}
}
@@ -750,11 +746,14 @@ impl Default for CanvasConfig {
#[serde(rename_all = "lowercase")]
pub enum ExecSecurityMode {
/// Block all shell execution.
#[serde(alias = "none", alias = "disabled")]
Deny,
/// Only allow commands in safe_bins or allowed_commands.
#[default]
#[serde(alias = "restricted")]
Allowlist,
/// Allow all commands (unsafe, dev only).
#[serde(alias = "allow", alias = "all", alias = "unrestricted")]
Full,
}
@@ -1169,7 +1168,7 @@ fn default_language() -> String {
impl Default for KernelConfig {
fn default() -> Self {
let home_dir = dirs_next_home().join(".openfang");
let home_dir = openfang_home_dir();
Self {
data_dir: home_dir.join("data"),
home_dir,
@@ -1309,9 +1308,16 @@ impl std::fmt::Debug for KernelConfig {
}
}
/// Fallback home directory resolution.
fn dirs_next_home() -> PathBuf {
dirs::home_dir().unwrap_or_else(std::env::temp_dir)
/// Resolve the OpenFang home directory.
///
/// Priority: `OPENFANG_HOME` env var > `~/.openfang`.
fn openfang_home_dir() -> PathBuf {
if let Ok(home) = std::env::var("OPENFANG_HOME") {
return PathBuf::from(home);
}
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
}
/// Default LLM model configuration.
@@ -1553,10 +1559,14 @@ pub struct DiscordConfig {
/// Env var name holding the bot token (NOT the token itself).
pub bot_token_env: String,
/// Guild (server) IDs allowed to interact (empty = allow all).
pub allowed_guilds: Vec<u64>,
/// Accepts strings for consistency with other channel configs.
pub allowed_guilds: Vec<String>,
/// User IDs allowed to interact (empty = allow all).
#[serde(default)]
pub allowed_users: Vec<String>,
/// Default agent name to route messages to.
pub default_agent: Option<String>,
/// Gateway intents bitmask (default: 33280 = GUILD_MESSAGES | MESSAGE_CONTENT).
/// Gateway intents bitmask (default: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT).
pub intents: u64,
/// Per-channel behavior overrides.
#[serde(default)]
@@ -1568,8 +1578,9 @@ impl Default for DiscordConfig {
Self {
bot_token_env: "DISCORD_BOT_TOKEN".to_string(),
allowed_guilds: vec![],
allowed_users: vec![],
default_agent: None,
intents: 33280,
intents: 37376,
overrides: ChannelOverrides::default(),
}
}
@@ -3226,7 +3237,7 @@ mod tests {
let dc = DiscordConfig::default();
assert_eq!(dc.bot_token_env, "DISCORD_BOT_TOKEN");
assert!(dc.allowed_guilds.is_empty());
assert_eq!(dc.intents, 33280);
assert_eq!(dc.intents, 37376);
}
#[test]

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