Compare commits

...
16 Commits
Author SHA1 Message Date
jaberjaber23 05431509be bugfix batch 2026-03-05 14:53:22 +03:00
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
77 changed files with 1812 additions and 546 deletions
Generated
+30 -19
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.3.3"
version = "0.3.18"
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.3.3"
version = "0.3.18"
dependencies = [
"async-trait",
"axum",
@@ -3933,7 +3934,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.3.3"
version = "0.3.18"
dependencies = [
"clap",
"clap_complete",
@@ -3960,7 +3961,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.3.3"
version = "0.3.18"
dependencies = [
"axum",
"open",
@@ -3986,7 +3987,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.3.3"
version = "0.3.18"
dependencies = [
"aes-gcm",
"argon2",
@@ -4014,7 +4015,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.3.3"
version = "0.3.18"
dependencies = [
"chrono",
"dashmap",
@@ -4031,7 +4032,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.3.3"
version = "0.3.18"
dependencies = [
"async-trait",
"chrono",
@@ -4067,7 +4068,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.3.3"
version = "0.3.18"
dependencies = [
"async-trait",
"chrono",
@@ -4086,7 +4087,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.3.3"
version = "0.3.18"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4105,7 +4106,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.3.3"
version = "0.3.18"
dependencies = [
"anyhow",
"async-trait",
@@ -4137,7 +4138,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.3.3"
version = "0.3.18"
dependencies = [
"chrono",
"hex",
@@ -4160,7 +4161,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.3.3"
version = "0.3.18"
dependencies = [
"async-trait",
"chrono",
@@ -4179,7 +4180,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.3.3"
version = "0.3.18"
dependencies = [
"async-trait",
"chrono",
@@ -4870,7 +4871,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.6.2",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -4907,7 +4908,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
@@ -5967,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"
@@ -6820,7 +6831,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.2",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -8791,7 +8802,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.3.3"
version = "0.3.18"
[[package]]
name = "yoke"
+4 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.3.4"
version = "0.3.19"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -122,6 +122,9 @@ 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"] }
+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 }
@@ -1041,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()));
+10 -20
View File
@@ -52,35 +52,25 @@ 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"
+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 ──────────────────────────────────────────────────────
+185 -68
View File
@@ -115,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,
@@ -255,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);
@@ -294,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}")})),
)
}
@@ -1126,6 +1184,7 @@ 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: "37376", advanced: true },
],
@@ -1959,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
@@ -1982,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));
}
}
@@ -2455,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) => {
@@ -2478,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"})),
@@ -2490,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)) => (
@@ -2512,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"})),
@@ -2524,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);
@@ -2545,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"})),
@@ -2557,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(()) => (
@@ -2575,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"})),
@@ -3614,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>>,
@@ -3623,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}")})),
@@ -5024,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,
@@ -5101,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!({
@@ -6733,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)?
@@ -6761,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));
@@ -8759,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)
+47 -3
View File
@@ -331,6 +331,10 @@ pub async fn build_router(
)
// 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),
@@ -736,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
@@ -745,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);
}
@@ -767,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
@@ -887,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)
}
}
+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()
+23 -2
View File
@@ -589,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>
@@ -2779,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>
@@ -3051,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>
@@ -3064,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>
@@ -3078,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>
@@ -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([
@@ -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 {
@@ -222,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]
@@ -247,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()
@@ -255,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
@@ -270,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()
@@ -278,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]
@@ -617,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
@@ -633,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
@@ -643,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]] {
@@ -661,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);
}
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -541,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.
+36 -10
View File
@@ -407,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 => {}
}
@@ -582,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;
}
}
}
};
+141 -14
View File
@@ -39,6 +39,7 @@ pub struct DiscordAdapter {
token: Zeroizing<String>,
client: reqwest::Client,
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<String>, 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!(
@@ -423,6 +431,7 @@ async fn parse_discord_message(
d: &serde_json::Value,
bot_user_id: &Arc<RwLock<Option<String>>>,
allowed_guilds: &[String],
allowed_users: &[String],
) -> Option<ChannelMessage> {
let author = d.get("author")?;
let author_id = author["id"].as_str()?;
@@ -439,6 +448,12 @@ 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() {
@@ -486,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(),
@@ -497,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,
})
}
@@ -523,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");
@@ -545,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());
}
@@ -565,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());
}
@@ -586,11 +624,11 @@ mod tests {
});
// Not in allowed guilds
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()]).await;
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[]).await;
assert!(msg.is_none());
// In allowed guilds
let msg = parse_discord_message(&d, &bot_id, &["999".into()]).await;
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[]).await;
assert!(msg.is_some());
}
@@ -609,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");
@@ -634,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());
}
@@ -653,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");
}
@@ -675,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".to_string(), "456".to_string()], 37376);
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("**") {
+71 -5
View File
@@ -13,7 +13,7 @@ use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tracing::{debug, error, info, warn};
use tracing::{debug, info, warn};
use zeroize::Zeroizing;
/// Maximum backoff duration on API failures.
@@ -79,8 +79,13 @@ 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,
@@ -313,10 +318,14 @@ impl ChannelAdapter for TelegramAdapter {
continue;
}
// Handle conflict (another bot instance polling)
// Handle conflict (another bot instance or stale session polling).
// On daemon restart, the old long-poll may still be active on Telegram's
// side for up to 30s. Retry with backoff instead of stopping permanently.
if status.as_u16() == 409 {
error!("Telegram 409 Conflict — another bot instance is running. Stopping.");
break;
warn!("Telegram 409 Conflict — stale polling session, retrying in {backoff:?}");
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
continue;
}
if !status.is_success() {
@@ -524,6 +533,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 {
+160 -62
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").
@@ -781,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");
@@ -910,6 +923,7 @@ 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),
@@ -1037,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
@@ -1119,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() {
@@ -1292,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")
}
@@ -1319,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,
@@ -1421,7 +1450,7 @@ 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 = dirs::home_dir()?.join(".openfang").join("config.toml");
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()?;
@@ -1451,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"));
@@ -1927,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() {
@@ -2085,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!();
}
@@ -2101,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}));
}
}
}
@@ -2333,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();
@@ -2359,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 {
@@ -2464,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();
@@ -2501,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!(
@@ -2528,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();
@@ -3143,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 {
@@ -3839,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();
@@ -4380,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>()
@@ -4394,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);
@@ -4553,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");
@@ -5395,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(
@@ -5927,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!(
@@ -5968,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
@@ -6001,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());
}
@@ -6039,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);
}
@@ -100,6 +100,86 @@ const PROVIDERS: &[ProviderInfo] = &[
needs_key: true,
hint: "",
},
ProviderInfo {
name: "xai",
display: "xAI (Grok)",
env_var: "XAI_API_KEY",
default_model: "grok-4-0709",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "perplexity",
display: "Perplexity",
env_var: "PERPLEXITY_API_KEY",
default_model: "sonar-pro",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "cohere",
display: "Cohere",
env_var: "COHERE_API_KEY",
default_model: "command-a-03-2025",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "cerebras",
display: "Cerebras",
env_var: "CEREBRAS_API_KEY",
default_model: "llama-4-scout-17b-16e-instruct",
needs_key: true,
hint: "fast inference",
},
ProviderInfo {
name: "sambanova",
display: "SambaNova",
env_var: "SAMBANOVA_API_KEY",
default_model: "DeepSeek-R1",
needs_key: true,
hint: "fast inference",
},
ProviderInfo {
name: "qwen",
display: "Qwen (Alibaba)",
env_var: "QWEN_API_KEY",
default_model: "qwen-plus",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "huggingface",
display: "Hugging Face",
env_var: "HUGGINGFACE_API_KEY",
default_model: "meta-llama/Llama-3.3-70B-Instruct",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "github-copilot",
display: "GitHub Copilot",
env_var: "GITHUB_TOKEN",
default_model: "gpt-4o",
needs_key: true,
hint: "via PAT",
},
ProviderInfo {
name: "replicate",
display: "Replicate",
env_var: "REPLICATE_API_KEY",
default_model: "meta/meta-llama-3-70b-instruct",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "ai21",
display: "AI21",
env_var: "AI21_API_KEY",
default_model: "jamba-1.5-large",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "ollama",
display: "Ollama",
@@ -116,6 +196,14 @@ const PROVIDERS: &[ProviderInfo] = &[
needs_key: false,
hint: "local",
},
ProviderInfo {
name: "vllm",
display: "vLLM",
env_var: "VLLM_API_KEY",
default_model: "local-model",
needs_key: false,
hint: "local",
},
];
// ── Public result type ─────────────────────────────────────────────────────
@@ -825,8 +913,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 +1036,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);
@@ -118,6 +118,7 @@ key = "elevenlabs_api_key"
label = "ElevenLabs API Key"
description = "API key from elevenlabs.io for high-quality text-to-speech. Required when ElevenLabs TTS is selected."
setting_type = "text"
env_var = "ELEVENLABS_API_KEY"
default = ""
# ─── Publishing settings ────────────────────────────────────────────────────
+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")
+228 -86
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,
@@ -538,52 +556,67 @@ impl OpenFangKernel {
.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()
.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"
);
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)
@@ -655,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)");
@@ -722,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) => {
@@ -736,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))
@@ -749,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))
@@ -985,21 +1028,24 @@ 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;
let is_default_provider = restored_entry.manifest.model.provider.is_empty();
let is_default_model = restored_entry.manifest.model.model.is_empty();
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.base_url.is_some() {
restored_entry.manifest.model.base_url = dm.base_url.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);
}
}
}
@@ -1019,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 {
@@ -1068,28 +1141,35 @@ impl OpenFangKernel {
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 agent didn't explicitly choose.
// Only override when the agent has empty (unset) provider/model fields.
// This preserves explicit model choices like provider="groq", model="llama-3.3-70b".
if manifest.model.api_key_env.is_none() && manifest.model.base_url.is_none() {
// 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);
let is_default_provider = manifest.model.provider.is_empty();
let is_default_model = manifest.model.model.is_empty();
// 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.base_url.is_some() {
manifest.model.base_url = dm.base_url.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);
}
}
}
@@ -2047,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)?;
@@ -2820,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
@@ -2844,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(),
);
}
@@ -2859,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)?;
@@ -3636,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,
@@ -4544,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());
}
_ => {}
}
@@ -4596,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,
@@ -4986,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,
@@ -5012,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)
+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);
@@ -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()),
])
}
}
+34 -22
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,
+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]
@@ -16,6 +16,7 @@ pub struct OpenAIDriver {
api_key: Zeroizing<String>,
base_url: String,
client: reqwest::Client,
extra_headers: Vec<(String, String)>,
}
impl OpenAIDriver {
@@ -25,8 +26,15 @@ impl OpenAIDriver {
api_key: Zeroizing::new(api_key),
base_url,
client: reqwest::Client::new(),
extra_headers: Vec::new(),
}
}
/// Create a driver with additional HTTP headers (e.g. for Copilot IDE auth).
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
}
#[derive(Debug, Serialize)]
@@ -322,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()
@@ -623,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()
@@ -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,
+36 -2
View File
@@ -8,6 +8,7 @@
use openfang_types::tool::ToolDefinition;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tracing::{debug, info};
@@ -59,6 +60,10 @@ pub struct McpConnection {
config: McpServerConfig,
/// Tools discovered from the server via tools/list.
tools: Vec<ToolDefinition>,
/// Map from namespaced tool name → original tool name from the server.
/// Needed because `normalize_name` replaces hyphens with underscores,
/// but the server expects the original name (e.g. "list-connections").
original_names: HashMap<String, String>,
/// Transport handle for sending requests.
transport: McpTransportHandle,
/// Next JSON-RPC request ID.
@@ -134,6 +139,7 @@ impl McpConnection {
let mut conn = Self {
config,
tools: Vec::new(),
original_names: HashMap::new(),
transport,
next_id: 1,
};
@@ -199,6 +205,10 @@ impl McpConnection {
// Namespace: mcp_{server}_{tool}
let namespaced = format_mcp_tool_name(server_name, raw_name);
// Store original name so we can send it back to the server
self.original_names
.insert(namespaced.clone(), raw_name.to_string());
self.tools.push(ToolDefinition {
name: namespaced,
description: format!("[MCP:{server_name}] {description}"),
@@ -219,8 +229,13 @@ impl McpConnection {
name: &str,
arguments: &serde_json::Value,
) -> Result<String, String> {
// Strip the namespace prefix to get the original tool name
let raw_name = strip_mcp_prefix(&self.config.name, name).unwrap_or(name);
// Look up the original tool name from the server (preserves hyphens etc.)
let raw_name = self
.original_names
.get(name)
.map(|s| s.as_str())
.or_else(|| strip_mcp_prefix(&self.config.name, name))
.unwrap_or(name);
let params = serde_json::json!({
"name": raw_name,
@@ -571,6 +586,25 @@ mod tests {
assert!(!is_mcp_tool(""));
}
#[test]
fn test_hyphenated_tool_name_preserved() {
// Tool names with hyphens get normalized to underscores for namespacing,
// but original_names map preserves the original for call_tool dispatch.
let namespaced = format_mcp_tool_name("sqlcl", "list-connections");
assert_eq!(namespaced, "mcp_sqlcl_list_connections");
// Simulate what discover_tools does
let mut original_names = HashMap::new();
original_names.insert(namespaced.clone(), "list-connections".to_string());
// call_tool should resolve to original hyphenated name
let raw = original_names
.get(&namespaced)
.map(|s| s.as_str())
.unwrap_or("list_connections");
assert_eq!(raw, "list-connections");
}
#[test]
fn test_extract_mcp_server() {
assert_eq!(
+221 -28
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;
@@ -169,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;
}
}
}
}
}
@@ -583,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(),
@@ -601,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(),
@@ -648,8 +695,8 @@ fn builtin_aliases() -> HashMap<String, String> {
("gpt5", "gpt-5.2"),
("gpt5-mini", "gpt-5-mini"),
("flash", "gemini-2.5-flash"),
("gemini-flash", "gemini-2.5-flash"),
("gemini-pro", "gemini-3.1-pro"),
("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"),
@@ -663,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
@@ -682,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"),
@@ -1039,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,
@@ -1052,16 +1100,29 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec!["gemini-pro".into()],
},
// gemini-3-flash removed: model doesn't exist. Use gemini-2.5-flash instead.
ModelCatalogEntry {
id: "gemini-3-deep-think".into(),
display_name: "Gemini 3 Deep Think".into(),
id: "gemini-3-flash-preview".into(),
display_name: "Gemini 3 Flash Preview".into(),
provider: "gemini".into(),
tier: ModelTier::Frontier,
tier: ModelTier::Smart,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 2.50,
output_cost_per_m: 15.0,
input_cost_per_m: 0.15,
output_cost_per_m: 0.60,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["gemini-flash".into()],
},
ModelCatalogEntry {
id: "gemini-3.1-flash-lite-preview".into(),
display_name: "Gemini 3.1 Flash Lite Preview".into(),
provider: "gemini".into(),
tier: ModelTier::Fast,
context_window: 1_048_576,
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"));
+4 -4
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());
}
}
@@ -627,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"]
}),
@@ -705,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"]
}),
+2 -2
View File
@@ -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,
@@ -698,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);
}
+15 -4
View File
@@ -1168,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,
@@ -1308,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.
@@ -1554,6 +1561,9 @@ pub struct DiscordConfig {
/// Guild (server) IDs allowed to interact (empty = allow all).
/// 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: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT).
@@ -1568,6 +1578,7 @@ impl Default for DiscordConfig {
Self {
bot_token_env: "DISCORD_BOT_TOKEN".to_string(),
allowed_guilds: vec![],
allowed_users: vec![],
default_agent: None,
intents: 37376,
overrides: ChannelOverrides::default(),
+6 -1
View File
@@ -36,9 +36,14 @@ pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com";
pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
pub const MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
pub const ZHIPU_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";
/// Z.AI domain aliases (same API, different domain).
pub const ZAI_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
pub const ZAI_CODING_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn/v1";
pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
pub const VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3";
pub const VOLCENGINE_CODING_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3";
// ── AWS Bedrock ───────────────────────────────────────────────────
pub const BEDROCK_BASE_URL: &str = "https://bedrock-runtime.us-east-1.amazonaws.com";