Compare commits

...
16 Commits
Author SHA1 Message Date
jaberjaber23 e58ae3e304 fix providers 2026-03-01 00:26:36 +03:00
jaberjaber23 74f5a91fdd bug fixes 2026-02-28 21:20:48 +03:00
jaberjaber23 6de0447e8c coffee badge 2026-02-28 15:42:03 +03:00
jaberjaber23 45dbf617a7 bump v0.2.0 2026-02-28 15:39:20 +03:00
jaberjaber23 b416bf417f critical fixes 2026-02-28 15:36:46 +03:00
jaberjaber23 1cd47d3905 fix bugs 2026-02-28 04:52:49 +03:00
jaberjaber23 a9124c34f2 real email 2026-02-28 02:24:58 +03:00
jaberjaber23 7bd0185695 bump version 2026-02-27 21:06:43 +03:00
jaberjaber23 9fc7ed87be fix MCP 2026-02-27 20:58:34 +03:00
jaberjaber23 0bb08f4ae1 PR fixes 2026-02-27 20:47:14 +03:00
jaberjaber23 51c1e154d9 MCP guidance 2026-02-27 18:06:17 +03:00
jaberjaber23 036cc14ad5 fix installer 2026-02-27 17:29:18 +03:00
jaberjaber23 b866ae7055 fix installer 2026-02-27 17:27:25 +03:00
jaberjaber23 15a859b7f9 fix issues 2026-02-27 17:13:10 +03:00
jaberjaber23 eda88ba5a2 provider URLs 2026-02-27 15:56:57 +03:00
jaberjaber23 0bb4e6f17b community fixes 2026-02-27 01:58:57 +03:00
72 changed files with 3760 additions and 372 deletions
+8
View File
@@ -42,6 +42,11 @@ jobs:
args: "--target x86_64-pc-windows-msvc"
rust_target: x86_64-pc-windows-msvc
- name: Windows ARM64
os: windows-latest
args: "--target aarch64-pc-windows-msvc"
rust_target: aarch64-pc-windows-msvc
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
@@ -152,6 +157,9 @@ jobs:
- target: x86_64-pc-windows-msvc
os: windows-latest
archive: zip
- target: aarch64-pc-windows-msvc
os: windows-latest
archive: zip
steps:
- uses: actions/checkout@v4
Generated
+315 -17
View File
@@ -159,6 +159,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ar_archive_writer"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
dependencies = [
"object",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -180,6 +189,12 @@ dependencies = [
"password-hash",
]
[[package]]
name = "arrayvec"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -424,6 +439,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.21.7"
@@ -527,6 +548,12 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bufstream"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8"
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -696,6 +723,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "charset"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e"
dependencies = [
"base64 0.22.1",
"encoding_rs",
]
[[package]]
name = "chrono"
version = "0.4.43"
@@ -710,6 +747,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "chumsky"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
dependencies = [
"hashbrown 0.14.5",
"stacker",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -891,7 +938,7 @@ dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -1071,6 +1118,17 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "cron"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740"
dependencies = [
"chrono",
"once_cell",
"winnow 0.6.26",
]
[[package]]
name = "crossbeam"
version = "0.8.4"
@@ -1570,6 +1628,22 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64 0.22.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "embed-resource"
version = "3.0.6"
@@ -1774,6 +1848,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1781,7 +1864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1795,6 +1878,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -2343,6 +2432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
@@ -2405,6 +2495,17 @@ dependencies = [
"digest",
]
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link 0.2.1",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -2699,6 +2800,31 @@ dependencies = [
"png 0.18.1",
]
[[package]]
name = "imap"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c617c55def8c42129e0dd503f11d7ee39d73f5c7e01eff55768b3879ff1d107d"
dependencies = [
"base64 0.13.1",
"bufstream",
"chrono",
"imap-proto",
"lazy_static",
"native-tls",
"nom 5.1.3",
"regex",
]
[[package]]
name = "imap-proto"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a6def1d5ac8975d70b3fd101d57953fe3278ef2ee5d7816cba54b1d1dfc22f"
dependencies = [
"nom 5.1.3",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2980,6 +3106,48 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lettre"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
dependencies = [
"async-trait",
"base64 0.22.1",
"chumsky",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna",
"mime",
"nom 8.0.0",
"percent-encoding",
"quoted_printable",
"rustls",
"socket2",
"tokio",
"tokio-rustls",
"url",
"webpki-roots",
]
[[package]]
name = "lexical-core"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"cfg-if",
"ryu",
"static_assertions",
]
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -3123,6 +3291,17 @@ dependencies = [
"libc",
]
[[package]]
name = "mailparse"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da03d5980411a724e8aaf7b61a7b5e386ec55a7fb49ee3d0ff79efc7e5e7c7e"
dependencies = [
"charset",
"data-encoding",
"quoted_printable",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -3268,6 +3447,23 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -3316,6 +3512,26 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "5.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b"
dependencies = [
"lexical-core",
"memchr",
"version_check",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
@@ -3650,7 +3866,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"async-trait",
"axum",
@@ -3686,7 +3902,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"async-trait",
"axum",
@@ -3696,6 +3912,10 @@ dependencies = [
"futures",
"hex",
"hmac",
"imap",
"lettre",
"mailparse",
"native-tls",
"openfang-types",
"reqwest 0.12.28",
"serde",
@@ -3713,7 +3933,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"clap",
"clap_complete",
@@ -3740,7 +3960,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"axum",
"open",
@@ -3766,7 +3986,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"aes-gcm",
"argon2",
@@ -3794,7 +4014,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"chrono",
"dashmap",
@@ -3811,10 +4031,11 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"async-trait",
"chrono",
"cron",
"crossbeam",
"dashmap",
"dirs 6.0.0",
@@ -3846,7 +4067,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"async-trait",
"chrono",
@@ -3865,7 +4086,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -3884,7 +4105,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"anyhow",
"async-trait",
@@ -3915,7 +4136,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"chrono",
"hex",
@@ -3937,7 +4158,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"async-trait",
"chrono",
@@ -3956,7 +4177,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"async-trait",
"chrono",
@@ -3975,12 +4196,50 @@ dependencies = [
"uuid",
]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -4521,6 +4780,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "pulley-interpreter"
version = "41.0.3"
@@ -4638,7 +4907,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -4650,6 +4919,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
[[package]]
name = "r-efi"
version = "5.3.0"
@@ -5147,6 +5422,7 @@ version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -5772,6 +6048,19 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.59.0",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -8250,6 +8539,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.6.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.7.14"
@@ -8491,7 +8789,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.1.0"
version = "0.2.1"
[[package]]
name = "yoke"
+7 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
version = "0.2.2"
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"
# Email (SMTP + IMAP)
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
imap = "2"
native-tls = "0.2"
mailparse = "0.15"
# Testing
tokio-test = "0.4"
tempfile = "3"
+2 -1
View File
@@ -22,6 +22,7 @@
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
</p>
---
@@ -412,7 +413,7 @@ MIT — use it however you want.
<p align="center">
<a href="https://www.rightnowai.co/">Website</a> &bull;
<a href="https://x.com/Akashi203">Twitter / X</a> &bull;
<a href="https://github.com/sponsors/RightNow-AI">Sponsor</a>
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
</p>
---
+16 -5
View File
@@ -785,6 +785,15 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
)
};
self.kernel.delivery_tracker.record(agent_id, receipt);
// Persist last channel for cron CronDelivery::LastChannel
if success {
let kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
let _ = self
.kernel
.memory
.structured_set(agent_id, "delivery.last_channel", kv_val);
}
}
async fn check_auto_reply(&self, agent_id: AgentId, message: &str) -> Option<String> {
@@ -884,11 +893,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
msg.push_str(&format!(" {}{}\n", card.name, url));
let desc = &card.description;
if !desc.is_empty() {
let short = if desc.len() > 60 {
&desc[..60]
} else {
desc.as_str()
};
let short = openfang_types::truncate_str(desc, 60);
msg.push_str(&format!(" {short}\n"));
}
}
@@ -1571,12 +1576,18 @@ pub async fn start_channel_bridge_with_config(
let mut started_names = Vec::new();
for (adapter, _) in adapters {
let name = adapter.name().to_string();
// Register adapter in kernel so agents can use `channel_send` tool
kernel
.channel_adapters
.insert(name.clone(), adapter.clone());
match manager.start_adapter(adapter).await {
Ok(()) => {
info!("{name} channel bridge started");
started_names.push(name);
}
Err(e) => {
// Remove from kernel map if start failed
kernel.channel_adapters.remove(&name);
error!("Failed to start {name} bridge: {e}");
}
}
+20
View File
@@ -90,6 +90,26 @@ pub async fn auth(
|| path == "/api/profiles"
|| path == "/api/config"
|| path.starts_with("/api/uploads/")
// Dashboard read endpoints — allow unauthenticated so the SPA can
// render before the user enters their API key.
|| path == "/api/models"
|| path == "/api/models/aliases"
|| path == "/api/providers"
|| path == "/api/budget"
|| path == "/api/budget/agents"
|| path.starts_with("/api/budget/agents/")
|| path == "/api/network/status"
|| path == "/api/a2a/agents"
|| path == "/api/approvals"
|| path.starts_with("/api/approvals/")
|| path == "/api/channels"
|| path == "/api/skills"
|| path == "/api/sessions"
|| path == "/api/integrations"
|| path == "/api/integrations/available"
|| path == "/api/integrations/health"
|| path.starts_with("/api/cron/")
|| path.starts_with("/api/providers/github-copilot/oauth/")
{
return next.run(request).await;
}
+355 -7
View File
@@ -285,6 +285,7 @@ pub async fn send_message(
input_tokens: result.total_usage.input_tokens,
output_tokens: result.total_usage.output_tokens,
iterations: result.iterations,
cost_usd: result.cost_usd,
})),
)
}
@@ -3639,10 +3640,9 @@ pub async fn hand_instance_browser(
url = data["url"].as_str().unwrap_or("").to_string();
title = data["title"].as_str().unwrap_or("").to_string();
content = data["content"].as_str().unwrap_or("").to_string();
// Truncate content to avoid huge payloads (keep first 2000 chars)
// Truncate content to avoid huge payloads (UTF-8 safe)
if content.len() > 2000 {
content.truncate(2000);
content.push_str("... (truncated)");
content = format!("{}... (truncated)", openfang_types::truncate_str(&content, 2000));
}
}
}
@@ -3996,9 +3996,9 @@ pub async fn network_status(State(state): State<Arc<AppState>>) -> impl IntoResp
// Tools endpoint
// ---------------------------------------------------------------------------
/// GET /api/tools — List all built-in tool definitions.
pub async fn list_tools() -> impl IntoResponse {
let tools: Vec<serde_json::Value> = builtin_tool_definitions()
/// GET /api/tools — List all tool definitions (built-in + MCP).
pub async fn list_tools(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let mut tools: Vec<serde_json::Value> = builtin_tool_definitions()
.iter()
.map(|t| {
serde_json::json!({
@@ -4009,6 +4009,18 @@ pub async fn list_tools() -> impl IntoResponse {
})
.collect();
// Include MCP tools so they're visible in Settings -> Tools
if let Ok(mcp_tools) = state.kernel.mcp_tools.lock() {
for t in mcp_tools.iter() {
tools.push(serde_json::json!({
"name": t.name,
"description": t.description,
"input_schema": t.input_schema,
"source": "mcp",
}));
}
}
Json(serde_json::json!({"tools": tools, "total": tools.len()}))
}
@@ -4842,6 +4854,7 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
"model_count": p.model_count,
"key_required": p.key_required,
"api_key_env": p.api_key_env,
"base_url": p.base_url,
});
// For local providers, add reachability info via health probe
@@ -5899,6 +5912,122 @@ pub async fn test_provider(
}
}
/// PUT /api/providers/{name}/url — Set a custom base URL for a provider.
pub async fn set_provider_url(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
// Validate provider exists
let provider_exists = {
let catalog = state
.kernel
.model_catalog
.read()
.unwrap_or_else(|e| e.into_inner());
catalog.get_provider(&name).is_some()
};
if !provider_exists {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})),
);
}
let base_url = match body["base_url"].as_str() {
Some(u) if !u.trim().is_empty() => u.trim().to_string(),
_ => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Missing or empty 'base_url' field"})),
);
}
};
// Validate URL scheme
if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "base_url must start with http:// or https://"})),
);
}
// Update catalog in memory
{
let mut catalog = state
.kernel
.model_catalog
.write()
.unwrap_or_else(|e| e.into_inner());
catalog.set_provider_url(&name, &base_url);
}
// Persist to config.toml [provider_urls] section
let config_path = state.kernel.config.home_dir.join("config.toml");
if let Err(e) = upsert_provider_url(&config_path, &name, &base_url) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
);
}
// Probe reachability at the new URL
let probe =
openfang_runtime::provider_health::probe_provider(&name, &base_url).await;
(
StatusCode::OK,
Json(serde_json::json!({
"status": "saved",
"provider": name,
"base_url": base_url,
"reachable": probe.reachable,
"latency_ms": probe.latency_ms,
})),
)
}
/// Upsert a provider URL in the `[provider_urls]` section of config.toml.
fn upsert_provider_url(
config_path: &std::path::Path,
provider: &str,
url: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let content = if config_path.exists() {
std::fs::read_to_string(config_path)?
} else {
String::new()
};
let mut doc: toml::Value = if content.trim().is_empty() {
toml::Value::Table(toml::map::Map::new())
} else {
toml::from_str(&content)?
};
let root = doc.as_table_mut().ok_or("Config is not a TOML table")?;
if !root.contains_key("provider_urls") {
root.insert(
"provider_urls".to_string(),
toml::Value::Table(toml::map::Map::new()),
);
}
let urls_table = root
.get_mut("provider_urls")
.and_then(|v| v.as_table_mut())
.ok_or("provider_urls is not a table")?;
urls_table.insert(provider.to_string(), toml::Value::String(url.to_string()));
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(config_path, toml::to_string_pretty(&doc)?)?;
Ok(())
}
/// POST /api/skills/create — Create a local prompt-only skill.
pub async fn create_skill(
State(state): State<Arc<AppState>>,
@@ -6813,6 +6942,36 @@ pub async fn patch_agent_config(
}
};
// Input length limits
const MAX_NAME_LEN: usize = 256;
const MAX_DESC_LEN: usize = 4096;
const MAX_PROMPT_LEN: usize = 65_536;
if let Some(ref name) = req.name {
if name.len() > MAX_NAME_LEN {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": format!("Name exceeds max length ({MAX_NAME_LEN} chars)")})),
);
}
}
if let Some(ref desc) = req.description {
if desc.len() > MAX_DESC_LEN {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": format!("Description exceeds max length ({MAX_DESC_LEN} chars)")})),
);
}
}
if let Some(ref prompt) = req.system_prompt {
if prompt.len() > MAX_PROMPT_LEN {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": format!("System prompt exceeds max length ({MAX_PROMPT_LEN} chars)")})),
);
}
}
// Validate color format if provided
if let Some(ref color) = req.color {
if !color.is_empty() && !color.starts_with('#') {
@@ -6952,6 +7111,13 @@ pub async fn clone_agent(
}
};
if req.new_name.len() > 256 {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": "Name exceeds max length (256 chars)"})),
);
}
if req.new_name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
@@ -7524,10 +7690,42 @@ pub async fn serve_upload(Path(file_id): Path<String>) -> impl IntoResponse {
// ---------------------------------------------------------------------------
/// GET /api/approvals — List pending approval requests.
///
/// Transforms field names to match the dashboard template expectations:
/// `action_summary` → `action`, `agent_id` → `agent_name`, `requested_at` → `created_at`.
pub async fn list_approvals(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pending = state.kernel.approval_manager.list_pending();
let total = pending.len();
Json(serde_json::json!({"approvals": pending, "total": total}))
// Resolve agent names for display
let registry_agents = state.kernel.registry.list();
let approvals: Vec<serde_json::Value> = pending
.into_iter()
.map(|a| {
let agent_name = registry_agents
.iter()
.find(|ag| ag.id.to_string() == a.agent_id || ag.name == a.agent_id)
.map(|ag| ag.name.as_str())
.unwrap_or(&a.agent_id);
serde_json::json!({
"id": a.id,
"agent_id": a.agent_id,
"agent_name": agent_name,
"tool_name": a.tool_name,
"description": a.description,
"action_summary": a.action_summary,
"action": a.action_summary,
"risk_level": a.risk_level,
"requested_at": a.requested_at,
"created_at": a.requested_at,
"timeout_secs": a.timeout_secs,
"status": "pending"
})
})
.collect();
Json(serde_json::json!({"approvals": approvals, "total": total}))
}
/// POST /api/approvals — Create a manual approval request (for external systems).
@@ -8505,3 +8703,153 @@ fn validate_webhook_token(headers: &axum::http::HeaderMap, token_env: &str) -> b
}
provided.as_bytes().ct_eq(expected.as_bytes()).into()
}
// ══════════════════════════════════════════════════════════════════════
// GitHub Copilot OAuth Device Flow
// ══════════════════════════════════════════════════════════════════════
/// State for an in-progress device flow.
struct CopilotFlowState {
device_code: String,
interval: u64,
expires_at: Instant,
}
/// Active device flows, keyed by poll_id. Auto-expire after the flow's TTL.
static COPILOT_FLOWS: LazyLock<DashMap<String, CopilotFlowState>> = LazyLock::new(DashMap::new);
/// POST /api/providers/github-copilot/oauth/start
///
/// Initiates a GitHub device flow for Copilot authentication.
/// Returns a user code and verification URI that the user visits in their browser.
pub async fn copilot_oauth_start() -> impl IntoResponse {
// Clean up expired flows first
COPILOT_FLOWS.retain(|_, state| state.expires_at > Instant::now());
match openfang_runtime::copilot_oauth::start_device_flow().await {
Ok(resp) => {
let poll_id = uuid::Uuid::new_v4().to_string();
COPILOT_FLOWS.insert(
poll_id.clone(),
CopilotFlowState {
device_code: resp.device_code,
interval: resp.interval,
expires_at: Instant::now()
+ std::time::Duration::from_secs(resp.expires_in),
},
);
(
StatusCode::OK,
Json(serde_json::json!({
"user_code": resp.user_code,
"verification_uri": resp.verification_uri,
"poll_id": poll_id,
"expires_in": resp.expires_in,
"interval": resp.interval,
})),
)
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e })),
),
}
}
/// GET /api/providers/github-copilot/oauth/poll/{poll_id}
///
/// Poll the status of a GitHub device flow.
/// Returns `pending`, `complete`, `expired`, `denied`, or `error`.
/// On `complete`, saves the token to secrets.env and sets GITHUB_TOKEN.
pub async fn copilot_oauth_poll(
State(state): State<Arc<AppState>>,
Path(poll_id): Path<String>,
) -> impl IntoResponse {
let flow = match COPILOT_FLOWS.get(&poll_id) {
Some(f) => f,
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"status": "not_found", "error": "Unknown poll_id"})),
)
}
};
if flow.expires_at <= Instant::now() {
drop(flow);
COPILOT_FLOWS.remove(&poll_id);
return (
StatusCode::OK,
Json(serde_json::json!({"status": "expired"})),
);
}
let device_code = flow.device_code.clone();
drop(flow);
match openfang_runtime::copilot_oauth::poll_device_flow(&device_code).await {
openfang_runtime::copilot_oauth::DeviceFlowStatus::Pending => (
StatusCode::OK,
Json(serde_json::json!({"status": "pending"})),
),
openfang_runtime::copilot_oauth::DeviceFlowStatus::Complete { access_token } => {
// Save to secrets.env
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
if let Err(e) = write_secret_env(&secrets_path, "GITHUB_TOKEN", &access_token) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")})),
);
}
// Set in current process
std::env::set_var("GITHUB_TOKEN", access_token.as_str());
// Refresh auth detection
state
.kernel
.model_catalog
.write()
.unwrap_or_else(|e| e.into_inner())
.detect_auth();
// Clean up flow state
COPILOT_FLOWS.remove(&poll_id);
(
StatusCode::OK,
Json(serde_json::json!({"status": "complete"})),
)
}
openfang_runtime::copilot_oauth::DeviceFlowStatus::SlowDown { new_interval } => {
// Update interval
if let Some(mut f) = COPILOT_FLOWS.get_mut(&poll_id) {
f.interval = new_interval;
}
(
StatusCode::OK,
Json(serde_json::json!({"status": "pending", "interval": new_interval})),
)
}
openfang_runtime::copilot_oauth::DeviceFlowStatus::Expired => {
COPILOT_FLOWS.remove(&poll_id);
(
StatusCode::OK,
Json(serde_json::json!({"status": "expired"})),
)
}
openfang_runtime::copilot_oauth::DeviceFlowStatus::AccessDenied => {
COPILOT_FLOWS.remove(&poll_id);
(
StatusCode::OK,
Json(serde_json::json!({"status": "denied"})),
)
}
openfang_runtime::copilot_oauth::DeviceFlowStatus::Error(e) => (
StatusCode::OK,
Json(serde_json::json!({"status": "error", "error": e})),
),
}
}
+13
View File
@@ -452,6 +452,15 @@ pub async fn build_router(
)
.route("/api/models/{*id}", axum::routing::get(routes::get_model))
.route("/api/providers", axum::routing::get(routes::list_providers))
// Copilot OAuth (must be before parametric {name} routes)
.route(
"/api/providers/github-copilot/oauth/start",
axum::routing::post(routes::copilot_oauth_start),
)
.route(
"/api/providers/github-copilot/oauth/poll/{poll_id}",
axum::routing::get(routes::copilot_oauth_poll),
)
.route(
"/api/providers/{name}/key",
axum::routing::post(routes::set_provider_key).delete(routes::delete_provider_key),
@@ -460,6 +469,10 @@ pub async fn build_router(
"/api/providers/{name}/test",
axum::routing::post(routes::test_provider),
)
.route(
"/api/providers/{name}/url",
axum::routing::put(routes::set_provider_url),
)
.route(
"/api/skills/create",
axum::routing::post(routes::create_skill),
+2
View File
@@ -46,6 +46,8 @@ pub struct MessageResponse {
pub input_tokens: u64,
pub output_tokens: u64,
pub iterations: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<f64>,
}
/// Request to install a skill from the marketplace.
+3
View File
@@ -1111,6 +1111,9 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
llm_errors::LlmErrorCategory::ModelNotFound => {
"Model unavailable. Use /model to see options.".to_string()
}
llm_errors::LlmErrorCategory::Format => {
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
}
_ => classified.sanitized_message,
}
}
+17 -11
View File
@@ -1098,19 +1098,25 @@ mark.search-highlight {
font-weight: 500;
}
/* Theme toggle */
.theme-toggle {
cursor: pointer;
padding: 6px 8px;
/* Theme switcher — 3-mode pill (Light / System / Dark) */
.theme-switcher {
display: inline-flex;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 16px;
background: none;
border: 1px solid transparent;
transition: all 0.2s;
border: 1px solid var(--border);
overflow: hidden;
}
.theme-toggle:hover { color: var(--accent); border-color: var(--border); }
.theme-opt {
cursor: pointer;
padding: 4px 8px;
font-size: 14px;
background: none;
border: none;
color: var(--text-muted);
transition: all 0.2s;
line-height: 1;
}
.theme-opt:hover { color: var(--text-primary); background: var(--bg-hover); }
.theme-opt.active { color: var(--accent); background: var(--accent-glow); }
/* Utility */
.flex { display: flex; }
+51 -7
View File
@@ -1,5 +1,15 @@
<body x-data="app" :data-theme="theme">
<!-- API Key Auth Prompt -->
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
</div>
</div>
<div class="app-layout" :class="{ 'focus-mode': $store.app.focusMode }">
<!-- Sidebar -->
<nav class="sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
@@ -13,7 +23,11 @@
</div>
</div>
</div>
<button class="theme-toggle" @click="toggleTheme()" :title="theme === 'dark' ? 'Switch to light' : 'Switch to dark'" x-text="theme === 'dark' ? '\u2600' : '\u263E'"></button>
<div class="theme-switcher">
<button class="theme-opt" :class="{ active: themeMode === 'light' }" @click="setTheme('light')" title="Light">&#9788;</button>
<button class="theme-opt" :class="{ active: themeMode === 'system' }" @click="setTheme('system')" title="System">&#9675;</button>
<button class="theme-opt" :class="{ active: themeMode === 'dark' }" @click="setTheme('dark')" title="Dark">&#9790;</button>
</div>
</div>
<div class="sidebar-status" :class="{ offline: !connected && !$store.app.booting }">
@@ -1088,10 +1102,12 @@
</div>
<div class="text-sm text-dim mb-2" x-text="a.description"></div>
<div class="text-xs text-dim">Agent: <span x-text="a.agent_name"></span> &middot; <span x-text="timeAgo(a.created_at)"></span></div>
<div class="approval-actions" x-show="a.status === 'pending'" style="display:flex;gap:8px;margin-top:12px">
<button class="btn btn-success btn-sm" @click="approve(a.id)">Approve</button>
<button class="btn btn-danger btn-sm" @click="reject(a.id)">Reject</button>
</div>
<template x-if="a.status === 'pending'">
<div class="approval-actions" style="display:flex;gap:8px;margin-top:12px">
<button class="btn btn-success btn-sm" @click="approve(a.id)">Approve</button>
<button class="btn btn-danger btn-sm" @click="reject(a.id)">Reject</button>
</div>
</template>
</div>
</template>
</div>
@@ -1519,7 +1535,7 @@
<th>Agent</th>
<th>Status</th>
<th>Last Run</th>
<th>Runs</th>
<th>Next Run</th>
<th>Actions</th>
</tr>
</thead>
@@ -1539,7 +1555,7 @@
<span class="badge" :class="job.enabled ? 'badge-success' : 'badge-dim'" x-text="job.enabled ? 'Active' : 'Paused'"></span>
</td>
<td class="text-xs" :title="formatTime(job.last_run)" x-text="relativeTime(job.last_run)"></td>
<td class="text-xs" x-text="job.run_count || 0"></td>
<td class="text-xs" :title="formatTime(job.next_run)" x-text="relativeTime(job.next_run)"></td>
<td>
<div class="flex gap-1">
<button class="btn btn-primary btn-sm" @click="runNow(job)" :disabled="runningJobId === job.id">
@@ -2868,6 +2884,21 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<template x-if="p.auth_status !== 'configured' && p.api_key_env">
<div class="text-xs text-dim mt-2">Or set <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px" x-text="p.api_key_env"></code> in your environment and restart</div>
</template>
<!-- Copilot OAuth button -->
<template x-if="p.id === 'github-copilot' && p.auth_status !== 'configured'">
<div class="mt-2">
<button class="btn btn-primary btn-sm" @click="startCopilotOAuth()" :disabled="copilotOAuth.polling" x-show="!copilotOAuth.userCode">Login with GitHub</button>
<div x-show="copilotOAuth.userCode" class="mt-2">
<div class="text-sm">Visit <a :href="copilotOAuth.verificationUri" target="_blank" x-text="copilotOAuth.verificationUri" style="color:var(--accent-light)"></a> and enter:</div>
<div style="font-size:24px;font-weight:bold;letter-spacing:4px;margin:8px 0;color:var(--accent-light)" x-text="copilotOAuth.userCode"></div>
<div class="text-xs text-dim"><span class="spinner" style="width:10px;height:10px;border-width:2px;display:inline-block;vertical-align:middle"></span> Waiting for authorization...</div>
</div>
</div>
</template>
<!-- Claude Code install hint -->
<template x-if="p.id === 'claude-code' && p.auth_status !== 'configured'">
<div class="mt-2 text-xs text-dim">Install: <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
</template>
<!-- Actions for configured providers -->
<template x-if="p.auth_status === 'configured'">
<div class="flex gap-2 mt-2">
@@ -2882,6 +2913,19 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<template x-if="!p.api_key_env || p.key_required === false">
<div class="text-xs mt-2" style="color:var(--success)" x-show="p.auth_status !== 'configured' && p.auth_status !== 'not_set' && p.auth_status !== 'missing'">No API key needed &mdash; runs locally or is free</div>
</template>
<!-- Base URL editor for local providers -->
<template x-if="p.is_local">
<div class="mt-3" style="border-top:1px solid var(--border);padding-top:8px">
<div class="text-xs text-dim mb-1">Base URL</div>
<div class="key-input-group">
<input type="text" :placeholder="'http://localhost:...'" x-model="providerUrlInputs[p.id]" style="font-size:12px">
<button class="btn btn-primary btn-sm" @click="saveProviderUrl(p)" :disabled="providerUrlSaving[p.id]">
<span x-show="!providerUrlSaving[p.id]">Save</span>
<span x-show="providerUrlSaving[p.id]" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
</button>
</div>
</div>
</template>
</div>
</template>
</div>
+56 -3
View File
@@ -87,6 +87,10 @@ function toolIcon(toolName) {
// Alpine.js global store
document.addEventListener('alpine:init', function() {
// Restore saved API key on load
var savedKey = localStorage.getItem('openfang-api-key');
if (savedKey) OpenFangAPI.setAuthToken(savedKey);
Alpine.store('app', {
agents: [],
connected: false,
@@ -99,6 +103,7 @@ document.addEventListener('alpine:init', function() {
pendingAgent: null,
focusMode: localStorage.getItem('openfang-focus') === 'true',
showOnboarding: false,
showAuthPrompt: false,
toggleFocusMode() {
this.focusMode = !this.focusMode;
@@ -146,6 +151,30 @@ document.addEventListener('alpine:init', function() {
dismissOnboarding() {
this.showOnboarding = false;
localStorage.setItem('openfang-onboarded', 'true');
},
async checkAuth() {
try {
await OpenFangAPI.get('/api/providers');
this.showAuthPrompt = false;
} catch(e) {
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0)) {
this.showAuthPrompt = true;
}
}
},
submitApiKey(key) {
if (!key || !key.trim()) return;
OpenFangAPI.setAuthToken(key.trim());
localStorage.setItem('openfang-api-key', key.trim());
this.showAuthPrompt = false;
this.refreshAgents();
},
clearApiKey() {
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
});
});
@@ -154,7 +183,12 @@ document.addEventListener('alpine:init', function() {
function app() {
return {
page: 'agents',
theme: localStorage.getItem('openfang-theme') || 'light',
themeMode: localStorage.getItem('openfang-theme-mode') || 'system',
theme: (() => {
var mode = localStorage.getItem('openfang-theme-mode') || 'system';
if (mode === 'system') return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
return mode;
})(),
sidebarCollapsed: localStorage.getItem('openfang-sidebar') === 'collapsed',
mobileMenuOpen: false,
connected: false,
@@ -167,6 +201,13 @@ function app() {
init() {
var self = this;
// Listen for OS theme changes (only matters when mode is 'system')
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
if (self.themeMode === 'system') {
self.theme = e.matches ? 'dark' : 'light';
}
});
// Hash routing
var validPages = ['overview','agents','sessions','approvals','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
var pageRedirects = {
@@ -225,6 +266,7 @@ function app() {
// Initial data load
this.pollStatus();
Alpine.store('app').checkOnboarding();
Alpine.store('app').checkAuth();
setInterval(function() { self.pollStatus(); }, 5000);
},
@@ -234,9 +276,20 @@ function app() {
this.mobileMenuOpen = false;
},
setTheme(mode) {
this.themeMode = mode;
localStorage.setItem('openfang-theme-mode', mode);
if (mode === 'system') {
this.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} else {
this.theme = mode;
}
},
toggleTheme() {
this.theme = this.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('openfang-theme', this.theme);
var modes = ['light', 'system', 'dark'];
var next = modes[(modes.indexOf(this.themeMode) + 1) % modes.length];
this.setTheme(next);
},
toggleSidebar() {
@@ -62,8 +62,29 @@ function schedulerPage() {
},
async loadJobs() {
var data = await OpenFangAPI.get('/api/schedules');
this.jobs = data.schedules || [];
var data = await OpenFangAPI.get('/api/cron/jobs');
var raw = data.jobs || [];
// Normalize cron API response to flat fields the UI expects
this.jobs = raw.map(function(j) {
var cron = '';
if (j.schedule) {
if (j.schedule.kind === 'cron') cron = j.schedule.expr || '';
else if (j.schedule.kind === 'every') cron = 'every ' + j.schedule.every_secs + 's';
else if (j.schedule.kind === 'at') cron = 'at ' + (j.schedule.at || '');
}
return {
id: j.id,
name: j.name,
cron: cron,
agent_id: j.agent_id,
message: j.action ? j.action.message || '' : '',
enabled: j.enabled,
last_run: j.last_run,
next_run: j.next_run,
delivery: j.delivery ? j.delivery.kind || '' : '',
created_at: j.created_at
};
});
},
async loadTriggers() {
@@ -82,25 +103,20 @@ function schedulerPage() {
async loadHistory() {
this.historyLoading = true;
try {
// Build history from jobs with run data + recent audit entries
var historyItems = [];
// Add job run info from schedule data
var jobs = this.jobs || [];
for (var i = 0; i < jobs.length; i++) {
var job = jobs[i];
if (job.last_run) {
historyItems.push({
timestamp: job.last_run,
name: job.name || job.description || '(unnamed)',
name: job.name || '(unnamed)',
type: 'schedule',
status: 'completed',
run_count: job.run_count || 0
run_count: 0
});
}
}
// Also load trigger fire counts
var triggers = this.triggers || [];
for (var j = 0; j < triggers.length; j++) {
var t = triggers[j];
@@ -114,12 +130,9 @@ function schedulerPage() {
});
}
}
// Sort by timestamp descending
historyItems.sort(function(a, b) {
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
});
this.history = historyItems;
} catch(e) {
this.history = [];
@@ -141,13 +154,15 @@ function schedulerPage() {
this.creating = true;
try {
var jobName = this.newJob.name;
await OpenFangAPI.post('/api/schedules', {
name: this.newJob.name,
cron: this.newJob.cron,
var body = {
agent_id: this.newJob.agent_id,
message: this.newJob.message,
name: this.newJob.name,
schedule: { kind: 'cron', expr: this.newJob.cron },
action: { kind: 'agent_turn', message: this.newJob.message || 'Scheduled task: ' + this.newJob.name },
delivery: { kind: 'last_channel' },
enabled: this.newJob.enabled
});
};
await OpenFangAPI.post('/api/cron/jobs', body);
this.showCreateForm = false;
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
OpenFangToast.success('Schedule "' + jobName + '" created');
@@ -161,7 +176,7 @@ function schedulerPage() {
async toggleJob(job) {
try {
var newState = !job.enabled;
await OpenFangAPI.put('/api/schedules/' + job.id, { enabled: newState });
await OpenFangAPI.put('/api/cron/jobs/' + job.id + '/enable', { enabled: newState });
job.enabled = newState;
OpenFangToast.success('Schedule ' + (newState ? 'enabled' : 'paused'));
} catch(e) {
@@ -174,7 +189,7 @@ function schedulerPage() {
var jobName = job.name || job.id;
OpenFangToast.confirm('Delete Schedule', 'Delete "' + jobName + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.del('/api/schedules/' + job.id);
await OpenFangAPI.del('/api/cron/jobs/' + job.id);
self.jobs = self.jobs.filter(function(j) { return j.id !== job.id; });
OpenFangToast.success('Schedule "' + jobName + '" deleted');
} catch(e) {
@@ -189,19 +204,17 @@ function schedulerPage() {
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
if (result.status === 'completed') {
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
// Update the job's last_run locally
job.last_run = new Date().toISOString();
job.run_count = (job.run_count || 0) + 1;
} else {
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
}
} catch(e) {
OpenFangToast.error('Failed to run schedule: ' + (e.message || e));
OpenFangToast.error('Run Now is not yet available for cron jobs');
}
this.runningJobId = '';
},
// ── Trigger helpers (reused from workflows page) ──
// ── Trigger helpers ──
triggerType(pattern) {
if (!pattern) return 'unknown';
@@ -259,13 +272,16 @@ function schedulerPage() {
for (var i = 0; i < agents.length; i++) {
if (agents[i].id === agentId) return agents[i].name;
}
// Truncate UUID
if (agentId.length > 12) return agentId.substring(0, 8) + '...';
return agentId;
},
describeCron(expr) {
if (!expr) return '';
// Handle non-cron schedule descriptions
if (expr.indexOf('every ') === 0) return expr;
if (expr.indexOf('at ') === 0) return 'One-time: ' + expr.substring(3);
var map = {
'* * * * *': 'Every minute',
'*/2 * * * *': 'Every 2 minutes',
@@ -291,7 +307,6 @@ function schedulerPage() {
};
if (map[expr]) return map[expr];
// Try to parse common patterns
var parts = expr.split(' ');
if (parts.length !== 5) return expr;
@@ -301,22 +316,26 @@ function schedulerPage() {
var mon = parts[3];
var dow = parts[4];
// "*/N * * * *" patterns
if (min.indexOf('*/') === 0 && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
return 'Every ' + min.substring(2) + ' minutes';
}
// "0 */N * * *" patterns
if (min === '0' && hour.indexOf('*/') === 0 && dom === '*' && mon === '*' && dow === '*') {
return 'Every ' + hour.substring(2) + ' hours';
}
// "M H * * *" — daily at specific time
if (dom === '*' && mon === '*' && dow === '*' && min.match(/^\d+$/) && hour.match(/^\d+$/)) {
var dowNames = { '0': 'Sun', '1': 'Mon', '2': 'Tue', '3': 'Wed', '4': 'Thu', '5': 'Fri', '6': 'Sat', '7': 'Sun',
'1-5': 'Weekdays', '0,6': 'Weekends', '6,0': 'Weekends' };
if (dom === '*' && mon === '*' && min.match(/^\d+$/) && hour.match(/^\d+$/)) {
var h = parseInt(hour, 10);
var m = parseInt(min, 10);
var ampm = h >= 12 ? 'PM' : 'AM';
var h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);
var mStr = m < 10 ? '0' + m : '' + m;
return 'Daily at ' + h12 + ':' + mStr + ' ' + ampm;
var timeStr = h12 + ':' + mStr + ' ' + ampm;
if (dow === '*') return 'Daily at ' + timeStr;
var dowLabel = dowNames[dow] || ('DoW ' + dow);
return dowLabel + ' at ' + timeStr;
}
return expr;
@@ -340,7 +359,14 @@ function schedulerPage() {
try {
var diff = Date.now() - new Date(ts).getTime();
if (isNaN(diff)) return 'never';
if (diff < 0) return 'just now';
if (diff < 0) {
// Future time
var absDiff = Math.abs(diff);
if (absDiff < 60000) return 'in <1m';
if (absDiff < 3600000) return 'in ' + Math.floor(absDiff / 60000) + 'm';
if (absDiff < 86400000) return 'in ' + Math.floor(absDiff / 3600000) + 'h';
return 'in ' + Math.floor(absDiff / 86400000) + 'd';
}
if (diff < 60000) return 'just now';
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
@@ -15,8 +15,11 @@ function settingsPage() {
modelProviderFilter: '',
modelTierFilter: '',
providerKeyInputs: {},
providerUrlInputs: {},
providerUrlSaving: {},
providerTesting: {},
providerTestResults: {},
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
loading: true,
loadError: '',
@@ -208,6 +211,12 @@ function settingsPage() {
try {
var data = await OpenFangAPI.get('/api/providers');
this.providers = data.providers || [];
for (var i = 0; i < this.providers.length; i++) {
var p = this.providers[i];
if (p.is_local && p.base_url && !this.providerUrlInputs[p.id]) {
this.providerUrlInputs[p.id] = p.base_url;
}
}
} catch(e) { this.providers = []; }
},
@@ -360,6 +369,54 @@ function settingsPage() {
}
},
async startCopilotOAuth() {
this.copilotOAuth.polling = true;
this.copilotOAuth.userCode = '';
try {
var resp = await OpenFangAPI.post('/api/providers/github-copilot/oauth/start', {});
this.copilotOAuth.userCode = resp.user_code;
this.copilotOAuth.verificationUri = resp.verification_uri;
this.copilotOAuth.pollId = resp.poll_id;
this.copilotOAuth.interval = resp.interval || 5;
window.open(resp.verification_uri, '_blank');
this.pollCopilotOAuth();
} catch(e) {
OpenFangToast.error('Failed to start Copilot login: ' + e.message);
this.copilotOAuth.polling = false;
}
},
pollCopilotOAuth() {
var self = this;
setTimeout(async function() {
if (!self.copilotOAuth.pollId) return;
try {
var resp = await OpenFangAPI.get('/api/providers/github-copilot/oauth/poll/' + self.copilotOAuth.pollId);
if (resp.status === 'complete') {
OpenFangToast.success('GitHub Copilot authenticated successfully!');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
await self.loadProviders();
await self.loadModels();
} else if (resp.status === 'pending') {
if (resp.interval) self.copilotOAuth.interval = resp.interval;
self.pollCopilotOAuth();
} else if (resp.status === 'expired') {
OpenFangToast.error('Device code expired. Please try again.');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
} else if (resp.status === 'denied') {
OpenFangToast.error('Access denied by user.');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
} else {
OpenFangToast.error('OAuth error: ' + (resp.error || resp.status));
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
}
} catch(e) {
OpenFangToast.error('Poll error: ' + e.message);
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
}
}, self.copilotOAuth.interval * 1000);
},
async testProvider(provider) {
this.providerTesting[provider.id] = true;
this.providerTestResults[provider.id] = null;
@@ -378,6 +435,28 @@ function settingsPage() {
this.providerTesting[provider.id] = false;
},
async saveProviderUrl(provider) {
var url = this.providerUrlInputs[provider.id];
if (!url || !url.trim()) { OpenFangToast.error('Please enter a base URL'); return; }
url = url.trim();
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
OpenFangToast.error('URL must start with http:// or https://'); return;
}
this.providerUrlSaving[provider.id] = true;
try {
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(provider.id) + '/url', { base_url: url });
if (result.reachable) {
OpenFangToast.success(provider.display_name + ' URL saved &mdash; reachable (' + (result.latency_ms || '?') + 'ms)');
} else {
OpenFangToast.warning(provider.display_name + ' URL saved but not reachable');
}
await this.loadProviders();
} catch(e) {
OpenFangToast.error('Failed to save URL: ' + e.message);
}
this.providerUrlSaving[provider.id] = false;
},
// -- Security methods --
async loadSecurity() {
this.secLoading = true;
+5
View File
@@ -27,5 +27,10 @@ sha2 = { workspace = true }
base64 = { workspace = true }
hex = { workspace = true }
lettre = { workspace = true }
imap = { workspace = true }
native-tls = { workspace = true }
mailparse = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+359 -35
View File
@@ -1,18 +1,32 @@
//! Email channel adapter (IMAP + SMTP).
//!
//! Polls IMAP for new emails and sends responses via SMTP.
//! Polls IMAP for new emails and sends responses via SMTP using `lettre`.
//! Uses the subject line for agent routing (e.g., "\[coder\] Fix this bug").
use crate::types::{ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser};
use async_trait::async_trait;
use chrono::Utc;
use dashmap::DashMap;
use futures::Stream;
use lettre::message::Mailbox;
use lettre::transport::smtp::authentication::Credentials;
use lettre::AsyncSmtpTransport;
use lettre::AsyncTransport;
use lettre::Tokio1Executor;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tracing::{debug, info};
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
/// Reply context for email threading (In-Reply-To / Subject continuity).
#[derive(Debug, Clone)]
struct ReplyCtx {
subject: String,
message_id: String,
}
/// Email channel adapter using IMAP for receiving and SMTP for sending.
pub struct EmailAdapter {
/// IMAP server host.
@@ -21,7 +35,7 @@ pub struct EmailAdapter {
imap_port: u16,
/// SMTP server host.
smtp_host: String,
/// SMTP port (587 for STARTTLS).
/// SMTP port (587 for STARTTLS, 465 for implicit TLS).
smtp_port: u16,
/// Email address (used for both IMAP and SMTP).
username: String,
@@ -36,6 +50,8 @@ pub struct EmailAdapter {
/// Shutdown signal.
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
/// Tracks reply context per sender for email threading.
reply_ctx: Arc<DashMap<String, ReplyCtx>>,
}
impl EmailAdapter {
@@ -69,16 +85,17 @@ impl EmailAdapter {
allowed_senders,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
reply_ctx: Arc::new(DashMap::new()),
}
}
/// Check if a sender is in the allowlist (empty = allow all). Used in tests.
#[allow(dead_code)]
fn is_allowed_sender(&self, sender: &str) -> bool {
self.allowed_senders.is_empty() || self.allowed_senders.iter().any(|s| sender.contains(s))
}
/// Extract agent name from subject line brackets, e.g., "[coder] Fix the bug" -> Some("coder")
#[allow(dead_code)]
fn extract_agent_from_subject(subject: &str) -> Option<String> {
let subject = subject.trim();
if subject.starts_with('[') {
@@ -93,7 +110,6 @@ impl EmailAdapter {
}
/// Strip the agent tag from a subject line.
#[allow(dead_code)]
fn strip_agent_tag(subject: &str) -> String {
let subject = subject.trim();
if subject.starts_with('[') {
@@ -103,6 +119,162 @@ impl EmailAdapter {
}
subject.to_string()
}
/// Build an async SMTP transport for sending emails.
async fn build_smtp_transport(
&self,
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error>> {
let creds =
Credentials::new(self.username.clone(), self.password.as_str().to_string());
let transport = if self.smtp_port == 465 {
// Implicit TLS (port 465)
AsyncSmtpTransport::<Tokio1Executor>::relay(&self.smtp_host)?
.port(self.smtp_port)
.credentials(creds)
.build()
} else {
// STARTTLS (port 587 or other)
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.smtp_host)?
.port(self.smtp_port)
.credentials(creds)
.build()
};
Ok(transport)
}
}
/// Extract `user@domain` from a potentially formatted email string like `"Name <user@domain>"`.
fn extract_email_addr(raw: &str) -> String {
let raw = raw.trim();
if let Some(start) = raw.find('<') {
if let Some(end) = raw.find('>') {
if end > start {
return raw[start + 1..end].trim().to_string();
}
}
}
raw.to_string()
}
/// Get a specific header value from a parsed email.
fn get_header(parsed: &mailparse::ParsedMail<'_>, name: &str) -> Option<String> {
parsed
.headers
.iter()
.find(|h| h.get_key().eq_ignore_ascii_case(name))
.map(|h| h.get_value())
}
/// Extract the text/plain body from a parsed email (handles multipart).
fn extract_text_body(parsed: &mailparse::ParsedMail<'_>) -> String {
if parsed.subparts.is_empty() {
return parsed.get_body().unwrap_or_default();
}
// Walk subparts looking for text/plain
for part in &parsed.subparts {
let ct = part.ctype.mimetype.to_lowercase();
if ct == "text/plain" {
return part.get_body().unwrap_or_default();
}
}
// Fallback: first subpart body
parsed
.subparts
.first()
.and_then(|p| p.get_body().ok())
.unwrap_or_default()
}
/// Fetch unseen emails from IMAP using blocking I/O.
/// Returns a Vec of (from_addr, subject, message_id, body).
fn fetch_unseen_emails(
host: &str,
port: u16,
username: &str,
password: &str,
folders: &[String],
) -> Result<Vec<(String, String, String, String)>, String> {
let tls = native_tls::TlsConnector::builder()
.build()
.map_err(|e| format!("TLS connector error: {e}"))?;
let client = imap::connect((host, port), host, &tls)
.map_err(|e| format!("IMAP connect failed: {e}"))?;
let mut session = client
.login(username, password)
.map_err(|(e, _)| format!("IMAP login failed: {e}"))?;
let mut results = Vec::new();
for folder in folders {
if let Err(e) = session.select(folder) {
warn!(folder, error = %e, "IMAP SELECT failed, skipping folder");
continue;
}
let uids = match session.uid_search("UNSEEN") {
Ok(uids) => uids,
Err(e) => {
warn!(folder, error = %e, "IMAP SEARCH UNSEEN failed");
continue;
}
};
if uids.is_empty() {
debug!(folder, "No unseen emails");
continue;
}
// Fetch in batches of up to 50 to avoid huge responses
let uid_list: Vec<u32> = uids.into_iter().take(50).collect();
let uid_set: String = uid_list
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(",");
let fetches = match session.uid_fetch(&uid_set, "RFC822") {
Ok(f) => f,
Err(e) => {
warn!(folder, error = %e, "IMAP FETCH failed");
continue;
}
};
for fetch in fetches.iter() {
let body_bytes = match fetch.body() {
Some(b) => b,
None => continue,
};
let parsed = match mailparse::parse_mail(body_bytes) {
Ok(p) => p,
Err(e) => {
warn!(error = %e, "Failed to parse email");
continue;
}
};
let from = get_header(&parsed, "From").unwrap_or_default();
let subject = get_header(&parsed, "Subject").unwrap_or_default();
let message_id = get_header(&parsed, "Message-ID").unwrap_or_default();
let text_body = extract_text_body(&parsed);
let from_addr = extract_email_addr(&from);
results.push((from_addr, subject, message_id, text_body));
}
// Mark fetched messages as Seen
if let Err(e) = session.uid_store(&uid_set, "+FLAGS (\\Seen)") {
warn!(error = %e, "Failed to mark emails as Seen");
}
}
let _ = session.logout();
Ok(results)
}
#[async_trait]
@@ -119,25 +291,23 @@ impl ChannelAdapter for EmailAdapter {
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let (_tx, rx) = mpsc::channel::<ChannelMessage>(256);
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let poll_interval = self.poll_interval;
let _allowed_senders = self.allowed_senders.clone();
let imap_host = self.imap_host.clone();
let imap_port = self.imap_port;
let _username = self.username.clone();
let _password = self.password.clone();
let _folders = self.folders.clone();
let username = self.username.clone();
let password = self.password.clone();
let folders = self.folders.clone();
let allowed_senders = self.allowed_senders.clone();
let mut shutdown_rx = self.shutdown_rx.clone();
let reply_ctx = self.reply_ctx.clone();
info!(
"Starting email adapter (IMAP: {}:{}, polling every {:?})",
imap_host, imap_port, poll_interval
"Starting email adapter (IMAP: {}:{}, SMTP: {}:{}, polling every {:?})",
imap_host, imap_port, self.smtp_host, self.smtp_port, poll_interval
);
tokio::spawn(async move {
// Email polling is blocking I/O, so we'll use spawn_blocking
// For now, implement as a polling loop with placeholder
// Full IMAP implementation requires the `imap` crate
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
@@ -147,14 +317,83 @@ impl ChannelAdapter for EmailAdapter {
_ = tokio::time::sleep(poll_interval) => {}
}
// Placeholder: In a full implementation, this would:
// 1. Connect to IMAP server via TLS
// 2. Select each folder
// 3. Search for UNSEEN messages
// 4. Fetch and parse each message (From, Subject, Body)
// 5. Convert to ChannelMessage
// 6. Mark as seen
debug!("Email poll cycle (IMAP {}:{})", imap_host, imap_port);
// IMAP operations are blocking I/O — run in spawn_blocking
let host = imap_host.clone();
let port = imap_port;
let user = username.clone();
let pass = password.clone();
let fldrs = folders.clone();
let emails = tokio::task::spawn_blocking(move || {
fetch_unseen_emails(&host, port, &user, pass.as_str(), &fldrs)
})
.await;
let emails = match emails {
Ok(Ok(emails)) => emails,
Ok(Err(e)) => {
error!("IMAP poll error: {e}");
continue;
}
Err(e) => {
error!("IMAP spawn_blocking panic: {e}");
continue;
}
};
for (from_addr, subject, message_id, body) in emails {
// Check allowed senders
if !allowed_senders.is_empty()
&& !allowed_senders.iter().any(|s| from_addr.contains(s))
{
debug!(from = %from_addr, "Email from non-allowed sender, skipping");
continue;
}
// Store reply context for threading
if !message_id.is_empty() {
reply_ctx.insert(
from_addr.clone(),
ReplyCtx {
subject: subject.clone(),
message_id: message_id.clone(),
},
);
}
// Extract target agent from subject brackets (stored in metadata for router)
let _target_agent =
EmailAdapter::extract_agent_from_subject(&subject);
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
// Build the message body: prepend subject context
let text = if clean_subject.is_empty() {
body.trim().to_string()
} else {
format!("Subject: {clean_subject}\n\n{}", body.trim())
};
let msg = ChannelMessage {
channel: ChannelType::Email,
platform_message_id: message_id.clone(),
sender: ChannelUser {
platform_id: from_addr.clone(),
display_name: from_addr.clone(),
openfang_user: None,
},
content: ChannelContent::Text(text),
target_agent: None, // Routing handled by bridge AgentRouter
timestamp: Utc::now(),
is_group: false,
thread_id: None,
metadata: std::collections::HashMap::new(),
};
if tx.send(msg).await.is_err() {
info!("Email channel receiver dropped, stopping poll");
return;
}
}
}
});
@@ -168,22 +407,71 @@ impl ChannelAdapter for EmailAdapter {
) -> Result<(), Box<dyn std::error::Error>> {
match content {
ChannelContent::Text(text) => {
// Placeholder: In a full implementation, this would:
// 1. Build email (From, To, Subject, Body) using lettre
// 2. Connect to SMTP server via STARTTLS
// 3. Send the email
// Parse recipient address
let to_addr = extract_email_addr(&user.platform_id);
let to_mailbox: Mailbox = to_addr
.parse()
.map_err(|e| format!("Invalid recipient email '{}': {}", to_addr, e))?;
let from_mailbox: Mailbox = self
.username
.parse()
.map_err(|e| format!("Invalid sender email '{}': {}", self.username, e))?;
// Extract subject from text body convention: "Subject: ...\n\n..."
let (subject, body) = if text.starts_with("Subject: ") {
if let Some(pos) = text.find("\n\n") {
let subj = text[9..pos].trim().to_string();
let body = text[pos + 2..].to_string();
(subj, body)
} else {
("OpenFang Reply".to_string(), text)
}
} else {
// Check reply context for subject continuity
let subj = self
.reply_ctx
.get(&to_addr)
.map(|ctx| format!("Re: {}", ctx.subject))
.unwrap_or_else(|| "OpenFang Reply".to_string());
(subj, text)
};
// Build email message
let mut builder = lettre::Message::builder()
.from(from_mailbox)
.to(to_mailbox)
.subject(&subject);
// Add In-Reply-To header for threading
if let Some(ctx) = self.reply_ctx.get(&to_addr) {
if !ctx.message_id.is_empty() {
builder = builder.in_reply_to(ctx.message_id.clone());
}
}
let email = builder
.body(body)
.map_err(|e| format!("Failed to build email: {e}"))?;
// Send via SMTP
let transport = self.build_smtp_transport().await?;
transport
.send(email)
.await
.map_err(|e| format!("SMTP send failed: {e}"))?;
info!(
"Would send email to {}: {} chars",
user.platform_id,
text.len()
);
debug!(
"SMTP: {}:{} -> {}",
self.smtp_host, self.smtp_port, user.platform_id
to = %to_addr,
subject = %subject,
"Email sent successfully via SMTP"
);
}
_ => {
info!("Unsupported email content type for {}", user.platform_id);
warn!(
"Unsupported email content type for {}, only text is supported",
user.platform_id
);
}
}
Ok(())
@@ -274,4 +562,40 @@ mod tests {
);
assert_eq!(EmailAdapter::strip_agent_tag("No brackets"), "No brackets");
}
#[test]
fn test_extract_email_addr() {
assert_eq!(
extract_email_addr("John Doe <john@example.com>"),
"john@example.com"
);
assert_eq!(extract_email_addr("user@example.com"), "user@example.com");
assert_eq!(extract_email_addr("<user@test.com>"), "user@test.com");
}
#[test]
fn test_subject_extraction_from_body() {
let text = "Subject: Test Subject\n\nThis is the body.";
assert!(text.starts_with("Subject: "));
let pos = text.find("\n\n").unwrap();
let subject = &text[9..pos];
let body = &text[pos + 2..];
assert_eq!(subject, "Test Subject");
assert_eq!(body, "This is the body.");
}
#[test]
fn test_reply_ctx_threading() {
let ctx_map: DashMap<String, ReplyCtx> = DashMap::new();
ctx_map.insert(
"user@test.com".to_string(),
ReplyCtx {
subject: "Original Subject".to_string(),
message_id: "<msg-123@test.com>".to_string(),
},
);
let ctx = ctx_map.get("user@test.com").unwrap();
assert_eq!(ctx.subject, "Original Subject");
assert_eq!(ctx.message_id, "<msg-123@test.com>");
}
}
+50 -11
View File
@@ -268,38 +268,48 @@ fn parse_mastodon_notification(
fn strip_html_tags(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
let mut tag_buf = String::new();
for ch in html.chars() {
match ch {
'<' => {
in_tag = true;
// Check if this is a <br> or </p> — insert newline
if html[result.len()..].starts_with("<br")
|| html[result.len()..].starts_with("</p")
tag_buf.clear();
}
'>' if in_tag => {
in_tag = false;
// Insert newline for block-level closing tags
let tag_lower = tag_buf.to_lowercase();
if tag_lower.starts_with("br")
|| tag_lower.starts_with("/p")
|| tag_lower.starts_with("/div")
|| tag_lower.starts_with("/li")
{
result.push('\n');
}
tag_buf.clear();
}
'>' => {
in_tag = false;
_ if in_tag => {
tag_buf.push(ch);
}
_ if !in_tag => {
_ => {
result.push(ch);
}
_ => {}
}
}
// Decode common HTML entities
result
// Decode HTML entities
let decoded = result
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.trim()
.to_string()
.replace("&#x27;", "'")
.replace("&nbsp;", " ");
decoded.trim().to_string()
}
#[async_trait]
@@ -577,6 +587,35 @@ mod tests {
assert_eq!(strip_html_tags("plain text"), "plain text");
}
#[test]
fn test_strip_html_tags_emoji() {
assert_eq!(
strip_html_tags("<p>Hello 🦀🔥 world</p>"),
"Hello 🦀🔥 world"
);
}
#[test]
fn test_strip_html_tags_cjk() {
assert_eq!(
strip_html_tags("<p>你好 <strong>世界</strong></p>"),
"你好 世界"
);
}
#[test]
fn test_strip_html_tags_numeric_entities() {
assert_eq!(strip_html_tags("&#39;hello&#39;"), "'hello'");
}
#[test]
fn test_strip_html_tags_div_newline() {
assert_eq!(
strip_html_tags("<div>one</div><div>two</div>").trim(),
"one\ntwo"
);
}
#[test]
fn test_parse_mastodon_notification_mention() {
let notif = serde_json::json!({
+3 -2
View File
@@ -278,8 +278,9 @@ pub fn split_message(text: &str, max_len: usize) -> Vec<&str> {
chunks.push(remaining);
break;
}
// Try to split at a newline near the boundary
let split_at = remaining[..max_len].rfind('\n').unwrap_or(max_len);
// Try to split at a newline near the boundary (UTF-8 safe)
let safe_end = openfang_types::truncate_str(remaining, max_len).len();
let split_at = remaining[..safe_end].rfind('\n').unwrap_or(safe_end);
let (chunk, rest) = remaining.split_at(split_at);
chunks.push(chunk);
// Skip the newline (and optional \r) we split on
+8 -2
View File
@@ -953,14 +953,20 @@ 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 info = read_daemon_info(&home_dir)?;
let url = format!("http://{}/api/health", info.listen_addr);
// Normalize listen address: replace 0.0.0.0 with 127.0.0.1 to avoid
// DNS/connectivity issues on macOS where 0.0.0.0 can hang.
let addr = info.listen_addr.replace("0.0.0.0", "127.0.0.1");
let url = format!("http://{addr}/api/health");
let client = reqwest::blocking::Client::builder()
.connect_timeout(std::time::Duration::from_secs(1))
.timeout(std::time::Duration::from_secs(2))
.build()
.ok()?;
let resp = client.get(&url).send().ok()?;
if resp.status().is_success() {
Some(format!("http://{}", info.listen_addr))
Some(format!("http://{addr}"))
} else {
None
}
@@ -175,6 +175,7 @@ impl StandaloneChat {
self.chat.last_tokens =
Some((r.total_usage.input_tokens, r.total_usage.output_tokens));
}
self.chat.last_cost_usd = r.cost_usd;
}
Err(e) => {
self.chat.status_msg = Some(format!("Error: {e}"));
@@ -227,6 +228,7 @@ impl StandaloneChat {
self.chat.thinking = true;
self.chat.streaming_chars = 0;
self.chat.last_tokens = None;
self.chat.last_cost_usd = None;
self.chat.status_msg = None;
match &self.backend {
@@ -1524,6 +1524,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
+1 -1
View File
@@ -341,6 +341,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
+11 -3
View File
@@ -58,6 +58,8 @@ pub struct ChatState {
pub scroll_offset: u16,
/// Token usage from last response.
pub last_tokens: Option<(u64, u64)>,
/// Cost in USD from last response.
pub last_cost_usd: Option<f64>,
/// Characters received during current stream (~4 chars ≈ 1 token).
pub streaming_chars: usize,
/// Status message (errors, etc.)
@@ -90,6 +92,7 @@ impl ChatState {
input: String::new(),
scroll_offset: 0,
last_tokens: None,
last_cost_usd: None,
streaming_chars: 0,
status_msg: None,
staged_messages: Vec::new(),
@@ -107,6 +110,7 @@ impl ChatState {
self.input.clear();
self.scroll_offset = 0;
self.last_tokens = None;
self.last_cost_usd = None;
self.streaming_chars = 0;
self.status_msg = None;
self.staged_messages.clear();
@@ -547,11 +551,15 @@ fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
)]));
}
// Add token usage if available
// Add token usage and cost if available
if let Some((input, output)) = state.last_tokens {
if input > 0 || output > 0 {
let cost_str = match state.last_cost_usd {
Some(c) if c > 0.0 => format!(" | ${:.4}", c),
_ => String::new(),
};
lines.push(Line::from(vec![Span::styled(
format!(" [tokens: {} in / {} out]", input, output),
format!(" [tokens: {} in / {} out{}]", input, output, cost_str),
theme::dim_style(),
)]));
}
@@ -653,6 +661,6 @@ fn truncate_line(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max_len.saturating_sub(1)])
format!("{}\u{2026}", openfang_types::truncate_str(s, max_len.saturating_sub(1)))
}
}
@@ -273,6 +273,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
@@ -535,7 +535,7 @@ fn draw_health(f: &mut Frame, area: Rect, state: &mut ExtensionsState) {
let error_display = if h.last_error.is_empty() {
"\u{2014}".to_string()
} else if h.last_error.len() > 30 {
format!("{}...", &h.last_error[..27])
format!("{}...", openfang_types::truncate_str(&h.last_error, 27))
} else {
h.last_error.clone()
};
+1 -5
View File
@@ -436,9 +436,5 @@ fn draw_active(f: &mut Frame, area: Rect, state: &mut HandsState) {
}
fn truncate(s: &str, max: usize) -> &str {
if s.len() > max {
&s[..max]
} else {
s
}
openfang_types::truncate_str(s, max)
}
+1 -1
View File
@@ -405,6 +405,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
@@ -549,6 +549,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
+1 -1
View File
@@ -208,6 +208,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
@@ -308,6 +308,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
@@ -604,7 +604,7 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
@@ -612,7 +612,7 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
@@ -399,6 +399,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
@@ -549,6 +549,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
+1 -1
View File
@@ -439,6 +439,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
@@ -697,6 +697,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
+49
View File
@@ -26,6 +26,32 @@ pub fn default_client_ids() -> HashMap<&'static str, &'static str> {
m
}
/// Resolve OAuth client IDs with config overrides applied on top of defaults.
pub fn resolve_client_ids(
config: &openfang_types::config::OAuthConfig,
) -> HashMap<String, String> {
let defaults = default_client_ids();
let mut resolved: HashMap<String, String> = defaults
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
if let Some(ref id) = config.google_client_id {
resolved.insert("google".into(), id.clone());
}
if let Some(ref id) = config.github_client_id {
resolved.insert("github".into(), id.clone());
}
if let Some(ref id) = config.microsoft_client_id {
resolved.insert("microsoft".into(), id.clone());
}
if let Some(ref id) = config.slack_client_id {
resolved.insert("slack".into(), id.clone());
}
resolved
}
/// OAuth2 token response (raw from provider, for deserialization).
#[derive(Debug, Serialize, Deserialize)]
pub struct OAuthTokens {
@@ -333,4 +359,27 @@ mod tests {
assert!(ids.contains_key("microsoft"));
assert!(ids.contains_key("slack"));
}
#[test]
fn resolve_client_ids_uses_defaults() {
let config = openfang_types::config::OAuthConfig::default();
let ids = resolve_client_ids(&config);
assert_eq!(ids["google"], "openfang-google-client-id");
assert_eq!(ids["github"], "openfang-github-client-id");
}
#[test]
fn resolve_client_ids_applies_overrides() {
let config = openfang_types::config::OAuthConfig {
google_client_id: Some("my-real-google-id".into()),
github_client_id: None,
microsoft_client_id: Some("my-msft-id".into()),
slack_client_id: None,
};
let ids = resolve_client_ids(&config);
assert_eq!(ids["google"], "my-real-google-id");
assert_eq!(ids["github"], "openfang-github-client-id"); // default
assert_eq!(ids["microsoft"], "my-msft-id");
assert_eq!(ids["slack"], "openfang-slack-client-id"); // default
}
}
+69 -4
View File
@@ -31,7 +31,6 @@ const SALT_LEN: usize = 16;
/// Nonce length for AES-256-GCM.
const NONCE_LEN: usize = 12;
/// Magic bytes for vault file format versioning.
#[allow(dead_code)]
const VAULT_MAGIC: &[u8; 4] = b"OFV1";
/// On-disk vault format (encrypted).
@@ -312,14 +311,34 @@ impl CredentialVault {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.path, content)?;
// Prepend OFV1 magic bytes for format detection
let mut output = Vec::with_capacity(VAULT_MAGIC.len() + content.len());
output.extend_from_slice(VAULT_MAGIC);
output.extend_from_slice(content.as_bytes());
std::fs::write(&self.path, output)?;
Ok(())
}
/// Load and decrypt vault from disk.
fn load(&mut self, master_key: &[u8; 32]) -> ExtensionResult<()> {
let content = std::fs::read_to_string(&self.path)?;
let vault_file: VaultFile = serde_json::from_str(&content)
let raw = std::fs::read(&self.path)?;
// Strip OFV1 magic header if present; legacy JSON files start with '{'
let content = if raw.starts_with(VAULT_MAGIC) {
std::str::from_utf8(&raw[VAULT_MAGIC.len()..])
.map_err(|e| ExtensionError::Vault(format!("UTF-8 decode failed: {e}")))?
} else if raw.first() == Some(&b'{') {
// Legacy JSON vault (no magic header)
std::str::from_utf8(&raw)
.map_err(|e| ExtensionError::Vault(format!("UTF-8 decode failed: {e}")))?
} else {
return Err(ExtensionError::Vault(
"Unrecognized vault file format".to_string(),
));
};
let vault_file: VaultFile = serde_json::from_str(content)
.map_err(|e| ExtensionError::Vault(format!("Vault file parse failed: {e}")))?;
if vault_file.version != 1 {
@@ -590,4 +609,50 @@ mod tests {
let k2 = derive_key(&master, &salt).unwrap();
assert_eq!(k1.as_ref(), k2.as_ref());
}
#[test]
fn vault_file_has_magic_header() {
let (_dir, mut vault) = test_vault();
let key = random_key();
vault.init_with_key(key).unwrap();
let raw = std::fs::read(&vault.path).unwrap();
assert_eq!(&raw[..4], b"OFV1");
}
#[test]
fn vault_legacy_json_compat() {
let (dir, mut vault) = test_vault();
let key = random_key();
vault.init_with_key(key.clone()).unwrap();
vault
.set("KEY".to_string(), Zeroizing::new("val".to_string()))
.unwrap();
// Strip the OFV1 magic header to simulate a legacy vault file
let raw = std::fs::read(&vault.path).unwrap();
assert_eq!(&raw[..4], b"OFV1");
std::fs::write(&vault.path, &raw[4..]).unwrap();
// Should still load (legacy compat)
let mut vault2 = CredentialVault::new(dir.path().join("vault.enc"));
vault2.unlock_with_key(key).unwrap();
assert_eq!(vault2.get("KEY").unwrap().as_str(), "val");
}
#[test]
fn vault_rejects_bad_magic() {
let (dir, mut vault) = test_vault();
let key = random_key();
vault.init_with_key(key.clone()).unwrap();
// Overwrite with unrecognized binary data
std::fs::write(&vault.path, b"BAAD not json").unwrap();
let mut vault2 = CredentialVault::new(dir.path().join("vault.enc"));
let result = vault2.unlock_with_key(key);
assert!(result.is_err());
let msg = format!("{:?}", result.unwrap_err());
assert!(msg.contains("Unrecognized vault file format"));
}
}
+1
View File
@@ -32,6 +32,7 @@ subtle = { workspace = true }
rand = { workspace = true }
hex = { workspace = true }
reqwest = { workspace = true }
cron = "0.15"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -41,6 +41,8 @@ pub enum HotAction {
ReloadA2aConfig,
/// Fallback provider chain changed.
ReloadFallbackProviders,
/// Provider base URL overrides changed.
ReloadProviderUrls,
}
// ---------------------------------------------------------------------------
@@ -235,6 +237,10 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
plan.hot_actions.push(HotAction::ReloadFallbackProviders);
}
if field_changed(&old.provider_urls, &new.provider_urls) {
plan.hot_actions.push(HotAction::ReloadProviderUrls);
}
// ----- No-op fields -----
if old.log_level != new.log_level {
@@ -461,6 +467,17 @@ mod tests {
assert!(plan.hot_actions.contains(&HotAction::ReloadExtensions));
}
#[test]
fn test_provider_urls_hot_reload() {
let a = default_cfg();
let mut b = default_cfg();
b.provider_urls
.insert("ollama".to_string(), "http://10.0.0.5:11434/v1".to_string());
let plan = build_reload_plan(&a, &b);
assert!(!plan.restart_required);
assert!(plan.hot_actions.contains(&HotAction::ReloadProviderUrls));
}
// -----------------------------------------------------------------------
// Mixed changes
// -----------------------------------------------------------------------
+85 -27
View File
@@ -222,16 +222,24 @@ impl CronScheduler {
}
/// Return jobs whose `next_run` is at or before `now` and are enabled.
///
/// **Important**: This also pre-advances each due job's `next_run` to the
/// next scheduled time. This prevents the same job from being returned as
/// "due" on subsequent tick iterations while it's still executing.
pub fn due_jobs(&self) -> Vec<CronJob> {
let now = Utc::now();
self.jobs
.iter()
.filter(|r| {
let meta = r.value();
meta.job.enabled && meta.job.next_run.map(|t| t <= now).unwrap_or(false)
})
.map(|r| r.value().job.clone())
.collect()
let mut due = Vec::new();
for mut entry in self.jobs.iter_mut() {
let meta = entry.value_mut();
if meta.job.enabled && meta.job.next_run.map(|t| t <= now).unwrap_or(false) {
due.push(meta.job.clone());
// Pre-advance next_run so the job won't fire again on the next
// tick while it's still executing. record_success/record_failure
// will recompute it again after execution completes.
meta.job.next_run = Some(compute_next_run(&meta.job.schedule));
}
}
due
}
// -- Outcome recording --------------------------------------------------
@@ -247,12 +255,9 @@ impl CronScheduler {
meta.job.last_run = Some(Utc::now());
meta.last_status = Some("ok".to_string());
meta.consecutive_errors = 0;
if meta.one_shot {
true
} else {
meta.job.next_run = Some(compute_next_run(&meta.job.schedule));
false
}
// one_shot jobs get removed; recurring jobs keep the next_run
// already pre-advanced by due_jobs() — no recompute needed.
meta.one_shot
} else {
return;
}
@@ -269,7 +274,10 @@ impl CronScheduler {
pub fn record_failure(&self, id: CronJobId, error_msg: &str) {
if let Some(mut meta) = self.jobs.get_mut(&id) {
meta.job.last_run = Some(Utc::now());
meta.last_status = Some(format!("error: {}", &error_msg[..error_msg.len().min(256)]));
meta.last_status = Some(format!(
"error: {}",
openfang_types::truncate_str(error_msg, 256)
));
meta.consecutive_errors += 1;
if meta.consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
warn!(
@@ -293,16 +301,37 @@ impl CronScheduler {
///
/// - `At { at }` — returns `at` directly.
/// - `Every { every_secs }` — returns `now + every_secs`.
/// - `Cron { .. }` — returns 60 seconds from now (placeholder until a cron
/// expression parser is added).
/// - `Cron { expr, tz }` — parses the cron expression and computes the next
/// matching time. Supports standard 5-field (`min hour dom month dow`) and
/// 6-field (`sec min hour dom month dow`) formats by converting to the
/// 7-field format required by the `cron` crate.
pub fn compute_next_run(schedule: &CronSchedule) -> chrono::DateTime<Utc> {
match schedule {
CronSchedule::At { at } => *at,
CronSchedule::Every { every_secs } => Utc::now() + Duration::seconds(*every_secs as i64),
CronSchedule::Cron { .. } => {
// Placeholder: real cron parsing will be added when the `cron`
// crate is brought in. For now, fire 60 seconds from now.
Utc::now() + Duration::seconds(60)
CronSchedule::Cron { expr, tz: _ } => {
// Convert standard 5/6-field cron to 7-field for the `cron` crate.
// Standard 5-field: min hour dom month dow
// 6-field: sec min hour dom month dow
// cron crate: sec min hour dom month dow year
let trimmed = expr.trim();
let fields: Vec<&str> = trimmed.split_whitespace().collect();
let seven_field = match fields.len() {
5 => format!("0 {trimmed} *"),
6 => format!("{trimmed} *"),
_ => expr.clone(),
};
match seven_field.parse::<cron::Schedule>() {
Ok(sched) => sched
.after(&Utc::now())
.next()
.unwrap_or_else(|| Utc::now() + Duration::hours(1)),
Err(e) => {
warn!("Failed to parse cron expression '{}': {}", expr, e);
Utc::now() + Duration::hours(1)
}
}
}
}
}
@@ -655,18 +684,47 @@ mod tests {
}
#[test]
fn test_compute_next_run_cron_placeholder() {
let before = Utc::now();
fn test_compute_next_run_cron_daily() {
let now = Utc::now();
let schedule = CronSchedule::Cron {
expr: "0 9 * * *".into(),
tz: None,
};
let next = compute_next_run(&schedule);
let after = Utc::now();
// Placeholder returns ~60s from now
assert!(next >= before + Duration::seconds(59));
assert!(next <= after + Duration::seconds(61));
// Should be within the next 24 hours (next 09:00 UTC)
assert!(next > now);
assert!(next <= now + Duration::hours(24));
assert_eq!(next.format("%M").to_string(), "00");
assert_eq!(next.format("%H").to_string(), "09");
}
#[test]
fn test_compute_next_run_cron_with_dow() {
let now = Utc::now();
let schedule = CronSchedule::Cron {
expr: "30 14 * * 1-5".into(),
tz: None,
};
let next = compute_next_run(&schedule);
// Should be within the next 7 days and at 14:30
assert!(next > now);
assert!(next <= now + Duration::days(7));
assert_eq!(next.format("%H:%M").to_string(), "14:30");
}
#[test]
fn test_compute_next_run_cron_invalid_expr() {
let now = Utc::now();
let schedule = CronSchedule::Cron {
expr: "not a cron".into(),
tz: None,
};
let next = compute_next_run(&schedule);
// Invalid expression falls back to 1 hour from now
assert!(next > now + Duration::minutes(59));
assert!(next <= now + Duration::minutes(61));
}
// -- error message truncation in record_failure -------------------------
+285 -42
View File
@@ -127,6 +127,8 @@ pub struct OpenFangKernel {
pub booted_at: std::time::Instant,
/// WhatsApp Web gateway child process PID (for shutdown cleanup).
pub whatsapp_gateway_pid: Arc<std::sync::Mutex<Option<u32>>>,
/// Channel adapters registered at bridge startup (for proactive `channel_send` tool).
pub channel_adapters: dashmap::DashMap<String, Arc<dyn openfang_channels::types::ChannelAdapter>>,
/// Weak self-reference for trigger dispatch (set after Arc wrapping).
self_handle: OnceLock<Weak<OpenFangKernel>>,
}
@@ -411,12 +413,8 @@ fn append_daily_memory_log(workspace: &Path, response: &str) {
return;
}
}
// Truncate long responses for the log
let summary = if trimmed.len() > 500 {
&trimmed[..500]
} else {
trimmed
};
// Truncate long responses for the log (UTF-8 safe)
let summary = openfang_types::truncate_str(trimmed, 500);
let timestamp = chrono::Utc::now().format("%H:%M:%S").to_string();
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
@@ -590,9 +588,16 @@ impl OpenFangKernel {
info!("RBAC enabled with {} users", auth.user_count());
}
// Initialize model catalog and detect provider auth
// Initialize model catalog, detect provider auth, and apply URL overrides
let mut model_catalog = openfang_runtime::model_catalog::ModelCatalog::new();
model_catalog.detect_auth();
if !config.provider_urls.is_empty() {
model_catalog.apply_url_overrides(&config.provider_urls);
info!(
"applied {} provider URL override(s)",
config.provider_urls.len()
);
}
let available_count = model_catalog.available_models().len();
let total_count = model_catalog.list_models().len();
let local_count = model_catalog
@@ -867,6 +872,7 @@ impl OpenFangKernel {
peer_node: None,
booted_at: std::time::Instant::now(),
whatsapp_gateway_pid: Arc::new(std::sync::Mutex::new(None)),
channel_adapters: dashmap::DashMap::new(),
self_handle: OnceLock::new(),
};
@@ -968,6 +974,9 @@ impl OpenFangKernel {
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();
}
}
// Create workspace directory for the agent
@@ -1083,12 +1092,22 @@ impl OpenFangKernel {
}
/// Send a message to an agent and get a response.
///
/// Automatically upgrades the kernel handle from `self_handle` so that
/// agent turns triggered by cron, channels, events, or inter-agent calls
/// have full access to kernel tools (cron_create, agent_send, etc.).
pub async fn send_message(
&self,
agent_id: AgentId,
message: &str,
) -> KernelResult<AgentLoopResult> {
self.send_message_with_handle(agent_id, message, None).await
let handle: Option<Arc<dyn KernelHandle>> = self
.self_handle
.get()
.and_then(|w| w.upgrade())
.map(|arc| arc as Arc<dyn KernelHandle>);
self.send_message_with_handle(agent_id, message, handle)
.await
}
/// Send a message with an optional kernel handle for inter-agent tools.
@@ -1324,7 +1343,7 @@ impl OpenFangKernel {
recalled_memories: vec![],
skill_summary: self.build_skill_summary(&manifest.skills),
skill_prompt_context: self.collect_prompt_context(&manifest.skills),
mcp_summary: if mcp_tool_count >= 3 {
mcp_summary: if mcp_tool_count > 0 {
self.build_mcp_summary(&manifest.mcp_servers)
} else {
String::new()
@@ -1383,6 +1402,16 @@ impl OpenFangKernel {
};
manifest.model.system_prompt =
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
// Store canonical context separately for injection as user message
// (keeps system prompt stable across turns for provider prompt caching)
if let Some(cc_msg) =
openfang_runtime::prompt_builder::build_canonical_context_message(&prompt_ctx)
{
manifest.metadata.insert(
"canonical_context_msg".to_string(),
serde_json::Value::String(cc_msg),
);
}
}
let memory = Arc::clone(&self.memory);
@@ -1772,7 +1801,7 @@ impl OpenFangKernel {
recalled_memories: vec![], // Recalled in agent_loop, not here
skill_summary: self.build_skill_summary(&manifest.skills),
skill_prompt_context: self.collect_prompt_context(&manifest.skills),
mcp_summary: if mcp_tool_count >= 3 {
mcp_summary: if mcp_tool_count > 0 {
self.build_mcp_summary(&manifest.mcp_servers)
} else {
String::new()
@@ -1831,6 +1860,16 @@ impl OpenFangKernel {
};
manifest.model.system_prompt =
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
// Store canonical context separately for injection as user message
// (keeps system prompt stable across turns for provider prompt caching)
if let Some(cc_msg) =
openfang_runtime::prompt_builder::build_canonical_context_message(&prompt_ctx)
{
manifest.metadata.insert(
"canonical_context_msg".to_string(),
serde_json::Value::String(cc_msg),
);
}
}
let is_stable = self.config.mode == openfang_types::config::KernelMode::Stable;
@@ -2213,7 +2252,10 @@ impl OpenFangKernel {
.map(|entry| entry.provider.clone())
});
if let Some(provider) = resolved_provider {
// If catalog lookup failed, try to infer provider from model name prefix
let provider = resolved_provider.or_else(|| infer_provider_from_model(model));
if let Some(provider) = provider {
self.registry
.update_model_and_provider(agent_id, model.to_string(), provider.clone())
.map_err(KernelError::OpenFang)?;
@@ -2222,7 +2264,7 @@ impl OpenFangKernel {
self.registry
.update_model(agent_id, model.to_string())
.map_err(KernelError::OpenFang)?;
info!(agent_id = %agent_id, model = %model, "Agent model updated");
info!(agent_id = %agent_id, model = %model, "Agent model updated (provider unchanged)");
}
// Persist the updated entry
@@ -2752,6 +2794,14 @@ impl OpenFangKernel {
self.cron_scheduler
.set_max_total_jobs(new_config.max_cron_jobs);
}
HotAction::ReloadProviderUrls => {
info!("Hot-reload: applying provider URL overrides");
let mut catalog = self
.model_catalog
.write()
.unwrap_or_else(|e| e.into_inner());
catalog.apply_url_overrides(&new_config.provider_urls);
}
_ => {
// Other hot actions (channels, web, browser, extensions, etc.)
// are logged but not applied here — they require subsystem-specific
@@ -3064,6 +3114,8 @@ impl OpenFangKernel {
let kernel = Arc::clone(self);
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(15));
// Use Skip to avoid burst-firing after a long job blocks the loop.
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut persist_counter = 0u32;
interval.tick().await; // Skip first immediate tick
loop {
@@ -3105,15 +3157,24 @@ impl OpenFangKernel {
tracing::debug!(job = %job_name, agent = %agent_id, "Cron: firing agent turn");
let timeout_s = timeout_secs.unwrap_or(120);
let timeout = std::time::Duration::from_secs(timeout_s);
let delivery = job.delivery.clone();
match tokio::time::timeout(
timeout,
kernel.send_message(agent_id, message),
)
.await
{
Ok(Ok(_result)) => {
Ok(Ok(result)) => {
tracing::info!(job = %job_name, "Cron job completed successfully");
kernel.cron_scheduler.record_success(job_id);
// Deliver response to configured channel
cron_deliver_response(
&kernel,
agent_id,
&result.response,
&delivery,
)
.await;
}
Ok(Err(e)) => {
let err_msg = format!("{e}");
@@ -3433,39 +3494,50 @@ impl OpenFangKernel {
let primary = if agent_provider == default_provider && !has_custom_key && !has_custom_url {
Arc::clone(&self.default_driver)
} else {
// Create a dedicated driver for this agent
// Auth profile rotation: if profiles are configured for this provider,
// select the highest-priority profile's key env var.
let default_key_env = manifest
.model
.api_key_env
.as_deref()
.unwrap_or(&self.config.default_model.api_key_env);
let api_key_env =
// Create a dedicated driver for this agent.
//
// IMPORTANT: When the agent's provider differs from the default,
// we must NOT pass the default provider's API key. Instead, pass None
// so create_driver() can look up the correct env var for the target provider.
let api_key = if has_custom_key {
// Agent explicitly set an API key env var — use it
manifest
.model
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
} else if agent_provider == default_provider {
// Same provider — use default key
std::env::var(&self.config.default_model.api_key_env).ok()
} else {
// Different provider — check auth profiles first, then let
// create_driver() look up the correct env var automatically.
if let Some(profiles) = self.config.auth_profiles.get(agent_provider.as_str()) {
if !profiles.is_empty() {
// Pick highest-priority profile (lowest priority number)
let mut sorted: Vec<_> = profiles.iter().collect();
sorted.sort_by_key(|p| p.priority);
let best = &sorted[0];
// Use the profile's env var if the key exists, otherwise fall back
if std::env::var(&best.api_key_env).is_ok() {
best.api_key_env.clone()
} else {
default_key_env.to_string()
}
} else {
default_key_env.to_string()
}
let mut sorted: Vec<_> = profiles.iter().collect();
sorted.sort_by_key(|p| p.priority);
sorted
.first()
.and_then(|best| std::env::var(&best.api_key_env).ok())
} else {
default_key_env.to_string()
};
// Pass None — create_driver() has per-provider env var lookups
None
}
};
// Don't inherit default provider's base_url when switching providers
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()
} else {
// Let create_driver() use the target provider's default base URL
None
};
let driver_config = DriverConfig {
provider: agent_provider.clone(),
api_key: std::env::var(&api_key_env).ok(),
base_url: manifest.model.base_url.clone(),
api_key,
base_url,
};
drivers::create_driver(&driver_config).map_err(|e| {
@@ -4031,7 +4103,18 @@ impl OpenFangKernel {
tool_names.join(", ")
));
}
summary.push_str("MCP tools are prefixed with mcp_{server}_ and work like regular tools.");
summary.push_str("MCP tools are prefixed with mcp_{server}_ and work like regular tools.\n");
// Add filesystem-specific guidance when a filesystem MCP server is connected
let has_filesystem = servers.keys().any(|s| s.contains("filesystem"));
if has_filesystem {
summary.push_str(
"IMPORTANT: For accessing files OUTSIDE your workspace directory, you MUST use \
the MCP filesystem tools (e.g. mcp_filesystem_read_file, mcp_filesystem_list_directory) \
instead of the built-in file_read/file_list/file_write tools, which are restricted to \
the workspace. The MCP filesystem server has been granted access to specific directories \
by the user.",
);
}
summary
}
@@ -4158,6 +4241,62 @@ fn manifest_to_capabilities(manifest: &AgentManifest) -> Vec<Capability> {
caps
}
/// Infer provider from a model name when catalog lookup fails.
///
/// Uses well-known model name prefixes to map to the correct provider.
/// 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() {
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());
}
}
_ => {}
}
}
// Infer from well-known model name patterns
if lower.starts_with("minimax") {
Some("minimax".to_string())
} else if lower.starts_with("gemini") {
Some("gemini".to_string())
} else if lower.starts_with("claude") {
Some("anthropic".to_string())
} else if lower.starts_with("gpt") || lower.starts_with("o1") || lower.starts_with("o3") || lower.starts_with("o4") {
Some("openai".to_string())
} else if lower.starts_with("llama") || lower.starts_with("mixtral") || lower.starts_with("qwen") {
// These could be on multiple providers; don't infer
None
} else if lower.starts_with("grok") {
Some("xai".to_string())
} else if lower.starts_with("deepseek") {
Some("deepseek".to_string())
} else if lower.starts_with("mistral") || lower.starts_with("codestral") || lower.starts_with("pixtral") {
Some("mistral".to_string())
} else if lower.starts_with("command") || lower.starts_with("embed-") {
Some("cohere".to_string())
} else if lower.starts_with("jamba") {
Some("ai21".to_string())
} else if lower.starts_with("sonar") {
Some("perplexity".to_string())
} else if lower.starts_with("glm") {
Some("zhipu".to_string())
} else if lower.starts_with("ernie") {
Some("qianfan".to_string())
} else if lower.starts_with("abab") {
Some("minimax".to_string())
} else {
None
}
}
/// 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 {
@@ -4167,6 +4306,74 @@ fn shared_memory_agent_id() -> AgentId {
]))
}
/// Deliver a cron job's agent response to the configured delivery target.
async fn cron_deliver_response(
kernel: &OpenFangKernel,
agent_id: AgentId,
response: &str,
delivery: &openfang_types::scheduler::CronDelivery,
) {
use openfang_types::scheduler::CronDelivery;
if response.is_empty() {
return;
}
match delivery {
CronDelivery::None => {}
CronDelivery::Channel { channel, to } => {
tracing::debug!(channel = %channel, to = %to, "Cron: delivering to channel");
// Persist as last channel for this agent (survives restarts)
let kv_val = serde_json::json!({"channel": channel, "recipient": to});
let _ = kernel
.memory
.structured_set(agent_id, "delivery.last_channel", kv_val);
}
CronDelivery::LastChannel => {
match kernel
.memory
.structured_get(agent_id, "delivery.last_channel")
{
Ok(Some(val)) => {
let channel = val["channel"].as_str().unwrap_or("");
let recipient = val["recipient"].as_str().unwrap_or("");
if !channel.is_empty() && !recipient.is_empty() {
tracing::info!(
channel = %channel,
recipient = %recipient,
"Cron: delivering to last channel"
);
}
}
_ => {
tracing::debug!("Cron: no last channel found for agent {}", agent_id);
}
}
}
CronDelivery::Webhook { url } => {
tracing::debug!(url = %url, "Cron: delivering via webhook");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build();
if let Ok(client) = client {
let payload = serde_json::json!({
"agent_id": agent_id.to_string(),
"response": response,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
match client.post(url).json(&payload).send().await {
Ok(resp) => {
tracing::debug!(status = %resp.status(), "Cron webhook delivered");
}
Err(e) => {
tracing::warn!(error = %e, "Cron webhook delivery failed");
}
}
}
}
}
}
#[async_trait]
impl KernelHandle for OpenFangKernel {
async fn spawn_agent(
@@ -4592,6 +4799,42 @@ impl KernelHandle for OpenFangKernel {
.map(|(url, _)| url.clone())
}
async fn send_channel_message(
&self,
channel: &str,
recipient: &str,
message: &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,
};
adapter
.send(&user, openfang_channels::types::ChannelContent::Text(message.to_string()))
.await
.map_err(|e| format!("Channel send failed: {e}"))?;
Ok(format!("Message sent to {} via {}", recipient, channel))
}
async fn spawn_agent_checked(
&self,
manifest_toml: &str,
+64 -3
View File
@@ -148,20 +148,33 @@ impl MeteringEngine {
/// | Model Family | Input $/M | Output $/M |
/// |-----------------------|-----------|------------|
/// | claude-haiku | 0.25 | 1.25 |
/// | claude-sonnet | 3.00 | 15.00 |
/// | claude-opus | 15.00 | 75.00 |
/// | claude-sonnet-4-6 | 3.00 | 15.00 |
/// | claude-opus-4-6 | 5.00 | 25.00 |
/// | claude-opus (legacy) | 15.00 | 75.00 |
/// | gpt-5.2(-pro) | 1.75 | 14.00 |
/// | gpt-5(.1) | 1.25 | 10.00 |
/// | gpt-5-mini | 0.25 | 2.00 |
/// | gpt-5-nano | 0.05 | 0.40 |
/// | gpt-4o | 2.50 | 10.00 |
/// | gpt-4o-mini | 0.15 | 0.60 |
/// | gpt-4.1 | 2.00 | 8.00 |
/// | gpt-4.1-mini | 0.40 | 1.60 |
/// | gpt-4.1-nano | 0.10 | 0.40 |
/// | o3-mini | 1.10 | 4.40 |
/// | gemini-2.0-flash | 0.10 | 0.40 |
/// | gemini-3.1 | 2.50 | 15.00 |
/// | gemini-3 | 0.50 | 3.00 |
/// | gemini-2.5-flash-lite | 0.04 | 0.15 |
/// | gemini-2.5-pro | 1.25 | 10.00 |
/// | gemini-2.5-flash | 0.15 | 0.60 |
/// | gemini-2.0-flash | 0.10 | 0.40 |
/// | deepseek-chat/v3 | 0.27 | 1.10 |
/// | deepseek-reasoner/r1 | 0.55 | 2.19 |
/// | llama-4-maverick | 0.50 | 0.77 |
/// | llama-4-scout | 0.11 | 0.34 |
/// | llama/mixtral (groq) | 0.05 | 0.10 |
/// | grok-4.1 | 0.20 | 0.50 |
/// | grok-4 | 3.00 | 15.00 |
/// | grok-3 | 3.00 | 15.00 |
/// | qwen | 0.20 | 0.60 |
/// | mistral-large | 2.00 | 6.00 |
/// | mistral-small | 0.10 | 0.30 |
@@ -222,14 +235,38 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
if model.contains("haiku") {
return (0.25, 1.25);
}
if model.contains("opus-4-6") || model.contains("claude-opus-4-6") {
return (5.0, 25.0);
}
if model.contains("opus") {
return (15.0, 75.0);
}
if model.contains("sonnet-4-6") || model.contains("claude-sonnet-4-6") {
return (3.0, 15.0);
}
if model.contains("sonnet") {
return (3.0, 15.0);
}
// ── OpenAI ─────────────────────────────────────────────────
if model.contains("gpt-5.2-pro") {
return (1.75, 14.0);
}
if model.contains("gpt-5.2") {
return (1.75, 14.0);
}
if model.contains("gpt-5.1") {
return (1.25, 10.0);
}
if model.contains("gpt-5-nano") {
return (0.05, 0.40);
}
if model.contains("gpt-5-mini") {
return (0.25, 2.0);
}
if model.contains("gpt-5") {
return (1.25, 10.0);
}
if model.contains("gpt-4o-mini") {
return (0.15, 0.60);
}
@@ -260,6 +297,15 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
}
// ── Google Gemini ──────────────────────────────────────────
if model.contains("gemini-3.1") {
return (2.50, 15.0);
}
if model.contains("gemini-3") {
return (0.50, 3.0);
}
if model.contains("gemini-2.5-flash-lite") {
return (0.04, 0.15);
}
if model.contains("gemini-2.5-pro") {
return (1.25, 10.0);
}
@@ -298,6 +344,12 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
}
// ── Open-source (Groq, Together, etc.) ─────────────────────
if model.contains("llama-4-maverick") {
return (0.50, 0.77);
}
if model.contains("llama-4-scout") {
return (0.11, 0.34);
}
if model.contains("llama") || model.contains("mixtral") {
return (0.05, 0.10);
}
@@ -330,6 +382,9 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
if model.contains("glm") {
return (1.50, 5.00);
}
if model.contains("codegeex") {
return (0.10, 0.10);
}
// ── Moonshot / Kimi ─────────────────────────────────────────
if model.contains("moonshot") || model.contains("kimi") {
@@ -374,6 +429,12 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
}
// ── xAI / Grok ──────────────────────────────────────────────
if model.contains("grok-4.1") {
return (0.20, 0.50);
}
if model.contains("grok-4") {
return (3.0, 15.0);
}
if model.contains("grok-3-mini") || model.contains("grok-2-mini") || model.contains("grok-mini")
{
return (0.30, 0.50);
+3 -1
View File
@@ -88,7 +88,9 @@ impl AgentScheduler {
// Reset the window if an hour has passed
tracker.reset_if_expired();
if tracker.total_tokens > quota.max_llm_tokens_per_hour {
if quota.max_llm_tokens_per_hour > 0
&& tracker.total_tokens > quota.max_llm_tokens_per_hour
{
return Err(OpenFangError::QuotaExceeded(format!(
"Token limit exceeded: {} / {}",
tracker.total_tokens, quota.max_llm_tokens_per_hour
+9 -4
View File
@@ -428,19 +428,24 @@ impl SessionStore {
};
let text = msg.content.text_content();
if !text.is_empty() {
// Truncate individual messages in summary to keep it compact
// Truncate individual messages in summary to keep it compact (UTF-8 safe)
let truncated = if text.len() > 200 {
format!("{}...", &text[..200])
format!("{}...", openfang_types::truncate_str(&text, 200))
} else {
text
};
summary_parts.push(format!("{role}: {truncated}"));
}
}
// Keep summary under ~4000 chars
// Keep summary under ~4000 chars (UTF-8 safe)
let mut full_summary = summary_parts.join("\n");
if full_summary.len() > 4000 {
full_summary = full_summary[full_summary.len() - 4000..].to_string();
let start = full_summary.len() - 4000;
// Find the next char boundary at or after `start`
let safe_start = (start..full_summary.len())
.find(|&i| full_summary.is_char_boundary(i))
.unwrap_or(full_summary.len());
full_summary = full_summary[safe_start..].to_string();
}
canonical.compacted_summary = Some(full_summary);
canonical.compaction_cursor = to_compact;
+67 -5
View File
@@ -215,6 +215,19 @@ pub async fn run_agent_loop(
// Validate and repair session history (drop orphans, merge consecutive)
let mut messages = crate::session_repair::validate_and_repair(&llm_messages);
// Inject canonical context as the first user message (not in system prompt)
// to keep the system prompt stable across turns for provider prompt caching.
if let Some(cc_msg) = manifest
.metadata
.get("canonical_context_msg")
.and_then(|v| v.as_str())
{
if !cc_msg.is_empty() {
messages.insert(0, Message::user(cc_msg));
}
}
let mut total_usage = TokenUsage::default();
let final_response;
@@ -253,6 +266,7 @@ pub async fn run_agent_loop(
// Build context budget from model's actual context window (or fallback to default)
let ctx_window = context_window_tokens.unwrap_or(DEFAULT_CONTEXT_WINDOW);
let context_budget = ContextBudget::new(ctx_window);
let mut any_tools_executed = false;
for iteration in 0..max_iterations {
debug!(iteration, "Agent loop iteration");
@@ -370,7 +384,7 @@ pub async fn run_agent_loop(
messages_count = messages.len(),
"Empty response from LLM — guard activated"
);
if iteration > 0 {
if any_tools_executed {
"[Task completed — the agent executed tools but did not produce a text summary.]".to_string()
} else {
"[The model returned an empty response. This usually means the model is overloaded, the context is too large, or the API key lacks credits. Try again or check /status.]".to_string()
@@ -471,6 +485,7 @@ pub async fn run_agent_loop(
StopReason::ToolUse => {
// Reset MaxTokens continuation counter on tool use
consecutive_max_tokens = 0;
any_tools_executed = true;
// Execute tool calls
let assistant_blocks = response.content.clone();
@@ -646,6 +661,22 @@ pub async fn run_agent_loop(
});
}
// Detect approval denials and inject guidance to prevent infinite retry loops
let denial_count = tool_result_blocks.iter().filter(|b| {
matches!(b, ContentBlock::ToolResult { content, is_error: true, .. }
if content.contains("requires human approval and was denied"))
}).count();
if denial_count > 0 {
tool_result_blocks.push(ContentBlock::Text {
text: format!(
"[System: {} tool call(s) were denied by approval policy. \
Do NOT retry denied tools. Explain to the user what you \
wanted to do and that it requires their approval.]",
denial_count
),
});
}
// Add tool results as a user message (Anthropic API requirement)
let tool_results_msg = Message {
role: Role::User,
@@ -1075,6 +1106,19 @@ pub async fn run_agent_loop_streaming(
// Validate and repair session history (drop orphans, merge consecutive)
let mut messages = crate::session_repair::validate_and_repair(&llm_messages);
// Inject canonical context as the first user message (not in system prompt)
// to keep the system prompt stable across turns for provider prompt caching.
if let Some(cc_msg) = manifest
.metadata
.get("canonical_context_msg")
.and_then(|v| v.as_str())
{
if !cc_msg.is_empty() {
messages.insert(0, Message::user(cc_msg));
}
}
let mut total_usage = TokenUsage::default();
let final_response;
@@ -1111,6 +1155,7 @@ pub async fn run_agent_loop_streaming(
// Build context budget from model's actual context window (or fallback to default)
let ctx_window = context_window_tokens.unwrap_or(DEFAULT_CONTEXT_WINDOW);
let context_budget = ContextBudget::new(ctx_window);
let mut any_tools_executed = false;
for iteration in 0..max_iterations {
debug!(iteration, "Streaming agent loop iteration");
@@ -1247,7 +1292,7 @@ pub async fn run_agent_loop_streaming(
messages_count = messages.len(),
"Empty response from LLM (streaming) — guard activated"
);
if iteration > 0 {
if any_tools_executed {
"[Task completed — the agent executed tools but did not produce a text summary.]".to_string()
} else {
"[The model returned an empty response. This usually means the model is overloaded, the context is too large, or the API key lacks credits. Try again or check /status.]".to_string()
@@ -1347,6 +1392,7 @@ pub async fn run_agent_loop_streaming(
StopReason::ToolUse => {
// Reset MaxTokens continuation counter on tool use
consecutive_max_tokens = 0;
any_tools_executed = true;
let assistant_blocks = response.content.clone();
@@ -1532,6 +1578,22 @@ pub async fn run_agent_loop_streaming(
});
}
// Detect approval denials and inject guidance to prevent infinite retry loops
let denial_count = tool_result_blocks.iter().filter(|b| {
matches!(b, ContentBlock::ToolResult { content, is_error: true, .. }
if content.contains("requires human approval and was denied"))
}).count();
if denial_count > 0 {
tool_result_blocks.push(ContentBlock::Text {
text: format!(
"[System: {} tool call(s) were denied by approval policy. \
Do NOT retry denied tools. Explain to the user what you \
wanted to do and that it requires their approval.]",
denial_count
),
});
}
let tool_results_msg = Message {
role: Role::User,
content: MessageContent::Blocks(tool_result_blocks.clone()),
@@ -2279,10 +2341,10 @@ mod tests {
.await
.expect("Loop should complete with fallback");
// After retry (iteration 1), should hit the iteration > 0 guard
// No tools were executed, so should get the empty response message
assert!(
result.response.contains("Task completed"),
"Expected fallback after retry failure, got: {:?}",
result.response.contains("empty response"),
"Expected empty response fallback (no tools executed), got: {:?}",
result.response
);
}
+6 -5
View File
@@ -11,6 +11,7 @@
//! 3. Minimal fallback without LLM (when summarization is unavailable)
use crate::llm_driver::{CompletionRequest, LlmDriver};
use crate::str_utils::safe_truncate_str;
use openfang_memory::session::Session;
use openfang_types::message::{ContentBlock, Message, MessageContent, Role};
use openfang_types::tool::ToolDefinition;
@@ -342,7 +343,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
if oversized {
let limit = config.max_chunk_chars / 4;
let truncated = if s.len() > limit {
format!("{}...[truncated from {} chars]", &s[..limit], s.len())
format!("{}...[truncated from {} chars]", safe_truncate_str(s, limit), s.len())
} else {
s.clone()
};
@@ -361,7 +362,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
let limit = config.max_chunk_chars / 4;
conversation_text.push_str(&format!(
"{role_label}: {}...[truncated from {} chars]\n\n",
&text[..limit],
safe_truncate_str(text, limit),
text.len()
));
} else {
@@ -373,7 +374,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
ContentBlock::ToolUse { name, input, .. } => {
let input_str = serde_json::to_string(input).unwrap_or_default();
let input_preview = if input_str.len() > 200 {
format!("{}...", &input_str[..200])
format!("{}...", safe_truncate_str(&input_str, 200))
} else {
input_str
};
@@ -388,7 +389,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
// Strip base64 blobs and injection markers before compaction
let cleaned = crate::session_repair::strip_tool_result_details(content);
let preview = if cleaned.len() > 2000 {
format!("{}...", &cleaned[..2000])
format!("{}...", safe_truncate_str(&cleaned, 2000))
} else {
cleaned
};
@@ -886,7 +887,7 @@ mod tests {
assert!(input_str.len() > 200);
// Just verify the truncation logic works correctly
let preview = if input_str.len() > 200 {
format!("{}...", &input_str[..200])
format!("{}...", safe_truncate_str(&input_str, 200))
} else {
input_str.clone()
};
@@ -0,0 +1,155 @@
//! GitHub Copilot OAuth — device flow for obtaining a GitHub PAT via browser login.
//!
//! Implements the OAuth 2.0 Device Authorization Grant (RFC 8628) using GitHub's
//! device flow endpoint. Users visit a URL, enter a code, and authorize the app.
//! Once complete, the resulting access token can be used with the CopilotDriver.
use serde::Deserialize;
use zeroize::Zeroizing;
/// GitHub device code request URL.
const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
/// GitHub OAuth token URL.
const GITHUB_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
/// Public OAuth client ID — same as VSCode Copilot extension.
const COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
/// Response from the device code initiation request.
#[derive(Debug, Deserialize)]
pub struct DeviceCodeResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
}
/// Status of a device flow polling attempt.
pub enum DeviceFlowStatus {
/// Authorization is pending — user hasn't completed the flow yet.
Pending,
/// Authorization succeeded — contains the access token.
Complete { access_token: Zeroizing<String> },
/// Server asked to slow down — use the new interval.
SlowDown { new_interval: u64 },
/// The device code expired — user must restart the flow.
Expired,
/// User explicitly denied access.
AccessDenied,
/// An unexpected error occurred.
Error(String),
}
/// Start a GitHub device flow for Copilot OAuth.
///
/// POST https://github.com/login/device/code
/// Returns a device code and user code for the user to enter at the verification URI.
pub async fn start_device_flow() -> Result<DeviceCodeResponse, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("HTTP client error: {e}"))?;
let resp = client
.post(GITHUB_DEVICE_CODE_URL)
.header("Accept", "application/json")
.form(&[("client_id", COPILOT_CLIENT_ID), ("scope", "read:user")])
.send()
.await
.map_err(|e| format!("Device code request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("Device code request returned {status}: {body}"));
}
resp.json::<DeviceCodeResponse>()
.await
.map_err(|e| format!("Failed to parse device code response: {e}"))
}
/// Poll the GitHub token endpoint for the device flow result.
///
/// POST https://github.com/login/oauth/access_token
/// Returns the current status of the authorization flow.
pub async fn poll_device_flow(device_code: &str) -> DeviceFlowStatus {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
{
Ok(c) => c,
Err(e) => return DeviceFlowStatus::Error(format!("HTTP client error: {e}")),
};
let resp = match client
.post(GITHUB_TOKEN_URL)
.header("Accept", "application/json")
.form(&[
("client_id", COPILOT_CLIENT_ID),
(
"grant_type",
"urn:ietf:params:oauth:grant-type:device_code",
),
("device_code", device_code),
])
.send()
.await
{
Ok(r) => r,
Err(e) => return DeviceFlowStatus::Error(format!("Token poll failed: {e}")),
};
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(e) => return DeviceFlowStatus::Error(format!("Failed to parse token response: {e}")),
};
// Check for error field first (GitHub returns 200 with error during polling)
if let Some(error) = body.get("error").and_then(|v| v.as_str()) {
return match error {
"authorization_pending" => DeviceFlowStatus::Pending,
"slow_down" => {
let interval = body
.get("interval")
.and_then(|v| v.as_u64())
.unwrap_or(10);
DeviceFlowStatus::SlowDown {
new_interval: interval,
}
}
"expired_token" => DeviceFlowStatus::Expired,
"access_denied" => DeviceFlowStatus::AccessDenied,
_ => {
let desc = body
.get("error_description")
.and_then(|v| v.as_str())
.unwrap_or(error);
DeviceFlowStatus::Error(desc.to_string())
}
};
}
// Success — extract access token
if let Some(token) = body.get("access_token").and_then(|v| v.as_str()) {
DeviceFlowStatus::Complete {
access_token: Zeroizing::new(token.to_string()),
}
} else {
DeviceFlowStatus::Error("No access_token in response".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_constants() {
assert!(GITHUB_DEVICE_CODE_URL.starts_with("https://"));
assert!(GITHUB_TOKEN_URL.starts_with("https://"));
assert!(!COPILOT_CLIENT_ID.is_empty());
}
}
@@ -0,0 +1,405 @@
//! Claude Code CLI backend driver.
//!
//! Spawns the `claude` CLI (Claude Code) as a subprocess in print mode (`-p`),
//! which is non-interactive and handles its own authentication.
//! This allows users with Claude Code installed to use it as an LLM provider
//! without needing a separate API key.
use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent};
use async_trait::async_trait;
use openfang_types::message::{ContentBlock, Role, StopReason, TokenUsage};
use serde::Deserialize;
use tokio::io::AsyncBufReadExt;
use tracing::{debug, warn};
/// LLM driver that delegates to the Claude Code CLI.
pub struct ClaudeCodeDriver {
cli_path: String,
}
impl ClaudeCodeDriver {
/// Create a new Claude Code driver.
///
/// `cli_path` overrides the CLI binary path; defaults to `"claude"` on PATH.
pub fn new(cli_path: Option<String>) -> Self {
Self {
cli_path: cli_path
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "claude".to_string()),
}
}
/// Detect if the Claude Code CLI is available on PATH.
pub fn detect() -> Option<String> {
let output = std::process::Command::new("claude")
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
/// Build a text prompt from the completion request messages.
fn build_prompt(request: &CompletionRequest) -> String {
let mut parts = Vec::new();
if let Some(ref sys) = request.system {
parts.push(format!("[System]\n{sys}"));
}
for msg in &request.messages {
let role_label = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
Role::System => "System",
};
let text = msg.content.text_content();
if !text.is_empty() {
parts.push(format!("[{role_label}]\n{text}"));
}
}
parts.join("\n\n")
}
/// Map a model ID like "claude-code/opus" to CLI --model flag value.
fn model_flag(model: &str) -> Option<String> {
let stripped = model
.strip_prefix("claude-code/")
.unwrap_or(model);
match stripped {
"opus" => Some("opus".to_string()),
"sonnet" => Some("sonnet".to_string()),
"haiku" => Some("haiku".to_string()),
_ => Some(stripped.to_string()),
}
}
}
/// JSON output from `claude -p --output-format json`.
#[derive(Debug, Deserialize)]
struct ClaudeJsonOutput {
result: Option<String>,
#[serde(default)]
usage: Option<ClaudeUsage>,
#[serde(default)]
#[allow(dead_code)]
cost_usd: Option<f64>,
}
/// Usage stats from Claude CLI JSON output.
#[derive(Debug, Deserialize, Default)]
struct ClaudeUsage {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
}
/// Stream JSON event from `claude -p --output-format stream-json`.
#[derive(Debug, Deserialize)]
struct ClaudeStreamEvent {
#[serde(default)]
r#type: String,
#[serde(default)]
content: Option<String>,
#[serde(default)]
result: Option<String>,
#[serde(default)]
usage: Option<ClaudeUsage>,
}
#[async_trait]
impl LlmDriver for ClaudeCodeDriver {
async fn complete(
&self,
request: CompletionRequest,
) -> Result<CompletionResponse, LlmError> {
let prompt = Self::build_prompt(&request);
let model_flag = Self::model_flag(&request.model);
let mut cmd = tokio::process::Command::new(&self.cli_path);
cmd.arg("-p")
.arg(&prompt)
.arg("--output-format")
.arg("json");
if let Some(ref model) = model_flag {
cmd.arg("--model").arg(model);
}
// SECURITY: Don't inherit all env vars — only safe ones
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
debug!(cli = %self.cli_path, "Spawning Claude Code CLI");
let output = cmd
.output()
.await
.map_err(|e| LlmError::Http(format!("Failed to spawn claude CLI: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(LlmError::Api {
status: output.status.code().unwrap_or(1) as u16,
message: format!("Claude CLI failed: {stderr}"),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
// Try JSON parse first
if let Ok(parsed) = serde_json::from_str::<ClaudeJsonOutput>(&stdout) {
let text = parsed.result.unwrap_or_default();
let usage = parsed.usage.unwrap_or_default();
return Ok(CompletionResponse {
content: vec![ContentBlock::Text { text: text.clone() }],
stop_reason: StopReason::EndTurn,
tool_calls: Vec::new(),
usage: TokenUsage {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
},
});
}
// Fallback: treat entire stdout as plain text
let text = stdout.trim().to_string();
Ok(CompletionResponse {
content: vec![ContentBlock::Text { text }],
stop_reason: StopReason::EndTurn,
tool_calls: Vec::new(),
usage: TokenUsage {
input_tokens: 0,
output_tokens: 0,
},
})
}
async fn stream(
&self,
request: CompletionRequest,
tx: tokio::sync::mpsc::Sender<StreamEvent>,
) -> Result<CompletionResponse, LlmError> {
let prompt = Self::build_prompt(&request);
let model_flag = Self::model_flag(&request.model);
let mut cmd = tokio::process::Command::new(&self.cli_path);
cmd.arg("-p")
.arg(&prompt)
.arg("--output-format")
.arg("stream-json");
if let Some(ref model) = model_flag {
cmd.arg("--model").arg(model);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
debug!(cli = %self.cli_path, "Spawning Claude Code CLI (streaming)");
let mut child = cmd
.spawn()
.map_err(|e| LlmError::Http(format!("Failed to spawn claude CLI: {e}")))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| LlmError::Http("No stdout from claude CLI".to_string()))?;
let reader = tokio::io::BufReader::new(stdout);
let mut lines = reader.lines();
let mut full_text = String::new();
let mut final_usage = TokenUsage {
input_tokens: 0,
output_tokens: 0,
};
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<ClaudeStreamEvent>(&line) {
Ok(event) => {
match event.r#type.as_str() {
"content" | "text" => {
if let Some(ref content) = event.content {
full_text.push_str(content);
let _ = tx
.send(StreamEvent::TextDelta {
text: content.clone(),
})
.await;
}
}
"result" | "done" | "complete" => {
if let Some(ref result) = event.result {
if full_text.is_empty() {
full_text = result.clone();
let _ = tx
.send(StreamEvent::TextDelta {
text: result.clone(),
})
.await;
}
}
if let Some(usage) = event.usage {
final_usage = TokenUsage {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
};
}
}
_ => {
// Unknown event type — try content field as fallback
if let Some(ref content) = event.content {
full_text.push_str(content);
let _ = tx
.send(StreamEvent::TextDelta {
text: content.clone(),
})
.await;
}
}
}
}
Err(e) => {
// Not valid JSON — treat as raw text
warn!(line = %line, error = %e, "Non-JSON line from Claude CLI");
full_text.push_str(&line);
let _ = tx
.send(StreamEvent::TextDelta { text: line })
.await;
}
}
}
// Wait for process to finish
let status = child
.wait()
.await
.map_err(|e| LlmError::Http(format!("Claude CLI wait failed: {e}")))?;
if !status.success() {
warn!(code = ?status.code(), "Claude CLI exited with error");
}
let _ = tx
.send(StreamEvent::ContentComplete {
stop_reason: StopReason::EndTurn,
usage: final_usage,
})
.await;
Ok(CompletionResponse {
content: vec![ContentBlock::Text { text: full_text }],
stop_reason: StopReason::EndTurn,
tool_calls: Vec::new(),
usage: final_usage,
})
}
}
/// Check if the Claude Code CLI is available.
pub fn claude_code_available() -> bool {
ClaudeCodeDriver::detect().is_some()
|| claude_credentials_exist()
}
/// Check if Claude credentials file exists (~/.claude/.credentials.json).
fn claude_credentials_exist() -> bool {
if let Some(home) = home_dir() {
home.join(".claude").join(".credentials.json").exists()
} else {
false
}
}
/// Cross-platform home directory.
fn home_dir() -> Option<std::path::PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("USERPROFILE").ok().map(std::path::PathBuf::from)
}
#[cfg(not(target_os = "windows"))]
{
std::env::var("HOME").ok().map(std::path::PathBuf::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_prompt_simple() {
use openfang_types::message::{Message, MessageContent};
let request = CompletionRequest {
model: "claude-code/sonnet".to_string(),
messages: vec![Message {
role: Role::User,
content: MessageContent::text("Hello"),
}],
tools: vec![],
max_tokens: 1024,
temperature: 0.7,
system: Some("You are helpful.".to_string()),
thinking: None,
};
let prompt = ClaudeCodeDriver::build_prompt(&request);
assert!(prompt.contains("[System]"));
assert!(prompt.contains("You are helpful."));
assert!(prompt.contains("[User]"));
assert!(prompt.contains("Hello"));
}
#[test]
fn test_model_flag_mapping() {
assert_eq!(
ClaudeCodeDriver::model_flag("claude-code/opus"),
Some("opus".to_string())
);
assert_eq!(
ClaudeCodeDriver::model_flag("claude-code/sonnet"),
Some("sonnet".to_string())
);
assert_eq!(
ClaudeCodeDriver::model_flag("claude-code/haiku"),
Some("haiku".to_string())
);
assert_eq!(
ClaudeCodeDriver::model_flag("custom-model"),
Some("custom-model".to_string())
);
}
#[test]
fn test_new_defaults_to_claude() {
let driver = ClaudeCodeDriver::new(None);
assert_eq!(driver.cli_path, "claude");
}
#[test]
fn test_new_with_custom_path() {
let driver = ClaudeCodeDriver::new(Some("/usr/local/bin/claude".to_string()));
assert_eq!(driver.cli_path, "/usr/local/bin/claude");
}
#[test]
fn test_new_with_empty_path() {
let driver = ClaudeCodeDriver::new(Some(String::new()));
assert_eq!(driver.cli_path, "claude");
}
}
+51 -4
View File
@@ -5,6 +5,7 @@
//! Mistral, Fireworks, Ollama, vLLM, and any OpenAI-compatible endpoint.
pub mod anthropic;
pub mod claude_code;
pub mod copilot;
pub mod fallback;
pub mod gemini;
@@ -17,7 +18,7 @@ use openfang_types::model_catalog::{
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_BASE_URL, ZHIPU_CODING_BASE_URL,
};
use std::sync::Arc;
@@ -132,6 +133,16 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
api_key_env: "GITHUB_TOKEN",
key_required: true,
}),
"codex" | "openai-codex" => Some(ProviderDefaults {
base_url: OPENAI_BASE_URL,
api_key_env: "OPENAI_API_KEY",
key_required: true,
}),
"claude-code" => Some(ProviderDefaults {
base_url: "",
api_key_env: "",
key_required: false,
}),
"moonshot" | "kimi" => Some(ProviderDefaults {
base_url: MOONSHOT_BASE_URL,
api_key_env: "MOONSHOT_API_KEY",
@@ -152,6 +163,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
api_key_env: "ZHIPU_API_KEY",
key_required: true,
}),
"zhipu_coding" | "codegeex" => Some(ProviderDefaults {
base_url: ZHIPU_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",
@@ -222,6 +238,31 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
return Ok(Arc::new(gemini::GeminiDriver::new(api_key, base_url)));
}
// Codex — reuses OpenAI driver with credential sync from Codex CLI
if provider == "codex" || provider == "openai-codex" {
let api_key = config
.api_key
.clone()
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
.or_else(crate::model_catalog::read_codex_credential)
.ok_or_else(|| {
LlmError::MissingApiKey(
"Set OPENAI_API_KEY or install Codex CLI".to_string(),
)
})?;
let base_url = config
.base_url
.clone()
.unwrap_or_else(|| OPENAI_BASE_URL.to_string());
return Ok(Arc::new(openai::OpenAIDriver::new(api_key, base_url)));
}
// Claude Code CLI — subprocess-based, no API key needed
if provider == "claude-code" {
let cli_path = config.base_url.clone();
return Ok(Arc::new(claude_code::ClaudeCodeDriver::new(cli_path)));
}
// GitHub Copilot — wraps OpenAI-compatible driver with automatic token exchange.
// The CopilotDriver exchanges the GitHub PAT for a Copilot API token on demand,
// caches it, and refreshes when expired.
@@ -282,8 +323,8 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
message: format!(
"Unknown provider '{}'. Supported: anthropic, gemini, openai, groq, openrouter, \
deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, perplexity, \
cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot. \
Or set base_url for a custom OpenAI-compatible endpoint.",
cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot, \
codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.",
provider
),
})
@@ -317,7 +358,10 @@ pub fn known_providers() -> &'static [&'static str] {
"qwen",
"minimax",
"zhipu",
"zhipu_coding",
"qianfan",
"codex",
"claude-code",
]
}
@@ -409,8 +453,11 @@ mod tests {
assert!(providers.contains(&"qwen"));
assert!(providers.contains(&"minimax"));
assert!(providers.contains(&"zhipu"));
assert!(providers.contains(&"zhipu_coding"));
assert!(providers.contains(&"qianfan"));
assert_eq!(providers.len(), 26);
assert!(providers.contains(&"codex"));
assert!(providers.contains(&"claude-code"));
assert_eq!(providers.len(), 29);
}
#[test]
+1 -5
View File
@@ -45,11 +45,7 @@ pub async fn generate_image(request: &ImageGenRequest) -> Result<ImageGenResult,
let status = response.status();
let error_body = response.text().await.unwrap_or_default();
// SECURITY: don't include full error body which might contain key info
let truncated = if error_body.len() > 500 {
&error_body[..500]
} else {
&error_body
};
let truncated = crate::str_utils::safe_truncate_str(&error_body, 500);
return Err(format!(
"Image generation failed (HTTP {}): {}",
status, truncated
@@ -172,6 +172,18 @@ pub trait KernelHandle: Send + Sync {
None
}
/// Send a message to a user on a named channel adapter (e.g., "email", "telegram").
/// Returns a confirmation string on success.
async fn send_channel_message(
&self,
channel: &str,
recipient: &str,
message: &str,
) -> Result<String, String> {
let _ = (channel, recipient, message);
Err("Channel 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`.
+2
View File
@@ -11,6 +11,7 @@ pub mod auth_cooldown;
pub mod browser;
pub mod command_lane;
pub mod compactor;
pub mod copilot_oauth;
pub mod context_budget;
pub mod context_overflow;
pub mod docker_sandbox;
@@ -39,6 +40,7 @@ pub mod routing;
pub mod sandbox;
pub mod session_repair;
pub mod shell_bleed;
pub mod str_utils;
pub mod subprocess_sandbox;
pub mod tool_policy;
pub mod tool_runner;
+8 -3
View File
@@ -331,15 +331,20 @@ pub fn sanitize_for_user(category: LlmErrorCategory, _raw: &str) -> String {
"The conversation is too long for the model's context window."
}
LlmErrorCategory::Format => {
"Invalid request format. This may be a bug \u{2014} please report it."
"LLM request failed. Check your API key and model configuration in Settings."
}
LlmErrorCategory::ModelNotFound => {
"The requested model was not found. Check the model name."
}
};
// Cap at 200 chars (all built-in messages are under 200, but defensive).
if msg.len() > 200 {
format!("{}...", &msg[..197])
if msg.chars().count() > 200 {
let end = msg
.char_indices()
.nth(197)
.map(|(i, _)| i)
.unwrap_or(msg.len());
format!("{}...", &msg[..end])
} else {
msg.to_string()
}
+1 -5
View File
@@ -520,11 +520,7 @@ impl LoopGuard {
let params_str = serde_json::to_string(params).unwrap_or_default();
hasher.update(params_str.as_bytes());
hasher.update(b"|");
let truncated = if result.len() > 1000 {
&result[..1000]
} else {
result
};
let truncated = crate::str_utils::safe_truncate_str(result, 1000);
hasher.update(truncated.as_bytes());
hex::encode(hasher.finalize())
}
+623 -59
View File
@@ -10,7 +10,7 @@ use openfang_types::model_catalog::{
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_BASE_URL, ZHIPU_CODING_BASE_URL,
};
use std::collections::HashMap;
@@ -48,16 +48,28 @@ impl ModelCatalog {
for provider in &mut self.providers {
if !provider.key_required {
provider.auth_status = AuthStatus::NotRequired;
} else if std::env::var(&provider.api_key_env).is_ok() {
provider.auth_status = AuthStatus::Configured;
} else {
// Special case: Gemini also accepts GOOGLE_API_KEY
if provider.id == "gemini" && std::env::var("GOOGLE_API_KEY").is_ok() {
provider.auth_status = AuthStatus::Configured;
} else {
provider.auth_status = AuthStatus::Missing;
}
continue;
}
// Primary: check the provider's declared env var
let has_key = std::env::var(&provider.api_key_env).is_ok();
// Secondary: provider-specific fallback auth
let has_fallback = match provider.id.as_str() {
"gemini" => std::env::var("GOOGLE_API_KEY").is_ok(),
"codex" => {
std::env::var("OPENAI_API_KEY").is_ok()
|| read_codex_credential().is_some()
}
"claude-code" => crate::drivers::claude_code::claude_code_available(),
_ => false,
};
provider.auth_status = if has_key || has_fallback {
AuthStatus::Configured
} else {
AuthStatus::Missing
};
}
}
@@ -128,6 +140,28 @@ impl ModelCatalog {
&self.aliases
}
/// Set a custom base URL for a provider, overriding the default.
///
/// Returns `true` if the provider was found and updated.
pub fn set_provider_url(&mut self, provider: &str, url: &str) -> bool {
if let Some(p) = self.providers.iter_mut().find(|p| p.id == provider) {
p.base_url = url.to_string();
true
} else {
false
}
}
/// 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.
pub fn apply_url_overrides(&mut self, overrides: &HashMap<String, String>) {
for (provider, url) in overrides {
self.set_provider_url(provider, url);
}
}
/// List models filtered by tier.
pub fn models_by_tier(&self, tier: ModelTier) -> Vec<&ModelCatalogEntry> {
self.models.iter().filter(|m| m.tier == tier).collect()
@@ -188,6 +222,53 @@ impl Default for ModelCatalog {
}
}
/// Read an OpenAI API key from the Codex CLI credential file.
///
/// Checks `$CODEX_HOME/auth.json` or `~/.codex/auth.json`.
/// Returns `Some(api_key)` if the file exists and contains a valid, non-expired token.
/// Only checks presence — the actual key value is used transiently, never stored.
pub fn read_codex_credential() -> Option<String> {
let codex_home = std::env::var("CODEX_HOME")
.map(std::path::PathBuf::from)
.ok()
.or_else(|| {
#[cfg(target_os = "windows")]
{
std::env::var("USERPROFILE")
.ok()
.map(|h| std::path::PathBuf::from(h).join(".codex"))
}
#[cfg(not(target_os = "windows"))]
{
std::env::var("HOME")
.ok()
.map(|h| std::path::PathBuf::from(h).join(".codex"))
}
})?;
let auth_path = codex_home.join("auth.json");
let content = std::fs::read_to_string(&auth_path).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
// Check expiry if present
if let Some(expires_at) = parsed.get("expires_at").and_then(|v| v.as_i64()) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if now >= expires_at {
return None; // Expired
}
}
parsed
.get("api_key")
.or_else(|| parsed.get("token"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
// ---------------------------------------------------------------------------
// Builtin data
// ---------------------------------------------------------------------------
@@ -413,6 +494,15 @@ fn builtin_providers() -> Vec<ProviderInfo> {
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "zhipu_coding".into(),
display_name: "Zhipu Coding (CodeGeeX)".into(),
api_key_env: "ZHIPU_API_KEY".into(),
base_url: ZHIPU_CODING_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "moonshot".into(),
display_name: "Moonshot (Kimi)".into(),
@@ -441,23 +531,45 @@ fn builtin_providers() -> Vec<ProviderInfo> {
auth_status: AuthStatus::Missing,
model_count: 0,
},
// ── OpenAI Codex ────────────────────────────────────────────
ProviderInfo {
id: "codex".into(),
display_name: "OpenAI Codex".into(),
api_key_env: "OPENAI_API_KEY".into(),
base_url: OPENAI_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
// ── Claude Code CLI ─────────────────────────────────────────
ProviderInfo {
id: "claude-code".into(),
display_name: "Claude Code".into(),
api_key_env: String::new(),
base_url: String::new(),
key_required: false,
auth_status: AuthStatus::NotRequired,
model_count: 0,
},
]
}
fn builtin_aliases() -> HashMap<String, String> {
let pairs = [
("sonnet", "claude-sonnet-4-20250514"),
("claude-sonnet", "claude-sonnet-4-20250514"),
("sonnet", "claude-sonnet-4-6"),
("claude-sonnet", "claude-sonnet-4-6"),
("haiku", "claude-haiku-4-5-20251001"),
("claude-haiku", "claude-haiku-4-5-20251001"),
("opus", "claude-opus-4-20250514"),
("claude-opus", "claude-opus-4-20250514"),
("opus", "claude-opus-4-6"),
("claude-opus", "claude-opus-4-6"),
("gpt4", "gpt-4o"),
("gpt4o", "gpt-4o"),
("gpt4-mini", "gpt-4o-mini"),
("flash", "gemini-2.5-flash"),
("gemini-flash", "gemini-2.5-flash"),
("gemini-pro", "gemini-2.5-pro"),
("gpt5", "gpt-5.2"),
("gpt5-mini", "gpt-5-mini"),
("flash", "gemini-3-flash"),
("gemini-flash", "gemini-3-flash"),
("gemini-pro", "gemini-3.1-pro"),
("deepseek", "deepseek-chat"),
("llama", "llama-3.3-70b-versatile"),
("llama-70b", "llama-3.3-70b-versatile"),
@@ -471,9 +583,10 @@ fn builtin_aliases() -> HashMap<String, String> {
("mistral-nemo", "open-mistral-nemo"),
("pixtral", "pixtral-large-latest"),
// xAI aliases
("grok", "grok-2"),
("grok", "grok-4"),
("grok-mini", "grok-2-mini"),
("grok3", "grok-3"),
("grok-fast", "grok-4.1-fast"),
// Perplexity alias
("sonar", "sonar-pro"),
// AI21 aliases
@@ -492,7 +605,19 @@ fn builtin_aliases() -> HashMap<String, String> {
("glm", "glm-4-plus"),
("ernie", "ernie-4.5-8k"),
("kimi", "moonshot-v1-128k"),
("minimax", "minimax-text-01"),
("minimax", "MiniMax-M2.5"),
("minimax-m2.5", "MiniMax-M2.5"),
("minimax-m2.1", "MiniMax-M2.1"),
("codegeex", "codegeex-4"),
// Codex aliases
("codex", "codex/gpt-4.1"),
("codex-4.1", "codex/gpt-4.1"),
("codex-o4", "codex/o4-mini"),
// Claude Code aliases
("claude-code", "claude-code/sonnet"),
("claude-code-opus", "claude-code/opus"),
("claude-code-sonnet", "claude-code/sonnet"),
("claude-code-haiku", "claude-code/haiku"),
];
pairs
.into_iter()
@@ -503,8 +628,36 @@ fn builtin_aliases() -> HashMap<String, String> {
fn builtin_models() -> Vec<ModelCatalogEntry> {
vec![
// ══════════════════════════════════════════════════════════════
// Anthropic (5)
// Anthropic (7)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "claude-opus-4-6".into(),
display_name: "Claude Opus 4.6".into(),
provider: "anthropic".into(),
tier: ModelTier::Frontier,
context_window: 200_000,
max_output_tokens: 128_000,
input_cost_per_m: 5.0,
output_cost_per_m: 25.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["opus".into(), "claude-opus".into()],
},
ModelCatalogEntry {
id: "claude-sonnet-4-6".into(),
display_name: "Claude Sonnet 4.6".into(),
provider: "anthropic".into(),
tier: ModelTier::Smart,
context_window: 200_000,
max_output_tokens: 64_000,
input_cost_per_m: 3.0,
output_cost_per_m: 15.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["sonnet".into(), "claude-sonnet".into()],
},
ModelCatalogEntry {
id: "claude-opus-4-20250514".into(),
display_name: "Claude Opus 4".into(),
@@ -517,7 +670,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["opus".into(), "claude-opus".into()],
aliases: vec![],
},
ModelCatalogEntry {
id: "claude-sonnet-4-20250514".into(),
@@ -531,7 +684,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["sonnet".into(), "claude-sonnet".into()],
aliases: vec![],
},
ModelCatalogEntry {
id: "claude-haiku-4-5-20251001".into(),
@@ -576,7 +729,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// OpenAI (10)
// OpenAI (16)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "gpt-4o".into(),
@@ -718,9 +871,149 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "gpt-5".into(),
display_name: "GPT-5".into(),
provider: "openai".into(),
tier: ModelTier::Frontier,
context_window: 400_000,
max_output_tokens: 128_000,
input_cost_per_m: 1.25,
output_cost_per_m: 10.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "gpt-5-mini".into(),
display_name: "GPT-5 Mini".into(),
provider: "openai".into(),
tier: ModelTier::Balanced,
context_window: 400_000,
max_output_tokens: 128_000,
input_cost_per_m: 0.25,
output_cost_per_m: 2.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["gpt5-mini".into()],
},
ModelCatalogEntry {
id: "gpt-5-nano".into(),
display_name: "GPT-5 Nano".into(),
provider: "openai".into(),
tier: ModelTier::Fast,
context_window: 400_000,
max_output_tokens: 128_000,
input_cost_per_m: 0.05,
output_cost_per_m: 0.40,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "gpt-5.1".into(),
display_name: "GPT-5.1".into(),
provider: "openai".into(),
tier: ModelTier::Frontier,
context_window: 400_000,
max_output_tokens: 128_000,
input_cost_per_m: 1.25,
output_cost_per_m: 10.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "gpt-5.2".into(),
display_name: "GPT-5.2".into(),
provider: "openai".into(),
tier: ModelTier::Frontier,
context_window: 400_000,
max_output_tokens: 128_000,
input_cost_per_m: 1.75,
output_cost_per_m: 14.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["gpt5".into()],
},
ModelCatalogEntry {
id: "gpt-5.2-pro".into(),
display_name: "GPT-5.2 Pro".into(),
provider: "openai".into(),
tier: ModelTier::Frontier,
context_window: 400_000,
max_output_tokens: 128_000,
input_cost_per_m: 1.75,
output_cost_per_m: 14.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Google Gemini (6)
// Google Gemini (10)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "gemini-3.1-pro".into(),
display_name: "Gemini 3.1 Pro".into(),
provider: "gemini".into(),
tier: ModelTier::Frontier,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 2.50,
output_cost_per_m: 15.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["gemini-pro".into()],
},
ModelCatalogEntry {
id: "gemini-3-flash".into(),
display_name: "Gemini 3 Flash".into(),
provider: "gemini".into(),
tier: ModelTier::Smart,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 0.50,
output_cost_per_m: 3.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["flash".into(), "gemini-flash".into()],
},
ModelCatalogEntry {
id: "gemini-3-deep-think".into(),
display_name: "Gemini 3 Deep Think".into(),
provider: "gemini".into(),
tier: ModelTier::Frontier,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 2.50,
output_cost_per_m: 15.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "gemini-2.5-flash-lite".into(),
display_name: "Gemini 2.5 Flash Lite".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,
aliases: vec![],
},
ModelCatalogEntry {
id: "gemini-2.5-pro".into(),
display_name: "Gemini 2.5 Pro".into(),
@@ -733,7 +1026,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["gemini-pro".into()],
aliases: vec![],
},
ModelCatalogEntry {
id: "gemini-2.5-flash".into(),
@@ -747,7 +1040,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["flash".into(), "gemini-flash".into()],
aliases: vec![],
},
ModelCatalogEntry {
id: "gemini-2.0-flash".into(),
@@ -865,7 +1158,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Groq (10)
// Groq (11)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "llama-3.3-70b-versatile".into(),
@@ -951,20 +1244,6 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "deepseek-r1-distill-llama-70b".into(),
display_name: "DeepSeek R1 Distill 70B".into(),
provider: "groq".into(),
tier: ModelTier::Smart,
context_window: 128_000,
max_output_tokens: 16_384,
input_cost_per_m: 0.75,
output_cost_per_m: 0.99,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "mixtral-8x7b-32768".into(),
display_name: "Mixtral 8x7B".into(),
@@ -1007,6 +1286,20 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "meta-llama/llama-4-scout-17b-16e-instruct".into(),
display_name: "Llama 4 Scout 17B".into(),
provider: "groq".into(),
tier: ModelTier::Balanced,
context_window: 128_000,
max_output_tokens: 8_192,
input_cost_per_m: 0.11,
output_cost_per_m: 0.34,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// OpenRouter (5)
// ══════════════════════════════════════════════════════════════
@@ -1744,8 +2037,36 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// xAI (4)
// xAI (6)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "grok-4".into(),
display_name: "Grok 4".into(),
provider: "xai".into(),
tier: ModelTier::Frontier,
context_window: 256_000,
max_output_tokens: 32_768,
input_cost_per_m: 3.0,
output_cost_per_m: 15.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["grok".into()],
},
ModelCatalogEntry {
id: "grok-4.1-fast".into(),
display_name: "Grok 4.1 Fast".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!["grok-fast".into()],
},
ModelCatalogEntry {
id: "grok-3".into(),
display_name: "Grok 3".into(),
@@ -1758,7 +2079,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
aliases: vec!["grok3".into()],
},
ModelCatalogEntry {
id: "grok-3-mini".into(),
@@ -1786,7 +2107,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["grok".into()],
aliases: vec![],
},
ModelCatalogEntry {
id: "grok-2-mini".into(),
@@ -2011,7 +2332,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// MiniMax (3)
// MiniMax (4)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "minimax-text-01".into(),
@@ -2027,6 +2348,20 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec!["minimax".into()],
},
ModelCatalogEntry {
id: "MiniMax-M2.5".into(),
display_name: "MiniMax M2.5".into(),
provider: "minimax".into(),
tier: ModelTier::Frontier,
context_window: 1_048_576,
max_output_tokens: 16_384,
input_cost_per_m: 1.10,
output_cost_per_m: 4.40,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["minimax-m2.5".into()],
},
ModelCatalogEntry {
id: "MiniMax-M2.1".into(),
display_name: "MiniMax M2.1".into(),
@@ -2039,7 +2374,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
aliases: vec!["minimax-m2.1".into()],
},
ModelCatalogEntry {
id: "abab6.5-chat".into(),
@@ -2115,6 +2450,23 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Zhipu Coding / CodeGeeX (1)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "codegeex-4".into(),
display_name: "CodeGeeX 4".into(),
provider: "zhipu_coding".into(),
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 8_192,
input_cost_per_m: 0.10,
output_cost_per_m: 0.10,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["codegeex".into()],
},
// ══════════════════════════════════════════════════════════════
// Moonshot / Kimi (3)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
@@ -2205,8 +2557,36 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// AWS Bedrock (6)
// AWS Bedrock (8)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "bedrock/anthropic.claude-opus-4-6".into(),
display_name: "Claude Opus 4.6 (Bedrock)".into(),
provider: "bedrock".into(),
tier: ModelTier::Frontier,
context_window: 200_000,
max_output_tokens: 128_000,
input_cost_per_m: 5.00,
output_cost_per_m: 25.00,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "bedrock/anthropic.claude-sonnet-4-6".into(),
display_name: "Claude Sonnet 4.6 (Bedrock)".into(),
provider: "bedrock".into(),
tier: ModelTier::Smart,
context_window: 200_000,
max_output_tokens: 64_000,
input_cost_per_m: 3.00,
output_cost_per_m: 15.00,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "bedrock/anthropic.claude-opus-4-20250514".into(),
display_name: "Claude Opus 4 (Bedrock)".into(),
@@ -2291,6 +2671,82 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// OpenAI Codex (2) — reuses OpenAI driver
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "codex/gpt-4.1".into(),
display_name: "GPT-4.1 (Codex)".into(),
provider: "codex".into(),
tier: ModelTier::Frontier,
context_window: 1_047_576,
max_output_tokens: 32_768,
input_cost_per_m: 2.00,
output_cost_per_m: 8.00,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["codex".into(), "codex-4.1".into()],
},
ModelCatalogEntry {
id: "codex/o4-mini".into(),
display_name: "o4-mini (Codex)".into(),
provider: "codex".into(),
tier: ModelTier::Smart,
context_window: 200_000,
max_output_tokens: 100_000,
input_cost_per_m: 1.10,
output_cost_per_m: 4.40,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["codex-o4".into()],
},
// ══════════════════════════════════════════════════════════════
// Claude Code CLI (3) — subprocess-based
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "claude-code/opus".into(),
display_name: "Claude Opus (CLI)".into(),
provider: "claude-code".into(),
tier: ModelTier::Frontier,
context_window: 200_000,
max_output_tokens: 128_000,
input_cost_per_m: 5.0,
output_cost_per_m: 25.0,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec!["claude-code-opus".into()],
},
ModelCatalogEntry {
id: "claude-code/sonnet".into(),
display_name: "Claude Sonnet (CLI)".into(),
provider: "claude-code".into(),
tier: ModelTier::Smart,
context_window: 200_000,
max_output_tokens: 64_000,
input_cost_per_m: 3.0,
output_cost_per_m: 15.0,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec!["claude-code".into(), "claude-code-sonnet".into()],
},
ModelCatalogEntry {
id: "claude-code/haiku".into(),
display_name: "Claude Haiku (CLI)".into(),
provider: "claude-code".into(),
tier: ModelTier::Fast,
context_window: 200_000,
max_output_tokens: 8_192,
input_cost_per_m: 0.25,
output_cost_per_m: 1.25,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec!["claude-code-haiku".into()],
},
]
}
@@ -2307,7 +2763,7 @@ mod tests {
#[test]
fn test_catalog_has_providers() {
let catalog = ModelCatalog::new();
assert_eq!(catalog.list_providers().len(), 27);
assert_eq!(catalog.list_providers().len(), 30);
}
#[test]
@@ -2323,7 +2779,7 @@ mod tests {
fn test_find_model_by_alias() {
let catalog = ModelCatalog::new();
let entry = catalog.find_model("sonnet").unwrap();
assert_eq!(entry.id, "claude-sonnet-4-20250514");
assert_eq!(entry.id, "claude-sonnet-4-6");
}
#[test]
@@ -2344,7 +2800,7 @@ mod tests {
let catalog = ModelCatalog::new();
assert_eq!(
catalog.resolve_alias("sonnet"),
Some("claude-sonnet-4-20250514")
Some("claude-sonnet-4-6")
);
assert_eq!(
catalog.resolve_alias("haiku"),
@@ -2357,7 +2813,7 @@ mod tests {
fn test_models_by_provider() {
let catalog = ModelCatalog::new();
let anthropic = catalog.models_by_provider("anthropic");
assert_eq!(anthropic.len(), 5);
assert_eq!(anthropic.len(), 7);
assert!(anthropic.iter().all(|m| m.provider == "anthropic"));
}
@@ -2415,7 +2871,7 @@ mod tests {
fn test_provider_model_counts() {
let catalog = ModelCatalog::new();
let anthropic = catalog.get_provider("anthropic").unwrap();
assert_eq!(anthropic.model_count, 5);
assert_eq!(anthropic.model_count, 7);
let groq = catalog.get_provider("groq").unwrap();
assert_eq!(groq.model_count, 10);
}
@@ -2425,9 +2881,9 @@ mod tests {
let catalog = ModelCatalog::new();
let aliases = catalog.list_aliases();
assert!(aliases.len() >= 20);
assert_eq!(aliases.get("sonnet").unwrap(), "claude-sonnet-4-20250514");
assert_eq!(aliases.get("sonnet").unwrap(), "claude-sonnet-4-6");
// New aliases
assert_eq!(aliases.get("grok").unwrap(), "grok-2");
assert_eq!(aliases.get("grok").unwrap(), "grok-4");
assert_eq!(aliases.get("jamba").unwrap(), "jamba-1.5-large");
}
@@ -2435,7 +2891,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-2");
assert_eq!(entry.id, "grok-4");
assert_eq!(entry.provider, "xai");
}
@@ -2456,11 +2912,13 @@ mod tests {
fn test_xai_models() {
let catalog = ModelCatalog::new();
let xai = catalog.models_by_provider("xai");
assert_eq!(xai.len(), 4);
assert!(xai.iter().any(|m| m.id == "grok-2"));
assert!(xai.iter().any(|m| m.id == "grok-2-mini"));
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!(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"));
assert!(xai.iter().any(|m| m.id == "grok-2-mini"));
}
#[test]
@@ -2525,6 +2983,7 @@ mod tests {
assert!(catalog.get_provider("qwen").is_some());
assert!(catalog.get_provider("minimax").is_some());
assert!(catalog.get_provider("zhipu").is_some());
assert!(catalog.get_provider("zhipu_coding").is_some());
assert!(catalog.get_provider("moonshot").is_some());
assert!(catalog.get_provider("qianfan").is_some());
assert!(catalog.get_provider("bedrock").is_some());
@@ -2535,14 +2994,119 @@ mod tests {
let catalog = ModelCatalog::new();
assert!(catalog.find_model("kimi").is_some());
assert!(catalog.find_model("glm").is_some());
assert!(catalog.find_model("codegeex").is_some());
assert!(catalog.find_model("ernie").is_some());
assert!(catalog.find_model("minimax").is_some());
// MiniMax M2.5 — by exact ID, alias, and case-insensitive
let m25 = catalog.find_model("MiniMax-M2.5").unwrap();
assert_eq!(m25.provider, "minimax");
assert_eq!(m25.tier, ModelTier::Frontier);
assert!(catalog.find_model("minimax-m2.5").is_some());
// Default "minimax" alias now points to M2.5
let default = catalog.find_model("minimax").unwrap();
assert_eq!(default.id, "MiniMax-M2.5");
}
#[test]
fn test_bedrock_models() {
let catalog = ModelCatalog::new();
let bedrock = catalog.models_by_provider("bedrock");
assert_eq!(bedrock.len(), 6);
assert_eq!(bedrock.len(), 8);
}
#[test]
fn test_set_provider_url() {
let mut catalog = ModelCatalog::new();
let old_url = catalog.get_provider("ollama").unwrap().base_url.clone();
assert_eq!(old_url, OLLAMA_BASE_URL);
let updated = catalog.set_provider_url("ollama", "http://192.168.1.100:11434/v1");
assert!(updated);
assert_eq!(
catalog.get_provider("ollama").unwrap().base_url,
"http://192.168.1.100:11434/v1"
);
}
#[test]
fn test_set_provider_url_unknown() {
let mut catalog = ModelCatalog::new();
let updated = catalog.set_provider_url("nonexistent", "http://localhost:9999");
assert!(!updated);
}
#[test]
fn test_apply_url_overrides() {
let mut catalog = ModelCatalog::new();
let mut overrides = HashMap::new();
overrides.insert("ollama".to_string(), "http://10.0.0.5:11434/v1".to_string());
overrides.insert("vllm".to_string(), "http://10.0.0.6:8000/v1".to_string());
overrides.insert("nonexistent".to_string(), "http://nowhere".to_string());
catalog.apply_url_overrides(&overrides);
assert_eq!(
catalog.get_provider("ollama").unwrap().base_url,
"http://10.0.0.5:11434/v1"
);
assert_eq!(
catalog.get_provider("vllm").unwrap().base_url,
"http://10.0.0.6:8000/v1"
);
// lmstudio should be unchanged
assert_eq!(
catalog.get_provider("lmstudio").unwrap().base_url,
LMSTUDIO_BASE_URL
);
}
#[test]
fn test_codex_provider() {
let catalog = ModelCatalog::new();
let codex = catalog.get_provider("codex").unwrap();
assert_eq!(codex.display_name, "OpenAI Codex");
assert_eq!(codex.api_key_env, "OPENAI_API_KEY");
assert!(codex.key_required);
}
#[test]
fn test_codex_models() {
let catalog = ModelCatalog::new();
let models = catalog.models_by_provider("codex");
assert_eq!(models.len(), 2);
assert!(models.iter().any(|m| m.id == "codex/gpt-4.1"));
assert!(models.iter().any(|m| m.id == "codex/o4-mini"));
}
#[test]
fn test_codex_aliases() {
let catalog = ModelCatalog::new();
let entry = catalog.find_model("codex").unwrap();
assert_eq!(entry.id, "codex/gpt-4.1");
}
#[test]
fn test_claude_code_provider() {
let catalog = ModelCatalog::new();
let cc = catalog.get_provider("claude-code").unwrap();
assert_eq!(cc.display_name, "Claude Code");
assert!(!cc.key_required);
}
#[test]
fn test_claude_code_models() {
let catalog = ModelCatalog::new();
let models = catalog.models_by_provider("claude-code");
assert_eq!(models.len(), 3);
assert!(models.iter().any(|m| m.id == "claude-code/opus"));
assert!(models.iter().any(|m| m.id == "claude-code/sonnet"));
assert!(models.iter().any(|m| m.id == "claude-code/haiku"));
}
#[test]
fn test_claude_code_aliases() {
let catalog = ModelCatalog::new();
let entry = catalog.find_model("claude-code").unwrap();
assert_eq!(entry.id, "claude-code/sonnet");
}
}
+52 -17
View File
@@ -147,17 +147,8 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String {
// Section 11 — Operational Guidelines (always present)
sections.push(OPERATIONAL_GUIDELINES.to_string());
// Section 12 — Canonical Context (skip for subagents)
if !ctx.is_subagent {
if let Some(ref canonical) = ctx.canonical_context {
if !canonical.is_empty() {
sections.push(format!(
"## Previous Conversation Context\n{}",
cap_str(canonical, 500)
));
}
}
}
// Section 12 — Canonical Context moved to build_canonical_context_message()
// to keep the system prompt stable across turns for provider prompt caching.
// Section 13 — Bootstrap Protocol (only on first-run, skip for subagents)
if !ctx.is_subagent {
@@ -245,6 +236,21 @@ pub fn build_tools_section(granted_tools: &[String]) -> String {
out
}
/// Build canonical context as a standalone user message (instead of system prompt).
///
/// This keeps the system prompt stable across turns, enabling provider prompt caching
/// (Anthropic cache_control, etc.). The canonical context changes every turn, so
/// injecting it in the system prompt caused 82%+ cache misses.
pub fn build_canonical_context_message(ctx: &PromptContext) -> Option<String> {
if ctx.is_subagent {
return None;
}
ctx.canonical_context
.as_ref()
.filter(|c| !c.is_empty())
.map(|c| format!("[Previous conversation context]\n{}", cap_str(c, 500)))
}
/// Build the memory section (Section 4).
///
/// Also used by `agent_loop.rs` to append recalled memories after DB lookup.
@@ -522,10 +528,15 @@ pub fn tool_hint(name: &str) -> &'static str {
/// Cap a string to `max_chars`, appending "..." if truncated.
fn cap_str(s: &str, max_chars: usize) -> String {
if s.len() <= max_chars {
if s.chars().count() <= max_chars {
s.to_string()
} else {
format!("{}...", &s[..max_chars])
let end = s
.char_indices()
.nth(max_chars)
.map(|(i, _)| i)
.unwrap_or(s.len());
format!("{}...", &s[..end])
}
}
@@ -786,13 +797,18 @@ mod tests {
}
#[test]
fn test_canonical_context() {
fn test_canonical_context_not_in_system_prompt() {
let mut ctx = basic_ctx();
ctx.canonical_context =
Some("User was discussing Rust async patterns last time.".to_string());
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("## Previous Conversation Context"));
assert!(prompt.contains("Rust async patterns"));
// Canonical context should NOT be in system prompt (moved to user message)
assert!(!prompt.contains("## Previous Conversation Context"));
assert!(!prompt.contains("Rust async patterns"));
// But should be available via build_canonical_context_message
let msg = build_canonical_context_message(&ctx);
assert!(msg.is_some());
assert!(msg.unwrap().contains("Rust async patterns"));
}
#[test]
@@ -801,7 +817,9 @@ mod tests {
ctx.is_subagent = true;
ctx.canonical_context = Some("Previous context here.".to_string());
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("## Previous Conversation Context"));
assert!(!prompt.contains("Previous Conversation Context"));
// Should also be None from build_canonical_context_message
assert!(build_canonical_context_message(&ctx).is_none());
}
#[test]
@@ -836,6 +854,23 @@ mod tests {
assert_eq!(result, "hello...");
}
#[test]
fn test_cap_str_multibyte_utf8() {
// This was panicking with "byte index is not a char boundary" (#38)
let chinese = "你好世界这是一个测试字符串";
let result = cap_str(chinese, 4);
assert_eq!(result, "你好世界...");
// Exact boundary
assert_eq!(cap_str(chinese, 100), chinese);
}
#[test]
fn test_cap_str_emoji() {
let emoji = "👋🌍🚀✨💯";
let result = cap_str(emoji, 3);
assert_eq!(result, "👋🌍🚀...");
}
#[test]
fn test_capitalize() {
assert_eq!(capitalize("files"), "Files");
+11 -11
View File
@@ -149,7 +149,7 @@ impl ModelRouter {
/// Resolve aliases in the routing config using the catalog.
///
/// For example, if "sonnet" is configured, resolves to "claude-sonnet-4-20250514".
/// For example, if "sonnet" is configured, resolves to "claude-sonnet-4-6".
pub fn resolve_aliases(&mut self, catalog: &crate::model_catalog::ModelCatalog) {
if let Some(resolved) = catalog.resolve_alias(&self.config.simple_model) {
self.config.simple_model = resolved.to_string();
@@ -172,8 +172,8 @@ mod tests {
fn default_config() -> ModelRoutingConfig {
ModelRoutingConfig {
simple_model: "llama-3.3-70b-versatile".to_string(),
medium_model: "claude-sonnet-4-20250514".to_string(),
complex_model: "claude-opus-4-20250514".to_string(),
medium_model: "claude-sonnet-4-6".to_string(),
complex_model: "claude-opus-4-6".to_string(),
simple_threshold: 200,
complex_threshold: 800,
}
@@ -274,11 +274,11 @@ mod tests {
);
assert_eq!(
router.model_for_complexity(TaskComplexity::Medium),
"claude-sonnet-4-20250514"
"claude-sonnet-4-6"
);
assert_eq!(
router.model_for_complexity(TaskComplexity::Complex),
"claude-opus-4-20250514"
"claude-opus-4-6"
);
}
@@ -294,8 +294,8 @@ mod tests {
let catalog = crate::model_catalog::ModelCatalog::new();
let config = ModelRoutingConfig {
simple_model: "llama-3.3-70b-versatile".to_string(),
medium_model: "claude-sonnet-4-20250514".to_string(),
complex_model: "claude-opus-4-20250514".to_string(),
medium_model: "claude-sonnet-4-6".to_string(),
complex_model: "claude-opus-4-6".to_string(),
simple_threshold: 200,
complex_threshold: 800,
};
@@ -309,8 +309,8 @@ mod tests {
let catalog = crate::model_catalog::ModelCatalog::new();
let config = ModelRoutingConfig {
simple_model: "unknown-model".to_string(),
medium_model: "claude-sonnet-4-20250514".to_string(),
complex_model: "claude-opus-4-20250514".to_string(),
medium_model: "claude-sonnet-4-6".to_string(),
complex_model: "claude-opus-4-6".to_string(),
simple_threshold: 200,
complex_threshold: 800,
};
@@ -338,11 +338,11 @@ mod tests {
);
assert_eq!(
router.model_for_complexity(TaskComplexity::Medium),
"claude-sonnet-4-20250514"
"claude-sonnet-4-6"
);
assert_eq!(
router.model_for_complexity(TaskComplexity::Complex),
"claude-opus-4-20250514"
"claude-opus-4-6"
);
}
@@ -520,7 +520,7 @@ pub fn strip_tool_result_details(content: &str) -> String {
} else {
format!(
"{}...[truncated from {} chars]",
&cleaned[..max_len],
crate::str_utils::safe_truncate_str(&cleaned, max_len),
cleaned.len()
)
}
+70
View File
@@ -0,0 +1,70 @@
//! UTF-8-safe string utilities.
/// Truncate a string to at most `max_bytes` bytes without splitting a multi-byte
/// character. Returns the full string when it already fits.
///
/// This avoids panics that occur when using `&s[..max_bytes]` on strings containing
/// multi-byte characters (e.g. Chinese, emoji, accented Latin).
#[inline]
pub fn safe_truncate_str(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
// Walk backwards to the nearest char boundary
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_within_limit() {
let s = "hello";
assert_eq!(safe_truncate_str(s, 10), "hello");
}
#[test]
fn ascii_exact_limit() {
let s = "hello";
assert_eq!(safe_truncate_str(s, 5), "hello");
}
#[test]
fn ascii_truncated() {
let s = "hello world";
assert_eq!(safe_truncate_str(s, 5), "hello");
}
#[test]
fn multibyte_chinese() {
// Each Chinese character is 3 bytes in UTF-8
let s = "\u{4f60}\u{597d}\u{4e16}\u{754c}"; // "hello world" in Chinese, 12 bytes
// Truncating at 7 bytes should not split the 3rd char (bytes 6..9)
let t = safe_truncate_str(s, 7);
assert_eq!(t, "\u{4f60}\u{597d}"); // 6 bytes, 2 chars
assert!(t.len() <= 7);
}
#[test]
fn multibyte_emoji() {
let s = "\u{1f600}\u{1f601}\u{1f602}"; // 3 emoji, 4 bytes each = 12 bytes
let t = safe_truncate_str(s, 5);
assert_eq!(t, "\u{1f600}"); // 4 bytes, 1 emoji
}
#[test]
fn zero_limit() {
let s = "hello";
assert_eq!(safe_truncate_str(s, 0), "");
}
#[test]
fn empty_string() {
assert_eq!(safe_truncate_str("", 10), "");
}
}
+80 -5
View File
@@ -135,10 +135,11 @@ pub async fn execute_tool(
if let Some(kh) = kernel {
if kh.requires_approval(tool_name) {
let agent_id_str = caller_agent_id.unwrap_or("unknown");
let input_str = input.to_string();
let summary = format!(
"{}: {}",
tool_name,
&input.to_string()[..input.to_string().len().min(200)]
openfang_types::truncate_str(&input_str, 200)
);
match kh.request_approval(agent_id_str, tool_name, &summary).await {
Ok(true) => {
@@ -293,6 +294,9 @@ pub async fn execute_tool(
"cron_list" => tool_cron_list(kernel, caller_agent_id).await,
"cron_cancel" => tool_cron_cancel(input, kernel).await,
// Channel send tool (proactive outbound messaging)
"channel_send" => tool_channel_send(input, kernel).await,
// Persistent process tools
"process_start" => tool_process_start(input, process_manager, caller_agent_id).await,
"process_poll" => tool_process_poll(input, process_manager).await,
@@ -879,7 +883,7 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
},
"action": {
"type": "object",
"description": "Action: {\"action\":\"system_event\",\"text\":\"...\"} or {\"action\":\"agent_turn\",\"message\":\"...\",\"timeout_secs\":300}"
"description": "Action: {\"kind\":\"system_event\",\"text\":\"...\"} or {\"kind\":\"agent_turn\",\"message\":\"...\",\"timeout_secs\":300}"
},
"delivery": {
"type": "object",
@@ -909,6 +913,21 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"required": ["job_id"]
}),
},
// --- 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(),
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" }
},
"required": ["channel", "recipient", "message"]
}),
},
// --- Hand tools (curated autonomous capability packages) ---
ToolDefinition {
name: "hand_list".to_string(),
@@ -1231,7 +1250,7 @@ async fn tool_web_fetch_legacy(input: &serde_json::Value) -> Result<String, Stri
let truncated = if body.len() > max_len {
format!(
"{}... [truncated, {} total bytes]",
&body[..max_len],
crate::str_utils::safe_truncate_str(&body, max_len),
body.len()
)
} else {
@@ -1367,7 +1386,7 @@ async fn tool_shell_exec(
let stdout_str = if stdout.len() > max_output {
format!(
"{}...\n[truncated, {} total bytes]",
&stdout[..max_output],
crate::str_utils::safe_truncate_str(&stdout, max_output),
stdout.len()
)
} else {
@@ -1376,7 +1395,7 @@ async fn tool_shell_exec(
let stderr_str = if stderr.len() > max_output {
format!(
"{}...\n[truncated, {} total bytes]",
&stderr[..max_output],
crate::str_utils::safe_truncate_str(&stderr, max_output),
stderr.len()
)
} else {
@@ -2001,6 +2020,60 @@ async fn tool_cron_cancel(
Ok(format!("Cron job '{job_id}' cancelled."))
}
// ---------------------------------------------------------------------------
// Channel send tool (proactive outbound messaging via configured adapters)
// ---------------------------------------------------------------------------
async fn tool_channel_send(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let channel = input["channel"]
.as_str()
.ok_or("Missing 'channel' parameter")?
.trim()
.to_lowercase();
let recipient = input["recipient"]
.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());
}
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}")
} else {
message.to_string()
}
} else {
message.to_string()
}
} else {
message.to_string()
};
kh.send_channel_message(&channel, recipient, &final_message)
.await
}
// ---------------------------------------------------------------------------
// Hand tools (delegated to kernel via KernelHandle trait)
// ---------------------------------------------------------------------------
@@ -2953,6 +3026,8 @@ mod tests {
assert!(names.contains(&"cron_create"));
assert!(names.contains(&"cron_list"));
assert!(names.contains(&"cron_cancel"));
// 1 channel send tool
assert!(names.contains(&"channel_send"));
// 4 hand tools
assert!(names.contains(&"hand_list"));
assert!(names.contains(&"hand_activate"));
+2 -2
View File
@@ -114,7 +114,7 @@ impl TtsEngine {
if !response.status().is_success() {
let status = response.status();
let err = response.text().await.unwrap_or_default();
let truncated = if err.len() > 500 { &err[..500] } else { &err };
let truncated = crate::str_utils::safe_truncate_str(&err, 500);
return Err(format!("OpenAI TTS failed (HTTP {status}): {truncated}"));
}
@@ -186,7 +186,7 @@ impl TtsEngine {
if !response.status().is_success() {
let status = response.status();
let err = response.text().await.unwrap_or_default();
let truncated = if err.len() > 500 { &err[..500] } else { &err };
let truncated = crate::str_utils::safe_truncate_str(&err, 500);
return Err(format!(
"ElevenLabs TTS failed (HTTP {status}): {truncated}"
));
+58 -1
View File
@@ -140,15 +140,28 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
}
let host = extract_host(url);
let hostname = host.split(':').next().unwrap_or(&host);
// For IPv6 bracket notation like [::1]:80, extract [::1] as hostname
let hostname = if host.starts_with('[') {
host.find(']')
.map(|i| &host[..=i])
.unwrap_or(&host)
} else {
host.split(':').next().unwrap_or(&host)
};
// Hostname-based blocklist (catches metadata endpoints)
let blocked = [
"localhost",
"ip6-localhost",
"metadata.google.internal",
"metadata.aws.internal",
"instance-data",
"169.254.169.254",
"100.100.100.200", // Alibaba Cloud IMDS
"192.0.0.192", // Azure IMDS alternative
"0.0.0.0",
"::1",
"[::1]",
];
if blocked.contains(&hostname) {
return Err(format!("SSRF blocked: {hostname} is a restricted hostname"));
@@ -192,6 +205,19 @@ fn is_private_ip(ip: &IpAddr) -> bool {
fn extract_host(url: &str) -> String {
if let Some(after_scheme) = url.split("://").nth(1) {
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
// Handle IPv6 bracket notation: [::1]:8080
if host_port.starts_with('[') {
// Extract [addr]:port or [addr]
if let Some(bracket_end) = host_port.find(']') {
let ipv6_host = &host_port[..=bracket_end]; // includes brackets
let after_bracket = &host_port[bracket_end + 1..];
if let Some(port) = after_bracket.strip_prefix(':') {
return format!("{ipv6_host}:{port}");
}
let default_port = if url.starts_with("https") { 443 } else { 80 };
return format!("{ipv6_host}:{default_port}");
}
}
if host_port.contains(':') {
host_port.to_string()
} else if url.starts_with("https") {
@@ -245,4 +271,35 @@ mod tests {
assert!(check_ssrf("ftp://internal.corp/data").is_err());
assert!(check_ssrf("gopher://evil.com").is_err());
}
#[test]
fn test_ssrf_blocks_cloud_metadata() {
// Alibaba Cloud IMDS
assert!(check_ssrf("http://100.100.100.200/latest/meta-data/").is_err());
// Azure IMDS alternative
assert!(check_ssrf("http://192.0.0.192/metadata/instance").is_err());
}
#[test]
fn test_ssrf_blocks_zero_ip() {
assert!(check_ssrf("http://0.0.0.0/").is_err());
}
#[test]
fn test_ssrf_blocks_ipv6_localhost() {
assert!(check_ssrf("http://[::1]/admin").is_err());
assert!(check_ssrf("http://[::1]:8080/api").is_err());
}
#[test]
fn test_extract_host_ipv6() {
let h = extract_host("http://[::1]:8080/path");
assert_eq!(h, "[::1]:8080");
let h2 = extract_host("https://[::1]/path");
assert_eq!(h2, "[::1]:443");
let h3 = extract_host("http://[::1]/path");
assert_eq!(h3, "[::1]:80");
}
}
@@ -146,7 +146,7 @@ impl WorkspaceContext {
if let Some(content) = self.get_file(&name) {
// Take first 200 chars as preview
let preview = if content.len() > 200 {
format!("{}...", &content[..200])
format!("{}...", crate::str_utils::safe_truncate_str(content, 200))
} else {
content.to_string()
};
@@ -56,7 +56,11 @@ pub fn resolve_sandbox_path(user_path: &str, workspace_root: &Path) -> Result<Pa
// Verify the canonical path is inside the workspace
if !canon_candidate.starts_with(&canon_root) {
return Err(format!(
"Access denied: path '{}' resolves outside workspace",
"Access denied: path '{}' resolves outside workspace. \
If you have an MCP filesystem server configured, use the \
mcp_filesystem_* tools (e.g. mcp_filesystem_read_file, \
mcp_filesystem_list_directory) to access files outside \
the workspace.",
user_path
));
}
+31 -1
View File
@@ -996,7 +996,7 @@ pub struct KernelConfig {
#[serde(default)]
pub webhook_triggers: Option<WebhookTriggerConfig>,
/// Execution approval policy.
#[serde(default)]
#[serde(default, alias = "approval_policy")]
pub approval: crate::approval::ApprovalPolicy,
/// Cron scheduler max total jobs across all agents. Default: 500.
#[serde(default = "default_max_cron_jobs")]
@@ -1039,6 +1039,34 @@ pub struct KernelConfig {
/// Global spending budget configuration.
#[serde(default)]
pub budget: BudgetConfig,
/// Provider base URL overrides (provider ID → custom base URL).
/// e.g. `ollama = "http://192.168.1.100:11434/v1"`
#[serde(default)]
pub provider_urls: HashMap<String, String>,
/// OAuth client ID overrides for PKCE flows.
#[serde(default)]
pub oauth: OAuthConfig,
}
/// OAuth client ID overrides for PKCE flows.
///
/// Configure in config.toml:
/// ```toml
/// [oauth]
/// google_client_id = "your-google-client-id"
/// github_client_id = "your-github-client-id"
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct OAuthConfig {
/// Google OAuth2 client ID for PKCE flow.
pub google_client_id: Option<String>,
/// GitHub OAuth client ID for PKCE flow.
pub github_client_id: Option<String>,
/// Microsoft (Entra ID) OAuth client ID.
pub microsoft_client_id: Option<String>,
/// Slack OAuth client ID.
pub slack_client_id: Option<String>,
}
/// Global spending budget configuration.
@@ -1183,6 +1211,8 @@ impl Default for KernelConfig {
auth_profiles: HashMap::new(),
thinking: None,
budget: BudgetConfig::default(),
provider_urls: HashMap::new(),
oauth: OAuthConfig::default(),
}
}
}
+48
View File
@@ -20,3 +20,51 @@ pub mod taint;
pub mod tool;
pub mod tool_compat;
pub mod webhook;
/// Safely truncate a string to at most `max_bytes`, never splitting a UTF-8 char.
pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_str_ascii() {
assert_eq!(truncate_str("hello world", 5), "hello");
}
#[test]
fn truncate_str_chinese() {
// Each Chinese character is 3 bytes
let s = "\u{4F60}\u{597D}\u{4E16}\u{754C}"; // 你好世界
assert_eq!(truncate_str(s, 6), "\u{4F60}\u{597D}"); // 你好
assert_eq!(truncate_str(s, 7), "\u{4F60}\u{597D}"); // still 你好 (7 is mid-char)
assert_eq!(truncate_str(s, 9), "\u{4F60}\u{597D}\u{4E16}"); // 你好世
}
#[test]
fn truncate_str_emoji() {
let s = "hi\u{1F600}there"; // hi😀there — emoji is 4 bytes
assert_eq!(truncate_str(s, 3), "hi"); // 3 is mid-emoji
assert_eq!(truncate_str(s, 6), "hi\u{1F600}"); // after emoji
}
#[test]
fn truncate_str_no_truncation() {
assert_eq!(truncate_str("short", 100), "short");
}
#[test]
fn truncate_str_empty() {
assert_eq!(truncate_str("", 10), "");
}
}
@@ -36,6 +36,7 @@ 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.chat/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 MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn/v1";
pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
+1 -2
View File
@@ -32,8 +32,7 @@ async function startConnection() {
const pino = (await import('pino')).default || await import('pino');
const logger = pino({ level: 'warn' });
const authDir = new URL('./auth_store/', import.meta.url || `file://${__dirname}/`).pathname
|| require('node:path').join(__dirname, 'auth_store');
const authDir = require('node:path').join(__dirname, 'auth_store');
const { state, saveCreds } = await useMultiFileAuthState(
require('node:path').join(__dirname, 'auth_store')
+1
View File
@@ -5,6 +5,7 @@
"bin": {
"openfang-whatsapp-gateway": "./index.js"
},
"type": "commonjs",
"main": "index.js",
"scripts": {
"start": "node index.js"
+31 -8
View File
@@ -20,17 +20,40 @@ function Write-Banner {
}
function Get-Architecture {
# Try multiple detection methods — piped iex can break some approaches
$arch = ""
# Method 1: .NET RuntimeInformation
try {
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
} catch {
# PowerShell 5.1 fallback
$arch = $env:PROCESSOR_ARCHITECTURE
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
} catch {}
# Method 2: PROCESSOR_ARCHITECTURE env var
if (-not $arch -or $arch -eq "") {
try { $arch = $env:PROCESSOR_ARCHITECTURE } catch {}
}
switch ($arch) {
{ $_ -in "X64", "AMD64" } { return "x86_64" }
{ $_ -in "Arm64", "ARM64" } { return "aarch64" }
# Method 3: WMI
if (-not $arch -or $arch -eq "") {
try {
$wmiArch = (Get-CimInstance Win32_Processor).Architecture
if ($wmiArch -eq 9) { $arch = "AMD64" }
elseif ($wmiArch -eq 12) { $arch = "ARM64" }
} catch {}
}
# Method 4: pointer size fallback (64-bit = 8 bytes)
if (-not $arch -or $arch -eq "") {
if ([IntPtr]::Size -eq 8) { $arch = "X64" }
}
$archUpper = "$arch".ToUpper().Trim()
switch ($archUpper) {
{ $_ -in "X64", "AMD64", "X86_64" } { return "x86_64" }
{ $_ -in "ARM64", "AARCH64", "ARM" } { return "aarch64" }
default {
Write-Host " Unsupported architecture: $arch" -ForegroundColor Red
Write-Host " Unsupported architecture: $arch (detection may have failed)" -ForegroundColor Red
Write-Host " Try: cargo install --git https://github.com/RightNow-AI/openfang openfang-cli" -ForegroundColor Yellow
exit 1
}
}