mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebcdc17c13 | ||
|
|
45e06b9bad | ||
|
|
c6b46ccbe1 | ||
|
|
9fc0fe71bf | ||
|
|
06df0795c8 | ||
|
|
eafeb6a012 | ||
|
|
05431509be | ||
|
|
9d3136e512 | ||
|
|
60566f22fb | ||
|
|
50440e4047 | ||
|
|
f45268aedc | ||
|
|
cc54e14114 | ||
|
|
1037ef768d | ||
|
|
c3dcf02e3c | ||
|
|
b157e3c7e6 | ||
|
|
74ac992420 | ||
|
|
53e1b31777 | ||
|
|
603a94e560 | ||
|
|
fac4ad33e5 | ||
|
|
fe96cd1004 | ||
|
|
7c85308cf6 | ||
|
|
a4a83b1699 | ||
|
|
8942d8c2b6 | ||
|
|
260dd7a125 | ||
|
|
294f0e7af8 | ||
|
|
444d82e4d6 | ||
|
|
62e6e0f088 | ||
|
|
d3385f2cdc |
@@ -0,0 +1,62 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What happened?
|
||||
placeholder: Describe the bug clearly and concisely.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this?
|
||||
placeholder: |
|
||||
1. Run `openfang start`
|
||||
2. Open dashboard at http://localhost:4200
|
||||
3. Click ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpenFang Version
|
||||
description: Output of `openfang -V`
|
||||
placeholder: "0.3.23"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- Linux (x86_64)
|
||||
- Linux (aarch64/ARM64)
|
||||
- macOS (Apple Silicon)
|
||||
- macOS (Intel)
|
||||
- Windows
|
||||
- Android (Termux)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Screenshots
|
||||
description: Paste relevant logs or attach screenshots.
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What feature would you like?
|
||||
placeholder: Describe the feature and why it would be useful.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you tried any workarounds?
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, screenshots, or references.
|
||||
@@ -0,0 +1,17 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "ci"
|
||||
@@ -0,0 +1,19 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? Link related issues with "Fixes #123". -->
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- Brief list of what changed. -->
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes
|
||||
- [ ] `cargo test --workspace` passes
|
||||
- [ ] Live integration tested (if applicable)
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] No new unsafe code
|
||||
- [ ] No secrets or API keys in diff
|
||||
- [ ] User input validated at boundaries
|
||||
Generated
+61
-20
@@ -2619,7 +2619,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.6.2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3128,7 +3128,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"socket2 0.6.2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"url",
|
||||
@@ -3866,7 +3866,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3888,6 +3888,7 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"socket2 0.5.10",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
@@ -3902,7 +3903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3933,7 +3934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3960,7 +3961,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3986,7 +3987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4014,7 +4015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4031,7 +4032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4067,7 +4068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4086,7 +4087,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4105,7 +4106,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4137,7 +4138,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4155,11 +4156,12 @@ dependencies = [
|
||||
"tracing",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4178,7 +4180,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4869,7 +4871,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"socket2 0.6.2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4906,7 +4908,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"socket2 0.6.2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -5966,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"
|
||||
@@ -6553,7 +6565,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6819,7 +6831,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"socket2 0.6.2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -8790,7 +8802,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.8"
|
||||
version = "0.3.23"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
@@ -8970,6 +8982,23 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
@@ -8988,6 +9017,18 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
||||
+7
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.9"
|
||||
version = "0.3.24"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -122,6 +122,12 @@ argon2 = "0.5"
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
# Socket options (SO_REUSEADDR)
|
||||
socket2 = "0.5"
|
||||
|
||||
# Zip archive extraction
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Email (SMTP + IMAP)
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
|
||||
imap = "2"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -33,9 +33,10 @@ governor = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -934,30 +934,6 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a default agent by name — find running or spawn from manifest.
|
||||
async fn resolve_default_agent(
|
||||
handle: &KernelBridgeAdapter,
|
||||
name: &str,
|
||||
router: &mut AgentRouter,
|
||||
adapter_name: &str,
|
||||
) {
|
||||
match handle.find_agent_by_name(name).await {
|
||||
Ok(Some(agent_id)) => {
|
||||
router.set_default(agent_id);
|
||||
info!("{adapter_name} default agent: {name} ({agent_id})");
|
||||
}
|
||||
_ => match handle.spawn_agent_by_name(name).await {
|
||||
Ok(agent_id) => {
|
||||
router.set_default(agent_id);
|
||||
info!("{adapter_name}: spawned default agent {name} ({agent_id})");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{adapter_name}: could not find or spawn default agent '{name}': {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a token from an env var, returning None with a warning if missing/empty.
|
||||
fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
|
||||
match std::env::var(env_var) {
|
||||
@@ -1065,6 +1041,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
let adapter = Arc::new(DiscordAdapter::new(
|
||||
token,
|
||||
dc_config.allowed_guilds.clone(),
|
||||
dc_config.allowed_users.clone(),
|
||||
dc_config.intents,
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
@@ -1545,12 +1522,40 @@ pub async fn start_channel_bridge_with_config(
|
||||
return (None, Vec::new());
|
||||
}
|
||||
|
||||
// Resolve default agent from first adapter that has one configured
|
||||
// Resolve per-channel default agents AND set the first one as system-wide fallback
|
||||
let mut router = AgentRouter::new();
|
||||
for (_, default_agent) in &adapters {
|
||||
let mut system_default_set = false;
|
||||
for (adapter, default_agent) in &adapters {
|
||||
if let Some(ref name) = default_agent {
|
||||
resolve_default_agent(&handle, name, &mut router, "Channel bridge").await;
|
||||
break; // Only need one default
|
||||
// Resolve agent name to ID
|
||||
let agent_id = match handle.find_agent_by_name(name).await {
|
||||
Ok(Some(id)) => Some(id),
|
||||
_ => match handle.spawn_agent_by_name(name).await {
|
||||
Ok(id) => Some(id),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"{}: could not find or spawn default agent '{}': {e}",
|
||||
adapter.name(),
|
||||
name
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Some(agent_id) = agent_id {
|
||||
// Register per-channel default
|
||||
let channel_key = format!("{:?}", adapter.channel_type());
|
||||
info!(
|
||||
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
|
||||
adapter.name()
|
||||
);
|
||||
router.set_channel_default(channel_key, agent_id);
|
||||
// First configured default also becomes system-wide fallback
|
||||
if !system_default_set {
|
||||
router.set_default(agent_id);
|
||||
system_default_set = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1616,6 +1621,35 @@ pub async fn reload_channels_from_disk(
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
// Re-read secrets.env so new API tokens are available in std::env
|
||||
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
|
||||
if secrets_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&secrets_path) {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(eq_pos) = trimmed.find('=') {
|
||||
let key = trimmed[..eq_pos].trim();
|
||||
let mut value = trimmed[eq_pos + 1..].trim().to_string();
|
||||
if !key.is_empty() {
|
||||
// Strip matching quotes
|
||||
if ((value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\'')))
|
||||
&& value.len() >= 2
|
||||
{
|
||||
value = value[1..value.len() - 1].to_string();
|
||||
}
|
||||
// Always overwrite — the file is the source of truth after dashboard edits
|
||||
std::env::set_var(key, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("Reloaded secrets.env for channel hot-reload");
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read config from disk
|
||||
let config_path = state.kernel.config.home_dir.join("config.toml");
|
||||
let fresh_config = openfang_kernel::config::load_config(Some(&config_path));
|
||||
|
||||
@@ -52,36 +52,30 @@ pub async fn auth(
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
// If no API key configured, restrict to loopback addresses only.
|
||||
// If no API key configured, skip authentication entirely (open access).
|
||||
if api_key.is_empty() {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Shutdown is loopback-only (CLI on same machine) — skip token auth
|
||||
let path = request.uri().path();
|
||||
if path == "/api/shutdown" {
|
||||
let is_loopback = request
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_loopback {
|
||||
tracing::warn!(
|
||||
"Rejected non-localhost request: no API key configured. \
|
||||
Set api_key in config.toml for remote access."
|
||||
);
|
||||
return Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"error": "No API key configured. Remote access denied. Configure api_key in ~/.openfang/config.toml"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
.unwrap_or(true); // default true for unix sockets / tests
|
||||
if is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these)
|
||||
let path = request.uri().path();
|
||||
if path == "/"
|
||||
|| path == "/logo.png"
|
||||
|| path == "/favicon.ico"
|
||||
|| path == "/.well-known/agent.json"
|
||||
|| path.starts_with("/a2a/")
|
||||
|| path == "/api/health"
|
||||
|| path == "/api/health/detail"
|
||||
|| path == "/api/status"
|
||||
|
||||
@@ -179,9 +179,8 @@ fn resolve_agent(state: &AppState, model: &str) -> Option<(AgentId, String)> {
|
||||
return Some((entry.id, entry.name.clone()));
|
||||
}
|
||||
|
||||
// 4. Fallback → first registered agent
|
||||
let agents = state.kernel.registry.list();
|
||||
agents.first().map(|e| (e.id, e.name.clone()))
|
||||
// No match — return None so the caller returns a proper 404
|
||||
None
|
||||
}
|
||||
|
||||
// ── Message conversion ──────────────────────────────────────────────────────
|
||||
@@ -336,7 +335,7 @@ pub async fn chat_completions(
|
||||
index: 0,
|
||||
message: ChoiceMessage {
|
||||
role: "assistant",
|
||||
content: Some(result.response),
|
||||
content: Some(crate::ws::strip_think_tags(&result.response)),
|
||||
tool_calls: None,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
|
||||
+546
-140
File diff suppressed because it is too large
Load Diff
@@ -321,12 +321,20 @@ pub async fn build_router(
|
||||
"/api/clawhub/skill/{slug}",
|
||||
axum::routing::get(routes::clawhub_skill_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/clawhub/skill/{slug}/code",
|
||||
axum::routing::get(routes::clawhub_skill_code),
|
||||
)
|
||||
.route(
|
||||
"/api/clawhub/install",
|
||||
axum::routing::post(routes::clawhub_install),
|
||||
)
|
||||
// Hands endpoints
|
||||
.route("/api/hands", axum::routing::get(routes::list_hands))
|
||||
.route(
|
||||
"/api/hands/install",
|
||||
axum::routing::post(routes::install_hand),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/active",
|
||||
axum::routing::get(routes::list_active_hands),
|
||||
@@ -732,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
|
||||
@@ -741,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);
|
||||
}
|
||||
|
||||
@@ -763,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
|
||||
@@ -883,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,8 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/js/pages/approvals.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/comms.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/runtime.js"),
|
||||
"\n</script>\n",
|
||||
// Alpine.js MUST be last — it processes x-data and fires alpine:init
|
||||
"<script>\n",
|
||||
|
||||
@@ -621,8 +621,12 @@ async fn handle_text_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip <think>...</think> blocks from model output
|
||||
// (e.g. MiniMax, DeepSeek reasoning tokens)
|
||||
let cleaned_response = strip_think_tags(&result.response);
|
||||
|
||||
// Guard: ensure we never send an empty response
|
||||
let content = if result.response.trim().is_empty() {
|
||||
let content = if cleaned_response.trim().is_empty() {
|
||||
format!(
|
||||
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
|
||||
result.total_usage.input_tokens,
|
||||
@@ -630,7 +634,7 @@ async fn handle_text_message(
|
||||
result.iterations,
|
||||
)
|
||||
} else {
|
||||
result.response
|
||||
cleaned_response
|
||||
};
|
||||
|
||||
// Estimate context pressure from last call
|
||||
@@ -796,7 +800,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 +1118,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()
|
||||
@@ -1147,6 +1160,27 @@ fn extract_status_code(s: &str) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip `<think>...</think>` blocks from model output.
|
||||
///
|
||||
/// Some models (MiniMax, DeepSeek, etc.) wrap their reasoning in `<think>` tags.
|
||||
/// These are internal chain-of-thought and shouldn't be shown to the user.
|
||||
pub fn strip_think_tags(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut remaining = text;
|
||||
while let Some(start) = remaining.find("<think>") {
|
||||
result.push_str(&remaining[..start]);
|
||||
if let Some(end) = remaining[start..].find("</think>") {
|
||||
remaining = &remaining[(start + end + 8)..]; // 8 = "</think>".len()
|
||||
} else {
|
||||
// Unclosed <think> tag — strip to end
|
||||
remaining = "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1223,4 +1257,18 @@ mod tests {
|
||||
fn test_sanitize_trims_whitespace() {
|
||||
assert_eq!(sanitize_user_input(" hello "), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_think_tags() {
|
||||
assert_eq!(
|
||||
strip_think_tags("<think>reasoning here</think>The answer is 42."),
|
||||
"The answer is 42."
|
||||
);
|
||||
assert_eq!(
|
||||
strip_think_tags("Hello <think>\nsome thinking\n</think> world"),
|
||||
"Hello world"
|
||||
);
|
||||
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
|
||||
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,6 +898,121 @@ mark.search-highlight {
|
||||
.slash-menu-item:last-child { border-bottom: none; }
|
||||
.slash-menu-item:hover, .slash-menu-item.slash-active { background: var(--surface2); }
|
||||
|
||||
/* Model switcher dropdown */
|
||||
.model-switcher-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
max-width: 200px;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-switcher-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
.model-switcher-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.model-switcher-btn:disabled:hover { border-color: var(--border); color: var(--text-dim); }
|
||||
.model-switcher-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
.model-switcher-chevron {
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.model-switcher-chevron.open { transform: rotate(180deg); }
|
||||
.model-switcher-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
width: 340px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
.model-switcher-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-switcher-search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.model-switcher-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.model-switcher-group-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 6px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-switcher-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.model-switcher-item:hover { background: var(--surface2); }
|
||||
.model-switcher-item.active {
|
||||
background: var(--accent-subtle, rgba(255,92,0,0.06));
|
||||
cursor: default;
|
||||
}
|
||||
.model-switcher-item-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-switcher-tier {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.model-switcher-tier.tier-frontier { background: rgba(168,85,247,0.15); color: #a855f7; }
|
||||
.model-switcher-tier.tier-smart { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
||||
.model-switcher-tier.tier-balanced { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||
.model-switcher-tier.tier-fast { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
||||
.model-switcher-tier.tier-local { background: rgba(148,163,184,0.12); color: var(--text-dim); }
|
||||
|
||||
/* Sidebar footer */
|
||||
.sidebar-footer {
|
||||
padding: 8px 0;
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'runtime' }" @click="navigate('runtime')" :aria-current="page === 'runtime' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></span>
|
||||
<span class="nav-label">Runtime</span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'settings' }" @click="navigate('settings')" :aria-current="page === 'settings' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg></span>
|
||||
<span class="nav-label">Settings</span>
|
||||
@@ -585,6 +589,14 @@
|
||||
|
||||
<!-- Messages area -->
|
||||
<div class="messages" id="messages" @dragover.prevent="dragOver = true" @dragleave="dragOver = false" @drop.prevent="handleDrop($event); dragOver = false">
|
||||
<!-- Empty state: no agent selected -->
|
||||
<template x-if="!currentAgent">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;padding:32px;opacity:0.8">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:16px;opacity:0.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to start chatting</h3>
|
||||
<p class="text-dim" style="font-size:13px;max-width:320px">Choose an agent from the sidebar or go to the Agents tab to create a new one.</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Message list -->
|
||||
<template x-if="currentAgent">
|
||||
<div>
|
||||
@@ -742,9 +754,55 @@
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Footer: tokens + queue + tips -->
|
||||
<!-- Footer: model switcher + tokens + queue + tips -->
|
||||
<div class="input-footer">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Model Switcher -->
|
||||
<div style="position:relative" x-show="currentAgent" @click.outside="showModelSwitcher = false" @keydown.escape.window="showModelSwitcher = false">
|
||||
<button class="model-switcher-btn" @click="toggleModelSwitcher()" :disabled="sending" title="Switch model (Ctrl+M)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
<span class="model-switcher-label" x-text="modelDisplayName || 'Model'"></span>
|
||||
<svg class="model-switcher-chevron" :class="{'open': showModelSwitcher}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<!-- Dropdown -->
|
||||
<div class="model-switcher-dropdown" x-show="showModelSwitcher" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform translate-y-1" x-transition:enter-end="opacity-100 transform translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||
<div class="model-switcher-search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
|
||||
</div>
|
||||
<div x-show="modelSwitching" style="display:flex;align-items:center;justify-content:center;padding:12px;gap:8px">
|
||||
<div class="tool-card-spinner"></div>
|
||||
<span class="text-xs text-dim">Switching...</span>
|
||||
</div>
|
||||
<div class="model-switcher-list" x-show="!modelSwitching">
|
||||
<template x-if="groupedSwitcherModels.length === 0">
|
||||
<div style="padding:16px;text-align:center" class="text-xs text-dim">No models found</div>
|
||||
</template>
|
||||
<template x-for="group in groupedSwitcherModels" :key="group.provider">
|
||||
<div>
|
||||
<div class="model-switcher-group-header" x-text="group.provider"></div>
|
||||
<template x-for="(m, mi) in group.models" :key="m.id">
|
||||
<div class="model-switcher-item" :class="{'active': currentAgent && m.id === currentAgent.model_name}" @click="switchModel(m)" @mouseenter="modelSwitcherIdx = filteredSwitcherModels.indexOf(m)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span class="model-switcher-item-name" x-text="m.display_name || m.id"></span>
|
||||
<span class="model-switcher-tier" :class="'tier-' + (m.tier || 'balanced').toLowerCase()" x-text="m.tier || 'Balanced'"></span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
|
||||
<span class="text-xs text-dim" x-text="m.id" style="font-family:var(--font-mono)"></span>
|
||||
<span class="text-xs text-dim" x-show="m.context_window" x-text="m.context_window >= 1000000 ? (m.context_window/1000000).toFixed(1)+'M' : Math.round(m.context_window/1000)+'K'"></span>
|
||||
<svg x-show="m.supports_vision" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Vision"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<svg x-show="m.supports_tools" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Tools"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<svg x-show="currentAgent && m.id === currentAgent.model_name" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-dim" x-text="tokenCount > 0 ? '~' + tokenCount + ' tokens' : (attachments.length ? attachments.length + ' file(s)' : '')"></span>
|
||||
<span class="queue-badge" x-show="messageQueue.length > 0" x-text="messageQueue.length + ' queued'"></span>
|
||||
</div>
|
||||
@@ -854,6 +912,36 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value" x-text="detailAgent.created_at ? new Date(detailAgent.created_at).toLocaleString() : '-'"></span></div>
|
||||
|
||||
<!-- Fallback Model Chain -->
|
||||
<div class="detail-row" style="align-items:flex-start">
|
||||
<span class="detail-label">Fallbacks</span>
|
||||
<div style="flex:1">
|
||||
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
|
||||
<div>
|
||||
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
|
||||
<div class="flex gap-1 items-center" style="margin-bottom:4px">
|
||||
<span class="badge" style="font-size:11px;font-family:var(--font-mono)" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger)" @click="removeFallback(idx)">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!detailAgent._fallbacks || detailAgent._fallbacks.length === 0">
|
||||
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
|
||||
</template>
|
||||
<template x-if="!editingFallback">
|
||||
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px;margin-top:4px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
|
||||
</template>
|
||||
<template x-if="editingFallback">
|
||||
<div class="flex gap-1 mt-1" style="align-items:center">
|
||||
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
|
||||
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" @click="chatWithAgent(detailAgent); showDetailModal = false">Chat</button>
|
||||
@@ -2305,7 +2393,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary btn-block" @click="installFromClawHub(skillDetail.slug)" :disabled="installingSlug === skillDetail.slug || skillDetail.installed || isSkillInstalled(skillDetail.slug)" x-text="skillDetail.installed || isSkillInstalled(skillDetail.slug) ? 'Already Installed' : installingSlug === skillDetail.slug ? 'Installing...' : 'Install from ClawHub'"></button>
|
||||
<button class="btn btn-primary" style="flex:1" @click="installFromClawHub(skillDetail.slug)" :disabled="installingSlug === skillDetail.slug || skillDetail.installed || isSkillInstalled(skillDetail.slug)" x-text="skillDetail.installed || isSkillInstalled(skillDetail.slug) ? 'Already Installed' : installingSlug === skillDetail.slug ? 'Installing...' : 'Install from ClawHub'"></button>
|
||||
<button class="btn btn-ghost" @click="viewSkillCode(skillDetail.slug)" :disabled="skillCodeLoading" x-text="skillCodeLoading ? 'Loading...' : showSkillCode ? 'Hide Code' : 'View Code'"></button>
|
||||
</div>
|
||||
<div x-show="showSkillCode && skillCode" x-transition class="mt-3" style="max-height:300px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-inset)">
|
||||
<div class="flex justify-between items-center" style="padding:6px 12px;border-bottom:1px solid var(--border)">
|
||||
<span class="text-xs text-dim" x-text="skillCodeFilename"></span>
|
||||
</div>
|
||||
<pre style="margin:0;padding:12px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all" x-text="skillCode"></pre>
|
||||
</div>
|
||||
<div class="text-xs text-dim mt-2 text-center">Skills are security-scanned before installation. Prompt injection and malware patterns are blocked.</div>
|
||||
</div>
|
||||
@@ -2722,7 +2817,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>
|
||||
@@ -2994,9 +3089,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">🤖</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>
|
||||
@@ -3007,6 +3108,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<td class="text-xs" x-text="formatCost(m.input_cost_per_m)"></td>
|
||||
<td class="text-xs" x-text="formatCost(m.output_cost_per_m)"></td>
|
||||
<td><span class="badge" :class="m.available ? 'badge-success' : 'badge-muted'" x-text="m.available ? 'Available' : 'Needs Key'"></span></td>
|
||||
<td><button x-show="m.tier === 'custom'" class="btn btn-ghost btn-sm" @click="deleteCustomModel(m.id)" title="Delete custom model" style="padding:2px 6px;font-size:11px;color:var(--text-muted)">✕</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
@@ -3021,6 +3123,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">🔧</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>
|
||||
@@ -4563,6 +4671,71 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ─── Runtime page ─── -->
|
||||
<template x-if="page === 'runtime'">
|
||||
<div x-data="runtimePage">
|
||||
<div class="page-header"><h2>Runtime</h2></div>
|
||||
<div class="page-body" x-init="loadData()">
|
||||
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading runtime info...</span></div>
|
||||
<div x-show="!loading">
|
||||
<div class="grid grid-cols-4" style="gap:16px;margin-bottom:24px">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Uptime</div>
|
||||
<div class="stat-value" x-text="uptime"></div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Agents</div>
|
||||
<div class="stat-value" x-text="agentCount"></div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Version</div>
|
||||
<div class="stat-value" x-text="version"></div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Default Model</div>
|
||||
<div class="stat-value" style="font-size:13px" x-text="defaultModel"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-header">System</div>
|
||||
<table class="table" style="margin-top:8px">
|
||||
<tbody>
|
||||
<tr><td style="width:180px;font-weight:500">Platform</td><td x-text="platform"></td></tr>
|
||||
<tr><td style="font-weight:500">Architecture</td><td x-text="arch"></td></tr>
|
||||
<tr><td style="font-weight:500">API Listen</td><td x-text="apiListen"></td></tr>
|
||||
<tr><td style="font-weight:500">Home Directory</td><td x-text="homeDir"></td></tr>
|
||||
<tr><td style="font-weight:500">Log Level</td><td x-text="logLevel"></td></tr>
|
||||
<tr><td style="font-weight:500">Network</td><td x-text="networkEnabled ? 'Enabled' : 'Disabled'"></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-header">Providers</div>
|
||||
<table class="table" style="margin-top:8px">
|
||||
<thead><tr><th>Provider</th><th>Status</th><th>Models</th><th>Latency</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="p in providers" :key="p.id">
|
||||
<tr>
|
||||
<td x-text="p.display_name || p.id"></td>
|
||||
<td><span class="badge" :class="p.reachable ? 'badge-success' : (p.auth_status === 'Configured' ? 'badge-success' : 'badge-dim')" x-text="p.reachable ? 'Online' : (p.auth_status === 'Configured' ? 'Ready' : 'Not configured')"></span></td>
|
||||
<td x-text="p.model_count"></td>
|
||||
<td x-text="p.latency_ms ? p.latency_ms + 'ms' : '-'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-ghost btn-sm" @click="loadData()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ function app() {
|
||||
});
|
||||
|
||||
// Hash routing
|
||||
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
|
||||
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','runtime','settings','wizard'];
|
||||
var pageRedirects = {
|
||||
'chat': 'agents',
|
||||
'templates': 'agents',
|
||||
|
||||
@@ -63,6 +63,9 @@ function agentsPage() {
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
modelSaving: false,
|
||||
// -- Fallback chain --
|
||||
editingFallback: false,
|
||||
newFallbackValue: '',
|
||||
|
||||
// -- Templates state --
|
||||
tplTemplates: [],
|
||||
@@ -316,12 +319,15 @@ function agentsPage() {
|
||||
OpenFangAPI.wsDisconnect();
|
||||
},
|
||||
|
||||
showDetail(agent) {
|
||||
async showDetail(agent) {
|
||||
this.detailAgent = agent;
|
||||
this.detailAgent._fallbacks = [];
|
||||
this.detailTab = 'info';
|
||||
this.agentFiles = [];
|
||||
this.editingFile = null;
|
||||
this.fileContent = '';
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
this.configForm = {
|
||||
name: agent.name || '',
|
||||
system_prompt: agent.system_prompt || '',
|
||||
@@ -331,6 +337,11 @@ function agentsPage() {
|
||||
vibe: (agent.identity && agent.identity.vibe) || ''
|
||||
};
|
||||
this.showDetailModal = true;
|
||||
// Fetch full agent detail to get fallback_models
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
|
||||
this.detailAgent._fallbacks = full.fallback_models || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
},
|
||||
|
||||
killAgent(agent) {
|
||||
@@ -601,6 +612,41 @@ function agentsPage() {
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Fallback model chain ──
|
||||
async addFallback() {
|
||||
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
|
||||
var parts = this.newFallbackValue.trim().split('/');
|
||||
var provider = parts.length > 1 ? parts[0] : this.detailAgent.model_provider;
|
||||
var model = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
|
||||
if (!this.detailAgent._fallbacks) this.detailAgent._fallbacks = [];
|
||||
this.detailAgent._fallbacks.push({ provider: provider, model: model });
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback added: ' + provider + '/' + model);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.pop();
|
||||
}
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
},
|
||||
|
||||
async removeFallback(idx) {
|
||||
if (!this.detailAgent || !this.detailAgent._fallbacks) return;
|
||||
var removed = this.detailAgent._fallbacks.splice(idx, 1);
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback removed');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.splice(idx, 0, removed[0]);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tool filters ──
|
||||
async loadToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
|
||||
@@ -141,7 +141,16 @@ function channelsPage() {
|
||||
|
||||
openSetup(ch) {
|
||||
this.setupModal = ch;
|
||||
this.formValues = {};
|
||||
// Pre-populate form values from saved config (non-secret fields).
|
||||
var vals = {};
|
||||
if (ch.fields) {
|
||||
ch.fields.forEach(function(f) {
|
||||
if (f.value !== undefined && f.value !== null && f.type !== 'secret') {
|
||||
vals[f.key] = String(f.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.formValues = vals;
|
||||
this.showAdvanced = false;
|
||||
this.showBusinessApi = false;
|
||||
this.setupStep = ch.configured ? 3 : 1;
|
||||
|
||||
@@ -34,6 +34,13 @@ function chatPage() {
|
||||
modelPickerList: [],
|
||||
modelPickerFilter: '',
|
||||
modelPickerIdx: 0,
|
||||
// Model switcher dropdown
|
||||
showModelSwitcher: false,
|
||||
modelSwitcherFilter: '',
|
||||
modelSwitcherIdx: 0,
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
_modelCacheTime: 0,
|
||||
slashCommands: [
|
||||
{ cmd: '/help', desc: 'Show available commands' },
|
||||
{ cmd: '/agents', desc: 'Switch to Agents page' },
|
||||
@@ -85,6 +92,36 @@ function chatPage() {
|
||||
}
|
||||
},
|
||||
|
||||
get modelDisplayName() {
|
||||
if (!this.currentAgent) return '';
|
||||
var name = this.currentAgent.model_name || '';
|
||||
var short = name.replace(/-\d{8}$/, '');
|
||||
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
|
||||
},
|
||||
|
||||
get filteredSwitcherModels() {
|
||||
var models = this._modelCache || [];
|
||||
if (!this.modelSwitcherFilter) return models;
|
||||
var f = this.modelSwitcherFilter.toLowerCase();
|
||||
return models.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(f) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
});
|
||||
},
|
||||
|
||||
get groupedSwitcherModels() {
|
||||
var filtered = this.filteredSwitcherModels;
|
||||
var groups = {}, order = [];
|
||||
filtered.forEach(function(m) {
|
||||
if (!groups[m.provider]) { groups[m.provider] = []; order.push(m.provider); }
|
||||
groups[m.provider].push(m);
|
||||
});
|
||||
return order.map(function(p) {
|
||||
return { provider: p.charAt(0).toUpperCase() + p.slice(1), models: groups[p] };
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
var self = this;
|
||||
|
||||
@@ -101,6 +138,11 @@ function chatPage() {
|
||||
var input = document.getElementById('msg-input');
|
||||
if (input) { input.focus(); self.inputText = '/'; }
|
||||
}
|
||||
// Ctrl+M for model switcher
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'm' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
self.toggleModelSwitcher();
|
||||
}
|
||||
// Ctrl+F for chat search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
@@ -172,6 +214,54 @@ function chatPage() {
|
||||
this.sendMessage();
|
||||
},
|
||||
|
||||
toggleModelSwitcher() {
|
||||
if (this.showModelSwitcher) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
var now = Date.now();
|
||||
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
|
||||
this.modelSwitcherFilter = '';
|
||||
this.modelSwitcherIdx = 0;
|
||||
this.showModelSwitcher = true;
|
||||
this.$nextTick(function() {
|
||||
var el = document.getElementById('model-switcher-search');
|
||||
if (el) el.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
OpenFangAPI.get('/api/models').then(function(data) {
|
||||
var models = (data.models || []).filter(function(m) { return m.available; });
|
||||
self._modelCache = models;
|
||||
self._modelCacheTime = Date.now();
|
||||
self.modelPickerList = models;
|
||||
self.modelSwitcherFilter = '';
|
||||
self.modelSwitcherIdx = 0;
|
||||
self.showModelSwitcher = true;
|
||||
self.$nextTick(function() {
|
||||
var el = document.getElementById('model-switcher-search');
|
||||
if (el) el.focus();
|
||||
});
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Failed to load models: ' + e.message);
|
||||
});
|
||||
},
|
||||
|
||||
switchModel(model) {
|
||||
if (!this.currentAgent) return;
|
||||
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
this.modelSwitching = true;
|
||||
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function() {
|
||||
self.currentAgent.model_name = model.id;
|
||||
self.currentAgent.model_provider = model.provider;
|
||||
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
|
||||
self.showModelSwitcher = false;
|
||||
self.modelSwitching = false;
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Switch failed: ' + e.message);
|
||||
self.modelSwitching = false;
|
||||
});
|
||||
},
|
||||
|
||||
// Fetch dynamic slash commands from server
|
||||
fetchCommands: function() {
|
||||
var self = this;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Runtime page — system overview and provider status
|
||||
document.addEventListener('alpine:init', function() {
|
||||
Alpine.data('runtimePage', function() {
|
||||
return {
|
||||
loading: true,
|
||||
uptime: '-',
|
||||
agentCount: 0,
|
||||
version: '-',
|
||||
defaultModel: '-',
|
||||
platform: '-',
|
||||
arch: '-',
|
||||
apiListen: '-',
|
||||
homeDir: '-',
|
||||
logLevel: '-',
|
||||
networkEnabled: false,
|
||||
providers: [],
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/status'),
|
||||
OpenFangAPI.get('/api/version'),
|
||||
OpenFangAPI.get('/api/providers'),
|
||||
OpenFangAPI.get('/api/agents')
|
||||
]);
|
||||
var status = results[0];
|
||||
var ver = results[1];
|
||||
var prov = results[2];
|
||||
var agents = results[3];
|
||||
|
||||
this.version = ver.version || '-';
|
||||
this.platform = ver.platform || '-';
|
||||
this.arch = ver.arch || '-';
|
||||
this.agentCount = Array.isArray(agents) ? agents.length : 0;
|
||||
this.defaultModel = status.default_model || '-';
|
||||
this.apiListen = status.api_listen || status.listen || '-';
|
||||
this.homeDir = status.home_dir || '-';
|
||||
this.logLevel = status.log_level || '-';
|
||||
this.networkEnabled = !!status.network_enabled;
|
||||
|
||||
// Compute uptime from uptime_seconds
|
||||
var diff = status.uptime_seconds || 0;
|
||||
if (diff < 60) this.uptime = diff + 's';
|
||||
else if (diff < 3600) this.uptime = Math.floor(diff / 60) + 'm ' + (diff % 60) + 's';
|
||||
else if (diff < 86400) this.uptime = Math.floor(diff / 3600) + 'h ' + Math.floor((diff % 3600) / 60) + 'm';
|
||||
else this.uptime = Math.floor(diff / 86400) + 'd ' + Math.floor((diff % 86400) / 3600) + 'h';
|
||||
|
||||
this.providers = (prov.providers || []).filter(function(p) {
|
||||
return p.auth_status === 'Configured' || p.reachable || p.is_local;
|
||||
});
|
||||
} catch(e) {
|
||||
console.error('Runtime load error:', e);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -258,6 +258,17 @@ function settingsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomModel(modelId) {
|
||||
if (!confirm('Delete custom model "' + modelId + '"?')) return;
|
||||
try {
|
||||
await OpenFangAPI.del('/api/models/custom/' + encodeURIComponent(modelId));
|
||||
OpenFangToast.success('Model deleted');
|
||||
await this.loadModels();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete: ' + (e.message || 'Unknown error'));
|
||||
}
|
||||
},
|
||||
|
||||
async loadConfigSchema() {
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
|
||||
@@ -25,6 +25,10 @@ function skillsPage() {
|
||||
// Skill detail modal
|
||||
skillDetail: null,
|
||||
detailLoading: false,
|
||||
showSkillCode: false,
|
||||
skillCode: '',
|
||||
skillCodeFilename: '',
|
||||
skillCodeLoading: false,
|
||||
|
||||
// MCP servers
|
||||
mcpServers: [],
|
||||
@@ -205,6 +209,26 @@ function skillsPage() {
|
||||
closeDetail() {
|
||||
this.skillDetail = null;
|
||||
this.installResult = null;
|
||||
this.showSkillCode = false;
|
||||
this.skillCode = '';
|
||||
this.skillCodeFilename = '';
|
||||
},
|
||||
|
||||
async viewSkillCode(slug) {
|
||||
if (this.showSkillCode) {
|
||||
this.showSkillCode = false;
|
||||
return;
|
||||
}
|
||||
this.skillCodeLoading = true;
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/clawhub/skill/' + encodeURIComponent(slug) + '/code');
|
||||
this.skillCode = data.code || '';
|
||||
this.skillCodeFilename = data.filename || 'source';
|
||||
this.showSkillCode = true;
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Could not load skill source code');
|
||||
}
|
||||
this.skillCodeLoading = false;
|
||||
},
|
||||
|
||||
// Install from ClawHub
|
||||
|
||||
@@ -441,8 +441,8 @@ function wizardPage() {
|
||||
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
|
||||
toml += 'profile = "' + tpl.profile + '"\n\n';
|
||||
toml += '[model]\nprovider = "' + provider + '"\n';
|
||||
toml += 'name = "' + model + '"\n\n';
|
||||
toml += '[prompt]\nsystem = """\n' + tpl.system_prompt + '\n"""\n';
|
||||
toml += 'model = "' + model + '"\n';
|
||||
toml += 'system_prompt = """\n' + tpl.system_prompt + '\n"""\n';
|
||||
|
||||
this.creatingAgent = true;
|
||||
try {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -373,10 +373,15 @@ async fn dispatch_message(
|
||||
|
||||
// Fetch per-channel overrides (if configured)
|
||||
let overrides = handle.channel_overrides(ct_str).await;
|
||||
let channel_default_format = match ct_str {
|
||||
"telegram" => OutputFormat::TelegramHtml,
|
||||
"slack" => OutputFormat::SlackMrkdwn,
|
||||
_ => OutputFormat::Markdown,
|
||||
};
|
||||
let output_format = overrides
|
||||
.as_ref()
|
||||
.and_then(|o| o.output_format)
|
||||
.unwrap_or(OutputFormat::Markdown);
|
||||
.unwrap_or(channel_default_format);
|
||||
let threading_enabled = overrides.as_ref().map(|o| o.threading).unwrap_or(false);
|
||||
let thread_id = if threading_enabled {
|
||||
message.thread_id.as_deref()
|
||||
@@ -402,8 +407,15 @@ async fn dispatch_message(
|
||||
}
|
||||
}
|
||||
GroupPolicy::MentionOnly => {
|
||||
// Pass through — adapters should only forward mentioned messages.
|
||||
// This is a hint for adapters, not enforced here.
|
||||
// Only allow messages where the bot was @mentioned or commands.
|
||||
let was_mentioned = message.metadata.get("was_mentioned")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_command = matches!(&message.content, ChannelContent::Command { .. });
|
||||
if !was_mentioned && !is_command {
|
||||
debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
GroupPolicy::All => {}
|
||||
}
|
||||
@@ -577,14 +589,33 @@ async fn dispatch_message(
|
||||
let agent_id = match agent_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"No agent assigned. Use /agents to list available agents, then /agent <name> to select one.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
).await;
|
||||
return;
|
||||
// Fallback: try "assistant" agent, then first available agent
|
||||
let fallback = handle.find_agent_by_name("assistant").await.ok().flatten();
|
||||
let fallback = match fallback {
|
||||
Some(id) => Some(id),
|
||||
None => handle
|
||||
.list_agents()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|agents| agents.first().map(|(id, _)| *id)),
|
||||
};
|
||||
match fallback {
|
||||
Some(id) => {
|
||||
// Auto-set this as the user's default so future messages route directly
|
||||
router.set_user_default(message.sender.platform_id.clone(), id);
|
||||
id
|
||||
}
|
||||
None => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"No agents available. Start the dashboard at http://127.0.0.1:4200 to create one.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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()], 33280);
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], 37376);
|
||||
assert_eq!(adapter.name(), "discord");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Discord);
|
||||
}
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">");
|
||||
|
||||
// Bold: **text** → <b>text</b>
|
||||
while let Some(start) = result.find("**") {
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct AgentRouter {
|
||||
direct_routes: DashMap<(String, String), AgentId>,
|
||||
/// System-wide default agent.
|
||||
default_agent: Option<AgentId>,
|
||||
/// Per-channel-type default agent (e.g., Telegram -> agent_a, Discord -> agent_b).
|
||||
channel_defaults: DashMap<String, AgentId>,
|
||||
/// Sorted bindings (most specific first). Uses Mutex for runtime updates via Arc.
|
||||
bindings: Mutex<Vec<(AgentBinding, String)>>,
|
||||
/// Broadcast configuration. Uses Mutex for runtime updates via Arc.
|
||||
@@ -47,6 +49,7 @@ impl AgentRouter {
|
||||
user_defaults: DashMap::new(),
|
||||
direct_routes: DashMap::new(),
|
||||
default_agent: None,
|
||||
channel_defaults: DashMap::new(),
|
||||
bindings: Mutex::new(Vec::new()),
|
||||
broadcast: Mutex::new(BroadcastConfig::default()),
|
||||
agent_name_cache: DashMap::new(),
|
||||
@@ -58,6 +61,11 @@ impl AgentRouter {
|
||||
self.default_agent = Some(agent_id);
|
||||
}
|
||||
|
||||
/// Set a per-channel-type default agent (e.g., "Telegram" -> agent_id).
|
||||
pub fn set_channel_default(&self, channel_key: String, agent_id: AgentId) {
|
||||
self.channel_defaults.insert(channel_key, agent_id);
|
||||
}
|
||||
|
||||
/// Set a user's default agent.
|
||||
pub fn set_user_default(&self, user_key: String, agent_id: AgentId) {
|
||||
self.user_defaults.insert(user_key, agent_id);
|
||||
@@ -125,7 +133,7 @@ impl AgentRouter {
|
||||
// 1. Check direct routes
|
||||
if let Some(agent) = self
|
||||
.direct_routes
|
||||
.get(&(channel_key, platform_user_id.to_string()))
|
||||
.get(&(channel_key.clone(), platform_user_id.to_string()))
|
||||
{
|
||||
return Some(*agent);
|
||||
}
|
||||
@@ -141,7 +149,12 @@ impl AgentRouter {
|
||||
return Some(*agent);
|
||||
}
|
||||
|
||||
// 3. System default
|
||||
// 3. Per-channel-type default
|
||||
if let Some(agent) = self.channel_defaults.get(&channel_key) {
|
||||
return Some(*agent);
|
||||
}
|
||||
|
||||
// 4. System default
|
||||
self.default_agent
|
||||
}
|
||||
|
||||
@@ -161,7 +174,7 @@ impl AgentRouter {
|
||||
let channel_key = format!("{channel_type:?}");
|
||||
if let Some(agent) = self
|
||||
.direct_routes
|
||||
.get(&(channel_key, platform_user_id.to_string()))
|
||||
.get(&(channel_key.clone(), platform_user_id.to_string()))
|
||||
{
|
||||
return Some(*agent);
|
||||
}
|
||||
@@ -173,6 +186,9 @@ impl AgentRouter {
|
||||
if let Some(agent) = self.user_defaults.get(platform_user_id) {
|
||||
return Some(*agent);
|
||||
}
|
||||
if let Some(agent) = self.channel_defaults.get(&channel_key) {
|
||||
return Some(*agent);
|
||||
}
|
||||
self.default_agent
|
||||
}
|
||||
|
||||
@@ -501,6 +517,30 @@ mod tests {
|
||||
assert_eq!(targets[1].1, Some(id2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_default_routing() {
|
||||
let mut router = AgentRouter::new();
|
||||
let system_default = AgentId::new();
|
||||
let telegram_default = AgentId::new();
|
||||
let discord_default = AgentId::new();
|
||||
|
||||
router.set_default(system_default);
|
||||
router.set_channel_default("Telegram".to_string(), telegram_default);
|
||||
router.set_channel_default("Discord".to_string(), discord_default);
|
||||
|
||||
// Telegram should use Telegram-specific default
|
||||
let resolved = router.resolve(&ChannelType::Telegram, "user1", None);
|
||||
assert_eq!(resolved, Some(telegram_default));
|
||||
|
||||
// Discord should use Discord-specific default
|
||||
let resolved = router.resolve(&ChannelType::Discord, "user1", None);
|
||||
assert_eq!(resolved, Some(discord_default));
|
||||
|
||||
// WhatsApp has no channel default — falls to system default
|
||||
let resolved = router.resolve(&ChannelType::WhatsApp, "user1", None);
|
||||
assert_eq!(resolved, Some(system_default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_bindings_legacy_behavior() {
|
||||
let mut router = AgentRouter::new();
|
||||
|
||||
@@ -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,12 +79,18 @@ impl TelegramAdapter {
|
||||
self.token.as_str()
|
||||
);
|
||||
|
||||
// Sanitize: strip unsupported HTML tags so Telegram doesn't reject with 400.
|
||||
// Telegram only allows: b, i, u, s, tg-spoiler, a, code, pre, blockquote.
|
||||
// Any other tag (e.g. <name>, <thinking>) causes a 400 Bad Request.
|
||||
let sanitized = sanitize_telegram_html(text);
|
||||
|
||||
// Telegram has a 4096 character limit per message — split if needed
|
||||
let chunks = split_message(text, 4096);
|
||||
let chunks = split_message(&sanitized, 4096);
|
||||
for chunk in chunks {
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": chunk,
|
||||
"parse_mode": "HTML",
|
||||
});
|
||||
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
@@ -97,6 +103,103 @@ impl TelegramAdapter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendPhoto` on the Telegram API.
|
||||
async fn api_send_photo(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
photo_url: &str,
|
||||
caption: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendPhoto",
|
||||
self.token.as_str()
|
||||
);
|
||||
let mut body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"photo": photo_url,
|
||||
});
|
||||
if let Some(cap) = caption {
|
||||
body["caption"] = serde_json::Value::String(cap.to_string());
|
||||
body["parse_mode"] = serde_json::Value::String("HTML".to_string());
|
||||
}
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendPhoto failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendDocument` on the Telegram API.
|
||||
async fn api_send_document(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
document_url: &str,
|
||||
filename: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendDocument",
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"document": document_url,
|
||||
"caption": filename,
|
||||
});
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendDocument failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendVoice` on the Telegram API.
|
||||
async fn api_send_voice(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
voice_url: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendVoice",
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"voice": voice_url,
|
||||
});
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendVoice failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendLocation` on the Telegram API.
|
||||
async fn api_send_location(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendLocation",
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
});
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendLocation failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendChatAction` to show "typing..." indicator.
|
||||
async fn api_send_typing(&self, chat_id: i64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
@@ -215,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() {
|
||||
@@ -305,10 +412,23 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(chat_id, &text).await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(chat_id, "(Unsupported content type)")
|
||||
ChannelContent::Image { url, caption } => {
|
||||
self.api_send_photo(chat_id, &url, caption.as_deref())
|
||||
.await?;
|
||||
}
|
||||
ChannelContent::File { url, filename } => {
|
||||
self.api_send_document(chat_id, &url, &filename).await?;
|
||||
}
|
||||
ChannelContent::Voice { url, .. } => {
|
||||
self.api_send_voice(chat_id, &url).await?;
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
self.api_send_location(chat_id, lat, lon).await?;
|
||||
}
|
||||
ChannelContent::Command { name, args } => {
|
||||
let text = format!("/{name} {}", args.join(" "));
|
||||
self.api_send_message(chat_id, text.trim()).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -413,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 `<...>`.
|
||||
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("<");
|
||||
result.push_str(tag_content);
|
||||
result.push_str(">");
|
||||
}
|
||||
// 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("<");
|
||||
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
|
||||
);
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+322
-71
@@ -390,6 +390,11 @@ enum HandCommands {
|
||||
List,
|
||||
/// Show currently active hand instances.
|
||||
Active,
|
||||
/// Install a hand from a local directory containing HAND.toml.
|
||||
Install {
|
||||
/// Path to the hand directory (must contain HAND.toml).
|
||||
path: String,
|
||||
},
|
||||
/// Activate a hand by ID.
|
||||
Activate {
|
||||
/// Hand ID (e.g. "clip", "lead", "researcher").
|
||||
@@ -405,6 +410,26 @@ enum HandCommands {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Check dependency status for a hand.
|
||||
CheckDeps {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Install missing dependencies for a hand.
|
||||
InstallDeps {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Pause a running hand instance.
|
||||
Pause {
|
||||
/// Instance ID (from `hand active`).
|
||||
id: String,
|
||||
},
|
||||
/// Resume a paused hand instance.
|
||||
Resume {
|
||||
/// Instance ID (from `hand active`).
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -612,6 +637,9 @@ enum CronCommands {
|
||||
spec: String,
|
||||
/// Prompt to send when the job fires.
|
||||
prompt: String,
|
||||
/// Optional job name (auto-generated if omitted).
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Delete a scheduled job.
|
||||
Delete {
|
||||
@@ -758,11 +786,19 @@ fn init_tracing_stderr() {
|
||||
.init();
|
||||
}
|
||||
|
||||
/// Get the OpenFang home directory, respecting OPENFANG_HOME env var.
|
||||
fn cli_openfang_home() -> std::path::PathBuf {
|
||||
if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
||||
return std::path::PathBuf::from(home);
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join(".openfang")
|
||||
}
|
||||
|
||||
/// Redirect tracing to a log file so it doesn't corrupt the ratatui TUI.
|
||||
fn init_tracing_file() {
|
||||
let log_dir = dirs::home_dir()
|
||||
.map(|h| h.join(".openfang"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
let log_dir = cli_openfang_home();
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
let log_path = log_dir.join("tui.log");
|
||||
|
||||
@@ -887,9 +923,14 @@ fn main() {
|
||||
Some(Commands::Hand(sub)) => match sub {
|
||||
HandCommands::List => cmd_hand_list(),
|
||||
HandCommands::Active => cmd_hand_active(),
|
||||
HandCommands::Install { path } => cmd_hand_install(&path),
|
||||
HandCommands::Activate { id } => cmd_hand_activate(&id),
|
||||
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
|
||||
HandCommands::Info { id } => cmd_hand_info(&id),
|
||||
HandCommands::CheckDeps { id } => cmd_hand_check_deps(&id),
|
||||
HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id),
|
||||
HandCommands::Pause { id } => cmd_hand_pause(&id),
|
||||
HandCommands::Resume { id } => cmd_hand_resume(&id),
|
||||
},
|
||||
Some(Commands::Config(sub)) => match sub {
|
||||
ConfigCommands::Show => cmd_config_show(),
|
||||
@@ -940,7 +981,8 @@ fn main() {
|
||||
agent,
|
||||
spec,
|
||||
prompt,
|
||||
} => cmd_cron_create(&agent, &spec, &prompt),
|
||||
name,
|
||||
} => cmd_cron_create(&agent, &spec, &prompt, name.as_deref()),
|
||||
CronCommands::Delete { id } => cmd_cron_delete(&id),
|
||||
CronCommands::Enable { id } => cmd_cron_toggle(&id, true),
|
||||
CronCommands::Disable { id } => cmd_cron_toggle(&id, false),
|
||||
@@ -1009,7 +1051,7 @@ pub(crate) fn restrict_dir_permissions(path: &std::path::Path) {
|
||||
pub(crate) fn restrict_dir_permissions(_path: &std::path::Path) {}
|
||||
|
||||
pub(crate) fn find_daemon() -> Option<String> {
|
||||
let home_dir = dirs::home_dir()?.join(".openfang");
|
||||
let home_dir = cli_openfang_home();
|
||||
let info = read_daemon_info(&home_dir)?;
|
||||
|
||||
// Normalize listen address: replace 0.0.0.0 with 127.0.0.1 to avoid
|
||||
@@ -1091,7 +1133,7 @@ fn cmd_init(quick: bool) {
|
||||
}
|
||||
};
|
||||
|
||||
let openfang_dir = home.join(".openfang");
|
||||
let openfang_dir = cli_openfang_home();
|
||||
|
||||
// --- Ensure directories exist ---
|
||||
if !openfang_dir.exists() {
|
||||
@@ -1264,8 +1306,14 @@ fn detect_best_provider() -> (&'static str, &'static str, &'static str) {
|
||||
ui::success("Detected Gemini (GOOGLE_API_KEY)");
|
||||
return ("gemini", "GOOGLE_API_KEY", "gemini-2.5-flash");
|
||||
}
|
||||
// Check if Ollama is running locally (no API key needed)
|
||||
if check_ollama_available() {
|
||||
ui::success("Detected Ollama running locally (no API key needed)");
|
||||
return ("ollama", "OLLAMA_API_KEY", "llama3.2");
|
||||
}
|
||||
ui::hint("No LLM provider API keys found");
|
||||
ui::hint("Groq offers a free tier: https://console.groq.com");
|
||||
ui::hint("Or install Ollama for local models: https://ollama.com");
|
||||
("groq", "GROQ_API_KEY", "llama-3.3-70b-versatile")
|
||||
}
|
||||
|
||||
@@ -1291,6 +1339,15 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
|
||||
]
|
||||
}
|
||||
|
||||
/// Quick probe to check if Ollama is running on localhost.
|
||||
fn check_ollama_available() -> bool {
|
||||
std::net::TcpStream::connect_timeout(
|
||||
&std::net::SocketAddr::from(([127, 0, 0, 1], 11434)),
|
||||
std::time::Duration::from_millis(500),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Write config.toml if it doesn't already exist.
|
||||
fn write_config_if_missing(
|
||||
openfang_dir: &std::path::Path,
|
||||
@@ -1391,11 +1448,28 @@ fn cmd_start(config: Option<PathBuf>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Read the api_key from ~/.openfang/config.toml (if any).
|
||||
fn read_api_key() -> Option<String> {
|
||||
let config_path = cli_openfang_home().join("config.toml");
|
||||
let text = std::fs::read_to_string(config_path).ok()?;
|
||||
let table: toml::Value = text.parse().ok()?;
|
||||
let key = table.get("api_key")?.as_str()?;
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(key.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_stop() {
|
||||
match find_daemon() {
|
||||
Some(base) => {
|
||||
let client = daemon_client();
|
||||
match client.post(format!("{base}/api/shutdown")).send() {
|
||||
let mut req = client.post(format!("{base}/api/shutdown"));
|
||||
if let Some(key) = read_api_key() {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
match req.send() {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
// Wait for daemon to actually stop (up to 5 seconds)
|
||||
for _ in 0..10 {
|
||||
@@ -1406,8 +1480,8 @@ fn cmd_stop() {
|
||||
}
|
||||
}
|
||||
// Still alive — force kill via PID
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let of_dir = home.join(".openfang");
|
||||
{
|
||||
let of_dir = cli_openfang_home();
|
||||
if let Some(info) = read_daemon_info(&of_dir) {
|
||||
force_kill_pid(info.pid);
|
||||
let _ = std::fs::remove_file(of_dir.join("daemon.json"));
|
||||
@@ -1882,8 +1956,8 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
}
|
||||
|
||||
let home = dirs::home_dir();
|
||||
if let Some(h) = &home {
|
||||
let openfang_dir = h.join(".openfang");
|
||||
if let Some(_h) = &home {
|
||||
let openfang_dir = cli_openfang_home();
|
||||
|
||||
// --- Check 1: OpenFang directory ---
|
||||
if openfang_dir.exists() {
|
||||
@@ -2001,20 +2075,23 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
}
|
||||
let answer = prompt_input(" Create default config? [Y/n] ");
|
||||
if answer.is_empty() || answer.starts_with('y') || answer.starts_with('Y') {
|
||||
let default_config = r#"# OpenFang Agent OS configuration
|
||||
let (provider, api_key_env, model) = detect_best_provider();
|
||||
let default_config = format!(
|
||||
r#"# OpenFang Agent OS configuration
|
||||
# See https://github.com/RightNow-AI/openfang for documentation
|
||||
|
||||
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
|
||||
api_listen = "127.0.0.1:4200"
|
||||
|
||||
[default_model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
provider = "{provider}"
|
||||
model = "{model}"
|
||||
api_key_env = "{api_key_env}"
|
||||
|
||||
[memory]
|
||||
decay_rate = 0.05
|
||||
"#;
|
||||
"#
|
||||
);
|
||||
let _ = std::fs::create_dir_all(&openfang_dir);
|
||||
if std::fs::write(&config_path, default_config).is_ok() {
|
||||
restrict_file_permissions(&config_path);
|
||||
@@ -2040,7 +2117,20 @@ decay_rate = 0.05
|
||||
all_ok = false;
|
||||
}
|
||||
|
||||
// --- Check 4: Port 4200 availability ---
|
||||
// --- Check 4: Port availability ---
|
||||
// Read api_listen from config (default: 127.0.0.1:4200)
|
||||
let api_listen = {
|
||||
let cfg_path = openfang_dir.join("config.toml");
|
||||
if cfg_path.exists() {
|
||||
std::fs::read_to_string(&cfg_path)
|
||||
.ok()
|
||||
.and_then(|s| toml::from_str::<openfang_types::config::KernelConfig>(&s).ok())
|
||||
.map(|c| c.api_listen)
|
||||
.unwrap_or_else(|| "127.0.0.1:4200".to_string())
|
||||
} else {
|
||||
"127.0.0.1:4200".to_string()
|
||||
}
|
||||
};
|
||||
if !json {
|
||||
println!();
|
||||
}
|
||||
@@ -2056,19 +2146,24 @@ decay_rate = 0.05
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "daemon", "status": "warn"}));
|
||||
|
||||
// Check if port 4200 is available
|
||||
match std::net::TcpListener::bind("127.0.0.1:4200") {
|
||||
// Check if the configured port is available
|
||||
let bind_addr = if api_listen.starts_with("0.0.0.0") {
|
||||
api_listen.replacen("0.0.0.0", "127.0.0.1", 1)
|
||||
} else {
|
||||
api_listen.clone()
|
||||
};
|
||||
match std::net::TcpListener::bind(&bind_addr) {
|
||||
Ok(_) => {
|
||||
if !json {
|
||||
ui::check_ok("Port 4200 is available");
|
||||
ui::check_ok(&format!("Port {api_listen} is available"));
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "port_4200", "status": "ok"}));
|
||||
checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen}));
|
||||
}
|
||||
Err(_) => {
|
||||
if !json {
|
||||
ui::check_warn("Port 4200 is in use by another process");
|
||||
ui::check_warn(&format!("Port {api_listen} is in use by another process"));
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "port_4200", "status": "warn"}));
|
||||
checks.push(serde_json::json!({"check": "port", "status": "warn", "address": api_listen}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2288,8 +2383,8 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// --- Check 11: .env keys vs config api_key_env consistency ---
|
||||
if let Some(ref h) = home {
|
||||
let openfang_dir = h.join(".openfang");
|
||||
{
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let config_path = openfang_dir.join("config.toml");
|
||||
if config_path.exists() {
|
||||
let config_str = std::fs::read_to_string(&config_path).unwrap_or_default();
|
||||
@@ -2314,8 +2409,8 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// --- Check 12: Config deserialization into KernelConfig ---
|
||||
if let Some(ref h) = home {
|
||||
let openfang_dir = h.join(".openfang");
|
||||
{
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let config_path = openfang_dir.join("config.toml");
|
||||
if config_path.exists() {
|
||||
if !json {
|
||||
@@ -2419,10 +2514,7 @@ decay_rate = 0.05
|
||||
if !json {
|
||||
println!("\n Skills:");
|
||||
}
|
||||
let skills_dir = home
|
||||
.as_ref()
|
||||
.map(|h| h.join(".openfang").join("skills"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("skills"));
|
||||
let skills_dir = cli_openfang_home().join("skills");
|
||||
let mut skill_reg = openfang_skills::registry::SkillRegistry::new(skills_dir.clone());
|
||||
skill_reg.load_bundled();
|
||||
let bundled_count = skill_reg.count();
|
||||
@@ -2456,12 +2548,20 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// Check for prompt injection issues in skill definitions
|
||||
// Only flag Critical-severity warnings (Warning-level hits are expected
|
||||
// in bundled skills that mention shell commands in educational context).
|
||||
let skills = skill_reg.list();
|
||||
let mut injection_warnings = 0;
|
||||
for skill in &skills {
|
||||
if let Some(ref prompt) = skill.manifest.prompt_context {
|
||||
let warnings = openfang_skills::verify::SkillVerifier::scan_prompt_content(prompt);
|
||||
if !warnings.is_empty() {
|
||||
let has_critical = warnings.iter().any(|w| {
|
||||
matches!(
|
||||
w.severity,
|
||||
openfang_skills::verify::WarningSeverity::Critical
|
||||
)
|
||||
});
|
||||
if has_critical {
|
||||
injection_warnings += 1;
|
||||
if !json {
|
||||
ui::check_warn(&format!(
|
||||
@@ -2483,11 +2583,11 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// --- Check 14: Extension registry health ---
|
||||
if let Some(ref h) = home {
|
||||
{
|
||||
if !json {
|
||||
println!("\n Extensions:");
|
||||
}
|
||||
let openfang_dir = h.join(".openfang");
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let mut ext_registry =
|
||||
openfang_extensions::registry::IntegrationRegistry::new(&openfang_dir);
|
||||
ext_registry.load_bundled();
|
||||
@@ -3098,12 +3198,7 @@ fn cmd_migrate(args: MigrateArgs) {
|
||||
}
|
||||
});
|
||||
|
||||
let target_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Error: Could not determine home directory");
|
||||
std::process::exit(1);
|
||||
})
|
||||
.join(".openfang");
|
||||
let target_dir = cli_openfang_home();
|
||||
|
||||
println!("Migrating from {} ({})...", source, source_dir.display());
|
||||
if args.dry_run {
|
||||
@@ -3794,6 +3889,52 @@ fn cmd_channel_toggle(channel: &str, enable: bool) {
|
||||
// Hand commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cmd_hand_install(path: &str) {
|
||||
let base = require_daemon("hand install");
|
||||
let dir = std::path::Path::new(path);
|
||||
let toml_path = dir.join("HAND.toml");
|
||||
let skill_path = dir.join("SKILL.md");
|
||||
|
||||
if !toml_path.exists() {
|
||||
eprintln!(
|
||||
"Error: No HAND.toml found in {}",
|
||||
dir.canonicalize()
|
||||
.unwrap_or_else(|_| dir.to_path_buf())
|
||||
.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let toml_content = std::fs::read_to_string(&toml_path).unwrap_or_else(|e| {
|
||||
eprintln!("Error reading {}: {e}", toml_path.display());
|
||||
std::process::exit(1);
|
||||
});
|
||||
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
|
||||
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/install"))
|
||||
.json(&serde_json::json!({
|
||||
"toml_content": toml_content,
|
||||
"skill_content": skill_content,
|
||||
}))
|
||||
.send(),
|
||||
);
|
||||
|
||||
if let Some(err) = body.get("error").and_then(|v| v.as_str()) {
|
||||
eprintln!("Error: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Installed hand: {} ({})",
|
||||
body["name"].as_str().unwrap_or("?"),
|
||||
body["id"].as_str().unwrap_or("?"),
|
||||
);
|
||||
println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?"));
|
||||
}
|
||||
|
||||
fn cmd_hand_list() {
|
||||
let base = require_daemon("hand list");
|
||||
let client = daemon_client();
|
||||
@@ -3952,6 +4093,87 @@ fn cmd_hand_info(id: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
fn cmd_hand_check_deps(id: &str) {
|
||||
let base = require_daemon("hand check-deps");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/check-deps"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&body).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_install_deps(id: &str) {
|
||||
let base = require_daemon("hand install-deps");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/install-deps"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Dependencies installed for hand '{id}'."));
|
||||
if let Some(results) = body.get("results") {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(results).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_pause(id: &str) {
|
||||
let base = require_daemon("hand pause");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/instances/{id}/pause"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Hand instance '{id}' paused."));
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_resume(id: &str) {
|
||||
let base = require_daemon("hand resume");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/instances/{id}/resume"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Hand instance '{id}' resumed."));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider / API key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -4254,8 +4476,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>()
|
||||
@@ -4268,7 +4491,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);
|
||||
@@ -4427,6 +4661,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");
|
||||
@@ -5149,16 +5386,40 @@ fn cmd_cron_list(json: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str) {
|
||||
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<&str>) {
|
||||
let base = require_daemon("cron create");
|
||||
let client = daemon_client();
|
||||
|
||||
// Use explicit name if provided, otherwise derive from agent + prompt
|
||||
let name = if let Some(n) = explicit_name {
|
||||
n.to_string()
|
||||
} else {
|
||||
let short_prompt: String = prompt
|
||||
.split_whitespace()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||
.take(64)
|
||||
.collect();
|
||||
format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt })
|
||||
};
|
||||
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/cron/jobs"))
|
||||
.json(&serde_json::json!({
|
||||
"agent_id": agent,
|
||||
"cron_expr": spec,
|
||||
"prompt": prompt,
|
||||
"name": name,
|
||||
"schedule": {
|
||||
"kind": "cron",
|
||||
"expr": spec
|
||||
},
|
||||
"action": {
|
||||
"kind": "agent_turn",
|
||||
"message": prompt
|
||||
}
|
||||
}))
|
||||
.send(),
|
||||
);
|
||||
@@ -5245,9 +5506,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(
|
||||
@@ -5777,13 +6036,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!(
|
||||
@@ -5818,14 +6071,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
|
||||
@@ -5851,11 +6097,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());
|
||||
}
|
||||
@@ -5889,12 +6139,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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -220,6 +220,8 @@ impl StandaloneChat {
|
||||
}
|
||||
ChatAction::SendMessage(msg) => self.send_message(msg),
|
||||
ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
|
||||
ChatAction::OpenModelPicker => self.open_model_picker(),
|
||||
ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,12 +269,13 @@ impl StandaloneChat {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/model \u{2014} show current model",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent & quit",
|
||||
"/exit \u{2014} end chat session",
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent & quit",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
@@ -294,8 +297,14 @@ impl StandaloneChat {
|
||||
self.chat.push_message(Role::System, s.join("\n"));
|
||||
}
|
||||
"/model" => {
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Model: {}", self.chat.model_label));
|
||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
||||
if args.is_empty() {
|
||||
// No argument: open the model picker
|
||||
self.open_model_picker();
|
||||
} else {
|
||||
// With argument: switch directly
|
||||
self.switch_model(args);
|
||||
}
|
||||
}
|
||||
"/clear" => {
|
||||
let name = self.chat.agent_name.clone();
|
||||
@@ -364,6 +373,160 @@ impl StandaloneChat {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model picker helpers ──────────────────────────────────────────────────
|
||||
|
||||
fn open_model_picker(&mut self) {
|
||||
use super::screens::chat::ModelEntry;
|
||||
|
||||
let models = match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
let client = crate::daemon_client();
|
||||
match client.get(format!("{base_url}/api/models")).send() {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>() {
|
||||
Ok(body) => body["models"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|m| m["available"].as_bool().unwrap_or(false))
|
||||
.map(|m| ModelEntry {
|
||||
id: m["id"].as_str().unwrap_or("").to_string(),
|
||||
display_name: m["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
let catalog = kernel.model_catalog.read().unwrap();
|
||||
catalog
|
||||
.available_models()
|
||||
.into_iter()
|
||||
.map(|e| ModelEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
provider: e.provider.clone(),
|
||||
tier: format!("{:?}", e.tier),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Backend::None => Vec::new(),
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
self.chat
|
||||
.push_message(Role::System, "No models available.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat.model_picker_models = models;
|
||||
self.chat.model_picker_filter.clear();
|
||||
self.chat.model_picker_idx = 0;
|
||||
self.chat.show_model_picker = true;
|
||||
}
|
||||
|
||||
fn switch_model(&mut self, model_id: &str) {
|
||||
// Skip if already on this model
|
||||
if self.chat.model_label.ends_with(model_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
if let Some(ref agent_id) = self.agent_id_daemon {
|
||||
let client = crate::daemon_client();
|
||||
let url = format!("{base_url}/api/agents/{agent_id}/model");
|
||||
match client
|
||||
.put(&url)
|
||||
.json(&serde_json::json!({"model": model_id}))
|
||||
.send()
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
// Re-fetch agent to get updated provider/model
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/agents/{agent_id}"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Failed to switch to {model_id}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
if let Some(id) = self.agent_id_inprocess {
|
||||
let provider = kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap()
|
||||
.find_model(model_id)
|
||||
.map(|e| e.provider.clone());
|
||||
let result = if let Some(ref prov) = provider {
|
||||
kernel.registry.update_model_and_provider(
|
||||
id,
|
||||
model_id.to_string(),
|
||||
prov.clone(),
|
||||
)
|
||||
} else {
|
||||
kernel.registry.update_model(id, model_id.to_string())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let prov_label = provider.unwrap_or_else(|| {
|
||||
kernel
|
||||
.registry
|
||||
.get(id)
|
||||
.map(|e| e.manifest.model.provider.clone())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::None => {
|
||||
self.chat
|
||||
.push_message(Role::System, "No backend connected.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent resolution helpers ─────────────────────────────────────────────
|
||||
|
||||
fn enter_chat_daemon(&mut self, id: String, name: String) {
|
||||
|
||||
@@ -1364,6 +1364,8 @@ impl App {
|
||||
}
|
||||
chat::ChatAction::SendMessage(msg) => self.send_message(msg),
|
||||
chat::ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
|
||||
chat::ChatAction::OpenModelPicker => self.open_model_picker(),
|
||||
chat::ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1848,6 +1850,159 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Model picker ────────────────────────────────────────────────────────
|
||||
|
||||
fn open_model_picker(&mut self) {
|
||||
let models = match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
let client = crate::daemon_client();
|
||||
match client.get(format!("{base_url}/api/models")).send() {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>() {
|
||||
Ok(body) => body["models"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|m| m["available"].as_bool().unwrap_or(false))
|
||||
.map(|m| chat::ModelEntry {
|
||||
id: m["id"].as_str().unwrap_or("").to_string(),
|
||||
display_name: m["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"]
|
||||
.as_str()
|
||||
.unwrap_or("Balanced")
|
||||
.to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
let catalog = kernel.model_catalog.read().unwrap();
|
||||
catalog
|
||||
.available_models()
|
||||
.into_iter()
|
||||
.map(|e| chat::ModelEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
provider: e.provider.clone(),
|
||||
tier: format!("{:?}", e.tier),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Backend::None => Vec::new(),
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
self.chat
|
||||
.push_message(chat::Role::System, "No models available.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat.model_picker_models = models;
|
||||
self.chat.model_picker_filter.clear();
|
||||
self.chat.model_picker_idx = 0;
|
||||
self.chat.show_model_picker = true;
|
||||
}
|
||||
|
||||
fn switch_model(&mut self, model_id: &str) {
|
||||
if self.chat.model_label.ends_with(model_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match (&self.backend, &self.chat_target) {
|
||||
(Backend::Daemon { base_url }, Some(target)) => {
|
||||
if let Some(ref agent_id) = target.agent_id_daemon {
|
||||
let client = crate::daemon_client();
|
||||
let url = format!("{base_url}/api/agents/{agent_id}/model");
|
||||
match client
|
||||
.put(&url)
|
||||
.json(&serde_json::json!({"model": model_id}))
|
||||
.send()
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/agents/{agent_id}"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Failed to switch to {model_id}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Backend::InProcess { kernel }, Some(target)) => {
|
||||
if let Some(id) = target.agent_id_inprocess {
|
||||
let provider = kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap()
|
||||
.find_model(model_id)
|
||||
.map(|e| e.provider.clone());
|
||||
let result = if let Some(ref prov) = provider {
|
||||
kernel.registry.update_model_and_provider(
|
||||
id,
|
||||
model_id.to_string(),
|
||||
prov.clone(),
|
||||
)
|
||||
} else {
|
||||
kernel.registry.update_model(id, model_id.to_string())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let prov_label = provider.unwrap_or_else(|| {
|
||||
kernel
|
||||
.registry
|
||||
.get(id)
|
||||
.map(|e| e.manifest.model.provider.clone())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.chat
|
||||
.push_message(chat::Role::System, "No backend connected.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Slash commands ──────────────────────────────────────────────────────
|
||||
|
||||
fn handle_slash_command(&mut self, cmd: &str) {
|
||||
@@ -1858,13 +2013,14 @@ impl App {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/model \u{2014} show current model",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
@@ -1989,10 +2145,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
"/model" => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Model: {}", self.chat.model_label),
|
||||
);
|
||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
||||
if args.is_empty() {
|
||||
self.open_model_picker();
|
||||
} else {
|
||||
self.switch_model(args);
|
||||
}
|
||||
}
|
||||
"/hands" => match &self.backend {
|
||||
Backend::InProcess { kernel } => {
|
||||
|
||||
@@ -5,9 +5,18 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Model entry for the picker.
|
||||
#[derive(Clone)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub provider: String,
|
||||
pub tier: String,
|
||||
}
|
||||
|
||||
/// Tool call metadata for rich rendering.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolInfo {
|
||||
@@ -68,6 +77,14 @@ pub struct ChatState {
|
||||
pub staged_messages: Vec<String>,
|
||||
/// Accumulates ToolInputDelta text for the current tool call.
|
||||
pub tool_input_buf: String,
|
||||
/// Model picker overlay state.
|
||||
pub show_model_picker: bool,
|
||||
/// Available models for the picker.
|
||||
pub model_picker_models: Vec<ModelEntry>,
|
||||
/// Filter text for model search.
|
||||
pub model_picker_filter: String,
|
||||
/// Selected index in the filtered model list.
|
||||
pub model_picker_idx: usize,
|
||||
}
|
||||
|
||||
pub enum ChatAction {
|
||||
@@ -75,6 +92,10 @@ pub enum ChatAction {
|
||||
SendMessage(String),
|
||||
Back,
|
||||
SlashCommand(String),
|
||||
/// Open the model picker (fetch models first).
|
||||
OpenModelPicker,
|
||||
/// Switch to a specific model by id.
|
||||
SwitchModel(String),
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
@@ -97,6 +118,10 @@ impl ChatState {
|
||||
status_msg: None,
|
||||
staged_messages: Vec::new(),
|
||||
tool_input_buf: String::new(),
|
||||
show_model_picker: false,
|
||||
model_picker_models: Vec::new(),
|
||||
model_picker_filter: String::new(),
|
||||
model_picker_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +140,9 @@ impl ChatState {
|
||||
self.status_msg = None;
|
||||
self.staged_messages.clear();
|
||||
self.tool_input_buf.clear();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
|
||||
/// Push a completed message into history.
|
||||
@@ -205,11 +233,81 @@ impl ChatState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return filtered models based on the current picker filter.
|
||||
pub fn filtered_models(&self) -> Vec<&ModelEntry> {
|
||||
if self.model_picker_filter.is_empty() {
|
||||
return self.model_picker_models.iter().collect();
|
||||
}
|
||||
let f = self.model_picker_filter.to_lowercase();
|
||||
self.model_picker_models
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.id.to_lowercase().contains(&f)
|
||||
|| m.display_name.to_lowercase().contains(&f)
|
||||
|| m.provider.to_lowercase().contains(&f)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> ChatAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::Back;
|
||||
}
|
||||
|
||||
// Ctrl+M: toggle model picker
|
||||
if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.is_streaming {
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::OpenModelPicker;
|
||||
}
|
||||
|
||||
// Model picker mode: intercept all keys
|
||||
if self.show_model_picker {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_model_picker = false;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
self.model_picker_idx = self.model_picker_idx.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let max = self.filtered_models().len().saturating_sub(1);
|
||||
if self.model_picker_idx < max {
|
||||
self.model_picker_idx += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let filtered = self.filtered_models();
|
||||
if let Some(entry) = filtered.get(self.model_picker_idx) {
|
||||
let model_id = entry.id.clone();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
return ChatAction::SwitchModel(model_id);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.model_picker_filter.pop();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.model_picker_filter.push(c);
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
|
||||
// When streaming, allow typing + staging messages, scrolling, and Esc
|
||||
if self.is_streaming {
|
||||
match key.code {
|
||||
@@ -361,13 +459,136 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut ChatState) {
|
||||
f.render_widget(Paragraph::new(input_line), chunks[2]);
|
||||
|
||||
// ── Hints ────────────────────────────────────────────────────────────────
|
||||
let hints = if state.is_streaming {
|
||||
let hints = if state.show_model_picker {
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Select [Esc] Close [type] Filter"
|
||||
} else if state.is_streaming {
|
||||
" [Enter] Stage [\u{2191}\u{2193}] Scroll [Esc] Stop"
|
||||
} else {
|
||||
" [Enter] Send [\u{2191}\u{2193}/PgUp/PgDn] Scroll [Esc] Back"
|
||||
" [Enter] Send [Ctrl+M] Models [\u{2191}\u{2193}] Scroll [Esc] Back"
|
||||
};
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(hints, theme::hint_style())]));
|
||||
f.render_widget(hints, chunks[3]);
|
||||
|
||||
// ── Model picker overlay ────────────────────────────────────────────────
|
||||
if state.show_model_picker {
|
||||
draw_model_picker(f, inner, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
let filtered = state.filtered_models();
|
||||
|
||||
// Center a popup — width ~50 cols, height capped at area
|
||||
if area.height < 6 || area.width < 20 {
|
||||
return; // Too small to show picker
|
||||
}
|
||||
let popup_w = area.width.clamp(30, 54);
|
||||
let popup_h = (filtered.len() as u16 + 4)
|
||||
.clamp(5, area.height.saturating_sub(2));
|
||||
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
||||
|
||||
// Clear background
|
||||
f.render_widget(Clear, popup_area);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Switch Model ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
f.render_widget(block, popup_area);
|
||||
|
||||
if inner.height < 2 || inner.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout: search bar | model list
|
||||
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
|
||||
|
||||
// Search bar
|
||||
let search_line = Line::from(vec![
|
||||
Span::styled("/ ", theme::dim_style()),
|
||||
Span::raw(&state.model_picker_filter),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(search_line), chunks[0]);
|
||||
|
||||
// Model list
|
||||
let visible_h = chunks[1].height as usize;
|
||||
let total = filtered.len();
|
||||
|
||||
if total == 0 {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" No models match",
|
||||
theme::dim_style(),
|
||||
)])),
|
||||
chunks[1],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll window: keep selected item visible
|
||||
let scroll_start = if state.model_picker_idx >= visible_h {
|
||||
state.model_picker_idx - visible_h + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let max_name = (chunks[1].width as usize).saturating_sub(14);
|
||||
for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) {
|
||||
let selected = i == state.model_picker_idx;
|
||||
let indicator = if selected { "\u{25b6} " } else { " " };
|
||||
|
||||
let name = if entry.display_name.is_empty() {
|
||||
&entry.id
|
||||
} else {
|
||||
&entry.display_name
|
||||
};
|
||||
let name_display = if name.len() > max_name && max_name > 1 {
|
||||
let truncated = openfang_types::truncate_str(name, max_name.saturating_sub(1));
|
||||
format!("{truncated}\u{2026}")
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
let tier_style = match entry.tier.to_lowercase().as_str() {
|
||||
"frontier" => Style::default().fg(theme::PURPLE),
|
||||
"smart" => Style::default().fg(theme::BLUE),
|
||||
"balanced" => Style::default().fg(theme::GREEN),
|
||||
"fast" => Style::default().fg(theme::YELLOW),
|
||||
_ => theme::dim_style(),
|
||||
};
|
||||
|
||||
let bg = if selected {
|
||||
Style::default()
|
||||
.fg(theme::TEXT_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme::TEXT_SECONDARY)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(indicator, Style::default().fg(theme::ACCENT)),
|
||||
Span::styled(name_display, bg),
|
||||
Span::raw(" "),
|
||||
Span::styled(entry.tier.to_lowercase(), tier_style),
|
||||
]));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), chunks[1]);
|
||||
}
|
||||
|
||||
fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -67,6 +67,66 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
default_model: "accounts/fireworks/models/llama-v3p3-70b-instruct",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "gemini",
|
||||
env_var: "GEMINI_API_KEY",
|
||||
default_model: "gemini-2.5-flash",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "xai",
|
||||
env_var: "XAI_API_KEY",
|
||||
default_model: "grok-4-0709",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "qwen",
|
||||
env_var: "DASHSCOPE_API_KEY",
|
||||
default_model: "qwen-plus",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "perplexity",
|
||||
env_var: "PERPLEXITY_API_KEY",
|
||||
default_model: "sonar-pro",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "cohere",
|
||||
env_var: "CO_API_KEY",
|
||||
default_model: "command-a",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "cerebras",
|
||||
env_var: "CEREBRAS_API_KEY",
|
||||
default_model: "llama-3.3-70b",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "sambanova",
|
||||
env_var: "SAMBANOVA_API_KEY",
|
||||
default_model: "Meta-Llama-3.3-70B-Instruct",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "moonshot",
|
||||
env_var: "MOONSHOT_API_KEY",
|
||||
default_model: "moonshot-v1-128k",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "zhipu",
|
||||
env_var: "ZHIPU_API_KEY",
|
||||
default_model: "glm-4-plus",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "zhipu_coding",
|
||||
env_var: "ZHIPU_API_KEY",
|
||||
default_model: "codegeex-4",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ollama",
|
||||
env_var: "OLLAMA_API_KEY",
|
||||
@@ -89,11 +149,15 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
|
||||
/// Check if first-run setup is needed.
|
||||
pub fn needs_setup() -> bool {
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => return true,
|
||||
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
std::path::PathBuf::from(h)
|
||||
} else {
|
||||
match dirs::home_dir() {
|
||||
Some(h) => h.join(".openfang"),
|
||||
None => return true,
|
||||
}
|
||||
};
|
||||
!home.join(".openfang").join("config.toml").exists()
|
||||
!of_home.join("config.toml").exists()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -294,16 +358,18 @@ impl WizardState {
|
||||
}
|
||||
};
|
||||
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
self.status_msg = "Could not determine home directory".to_string();
|
||||
self.step = WizardStep::Done;
|
||||
return;
|
||||
let openfang_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
std::path::PathBuf::from(h)
|
||||
} else {
|
||||
match dirs::home_dir() {
|
||||
Some(h) => h.join(".openfang"),
|
||||
None => {
|
||||
self.status_msg = "Could not determine home directory".to_string();
|
||||
self.step = WizardStep::Done;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let openfang_dir = home.join(".openfang");
|
||||
let _ = std::fs::create_dir_all(openfang_dir.join("agents"));
|
||||
let _ = std::fs::create_dir_all(openfang_dir.join("data"));
|
||||
crate::restrict_dir_permissions(&openfang_dir);
|
||||
|
||||
@@ -113,6 +113,14 @@ value = "elevenlabs"
|
||||
label = "ElevenLabs"
|
||||
provider_env = "ELEVENLABS_API_KEY"
|
||||
|
||||
[[settings]]
|
||||
key = "elevenlabs_api_key"
|
||||
label = "ElevenLabs API Key"
|
||||
description = "API key from elevenlabs.io for high-quality text-to-speech. Required when ElevenLabs TTS is selected."
|
||||
setting_type = "text"
|
||||
env_var = "ELEVENLABS_API_KEY"
|
||||
default = ""
|
||||
|
||||
# ─── Publishing settings ────────────────────────────────────────────────────
|
||||
|
||||
[[settings]]
|
||||
|
||||
@@ -28,6 +28,13 @@ steps = [
|
||||
|
||||
# ─── Configurable settings ───────────────────────────────────────────────────
|
||||
|
||||
[[settings]]
|
||||
key = "twitter_bearer_token"
|
||||
label = "Twitter Bearer Token"
|
||||
description = "Bearer Token from the Twitter/X Developer Portal. Required for all Twitter API operations."
|
||||
setting_type = "text"
|
||||
default = ""
|
||||
|
||||
[[settings]]
|
||||
key = "twitter_style"
|
||||
label = "Content Style"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
@@ -532,51 +550,73 @@ impl OpenFangKernel {
|
||||
let driver_config = DriverConfig {
|
||||
provider: config.default_model.provider.clone(),
|
||||
api_key: std::env::var(&config.default_model.api_key_env).ok(),
|
||||
base_url: config.default_model.base_url.clone(),
|
||||
base_url: config
|
||||
.default_model
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| config.provider_urls.get(&config.default_model.provider).cloned()),
|
||||
};
|
||||
let primary_driver = drivers::create_driver(&driver_config)
|
||||
.map_err(|e| KernelError::BootFailed(format!("LLM driver init failed: {e}")))?;
|
||||
// Primary driver failure is non-fatal: the dashboard should remain accessible
|
||||
// even if the LLM provider is misconfigured. Users can fix config via dashboard.
|
||||
let primary_result = drivers::create_driver(&driver_config);
|
||||
let mut driver_chain: Vec<Arc<dyn LlmDriver>> = Vec::new();
|
||||
|
||||
// If fallback providers are configured, wrap the primary driver in a FallbackDriver
|
||||
let driver: Arc<dyn LlmDriver> = if !config.fallback_providers.is_empty() {
|
||||
let mut chain: Vec<Arc<dyn LlmDriver>> = vec![primary_driver.clone()];
|
||||
for fb in &config.fallback_providers {
|
||||
let fb_config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
api_key: if fb.api_key_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
std::env::var(&fb.api_key_env).ok()
|
||||
},
|
||||
base_url: fb.base_url.clone(),
|
||||
};
|
||||
match drivers::create_driver(&fb_config) {
|
||||
Ok(d) => {
|
||||
info!(
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
"Fallback provider configured"
|
||||
);
|
||||
chain.push(d);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
provider = %fb.provider,
|
||||
error = %e,
|
||||
"Fallback provider init failed — skipped"
|
||||
);
|
||||
}
|
||||
match &primary_result {
|
||||
Ok(d) => driver_chain.push(d.clone()),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
provider = %config.default_model.provider,
|
||||
error = %e,
|
||||
"Primary LLM driver init failed — dashboard will still be accessible"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add fallback providers to the chain
|
||||
for fb in &config.fallback_providers {
|
||||
let fb_config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
api_key: if fb.api_key_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
std::env::var(&fb.api_key_env).ok()
|
||||
},
|
||||
base_url: fb
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| config.provider_urls.get(&fb.provider).cloned()),
|
||||
};
|
||||
match drivers::create_driver(&fb_config) {
|
||||
Ok(d) => {
|
||||
info!(
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
"Fallback provider configured"
|
||||
);
|
||||
driver_chain.push(d);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
provider = %fb.provider,
|
||||
error = %e,
|
||||
"Fallback provider init failed — skipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
if chain.len() > 1 {
|
||||
Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::new(
|
||||
chain,
|
||||
))
|
||||
} else {
|
||||
primary_driver
|
||||
}
|
||||
}
|
||||
|
||||
// Use the chain, or create a stub driver if everything failed
|
||||
let driver: Arc<dyn LlmDriver> = if driver_chain.len() > 1 {
|
||||
Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::new(
|
||||
driver_chain,
|
||||
))
|
||||
} else if let Some(single) = driver_chain.into_iter().next() {
|
||||
single
|
||||
} else {
|
||||
primary_driver
|
||||
// All drivers failed — use a stub that returns a helpful error.
|
||||
// The kernel boots, dashboard is accessible, users can fix their config.
|
||||
warn!("No LLM drivers available — agents will return errors until a provider is configured");
|
||||
Arc::new(StubDriver) as Arc<dyn LlmDriver>
|
||||
};
|
||||
|
||||
// Initialize metering engine (shares the same SQLite connection as the memory substrate)
|
||||
@@ -648,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)");
|
||||
@@ -715,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) => {
|
||||
@@ -729,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))
|
||||
@@ -742,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))
|
||||
@@ -897,6 +947,61 @@ impl OpenFangKernel {
|
||||
let agent_id = entry.id;
|
||||
let name = entry.name.clone();
|
||||
|
||||
// Check if TOML on disk is newer/different — if so, update from file
|
||||
let mut entry = entry;
|
||||
let toml_path = kernel
|
||||
.config
|
||||
.home_dir
|
||||
.join("agents")
|
||||
.join(&name)
|
||||
.join("agent.toml");
|
||||
if toml_path.exists() {
|
||||
match std::fs::read_to_string(&toml_path) {
|
||||
Ok(toml_str) => {
|
||||
match toml::from_str::<openfang_types::agent::AgentManifest>(
|
||||
&toml_str,
|
||||
) {
|
||||
Ok(disk_manifest) => {
|
||||
// Compare key fields to detect changes
|
||||
let changed = disk_manifest.name != entry.manifest.name
|
||||
|| disk_manifest.description != entry.manifest.description
|
||||
|| disk_manifest.model.system_prompt != entry.manifest.model.system_prompt
|
||||
|| disk_manifest.model.provider != entry.manifest.model.provider
|
||||
|| disk_manifest.model.model != entry.manifest.model.model
|
||||
|| disk_manifest.capabilities.tools != entry.manifest.capabilities.tools;
|
||||
if changed {
|
||||
info!(
|
||||
agent = %name,
|
||||
"Agent TOML on disk differs from DB, updating"
|
||||
);
|
||||
entry.manifest = disk_manifest;
|
||||
// Persist the update back to DB
|
||||
if let Err(e) = kernel.memory.save_agent(&entry) {
|
||||
warn!(
|
||||
agent = %name,
|
||||
"Failed to persist TOML update: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
agent = %name,
|
||||
path = %toml_path.display(),
|
||||
"Invalid agent TOML on disk, using DB version: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
agent = %name,
|
||||
"Failed to read agent TOML: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-grant capabilities
|
||||
let caps = manifest_to_capabilities(&entry.manifest);
|
||||
kernel.capabilities.grant(agent_id, caps);
|
||||
@@ -922,24 +1027,32 @@ impl OpenFangKernel {
|
||||
&mut restored_entry.manifest.resources,
|
||||
);
|
||||
|
||||
// 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()
|
||||
// Apply default_model to restored agents.
|
||||
//
|
||||
// Two cases:
|
||||
// 1. Agent has empty/default provider → always apply default_model
|
||||
// 2. Agent named "assistant" (auto-spawned) → update to match
|
||||
// default_model so config.toml changes take effect on restart
|
||||
{
|
||||
let dm = &kernel.config.default_model;
|
||||
let is_default_provider = restored_entry.manifest.model.provider.is_empty()
|
||||
|| restored_entry.manifest.model.provider == "anthropic";
|
||||
|| restored_entry.manifest.model.provider == "default";
|
||||
let is_default_model = restored_entry.manifest.model.model.is_empty()
|
||||
|| restored_entry.manifest.model.model == "claude-sonnet-4-20250514";
|
||||
if is_default_provider && is_default_model {
|
||||
|| restored_entry.manifest.model.model == "default";
|
||||
let is_auto_spawned = restored_entry.name == "assistant"
|
||||
&& restored_entry.manifest.description == "General-purpose assistant";
|
||||
if is_default_provider && is_default_model || is_auto_spawned {
|
||||
if !dm.provider.is_empty() {
|
||||
restored_entry.manifest.model.provider = dm.provider.clone();
|
||||
}
|
||||
if !dm.model.is_empty() {
|
||||
restored_entry.manifest.model.model = dm.model.clone();
|
||||
}
|
||||
if !dm.api_key_env.is_empty() {
|
||||
restored_entry.manifest.model.api_key_env = Some(dm.api_key_env.clone());
|
||||
}
|
||||
if dm.base_url.is_some() {
|
||||
restored_entry.manifest.model.base_url = dm.base_url.clone();
|
||||
restored_entry.manifest.model.base_url.clone_from(&dm.base_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -959,6 +1072,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 {
|
||||
@@ -1008,28 +1148,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1987,7 +2134,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)?;
|
||||
@@ -2760,6 +2916,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
|
||||
@@ -2784,10 +2949,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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2799,6 +2977,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)?;
|
||||
|
||||
@@ -3125,18 +3310,30 @@ impl OpenFangKernel {
|
||||
/// `Continuous`, `Periodic`, or `Proactive` schedules.
|
||||
pub fn start_background_agents(self: &Arc<Self>) {
|
||||
let agents = self.registry.list();
|
||||
let mut started = 0u32;
|
||||
let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> =
|
||||
Vec::new();
|
||||
|
||||
for entry in &agents {
|
||||
if matches!(entry.manifest.schedule, ScheduleMode::Reactive) {
|
||||
continue;
|
||||
}
|
||||
self.start_background_for_agent(entry.id, &entry.name, &entry.manifest.schedule);
|
||||
started += 1;
|
||||
bg_agents.push((entry.id, entry.name.clone(), entry.manifest.schedule.clone()));
|
||||
}
|
||||
|
||||
if started > 0 {
|
||||
info!("Started {started} background agent loop(s)");
|
||||
if !bg_agents.is_empty() {
|
||||
let count = bg_agents.len();
|
||||
let kernel = Arc::clone(self);
|
||||
// Stagger agent startup to prevent rate-limit storm on shared providers.
|
||||
// Each agent gets a 500ms delay before the next one starts.
|
||||
tokio::spawn(async move {
|
||||
for (i, (id, name, schedule)) in bg_agents.into_iter().enumerate() {
|
||||
kernel.start_background_for_agent(id, &name, &schedule);
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
info!("Started {count} background agent loop(s) (staggered)");
|
||||
});
|
||||
}
|
||||
|
||||
// Start heartbeat monitor for agent health checking
|
||||
@@ -3564,7 +3761,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,
|
||||
@@ -3698,10 +3895,14 @@ impl OpenFangKernel {
|
||||
let base_url = if has_custom_url {
|
||||
manifest.model.base_url.clone()
|
||||
} else if agent_provider == default_provider {
|
||||
self.config.default_model.base_url.clone()
|
||||
self.config
|
||||
.default_model
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| self.config.provider_urls.get(agent_provider.as_str()).cloned())
|
||||
} else {
|
||||
// Let create_driver() use the target provider's default base URL
|
||||
None
|
||||
// Check provider_urls before falling back to hardcoded defaults
|
||||
self.config.provider_urls.get(agent_provider.as_str()).cloned()
|
||||
};
|
||||
|
||||
let driver_config = DriverConfig {
|
||||
@@ -3727,7 +3928,10 @@ impl OpenFangKernel {
|
||||
.api_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok()),
|
||||
base_url: fb.base_url.clone(),
|
||||
base_url: fb
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| self.config.provider_urls.get(&fb.provider).cloned()),
|
||||
};
|
||||
match drivers::create_driver(&config) {
|
||||
Ok(d) => chain.push((d, fb.model.clone())),
|
||||
@@ -4465,17 +4669,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());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -4517,7 +4727,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,
|
||||
@@ -4907,6 +5117,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,
|
||||
@@ -4933,8 +5161,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,
|
||||
@@ -5053,6 +5281,59 @@ impl KernelHandle for OpenFangKernel {
|
||||
Ok(format!("Message sent to {} via {}", recipient, channel))
|
||||
}
|
||||
|
||||
async fn send_channel_media(
|
||||
&self,
|
||||
channel: &str,
|
||||
recipient: &str,
|
||||
media_type: &str,
|
||||
media_url: &str,
|
||||
caption: Option<&str>,
|
||||
filename: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let adapter = self
|
||||
.channel_adapters
|
||||
.get(channel)
|
||||
.ok_or_else(|| {
|
||||
let available: Vec<String> = self
|
||||
.channel_adapters
|
||||
.iter()
|
||||
.map(|e| e.key().clone())
|
||||
.collect();
|
||||
format!(
|
||||
"Channel '{}' not found. Available channels: {:?}",
|
||||
channel, available
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let user = openfang_channels::types::ChannelUser {
|
||||
platform_id: recipient.to_string(),
|
||||
display_name: recipient.to_string(),
|
||||
openfang_user: None,
|
||||
};
|
||||
|
||||
let content = match media_type {
|
||||
"image" => openfang_channels::types::ChannelContent::Image {
|
||||
url: media_url.to_string(),
|
||||
caption: caption.map(|s| s.to_string()),
|
||||
},
|
||||
"file" => openfang_channels::types::ChannelContent::File {
|
||||
url: media_url.to_string(),
|
||||
filename: filename.unwrap_or("file").to_string(),
|
||||
},
|
||||
_ => {
|
||||
return Err(format!("Unsupported media type: '{media_type}'. Use 'image' or 'file'."));
|
||||
}
|
||||
};
|
||||
|
||||
adapter
|
||||
.send(&user, content)
|
||||
.await
|
||||
.map_err(|e| format!("Channel media send failed: {e}"))?;
|
||||
|
||||
Ok(format!("{} sent to {} via {}", media_type, recipient, channel))
|
||||
}
|
||||
|
||||
async fn spawn_agent_checked(
|
||||
&self,
|
||||
manifest_toml: &str,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -177,6 +177,21 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's fallback model chain.
|
||||
pub fn update_fallback_models(
|
||||
&self,
|
||||
id: AgentId,
|
||||
fallback_models: Vec<openfang_types::agent::FallbackModel>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
entry.manifest.fallback_models = fallback_models;
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's skill allowlist.
|
||||
pub fn update_skills(&self, id: AgentId, skills: Vec<String>) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
|
||||
@@ -54,10 +54,11 @@ impl SetupWizard {
|
||||
/// model configuration, capabilities, and schedule.
|
||||
pub fn build_plan(intent: AgentIntent) -> SetupPlan {
|
||||
// Map model tier to provider/model
|
||||
// Use "default" so the kernel applies config.toml's [default_model].
|
||||
// Only "complex" tier gets an explicit Anthropic override.
|
||||
let (provider, model) = match intent.model_tier.as_str() {
|
||||
"simple" => ("groq", "llama-3.3-70b-versatile"),
|
||||
"complex" => ("anthropic", "claude-sonnet-4-20250514"),
|
||||
_ => ("groq", "llama-3.3-70b-versatile"), // medium default
|
||||
_ => ("default", "default"),
|
||||
};
|
||||
|
||||
// Build capabilities from intent
|
||||
@@ -285,7 +286,7 @@ mod tests {
|
||||
let plan = SetupWizard::build_plan(intent);
|
||||
|
||||
assert_eq!(plan.manifest.name, "research-bot");
|
||||
assert_eq!(plan.manifest.model.provider, "groq");
|
||||
assert_eq!(plan.manifest.model.provider, "default");
|
||||
assert!(plan
|
||||
.manifest
|
||||
.capabilities
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -125,11 +130,19 @@ impl StructuredStore {
|
||||
"ALTER TABLE agents ADD COLUMN session_id TEXT DEFAULT ''",
|
||||
[],
|
||||
);
|
||||
// Add identity column (migration compat)
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE agents ADD COLUMN identity TEXT DEFAULT '{}'",
|
||||
[],
|
||||
);
|
||||
|
||||
let identity_json = serde_json::to_string(&entry.identity)
|
||||
.map_err(|e| OpenFangError::Serialization(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO agents (id, name, manifest, state, created_at, updated_at, session_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET name = ?2, manifest = ?3, state = ?4, updated_at = ?6, session_id = ?7",
|
||||
"INSERT INTO agents (id, name, manifest, state, created_at, updated_at, session_id, identity)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(id) DO UPDATE SET name = ?2, manifest = ?3, state = ?4, updated_at = ?6, session_id = ?7, identity = ?8",
|
||||
rusqlite::params![
|
||||
entry.id.0.to_string(),
|
||||
entry.name,
|
||||
@@ -138,6 +151,7 @@ impl StructuredStore {
|
||||
entry.created_at.to_rfc3339(),
|
||||
now,
|
||||
entry.session_id.0.to_string(),
|
||||
identity_json,
|
||||
],
|
||||
)
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
@@ -152,10 +166,13 @@ impl StructuredStore {
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents WHERE id = ?1")
|
||||
.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id, identity FROM agents WHERE id = ?1")
|
||||
.or_else(|_| {
|
||||
// Fallback without session_id column for old DBs
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents WHERE id = ?1")
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents WHERE id = ?1")
|
||||
.or_else(|_| {
|
||||
// Fallback without session_id column for old DBs
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents WHERE id = ?1")
|
||||
})
|
||||
})
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
|
||||
@@ -170,11 +187,16 @@ impl StructuredStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str))
|
||||
let identity_str: Option<String> = if col_count >= 8 {
|
||||
row.get(7).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str)) => {
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str)) => {
|
||||
let manifest = rmp_serde::from_slice(&manifest_blob)
|
||||
.map_err(|e| OpenFangError::Serialization(e.to_string()))?;
|
||||
let state = serde_json::from_str(&state_str)
|
||||
@@ -186,6 +208,9 @@ impl StructuredStore {
|
||||
.and_then(|s| uuid::Uuid::parse_str(&s).ok())
|
||||
.map(openfang_types::agent::SessionId)
|
||||
.unwrap_or_else(openfang_types::agent::SessionId::new);
|
||||
let identity = identity_str
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Some(AgentEntry {
|
||||
id: agent_id,
|
||||
name,
|
||||
@@ -198,7 +223,7 @@ impl StructuredStore {
|
||||
children: vec![],
|
||||
session_id,
|
||||
tags: vec![],
|
||||
identity: Default::default(),
|
||||
identity,
|
||||
onboarding_completed: false,
|
||||
onboarding_completed_at: None,
|
||||
}))
|
||||
@@ -234,11 +259,14 @@ impl StructuredStore {
|
||||
.lock()
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
|
||||
// Try with session_id column first, fall back without
|
||||
// Try with identity+session_id columns first, fall back gracefully
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents",
|
||||
"SELECT id, name, manifest, state, created_at, updated_at, session_id, identity FROM agents",
|
||||
)
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents")
|
||||
})
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents")
|
||||
})
|
||||
@@ -257,6 +285,11 @@ impl StructuredStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let identity_str: Option<String> = if col_count >= 8 {
|
||||
row.get(7).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((
|
||||
id_str,
|
||||
name,
|
||||
@@ -264,6 +297,7 @@ impl StructuredStore {
|
||||
state_str,
|
||||
created_str,
|
||||
session_id_str,
|
||||
identity_str,
|
||||
))
|
||||
})
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
@@ -273,7 +307,7 @@ impl StructuredStore {
|
||||
let mut repair_queue: Vec<(String, Vec<u8>, String)> = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
let (id_str, name, manifest_blob, state_str, created_str, session_id_str) = match row {
|
||||
let (id_str, name, manifest_blob, state_str, created_str, session_id_str, identity_str) = match row {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("Skipping agent row with read error: {e}");
|
||||
@@ -337,6 +371,10 @@ impl StructuredStore {
|
||||
.map(openfang_types::agent::SessionId)
|
||||
.unwrap_or_else(openfang_types::agent::SessionId::new);
|
||||
|
||||
let identity = identity_str
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
agents.push(AgentEntry {
|
||||
id: agent_id,
|
||||
name,
|
||||
@@ -349,7 +387,7 @@ impl StructuredStore {
|
||||
children: vec![],
|
||||
session_id,
|
||||
tags: vec![],
|
||||
identity: Default::default(),
|
||||
identity,
|
||||
onboarding_completed: false,
|
||||
onboarding_completed_at: None,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1826,6 +1835,136 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern 3: <tool>TOOL_NAME{JSON}</tool> (Qwen / DeepSeek variant)
|
||||
search_from = 0;
|
||||
while let Some(start) = text[search_from..].find("<tool>") {
|
||||
let abs_start = search_from + start;
|
||||
let after_tag = abs_start + "<tool>".len();
|
||||
|
||||
let Some(close_offset) = text[after_tag..].find("</tool>") else {
|
||||
search_from = after_tag;
|
||||
continue;
|
||||
};
|
||||
let inner = &text[after_tag..after_tag + close_offset];
|
||||
search_from = after_tag + close_offset + "</tool>".len();
|
||||
|
||||
let Some(brace_pos) = inner.find('{') else {
|
||||
continue;
|
||||
};
|
||||
let tool_name = inner[..brace_pos].trim();
|
||||
let json_body = inner[brace_pos..].trim();
|
||||
|
||||
if tool_name.is_empty() || !tool_names.contains(&tool_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let input: serde_json::Value = match serde_json::from_str(json_body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if calls
|
||||
.iter()
|
||||
.any(|c| c.name == tool_name && c.input == input)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
tool = tool_name,
|
||||
"Recovered text-based tool call (<tool> variant) → synthetic ToolUse"
|
||||
);
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", uuid::Uuid::new_v4()),
|
||||
name: tool_name.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
// Pattern 4: Markdown code blocks containing tool_name {JSON}
|
||||
// Matches: ```\nexec {"command":"ls"}\n``` or ```bash\nexec {"command":"ls"}\n```
|
||||
{
|
||||
let mut in_block = false;
|
||||
let mut block_content = String::new();
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("```") {
|
||||
if in_block {
|
||||
// End of block — try to extract tool call from content
|
||||
let content = block_content.trim();
|
||||
if let Some(brace_pos) = content.find('{') {
|
||||
let potential_tool = content[..brace_pos].trim();
|
||||
if tool_names.contains(&potential_tool) {
|
||||
if let Ok(input) = serde_json::from_str::<serde_json::Value>(
|
||||
content[brace_pos..].trim(),
|
||||
) {
|
||||
if !calls
|
||||
.iter()
|
||||
.any(|c| c.name == potential_tool && c.input == input)
|
||||
{
|
||||
info!(
|
||||
tool = potential_tool,
|
||||
"Recovered tool call from markdown code block"
|
||||
);
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", uuid::Uuid::new_v4()),
|
||||
name: potential_tool.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
block_content.clear();
|
||||
in_block = false;
|
||||
} else {
|
||||
in_block = true;
|
||||
block_content.clear();
|
||||
}
|
||||
} else if in_block {
|
||||
if !block_content.is_empty() {
|
||||
block_content.push('\n');
|
||||
}
|
||||
block_content.push_str(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 5: Backtick-wrapped tool call: `tool_name {"key":"value"}`
|
||||
{
|
||||
let parts: Vec<&str> = text.split('`').collect();
|
||||
// Every odd-indexed element is inside backticks
|
||||
for chunk in parts.iter().skip(1).step_by(2) {
|
||||
let trimmed = chunk.trim();
|
||||
if let Some(brace_pos) = trimmed.find('{') {
|
||||
let potential_tool = trimmed[..brace_pos].trim();
|
||||
if !potential_tool.is_empty()
|
||||
&& !potential_tool.contains(' ')
|
||||
&& tool_names.contains(&potential_tool)
|
||||
{
|
||||
if let Ok(input) =
|
||||
serde_json::from_str::<serde_json::Value>(trimmed[brace_pos..].trim())
|
||||
{
|
||||
if !calls
|
||||
.iter()
|
||||
.any(|c| c.name == potential_tool && c.input == input)
|
||||
{
|
||||
info!(
|
||||
tool = potential_tool,
|
||||
"Recovered tool call from backtick-wrapped text"
|
||||
);
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", uuid::Uuid::new_v4()),
|
||||
name: potential_tool.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calls
|
||||
}
|
||||
|
||||
@@ -2683,6 +2822,86 @@ mod tests {
|
||||
assert_eq!(calls[1].name, "web_fetch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_tool_tag_variant() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = r#"I'll run that for you. <tool>exec{"command":"ls -la"}</tool>"#;
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "exec");
|
||||
assert_eq!(calls[0].input["command"], "ls -la");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_markdown_code_block() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = "I'll execute that command:\n```\nexec {\"command\": \"ls -la\"}\n```";
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "exec");
|
||||
assert_eq!(calls[0].input["command"], "ls -la");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_markdown_code_block_with_lang() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "web_search".into(),
|
||||
description: "Search".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = "```json\nweb_search {\"query\": \"rust\"}\n```";
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "web_search");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_backtick_wrapped() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = r#"Let me run `exec {"command":"pwd"}` for you."#;
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "exec");
|
||||
assert_eq!(calls[0].input["command"], "pwd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_backtick_ignores_unknown_tool() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = r#"Try `unknown_tool {"key":"val"}` instead."#;
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert!(calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_no_duplicates_across_patterns() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
// Same call in both function tag and tool tag — should only appear once
|
||||
let text = r#"<function=exec>{"command":"ls"}</function> <tool>exec{"command":"ls"}</tool>"#;
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
}
|
||||
|
||||
// --- End-to-end integration test: text-as-tool-call recovery through agent loop ---
|
||||
|
||||
/// Mock driver that simulates a Groq/Llama model outputting tool calls as text.
|
||||
|
||||
@@ -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()),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,16 +26,29 @@ impl OpenAIDriver {
|
||||
api_key: Zeroizing::new(api_key),
|
||||
base_url,
|
||||
client: reqwest::Client::new(),
|
||||
extra_headers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a driver with additional HTTP headers (e.g. for Copilot IDE auth).
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OaiRequest {
|
||||
model: String,
|
||||
messages: Vec<OaiMessage>,
|
||||
max_tokens: u32,
|
||||
temperature: f32,
|
||||
/// Classic token limit field (used by most models).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
/// New token limit field required by GPT-5 and o-series reasoning models.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_completion_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
tools: Vec<OaiTool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -43,6 +57,16 @@ struct OaiRequest {
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
/// Returns true if a model uses `max_completion_tokens` instead of `max_tokens`.
|
||||
fn uses_completion_tokens(model: &str) -> bool {
|
||||
let m = model.to_lowercase();
|
||||
m.starts_with("gpt-5")
|
||||
|| m.starts_with("gpt5")
|
||||
|| m.starts_with("o1")
|
||||
|| m.starts_with("o3")
|
||||
|| m.starts_with("o4")
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OaiMessage {
|
||||
role: String,
|
||||
@@ -276,11 +300,17 @@ impl LlmDriver for OpenAIDriver {
|
||||
Some(serde_json::json!("auto"))
|
||||
};
|
||||
|
||||
let (mt, mct) = if uses_completion_tokens(&request.model) {
|
||||
(None, Some(request.max_tokens))
|
||||
} else {
|
||||
(Some(request.max_tokens), None)
|
||||
};
|
||||
let mut oai_request = OaiRequest {
|
||||
model: request.model.clone(),
|
||||
messages: oai_messages,
|
||||
max_tokens: request.max_tokens,
|
||||
temperature: request.temperature,
|
||||
max_tokens: mt,
|
||||
max_completion_tokens: mct,
|
||||
temperature: Some(request.temperature),
|
||||
tools: oai_tools,
|
||||
tool_choice,
|
||||
stream: false,
|
||||
@@ -301,6 +331,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
req_builder = req_builder
|
||||
.header("authorization", format!("Bearer {}", self.api_key.as_str()));
|
||||
}
|
||||
for (k, v) in &self.extra_headers {
|
||||
req_builder = req_builder.header(k, v);
|
||||
}
|
||||
|
||||
let resp = req_builder
|
||||
.send()
|
||||
@@ -339,16 +372,53 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
|
||||
if status == 400
|
||||
&& body.contains("max_tokens")
|
||||
&& (body.contains("unsupported_parameter")
|
||||
|| body.contains("max_completion_tokens"))
|
||||
&& oai_request.max_tokens.is_some()
|
||||
&& attempt < max_retries
|
||||
{
|
||||
let val = oai_request.max_tokens.unwrap();
|
||||
warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model");
|
||||
oai_request.max_tokens = None;
|
||||
oai_request.max_completion_tokens = Some(val);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-cap max_tokens when model rejects our value (e.g. Groq Maverick limit 8192)
|
||||
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
|
||||
// Extract the limit from error: "must be less than or equal to `8192`"
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2);
|
||||
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
|
||||
warn!(old = current, new = cap, "Auto-capping max_tokens to model limit");
|
||||
if oai_request.max_completion_tokens.is_some() {
|
||||
oai_request.max_completion_tokens = Some(cap);
|
||||
} else {
|
||||
oai_request.max_tokens = Some(cap);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Model doesn't support function calling — retry without tools
|
||||
// (e.g. GLM-5 on DashScope returns 500 "internal error" when tools are sent)
|
||||
let body_lower = body.to_lowercase();
|
||||
if !oai_request.tools.is_empty()
|
||||
&& attempt < max_retries
|
||||
&& (status == 500
|
||||
|| body_lower.contains("internal error")
|
||||
|| (status == 400
|
||||
&& (body_lower.contains("does not support tools")
|
||||
|| body_lower.contains("tool")
|
||||
&& body_lower.contains("not supported"))))
|
||||
{
|
||||
warn!(
|
||||
old = oai_request.max_tokens,
|
||||
new = cap,
|
||||
"Auto-capping max_tokens to model limit"
|
||||
model = %oai_request.model,
|
||||
status,
|
||||
"Model may not support tools, retrying without tools"
|
||||
);
|
||||
oai_request.max_tokens = cap;
|
||||
oai_request.tools.clear();
|
||||
oai_request.tool_choice = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -555,11 +625,17 @@ impl LlmDriver for OpenAIDriver {
|
||||
Some(serde_json::json!("auto"))
|
||||
};
|
||||
|
||||
let (mt, mct) = if uses_completion_tokens(&request.model) {
|
||||
(None, Some(request.max_tokens))
|
||||
} else {
|
||||
(Some(request.max_tokens), None)
|
||||
};
|
||||
let mut oai_request = OaiRequest {
|
||||
model: request.model.clone(),
|
||||
messages: oai_messages,
|
||||
max_tokens: request.max_tokens,
|
||||
temperature: request.temperature,
|
||||
max_tokens: mt,
|
||||
max_completion_tokens: mct,
|
||||
temperature: Some(request.temperature),
|
||||
tools: oai_tools,
|
||||
tool_choice,
|
||||
stream: true,
|
||||
@@ -581,6 +657,9 @@ impl LlmDriver for OpenAIDriver {
|
||||
req_builder = req_builder
|
||||
.header("authorization", format!("Bearer {}", self.api_key.as_str()));
|
||||
}
|
||||
for (k, v) in &self.extra_headers {
|
||||
req_builder = req_builder.header(k, v);
|
||||
}
|
||||
|
||||
let resp = req_builder
|
||||
.send()
|
||||
@@ -620,15 +699,52 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
|
||||
if status == 400
|
||||
&& body.contains("max_tokens")
|
||||
&& (body.contains("unsupported_parameter")
|
||||
|| body.contains("max_completion_tokens"))
|
||||
&& oai_request.max_tokens.is_some()
|
||||
&& attempt < max_retries
|
||||
{
|
||||
let val = oai_request.max_tokens.unwrap();
|
||||
warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model (stream)");
|
||||
oai_request.max_tokens = None;
|
||||
oai_request.max_completion_tokens = Some(val);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-cap max_tokens when model rejects our value
|
||||
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2);
|
||||
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
|
||||
warn!(old = current, new = cap, "Auto-capping max_tokens (stream)");
|
||||
if oai_request.max_completion_tokens.is_some() {
|
||||
oai_request.max_completion_tokens = Some(cap);
|
||||
} else {
|
||||
oai_request.max_tokens = Some(cap);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Model doesn't support function calling — retry without tools
|
||||
let body_lower = body.to_lowercase();
|
||||
if !oai_request.tools.is_empty()
|
||||
&& attempt < max_retries
|
||||
&& (status == 500
|
||||
|| body_lower.contains("internal error")
|
||||
|| (status == 400
|
||||
&& (body_lower.contains("does not support tools")
|
||||
|| body_lower.contains("tool")
|
||||
&& body_lower.contains("not supported"))))
|
||||
{
|
||||
warn!(
|
||||
old = oai_request.max_tokens,
|
||||
new = cap,
|
||||
"Auto-capping max_tokens (stream)"
|
||||
model = %oai_request.model,
|
||||
status,
|
||||
"Model may not support tools (stream), retrying without tools"
|
||||
);
|
||||
oai_request.max_tokens = cap;
|
||||
oai_request.tools.clear();
|
||||
oai_request.tool_choice = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,16 @@ pub trait KernelHandle: Send + Sync {
|
||||
Err("Hands system not available".to_string())
|
||||
}
|
||||
|
||||
/// Install a Hand from TOML content.
|
||||
async fn hand_install(
|
||||
&self,
|
||||
toml_content: &str,
|
||||
skill_content: &str,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let _ = (toml_content, skill_content);
|
||||
Err("Hands system not available".to_string())
|
||||
}
|
||||
|
||||
/// Activate a Hand — spawns a specialized autonomous agent.
|
||||
async fn hand_activate(
|
||||
&self,
|
||||
@@ -184,6 +194,21 @@ pub trait KernelHandle: Send + Sync {
|
||||
Err("Channel send not available".to_string())
|
||||
}
|
||||
|
||||
/// Send media content (image/file) to a user on a named channel adapter.
|
||||
/// `media_type` is "image" or "file", `media_url` is the URL, `caption` is optional text.
|
||||
async fn send_channel_media(
|
||||
&self,
|
||||
channel: &str,
|
||||
recipient: &str,
|
||||
media_type: &str,
|
||||
media_url: &str,
|
||||
caption: Option<&str>,
|
||||
filename: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let _ = (channel, recipient, media_type, media_url, caption, filename);
|
||||
Err("Channel media send not available".to_string())
|
||||
}
|
||||
|
||||
/// Spawn an agent with capability inheritance enforcement.
|
||||
/// `parent_caps` are the parent's granted capabilities. The kernel MUST verify
|
||||
/// that every capability in the child manifest is covered by `parent_caps`.
|
||||
|
||||
@@ -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};
|
||||
@@ -32,7 +33,7 @@ pub struct McpServerConfig {
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
30
|
||||
60
|
||||
}
|
||||
|
||||
/// Transport type for MCP server connections.
|
||||
@@ -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,
|
||||
@@ -393,7 +408,31 @@ impl McpConnection {
|
||||
return Err("MCP command path contains '..': rejected".to_string());
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new(command);
|
||||
// On Windows, npm/npx install as .cmd batch wrappers. Detect and adapt.
|
||||
let resolved_command: String = if cfg!(windows) {
|
||||
// If the user already specified .cmd/.bat, use as-is
|
||||
if command.ends_with(".cmd") || command.ends_with(".bat") {
|
||||
command.to_string()
|
||||
} else {
|
||||
// Check if the .cmd variant exists on PATH
|
||||
let cmd_variant = format!("{command}.cmd");
|
||||
let has_cmd = std::env::var("PATH")
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.any(|dir| {
|
||||
std::path::Path::new(dir).join(&cmd_variant).exists()
|
||||
});
|
||||
if has_cmd {
|
||||
cmd_variant
|
||||
} else {
|
||||
command.to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
command.to_string()
|
||||
};
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&resolved_command);
|
||||
cmd.args(args);
|
||||
cmd.stdin(Stdio::piped());
|
||||
cmd.stdout(Stdio::piped());
|
||||
@@ -410,10 +449,41 @@ impl McpConnection {
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
// On Windows, npm/node need APPDATA, USERPROFILE, LOCALAPPDATA, and SystemRoot
|
||||
if cfg!(windows) {
|
||||
for var in &[
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"USERPROFILE",
|
||||
"SystemRoot",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"HOME",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
cmd.env(var, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn MCP server '{command}': {e}"))?;
|
||||
.map_err(|e| format!("Failed to spawn MCP server '{resolved_command}': {e}"))?;
|
||||
|
||||
// Log stderr in background for debugging MCP server issues
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let cmd_name = resolved_command.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let reader = tokio::io::BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::debug!(mcp_server = %cmd_name, "stderr: {line}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
@@ -516,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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,9 +26,17 @@ impl ModelCatalog {
|
||||
/// Create a new catalog populated with builtin models and providers.
|
||||
pub fn new() -> Self {
|
||||
let models = builtin_models();
|
||||
let aliases = builtin_aliases();
|
||||
let mut aliases = builtin_aliases();
|
||||
let mut providers = builtin_providers();
|
||||
|
||||
// Auto-register aliases defined on model entries
|
||||
for model in &models {
|
||||
for alias in &model.aliases {
|
||||
let lower = alias.to_lowercase();
|
||||
aliases.entry(lower).or_insert_with(|| model.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Set model counts on providers
|
||||
for provider in &mut providers {
|
||||
provider.model_count = models.iter().filter(|m| m.provider == provider.id).count();
|
||||
@@ -161,17 +170,39 @@ impl ModelCatalog {
|
||||
p.base_url = url.to_string();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
// Custom provider — add a new entry so it appears in /api/providers
|
||||
let env_var = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"));
|
||||
self.providers.push(ProviderInfo {
|
||||
id: provider.to_string(),
|
||||
display_name: provider.to_string(),
|
||||
api_key_env: env_var,
|
||||
base_url: url.to_string(),
|
||||
key_required: true,
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
});
|
||||
// Re-detect auth for the newly added provider
|
||||
self.detect_auth();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a batch of provider URL overrides from config.
|
||||
///
|
||||
/// Each entry maps a provider ID to a custom base URL.
|
||||
/// Unknown providers are silently skipped.
|
||||
/// Unknown providers are automatically added as custom OpenAI-compatible entries.
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,11 +261,16 @@ impl ModelCatalog {
|
||||
|
||||
/// Add a custom model at runtime.
|
||||
///
|
||||
/// Returns `true` if the model was added, `false` if a model with that ID
|
||||
/// already exists (case-insensitive).
|
||||
/// Returns `true` if the model was added, `false` if a model with the same
|
||||
/// ID **and** provider already exists (case-insensitive).
|
||||
pub fn add_custom_model(&mut self, entry: ModelCatalogEntry) -> bool {
|
||||
let lower = entry.id.to_lowercase();
|
||||
if self.models.iter().any(|m| m.id.to_lowercase() == lower) {
|
||||
let lower_id = entry.id.to_lowercase();
|
||||
let lower_provider = entry.provider.to_lowercase();
|
||||
if self
|
||||
.models
|
||||
.iter()
|
||||
.any(|m| m.id.to_lowercase() == lower_id && m.provider.to_lowercase() == lower_provider)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let provider = entry.provider.clone();
|
||||
@@ -583,6 +619,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 +655,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 +721,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 +736,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 +756,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 +1113,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 +1126,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 +2261,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 +2275,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 +2319,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(),
|
||||
@@ -2468,6 +2597,76 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen3-235b-a22b".into(),
|
||||
display_name: "Qwen3 235B".into(),
|
||||
provider: "qwen".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 4.00,
|
||||
output_cost_per_m: 12.00,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["qwen3".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen3-30b-a3b".into(),
|
||||
display_name: "Qwen3 30B".into(),
|
||||
provider: "qwen".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.30,
|
||||
output_cost_per_m: 0.60,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen-coder-plus-latest".into(),
|
||||
display_name: "Qwen Coder Plus (Latest)".into(),
|
||||
provider: "qwen".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.80,
|
||||
output_cost_per_m: 2.00,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["qwen-coder".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen2.5-coder-32b-instruct".into(),
|
||||
display_name: "Qwen 2.5 Coder 32B".into(),
|
||||
provider: "qwen".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.80,
|
||||
output_cost_per_m: 2.00,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "qwen-vl-max".into(),
|
||||
display_name: "Qwen VL Max".into(),
|
||||
provider: "qwen".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 32_768,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 3.00,
|
||||
output_cost_per_m: 9.00,
|
||||
supports_tools: false,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// MiniMax (4)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -2528,7 +2727,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Zhipu AI / GLM (4)
|
||||
// Zhipu AI / GLM (6)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "glm-4-plus".into(),
|
||||
@@ -2586,6 +2785,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 +2921,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 +3186,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 +3306,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 +3314,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 +3335,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"));
|
||||
@@ -3168,8 +3457,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_set_provider_url_unknown() {
|
||||
let mut catalog = ModelCatalog::new();
|
||||
let updated = catalog.set_provider_url("nonexistent", "http://localhost:9999");
|
||||
assert!(!updated);
|
||||
let initial_count = catalog.list_providers().len();
|
||||
let updated = catalog.set_provider_url("my-custom-llm", "http://localhost:9999");
|
||||
// Unknown providers are now auto-registered as custom entries
|
||||
assert!(updated);
|
||||
assert_eq!(catalog.list_providers().len(), initial_count + 1);
|
||||
assert_eq!(
|
||||
catalog.get_provider("my-custom-llm").unwrap().base_url,
|
||||
"http://localhost:9999"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -215,6 +215,9 @@ const TOOL_CALL_BEHAVIOR: &str = "\
|
||||
- Prefer action over narration. If you can answer by using a tool, do it.
|
||||
- When executing multiple sequential tool calls, batch them — don't output reasoning between each call.
|
||||
- If a tool returns useful results, present the KEY information, not the raw output.
|
||||
- When web_fetch or web_search returns content, you MUST include the relevant data in your response. \
|
||||
Quote specific facts, numbers, or passages from the fetched content. Never say you fetched something \
|
||||
without sharing what you found.
|
||||
- Start with the answer, not meta-commentary about how you'll help.
|
||||
- IMPORTANT: If your instructions or persona mention a shell command, script path, or code snippet, \
|
||||
execute it via the appropriate tool call (shell_exec, file_write, etc.). Never output commands as \
|
||||
|
||||
@@ -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"]
|
||||
}),
|
||||
@@ -996,16 +996,19 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
|
||||
// --- Channel send tool (proactive outbound messaging) ---
|
||||
ToolDefinition {
|
||||
name: "channel_send".to_string(),
|
||||
description: "Send a message to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally prefix the message with 'Subject: Your Subject\\n\\n' to set the email subject.".to_string(),
|
||||
description: "Send a message or media to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally set subject. For media: set image_url or file_url to send an image or file instead of (or alongside) text.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": { "type": "string", "description": "Channel adapter name (e.g., 'email', 'telegram', 'slack', 'discord')" },
|
||||
"recipient": { "type": "string", "description": "Platform-specific recipient identifier (email address, user ID, etc.)" },
|
||||
"subject": { "type": "string", "description": "Optional subject line (used for email; ignored for other channels)" },
|
||||
"message": { "type": "string", "description": "The message body to send" }
|
||||
"message": { "type": "string", "description": "The message body to send (required for text, optional caption for media)" },
|
||||
"image_url": { "type": "string", "description": "URL of an image to send (supported on Telegram, Discord, Slack)" },
|
||||
"file_url": { "type": "string", "description": "URL of a file to send as attachment" },
|
||||
"filename": { "type": "string", "description": "Filename for file attachments (defaults to 'file')" }
|
||||
},
|
||||
"required": ["channel", "recipient", "message"]
|
||||
"required": ["channel", "recipient"]
|
||||
}),
|
||||
},
|
||||
// --- Hand tools (curated autonomous capability packages) ---
|
||||
@@ -2119,24 +2122,44 @@ async fn tool_channel_send(
|
||||
.as_str()
|
||||
.ok_or("Missing 'recipient' parameter")?
|
||||
.trim();
|
||||
let message = input["message"]
|
||||
.as_str()
|
||||
.ok_or("Missing 'message' parameter")?;
|
||||
|
||||
if recipient.is_empty() {
|
||||
return Err("Recipient cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Check for media content (image_url or file_url)
|
||||
let image_url = input["image_url"].as_str().filter(|s| !s.is_empty());
|
||||
let file_url = input["file_url"].as_str().filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(url) = image_url {
|
||||
let caption = input["message"].as_str().filter(|s| !s.is_empty());
|
||||
return kh
|
||||
.send_channel_media(&channel, recipient, "image", url, caption, None)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(url) = file_url {
|
||||
let caption = input["message"].as_str().filter(|s| !s.is_empty());
|
||||
let filename = input["filename"].as_str();
|
||||
return kh
|
||||
.send_channel_media(&channel, recipient, "file", url, caption, filename)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Text-only message
|
||||
let message = input["message"]
|
||||
.as_str()
|
||||
.ok_or("Missing 'message' parameter (required for text messages)")?;
|
||||
|
||||
if message.is_empty() {
|
||||
return Err("Message cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// For email channels, validate email format and prepend subject
|
||||
let final_message = if channel == "email" {
|
||||
// Basic email format validation
|
||||
if !recipient.contains('@') || !recipient.contains('.') {
|
||||
return Err(format!("Invalid email address: '{recipient}'"));
|
||||
}
|
||||
// Prepend subject if provided
|
||||
if let Some(subject) = input["subject"].as_str() {
|
||||
if !subject.is_empty() {
|
||||
format!("Subject: {subject}\n\n{message}")
|
||||
|
||||
@@ -20,6 +20,7 @@ reqwest = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::SkillError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response types (matching actual ClawHub v1 API — verified Feb 2026)
|
||||
@@ -123,7 +123,7 @@ pub struct ClawHubSearchEntry {
|
||||
#[serde(default)]
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
pub version: Option<String>,
|
||||
/// Unix ms timestamp.
|
||||
#[serde(default)]
|
||||
pub updated_at: i64,
|
||||
@@ -459,27 +459,49 @@ impl ClawHubClient {
|
||||
let skill_dir = target_dir.join(slug);
|
||||
std::fs::create_dir_all(&skill_dir)?;
|
||||
|
||||
// Extract: ClawHub delivers as zip bundles. For now, detect content type
|
||||
// and write accordingly. A full zip extraction would use the `zip` crate.
|
||||
// Detect content type and extract accordingly
|
||||
let content_str = String::from_utf8_lossy(&bytes);
|
||||
let is_skillmd = content_str.trim_start().starts_with("---");
|
||||
|
||||
if is_skillmd {
|
||||
std::fs::write(skill_dir.join("SKILL.md"), &*bytes)?;
|
||||
} else {
|
||||
// Try to detect if it's a zip by magic bytes (PK\x03\x04)
|
||||
if bytes.len() >= 4 && bytes[0] == 0x50 && bytes[1] == 0x4b {
|
||||
// It's a zip — write as raw zip for future extraction
|
||||
std::fs::write(skill_dir.join("skill.zip"), &*bytes)?;
|
||||
// Try to fetch the SKILL.md file directly as fallback
|
||||
if let Ok(skillmd_content) = self.get_file(slug, "SKILL.md").await {
|
||||
std::fs::write(skill_dir.join("SKILL.md"), &skillmd_content)?;
|
||||
} else {
|
||||
std::fs::write(skill_dir.join("package.json"), &*bytes)?;
|
||||
} else if bytes.len() >= 4 && bytes[0] == 0x50 && bytes[1] == 0x4b {
|
||||
// Zip archive — extract all files
|
||||
let cursor = std::io::Cursor::new(&*bytes);
|
||||
match zip::ZipArchive::new(cursor) {
|
||||
Ok(mut archive) => {
|
||||
for i in 0..archive.len() {
|
||||
let mut file = match archive.by_index(i) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!(index = i, error = %e, "Skipping zip entry");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(enclosed_name) = file.enclosed_name() else {
|
||||
warn!("Skipping zip entry with unsafe path");
|
||||
continue;
|
||||
};
|
||||
let out_path = skill_dir.join(enclosed_name);
|
||||
if file.is_dir() {
|
||||
std::fs::create_dir_all(&out_path)?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut out_file = std::fs::File::create(&out_path)?;
|
||||
std::io::copy(&mut file, &mut out_file)?;
|
||||
}
|
||||
}
|
||||
info!(slug, entries = archive.len(), "Extracted skill zip");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(slug, error = %e, "Failed to read zip, saving raw");
|
||||
std::fs::write(skill_dir.join("skill.zip"), &*bytes)?;
|
||||
}
|
||||
} else {
|
||||
std::fs::write(skill_dir.join("package.json"), &*bytes)?;
|
||||
}
|
||||
} else {
|
||||
std::fs::write(skill_dir.join("package.json"), &*bytes)?;
|
||||
}
|
||||
|
||||
// Step 2-3: Detect format and convert
|
||||
@@ -676,7 +698,7 @@ mod tests {
|
||||
assert_eq!(entry.slug, "github");
|
||||
assert_eq!(entry.display_name, "Github");
|
||||
assert!(entry.score > 3.0);
|
||||
assert_eq!(entry.version, "1.0.0");
|
||||
assert_eq!(entry.version.as_deref(), Some("1.0.0"));
|
||||
assert_eq!(entry.updated_at, 1771777539580);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 +1561,12 @@ 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: 33280 = GUILD_MESSAGES | MESSAGE_CONTENT).
|
||||
/// Gateway intents bitmask (default: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT).
|
||||
pub intents: u64,
|
||||
/// Per-channel behavior overrides.
|
||||
#[serde(default)]
|
||||
@@ -1568,8 +1578,9 @@ impl Default for DiscordConfig {
|
||||
Self {
|
||||
bot_token_env: "DISCORD_BOT_TOKEN".to_string(),
|
||||
allowed_guilds: vec![],
|
||||
allowed_users: vec![],
|
||||
default_agent: None,
|
||||
intents: 33280,
|
||||
intents: 37376,
|
||||
overrides: ChannelOverrides::default(),
|
||||
}
|
||||
}
|
||||
@@ -3226,7 +3237,7 @@ mod tests {
|
||||
let dc = DiscordConfig::default();
|
||||
assert_eq!(dc.bot_token_env, "DISCORD_BOT_TOKEN");
|
||||
assert!(dc.allowed_guilds.is_empty());
|
||||
assert_eq!(dc.intents, 33280);
|
||||
assert_eq!(dc.intents, 37376);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user