mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73ad49a3a1 | ||
|
|
7ec0e024c7 | ||
|
|
7c81c187c4 | ||
|
|
7ae80b1b9f | ||
|
|
e58ae3e304 | ||
|
|
74f5a91fdd | ||
|
|
6de0447e8c | ||
|
|
45dbf617a7 | ||
|
|
b416bf417f | ||
|
|
1cd47d3905 | ||
|
|
a9124c34f2 | ||
|
|
7bd0185695 | ||
|
|
9fc7ed87be | ||
|
|
0bb08f4ae1 | ||
|
|
51c1e154d9 | ||
|
|
036cc14ad5 | ||
|
|
b866ae7055 | ||
|
|
15a859b7f9 | ||
|
|
eda88ba5a2 | ||
|
|
0bb4e6f17b | ||
|
|
a1d6776989 | ||
|
|
ff408248d1 | ||
|
|
94f8c2d60d | ||
|
|
9728eb1118 | ||
|
|
a8fae5f711 |
@@ -0,0 +1 @@
|
||||
github: RightNow-AI
|
||||
@@ -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
|
||||
@@ -138,10 +143,10 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
os: ubuntu-22.04
|
||||
archive: tar.gz
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
os: ubuntu-22.04
|
||||
archive: tar.gz
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
@@ -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
+250
-28
@@ -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"
|
||||
@@ -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"
|
||||
@@ -2358,6 +2432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2420,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"
|
||||
@@ -2714,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"
|
||||
@@ -2995,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"
|
||||
@@ -3138,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"
|
||||
@@ -3348,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"
|
||||
@@ -3682,7 +3866,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3718,7 +3902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3728,6 +3912,10 @@ dependencies = [
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
"imap",
|
||||
"lettre",
|
||||
"mailparse",
|
||||
"native-tls",
|
||||
"openfang-types",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -3745,7 +3933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3772,7 +3960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3798,7 +3986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -3826,7 +4014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -3843,15 +4031,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"cron",
|
||||
"crossbeam",
|
||||
"dashmap",
|
||||
"dirs 6.0.0",
|
||||
"futures",
|
||||
"hex",
|
||||
"libc",
|
||||
"openfang-channels",
|
||||
"openfang-extensions",
|
||||
"openfang-hands",
|
||||
@@ -3877,7 +4067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -3896,7 +4086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -3915,7 +4105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -3946,7 +4136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -3968,7 +4158,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -3987,7 +4177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4590,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"
|
||||
@@ -4707,7 +4907,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4719,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"
|
||||
@@ -5216,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",
|
||||
@@ -5841,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"
|
||||
@@ -6614,16 +6834,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -6664,9 +6874,11 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tungstenite 0.24.0",
|
||||
]
|
||||
|
||||
@@ -6957,8 +7169,9 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.8.5",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
@@ -8326,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"
|
||||
@@ -8567,7 +8789,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.2.5"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+8
-2
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.2.6"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -76,7 +76,7 @@ bytes = "1"
|
||||
futures = "0.3"
|
||||
|
||||
# WebSocket client (for Discord/Slack gateway)
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
url = "2"
|
||||
|
||||
# WASM sandbox
|
||||
@@ -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"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<p align="center">
|
||||
Open-source Agent OS built in Rust. 137K LOC. 14 crates. 1,767+ tests. Zero clippy warnings.<br/>
|
||||
<strong>One binary. Production-grade. Agents that actually work for you.</strong>
|
||||
<strong>One binary. Battle-tested. Agents that actually work for you.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
@@ -34,7 +35,7 @@
|
||||
|
||||
## What is OpenFang?
|
||||
|
||||
OpenFang is a **production-grade Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
|
||||
OpenFang is an **open-source Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
|
||||
|
||||
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you** — on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
|
||||
|
||||
@@ -370,7 +371,7 @@ cargo fmt --all -- --check
|
||||
|
||||
## Stability Notice
|
||||
|
||||
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is production-grade. That said:
|
||||
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
|
||||
- **Breaking changes** may occur between minor versions until v1.0
|
||||
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
|
||||
@@ -392,11 +393,31 @@ MIT — use it however you want.
|
||||
- [Website & Documentation](https://openfang.sh)
|
||||
- [Quick Start Guide](https://openfang.sh/docs/getting-started)
|
||||
- [GitHub](https://github.com/RightNow-AI/openfang)
|
||||
- [Discord](https://discord.gg/sSJqgNnq6X)
|
||||
- [Twitter / X](https://x.com/openfangg)
|
||||
|
||||
---
|
||||
|
||||
## Built by RightNow
|
||||
|
||||
<p align="center">
|
||||
<strong>Built with Rust. Secured with 16 layers. Agents that actually work for you.</strong><br/>
|
||||
<sub>OpenFang is developed by <a href="https://github.com/RightNow-AI">RightNow AI</a></sub>
|
||||
<a href="https://www.rightnowai.co/">
|
||||
<img src="public/assets/rightnow-logo.webp" width="60" alt="RightNow Logo" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
OpenFang is built and maintained by <a href="https://x.com/Akashi203"><strong>Jaber</strong></a>, Founder of <a href="https://www.rightnowai.co/"><strong>RightNow</strong></a>.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.rightnowai.co/">Website</a> •
|
||||
<a href="https://x.com/Akashi203">Twitter / X</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>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>Built with Rust. Secured with 16 layers. Agents that actually work for you.</strong>
|
||||
</p>
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,31 @@ 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/hands"
|
||||
|| path == "/api/hands/active"
|
||||
|| path.starts_with("/api/hands/")
|
||||
|| path == "/api/skills"
|
||||
|| path == "/api/sessions"
|
||||
|| path == "/api/integrations"
|
||||
|| path == "/api/integrations/available"
|
||||
|| path == "/api/integrations/health"
|
||||
|| path == "/api/workflows"
|
||||
|| path == "/api/logs/stream"
|
||||
|| path.starts_with("/api/cron/")
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/")
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,7 @@ pub async fn build_router(
|
||||
peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())),
|
||||
bridge_manager: tokio::sync::Mutex::new(bridge),
|
||||
channels_config: tokio::sync::RwLock::new(channels_config),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
});
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
@@ -155,6 +156,10 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/session/reset",
|
||||
axum::routing::post(routes::reset_session),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/history",
|
||||
axum::routing::delete(routes::clear_agent_history),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/session/compact",
|
||||
axum::routing::post(routes::compact_session),
|
||||
@@ -167,6 +172,10 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/model",
|
||||
axum::routing::put(routes::set_model),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/tools",
|
||||
axum::routing::get(routes::get_agent_tools).put(routes::set_agent_tools),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/skills",
|
||||
axum::routing::get(routes::get_agent_skills).put(routes::set_agent_skills),
|
||||
@@ -334,6 +343,11 @@ pub async fn build_router(
|
||||
"/api/hands/{hand_id}/install-deps",
|
||||
axum::routing::post(routes::install_hand_deps),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/{hand_id}/settings",
|
||||
axum::routing::get(routes::get_hand_settings)
|
||||
.put(routes::update_hand_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/instances/{id}/pause",
|
||||
axum::routing::post(routes::pause_hand),
|
||||
@@ -376,6 +390,27 @@ pub async fn build_router(
|
||||
"/api/network/status",
|
||||
axum::routing::get(routes::network_status),
|
||||
)
|
||||
// Agent communication (Comms) endpoints
|
||||
.route(
|
||||
"/api/comms/topology",
|
||||
axum::routing::get(routes::comms_topology),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/events",
|
||||
axum::routing::get(routes::comms_events),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/events/stream",
|
||||
axum::routing::get(routes::comms_events_stream),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/send",
|
||||
axum::routing::post(routes::comms_send),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/task",
|
||||
axum::routing::post(routes::comms_task),
|
||||
)
|
||||
// Tools endpoint
|
||||
.route("/api/tools", axum::routing::get(routes::list_tools))
|
||||
// Config endpoints
|
||||
@@ -420,7 +455,8 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/budget/agents/{id}",
|
||||
axum::routing::get(routes::agent_budget_status),
|
||||
axum::routing::get(routes::agent_budget_status)
|
||||
.put(routes::update_agent_budget),
|
||||
)
|
||||
// Session endpoints
|
||||
.route("/api/sessions", axum::routing::get(routes::list_sessions))
|
||||
@@ -449,8 +485,25 @@ pub async fn build_router(
|
||||
"/api/models/aliases",
|
||||
axum::routing::get(routes::list_aliases),
|
||||
)
|
||||
.route(
|
||||
"/api/models/custom",
|
||||
axum::routing::post(routes::add_custom_model),
|
||||
)
|
||||
.route(
|
||||
"/api/models/custom/{*id}",
|
||||
axum::routing::delete(routes::remove_custom_model),
|
||||
)
|
||||
.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),
|
||||
@@ -459,6 +512,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),
|
||||
@@ -710,11 +767,12 @@ pub async fn run_daemon(
|
||||
// Run server with graceful shutdown.
|
||||
// SECURITY: `into_make_service_with_connect_info` injects the peer
|
||||
// SocketAddr so the auth middleware can check for loopback connections.
|
||||
let api_shutdown = state.shutdown_notify.clone();
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.with_graceful_shutdown(shutdown_signal(api_shutdown))
|
||||
.await?;
|
||||
|
||||
// Clean up daemon info file
|
||||
@@ -752,11 +810,11 @@ pub fn read_daemon_info(home_dir: &Path) -> Option<DaemonInfo> {
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
/// Wait for an OS termination signal.
|
||||
/// Wait for an OS termination signal OR an API shutdown request.
|
||||
///
|
||||
/// On Unix: listens for SIGINT and SIGTERM.
|
||||
/// On Windows: listens for Ctrl+C.
|
||||
async fn shutdown_signal() {
|
||||
/// On Unix: listens for SIGINT, SIGTERM, and API notify.
|
||||
/// On Windows: listens for Ctrl+C and API notify.
|
||||
async fn shutdown_signal(api_shutdown: Arc<tokio::sync::Notify>) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
@@ -770,15 +828,22 @@ async fn shutdown_signal() {
|
||||
_ = sigterm.recv() => {
|
||||
info!("Received SIGTERM, shutting down...");
|
||||
}
|
||||
_ = api_shutdown.notified() => {
|
||||
info!("Shutdown requested via API, shutting down...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to install Ctrl+C handler");
|
||||
info!("Ctrl+C received, shutting down...");
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("Ctrl+C received, shutting down...");
|
||||
}
|
||||
_ = api_shutdown.notified() => {
|
||||
info!("Shutdown requested via API, shutting down...");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,9 +102,15 @@ impl StreamChunker {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Forced break at max_chunk_chars
|
||||
// Priority 4: Forced break at max_chunk_chars (char-boundary safe)
|
||||
if self.buffer.len() >= self.max_chunk_chars {
|
||||
let break_at = self.max_chunk_chars;
|
||||
let mut break_at = self.max_chunk_chars;
|
||||
while break_at > 0 && !self.buffer.is_char_boundary(break_at) {
|
||||
break_at -= 1;
|
||||
}
|
||||
if break_at == 0 {
|
||||
break_at = self.buffer.len();
|
||||
}
|
||||
let chunk = self.buffer[..break_at].to_string();
|
||||
self.buffer = self.buffer[break_at..].to_string();
|
||||
return Some(chunk);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -123,6 +123,8 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/js/pages/wizard.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/approvals.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/comms.js"),
|
||||
"\n</script>\n",
|
||||
// Alpine.js MUST be last — it processes x-data and fires alpine:init
|
||||
"<script>\n",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
@@ -3066,4 +3072,16 @@ mark.search-highlight {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.flex-col { flex-direction: column; }
|
||||
/* Comms page */
|
||||
.comms-topo-tree { padding: 4px 0 4px 8px; }
|
||||
.comms-topo-child { padding: 0 0 0 20px; display: flex; align-items: center; gap: 4px; }
|
||||
.comms-topo-branch { color: var(--text-dim); font-family: var(--font-mono); white-space: pre; }
|
||||
.comms-topo-node { display: flex; align-items: center; gap: 4px; padding: 2px 0; }
|
||||
.comms-event-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; transition: background var(--transition-fast);
|
||||
}
|
||||
.comms-event-row:hover { background: var(--bg-hover); }
|
||||
.comms-event-time { min-width: 50px; text-align: right; }
|
||||
.comms-event-detail { margin-left: auto; }
|
||||
|
||||
@@ -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">☼</button>
|
||||
<button class="theme-opt" :class="{ active: themeMode === 'system' }" @click="setTheme('system')" title="System">○</button>
|
||||
<button class="theme-opt" :class="{ active: themeMode === 'dark' }" @click="setTheme('dark')" title="Dark">☾</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-status" :class="{ offline: !connected && !$store.app.booting }">
|
||||
@@ -82,6 +96,10 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
|
||||
<span class="nav-label">Approvals</span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
|
||||
<span class="nav-label">Comms</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -551,7 +569,7 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="$store.app.toggleFocusMode()" title="Ctrl+Shift+F">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><template x-if="!$store.app.focusMode"><g><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g></template><template x-if="$store.app.focusMode"><g><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></template></svg>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g x-show="!$store.app.focusMode"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g><g x-show="$store.app.focusMode"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></svg>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" @click="killAgent()">Stop</button>
|
||||
</div>
|
||||
@@ -678,6 +696,16 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Model autocomplete picker -->
|
||||
<div x-show="showModelPicker && filteredModelPicker.length" class="slash-menu" style="max-height:280px;overflow-y:auto">
|
||||
<div class="text-xs text-dim" style="padding:4px 10px;border-bottom:1px solid var(--border)">Available models — pick one or keep typing</div>
|
||||
<template x-for="(m, idx) in filteredModelPicker" :key="m.id">
|
||||
<div class="slash-menu-item" :class="{ 'slash-active': idx === modelPickerIdx }" @click="pickModel(m.id)" @mouseenter="modelPickerIdx = idx">
|
||||
<span class="font-bold" style="font-size:12px;font-family:var(--font-mono)" x-text="m.id"></span>
|
||||
<span class="text-xs text-dim" x-text="m.provider + (m.display_name && m.display_name !== m.id ? ' · ' + m.display_name : '')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Input row -->
|
||||
<div class="input-row">
|
||||
<button class="btn btn-ghost btn-sm" @click="$refs.fileInput.click()" title="Attach file" style="padding:6px 8px;flex-shrink:0">
|
||||
@@ -695,10 +723,10 @@
|
||||
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
|
||||
</div>
|
||||
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
|
||||
@keydown.enter.prevent="if(!$event.shiftKey){if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false"
|
||||
@keydown.arrow-up.prevent="if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@keydown.enter.prevent="if(!$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false; showModelPicker = false"
|
||||
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@input="$el.style.height='auto';$el.style.height=Math.min($el.scrollHeight,150)+'px'"
|
||||
x-model="inputText"
|
||||
:class="{ 'streaming-active': sending }"></textarea>
|
||||
@@ -793,7 +821,7 @@
|
||||
<div class="tabs" style="margin-bottom:16px">
|
||||
<div class="tab" :class="{ active: detailTab === 'info' }" @click="detailTab = 'info'">Info</div>
|
||||
<div class="tab" :class="{ active: detailTab === 'files' }" @click="detailTab = 'files'; loadAgentFiles()">Files</div>
|
||||
<div class="tab" :class="{ active: detailTab === 'config' }" @click="detailTab = 'config'">Config</div>
|
||||
<div class="tab" :class="{ active: detailTab === 'config' }" @click="detailTab = 'config'; loadToolFilters()">Config</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Info -->
|
||||
@@ -808,12 +836,29 @@
|
||||
</div>
|
||||
<div class="detail-row" x-show="detailAgent.profile"><span class="detail-label">Profile</span><span class="detail-value" style="text-transform:capitalize" x-text="detailAgent.profile || '-'"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="detailAgent.model_provider"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Model</span><span class="detail-value" x-text="detailAgent.model_name"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Model</span>
|
||||
<template x-if="!editingModel">
|
||||
<span>
|
||||
<span class="detail-value" x-text="detailAgent.model_name"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingModel = true; newModelValue = detailAgent.model_provider + '/' + detailAgent.model_name">Change</button>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="editingModel">
|
||||
<span class="flex gap-1" style="align-items:center">
|
||||
<input class="form-input" style="width:240px;font-size:12px" x-model="newModelValue" placeholder="provider/model" @keydown.enter="changeModel()" @keydown.escape="editingModel = false">
|
||||
<button class="btn btn-primary btn-sm" @click="changeModel()" :disabled="modelSaving" style="padding:2px 10px">
|
||||
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingModel = false" style="padding:2px 8px">Cancel</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value" x-text="detailAgent.created_at ? new Date(detailAgent.created_at).toLocaleString() : '-'"></span></div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" @click="chatWithAgent(detailAgent); showDetailModal = false">Chat</button>
|
||||
<button class="btn btn-ghost" @click="cloneAgent(detailAgent)">Clone</button>
|
||||
<button class="btn btn-ghost" @click="clearHistory(detailAgent)">Clear History</button>
|
||||
<button class="btn btn-danger" @click="killAgent(detailAgent)">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -872,6 +917,36 @@
|
||||
<button class="btn btn-primary mt-4" @click="saveConfig()" :disabled="configSaving">
|
||||
<span x-show="!configSaving">Save Config</span><span x-show="configSaving">Saving...</span>
|
||||
</button>
|
||||
|
||||
<!-- Tool Filters -->
|
||||
<div class="mt-4" style="border-top:1px solid var(--border);padding-top:16px">
|
||||
<h4 style="margin-bottom:8px;font-size:13px">Tool Filters</h4>
|
||||
<p class="text-xs text-dim" style="margin-bottom:12px">Allowlist: only these tools available (empty = all). Blocklist: these tools excluded.</p>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px">Allowlist <span class="text-dim" x-text="'(' + toolFilters.tool_allowlist.length + ')'"></span></label>
|
||||
<div class="flex flex-wrap gap-1 mb-1">
|
||||
<template x-for="(t, i) in toolFilters.tool_allowlist" :key="'al-'+i">
|
||||
<span class="badge" style="cursor:pointer" @click="removeAllowTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> ×</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<input class="form-input" style="font-size:12px;flex:1" x-model="newAllowTool" placeholder="tool name" @keydown.enter="addAllowTool()">
|
||||
<button class="btn btn-ghost btn-sm" @click="addAllowTool()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px">Blocklist <span class="text-dim" x-text="'(' + toolFilters.tool_blocklist.length + ')'"></span></label>
|
||||
<div class="flex flex-wrap gap-1 mb-1">
|
||||
<template x-for="(t, i) in toolFilters.tool_blocklist" :key="'bl-'+i">
|
||||
<span class="badge badge-danger" style="cursor:pointer" @click="removeBlockTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> ×</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<input class="form-input" style="font-size:12px;flex:1" x-model="newBlockTool" placeholder="tool name" @keydown.enter="addBlockTool()">
|
||||
<button class="btn btn-ghost btn-sm" @click="addBlockTool()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1088,10 +1163,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> · <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 +1596,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 +1616,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 +2945,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 +2974,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 — 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>
|
||||
@@ -2914,6 +3019,31 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<option :value="t" x-text="t"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" @click="showCustomModelForm = !showCustomModelForm" x-text="showCustomModelForm ? 'Cancel' : '+ Custom Model'"></button>
|
||||
</div>
|
||||
<!-- Custom model form -->
|
||||
<div x-show="showCustomModelForm" class="info-card mb-4" style="border:1px solid var(--accent,#7c3aed)">
|
||||
<h4 style="margin-top:0">Add Custom Model</h4>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
||||
<div>
|
||||
<label class="text-xs text-dim">Model ID (required)</label>
|
||||
<input class="form-input" x-model="customModelId" placeholder="e.g. my-org/my-model">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Provider</label>
|
||||
<input class="form-input" x-model="customModelProvider" placeholder="openrouter">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Context Window</label>
|
||||
<input class="form-input" type="number" x-model.number="customModelContext" placeholder="128000">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Max Output Tokens</label>
|
||||
<input class="form-input" type="number" x-model.number="customModelMaxOutput" placeholder="8192">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="addCustomModel()" :disabled="!customModelId.trim()">Add Model</button>
|
||||
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
|
||||
<div class="table-wrap" x-show="filteredModels.length">
|
||||
@@ -2922,7 +3052,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<tbody>
|
||||
<template x-for="m in filteredModels" :key="m.id">
|
||||
<tr>
|
||||
<td class="font-bold" style="font-size:11px" x-text="m.display_name || m.id"></td>
|
||||
<td style="font-size:11px"><div class="font-bold" x-text="m.display_name || m.id"></div><div class="text-xs text-dim" style="font-family:var(--font-mono);opacity:0.7;user-select:all" x-show="m.display_name && m.display_name !== m.id" x-text="m.id"></div></td>
|
||||
<td class="text-dim" x-text="m.provider"></td>
|
||||
<td><span class="tier-badge" :class="tierBadgeClass(m.tier)" x-text="m.tier || '-'"></span></td>
|
||||
<td class="text-xs" x-text="formatContext(m.context_window)"></td>
|
||||
@@ -3951,6 +4081,176 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Page: Comms -->
|
||||
<template x-if="page === 'comms'">
|
||||
<div x-data="commsPage" x-init="loadData()" @page-leave.window="stopSSE()">
|
||||
<div class="page-header">
|
||||
<h2>Agent Comms</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-primary btn-sm" @click="openSendModal()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/></svg>
|
||||
Send Message
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="openTaskModal()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
|
||||
Post Task
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="loadData()" title="Refresh">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-body">
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" style="animation:fadeIn 0.2s">
|
||||
<div class="card mb-4"><div class="skeleton skeleton-text" style="width:160px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:120px"></div></div>
|
||||
<div class="card"><div class="skeleton skeleton-text" style="width:120px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:200px"></div></div>
|
||||
</div>
|
||||
<!-- Error -->
|
||||
<div x-show="!loading && loadError" class="error-state" style="animation:fadeIn 0.3s">
|
||||
<h3 style="color:var(--error)">Connection Error</h3>
|
||||
<p class="text-xs text-dim" x-text="loadError"></p>
|
||||
<button class="btn btn-primary btn-sm" @click="loadData()" style="margin-top:8px">Retry</button>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div x-show="!loading && !loadError" style="animation:fadeIn 0.3s">
|
||||
<!-- Topology -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/></svg>
|
||||
Agent Topology
|
||||
<span class="badge badge-dim" style="margin-left:8px;font-weight:400" x-text="topology.nodes.length + ' agents'"></span>
|
||||
</div>
|
||||
<div style="padding:8px 0;font-family:var(--font-mono);font-size:12px;line-height:1.8">
|
||||
<template x-if="topology.nodes.length === 0">
|
||||
<div class="text-dim" style="text-align:center;padding:24px">No agents running</div>
|
||||
</template>
|
||||
<template x-for="root in rootNodes()" :key="root.id">
|
||||
<div class="comms-topo-tree">
|
||||
<div class="comms-topo-node" :title="root.id">
|
||||
<span :class="stateBadgeClass(root.state)" style="font-size:10px;padding:1px 6px" x-text="root.state"></span>
|
||||
<strong x-text="root.name" style="margin:0 4px"></strong>
|
||||
<span class="text-dim" x-text="root.model"></span>
|
||||
<template x-for="peer in peersOf(root.id)" :key="peer.id">
|
||||
<span class="text-dim" style="margin-left:8px" x-text="'\u2194 ' + peer.name"></span>
|
||||
</template>
|
||||
</div>
|
||||
<template x-for="(child, ci) in childrenOf(root.id)" :key="child.id">
|
||||
<div class="comms-topo-child">
|
||||
<span class="comms-topo-branch" x-text="ci < childrenOf(root.id).length - 1 ? '\u251c\u2500\u2500 ' : '\u2514\u2500\u2500 '"></span>
|
||||
<span :class="stateBadgeClass(child.state)" style="font-size:10px;padding:1px 6px" x-text="child.state"></span>
|
||||
<strong x-text="child.name" style="margin:0 4px"></strong>
|
||||
<span class="text-dim" x-text="child.model"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Event Feed -->
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
Live Event Feed
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-success" style="font-size:9px;padding:2px 6px;animation:pulse-ring 2s infinite">LIVE</span>
|
||||
<span class="text-xs text-dim" x-text="events.length + ' events'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="max-height:400px;overflow-y:auto">
|
||||
<template x-if="events.length === 0">
|
||||
<div class="text-dim" style="text-align:center;padding:24px">No inter-agent events yet</div>
|
||||
</template>
|
||||
<template x-for="ev in events" :key="ev.id">
|
||||
<div class="comms-event-row">
|
||||
<span class="comms-event-time text-xs text-dim" x-text="timeAgo(ev.timestamp)"></span>
|
||||
<span :class="eventBadgeClass(ev.kind)" style="font-size:10px;padding:1px 6px;min-width:70px;text-align:center" x-text="eventLabel(ev.kind)"></span>
|
||||
<span style="font-weight:600;font-size:12px" x-text="ev.source_name"></span>
|
||||
<span class="text-dim" x-show="ev.target_name" x-text="'\u2192 ' + ev.target_name"></span>
|
||||
<span class="comms-event-detail text-dim text-xs" x-text="ev.detail" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Message Modal -->
|
||||
<div x-show="showSendModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showSendModal=false" x-transition>
|
||||
<div class="card" style="width:420px;max-width:90vw" @click.stop>
|
||||
<div class="card-header">Send Agent Message</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">From Agent</label>
|
||||
<select x-model="sendFrom" class="input" style="width:100%">
|
||||
<option value="">Select agent...</option>
|
||||
<template x-for="n in topology.nodes" :key="n.id">
|
||||
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">To Agent</label>
|
||||
<select x-model="sendTo" class="input" style="width:100%">
|
||||
<option value="">Select agent...</option>
|
||||
<template x-for="n in topology.nodes" :key="n.id">
|
||||
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Message</label>
|
||||
<textarea x-model="sendMsg" class="input" rows="3" placeholder="Type a message..." style="width:100%;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2" style="justify-content:flex-end">
|
||||
<button class="btn btn-ghost btn-sm" @click="showSendModal=false">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @click="submitSend()" :disabled="sendLoading || !sendFrom || !sendTo || !sendMsg.trim()">
|
||||
<span x-show="sendLoading">Sending...</span>
|
||||
<span x-show="!sendLoading">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Task Modal -->
|
||||
<div x-show="showTaskModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showTaskModal=false" x-transition>
|
||||
<div class="card" style="width:420px;max-width:90vw" @click.stop>
|
||||
<div class="card-header">Post Task</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Title</label>
|
||||
<input type="text" x-model="taskTitle" class="input" placeholder="Task title..." style="width:100%">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Description</label>
|
||||
<textarea x-model="taskDesc" class="input" rows="3" placeholder="Task description..." style="width:100%;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Assign To (optional)</label>
|
||||
<select x-model="taskAssign" class="input" style="width:100%">
|
||||
<option value="">Unassigned</option>
|
||||
<template x-for="n in topology.nodes" :key="n.id">
|
||||
<option :value="n.id" x-text="n.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2" style="justify-content:flex-end">
|
||||
<button class="btn btn-ghost btn-sm" @click="showTaskModal=false">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @click="submitTask()" :disabled="taskLoading || !taskTitle.trim()">
|
||||
<span x-show="taskLoading">Posting...</span>
|
||||
<span x-show="!taskLoading">Post Task</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Page: Setup Wizard -->
|
||||
<template x-if="page === 'wizard'">
|
||||
<div x-data="wizardPage">
|
||||
|
||||
@@ -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,39 @@ document.addEventListener('alpine:init', function() {
|
||||
dismissOnboarding() {
|
||||
this.showOnboarding = false;
|
||||
localStorage.setItem('openfang-onboarded', 'true');
|
||||
},
|
||||
|
||||
async checkAuth() {
|
||||
try {
|
||||
// Use a protected endpoint (not in the public allowlist) to detect
|
||||
// whether the server requires an API key.
|
||||
await OpenFangAPI.get('/api/tools');
|
||||
this.showAuthPrompt = false;
|
||||
} catch(e) {
|
||||
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
|
||||
// Only show prompt if we don't already have a saved key
|
||||
var saved = localStorage.getItem('openfang-api-key');
|
||||
if (saved) {
|
||||
// Saved key might be stale — clear it and show prompt
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
}
|
||||
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 +192,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,8 +210,15 @@ 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 validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
|
||||
var pageRedirects = {
|
||||
'chat': 'agents',
|
||||
'templates': 'agents',
|
||||
@@ -225,6 +275,7 @@ function app() {
|
||||
// Initial data load
|
||||
this.pollStatus();
|
||||
Alpine.store('app').checkOnboarding();
|
||||
Alpine.store('app').checkAuth();
|
||||
setInterval(function() { self.pollStatus(); }, 5000);
|
||||
},
|
||||
|
||||
@@ -234,9 +285,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() {
|
||||
|
||||
@@ -54,6 +54,15 @@ function agentsPage() {
|
||||
filesLoading: false,
|
||||
configForm: {},
|
||||
configSaving: false,
|
||||
// -- Tool filters --
|
||||
toolFilters: { tool_allowlist: [], tool_blocklist: [] },
|
||||
toolFiltersLoading: false,
|
||||
newAllowTool: '',
|
||||
newBlockTool: '',
|
||||
// -- Model switch --
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
modelSaving: false,
|
||||
|
||||
// -- Templates state --
|
||||
tplTemplates: [],
|
||||
@@ -559,6 +568,88 @@ function agentsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Clear agent history ──
|
||||
async clearHistory(agent) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Clear History', 'Clear all conversation history for "' + agent.name + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/agents/' + agent.id + '/history');
|
||||
OpenFangToast.success('History cleared for "' + agent.name + '"');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to clear history: ' + e.message);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ── Model switch ──
|
||||
async changeModel() {
|
||||
if (!this.detailAgent || !this.newModelValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
OpenFangToast.success('Model changed (memory reset)');
|
||||
this.editingModel = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
// Refresh detailAgent
|
||||
var agents = Alpine.store('app').agents;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to change model: ' + e.message);
|
||||
}
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Tool filters ──
|
||||
async loadToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
this.toolFiltersLoading = true;
|
||||
try {
|
||||
this.toolFilters = await OpenFangAPI.get('/api/agents/' + this.detailAgent.id + '/tools');
|
||||
} catch(e) {
|
||||
this.toolFilters = { tool_allowlist: [], tool_blocklist: [] };
|
||||
}
|
||||
this.toolFiltersLoading = false;
|
||||
},
|
||||
|
||||
addAllowTool() {
|
||||
var t = this.newAllowTool.trim();
|
||||
if (t && this.toolFilters.tool_allowlist.indexOf(t) === -1) {
|
||||
this.toolFilters.tool_allowlist.push(t);
|
||||
this.newAllowTool = '';
|
||||
this.saveToolFilters();
|
||||
}
|
||||
},
|
||||
|
||||
removeAllowTool(tool) {
|
||||
this.toolFilters.tool_allowlist = this.toolFilters.tool_allowlist.filter(function(t) { return t !== tool; });
|
||||
this.saveToolFilters();
|
||||
},
|
||||
|
||||
addBlockTool() {
|
||||
var t = this.newBlockTool.trim();
|
||||
if (t && this.toolFilters.tool_blocklist.indexOf(t) === -1) {
|
||||
this.toolFilters.tool_blocklist.push(t);
|
||||
this.newBlockTool = '';
|
||||
this.saveToolFilters();
|
||||
}
|
||||
},
|
||||
|
||||
removeBlockTool(tool) {
|
||||
this.toolFilters.tool_blocklist = this.toolFilters.tool_blocklist.filter(function(t) { return t !== tool; });
|
||||
this.saveToolFilters();
|
||||
},
|
||||
|
||||
async saveToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/tools', this.toolFilters);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update tool filters: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async spawnBuiltin(t) {
|
||||
var toml = 'name = "' + t.name + '"\n';
|
||||
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
|
||||
|
||||
@@ -29,6 +29,11 @@ function chatPage() {
|
||||
_audioChunks: [],
|
||||
recordingTime: 0,
|
||||
_recordingTimer: null,
|
||||
// Model autocomplete state
|
||||
showModelPicker: false,
|
||||
modelPickerList: [],
|
||||
modelPickerFilter: '',
|
||||
modelPickerIdx: 0,
|
||||
slashCommands: [
|
||||
{ cmd: '/help', desc: 'Show available commands' },
|
||||
{ cmd: '/agents', desc: 'Switch to Agents page' },
|
||||
@@ -126,18 +131,47 @@ function chatPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for slash commands
|
||||
// Watch for slash commands + model autocomplete
|
||||
this.$watch('inputText', function(val) {
|
||||
if (val.startsWith('/')) {
|
||||
var modelMatch = val.match(/^\/model\s+(.*)$/i);
|
||||
if (modelMatch) {
|
||||
self.showSlashMenu = false;
|
||||
self.modelPickerFilter = modelMatch[1].toLowerCase();
|
||||
if (!self.modelPickerList.length) {
|
||||
OpenFangAPI.get('/api/models').then(function(data) {
|
||||
self.modelPickerList = (data.models || []).filter(function(m) { return m.available; });
|
||||
self.showModelPicker = true;
|
||||
self.modelPickerIdx = 0;
|
||||
}).catch(function() {});
|
||||
} else {
|
||||
self.showModelPicker = true;
|
||||
}
|
||||
} else if (val.startsWith('/')) {
|
||||
self.showModelPicker = false;
|
||||
self.slashFilter = val.slice(1).toLowerCase();
|
||||
self.showSlashMenu = true;
|
||||
self.slashIdx = 0;
|
||||
} else {
|
||||
self.showSlashMenu = false;
|
||||
self.showModelPicker = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
get filteredModelPicker() {
|
||||
if (!this.modelPickerFilter) return this.modelPickerList.slice(0, 15);
|
||||
var f = this.modelPickerFilter;
|
||||
return this.modelPickerList.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 || (m.display_name || '').toLowerCase().indexOf(f) !== -1 || m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
}).slice(0, 15);
|
||||
},
|
||||
|
||||
pickModel(modelId) {
|
||||
this.showModelPicker = false;
|
||||
this.inputText = '/model ' + modelId;
|
||||
this.sendMessage();
|
||||
},
|
||||
|
||||
// Fetch dynamic slash commands from server
|
||||
fetchCommands: function() {
|
||||
var self = this;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// OpenFang Comms Page — Agent topology & inter-agent communication feed
|
||||
'use strict';
|
||||
|
||||
function commsPage() {
|
||||
return {
|
||||
topology: { nodes: [], edges: [] },
|
||||
events: [],
|
||||
loading: true,
|
||||
loadError: '',
|
||||
sseSource: null,
|
||||
showSendModal: false,
|
||||
showTaskModal: false,
|
||||
sendFrom: '',
|
||||
sendTo: '',
|
||||
sendMsg: '',
|
||||
sendLoading: false,
|
||||
taskTitle: '',
|
||||
taskDesc: '',
|
||||
taskAssign: '',
|
||||
taskLoading: false,
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/comms/topology'),
|
||||
OpenFangAPI.get('/api/comms/events?limit=200')
|
||||
]);
|
||||
this.topology = results[0] || { nodes: [], edges: [] };
|
||||
this.events = results[1] || [];
|
||||
this.startSSE();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load comms data.';
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
startSSE() {
|
||||
if (this.sseSource) this.sseSource.close();
|
||||
var self = this;
|
||||
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
|
||||
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
|
||||
this.sseSource = new EventSource(url);
|
||||
this.sseSource.onmessage = function(ev) {
|
||||
if (ev.data === 'ping') return;
|
||||
try {
|
||||
var event = JSON.parse(ev.data);
|
||||
self.events.unshift(event);
|
||||
if (self.events.length > 200) self.events.length = 200;
|
||||
// Refresh topology on spawn/terminate events
|
||||
if (event.kind === 'agent_spawned' || event.kind === 'agent_terminated') {
|
||||
self.refreshTopology();
|
||||
}
|
||||
} catch(e) { /* ignore parse errors */ }
|
||||
};
|
||||
},
|
||||
|
||||
stopSSE() {
|
||||
if (this.sseSource) {
|
||||
this.sseSource.close();
|
||||
this.sseSource = null;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshTopology() {
|
||||
try {
|
||||
this.topology = await OpenFangAPI.get('/api/comms/topology');
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
rootNodes() {
|
||||
var childIds = {};
|
||||
var self = this;
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'parent_child') childIds[e.to] = true;
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return !childIds[n.id]; });
|
||||
},
|
||||
|
||||
childrenOf(id) {
|
||||
var childIds = {};
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'parent_child' && e.from === id) childIds[e.to] = true;
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return childIds[n.id]; });
|
||||
},
|
||||
|
||||
peersOf(id) {
|
||||
var peerIds = {};
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'peer') {
|
||||
if (e.from === id) peerIds[e.to] = true;
|
||||
if (e.to === id) peerIds[e.from] = true;
|
||||
}
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return peerIds[n.id]; });
|
||||
},
|
||||
|
||||
stateBadgeClass(state) {
|
||||
switch(state) {
|
||||
case 'Running': return 'badge badge-success';
|
||||
case 'Suspended': return 'badge badge-warning';
|
||||
case 'Terminated': case 'Crashed': return 'badge badge-danger';
|
||||
default: return 'badge badge-dim';
|
||||
}
|
||||
},
|
||||
|
||||
eventBadgeClass(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return 'badge badge-info';
|
||||
case 'agent_spawned': return 'badge badge-success';
|
||||
case 'agent_terminated': return 'badge badge-danger';
|
||||
case 'task_posted': return 'badge badge-warning';
|
||||
case 'task_claimed': return 'badge badge-info';
|
||||
case 'task_completed': return 'badge badge-success';
|
||||
default: return 'badge badge-dim';
|
||||
}
|
||||
},
|
||||
|
||||
eventIcon(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return '\u2709';
|
||||
case 'agent_spawned': return '+';
|
||||
case 'agent_terminated': return '\u2715';
|
||||
case 'task_posted': return '\u2691';
|
||||
case 'task_claimed': return '\u2690';
|
||||
case 'task_completed': return '\u2713';
|
||||
default: return '\u2022';
|
||||
}
|
||||
},
|
||||
|
||||
eventLabel(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return 'Message';
|
||||
case 'agent_spawned': return 'Spawned';
|
||||
case 'agent_terminated': return 'Terminated';
|
||||
case 'task_posted': return 'Task Posted';
|
||||
case 'task_claimed': return 'Task Claimed';
|
||||
case 'task_completed': return 'Task Done';
|
||||
default: return kind;
|
||||
}
|
||||
},
|
||||
|
||||
timeAgo(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
var d = new Date(dateStr);
|
||||
var secs = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (secs < 60) return secs + 's ago';
|
||||
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
|
||||
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
|
||||
return Math.floor(secs / 86400) + 'd ago';
|
||||
},
|
||||
|
||||
openSendModal() {
|
||||
this.sendFrom = '';
|
||||
this.sendTo = '';
|
||||
this.sendMsg = '';
|
||||
this.showSendModal = true;
|
||||
},
|
||||
|
||||
async submitSend() {
|
||||
if (!this.sendFrom || !this.sendTo || !this.sendMsg.trim()) return;
|
||||
this.sendLoading = true;
|
||||
try {
|
||||
await OpenFangAPI.post('/api/comms/send', {
|
||||
from_agent_id: this.sendFrom,
|
||||
to_agent_id: this.sendTo,
|
||||
message: this.sendMsg
|
||||
});
|
||||
OpenFangToast.success('Message sent');
|
||||
this.showSendModal = false;
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Send failed');
|
||||
}
|
||||
this.sendLoading = false;
|
||||
},
|
||||
|
||||
openTaskModal() {
|
||||
this.taskTitle = '';
|
||||
this.taskDesc = '';
|
||||
this.taskAssign = '';
|
||||
this.showTaskModal = true;
|
||||
},
|
||||
|
||||
async submitTask() {
|
||||
if (!this.taskTitle.trim()) return;
|
||||
this.taskLoading = true;
|
||||
try {
|
||||
var body = { title: this.taskTitle, description: this.taskDesc };
|
||||
if (this.taskAssign) body.assigned_to = this.taskAssign;
|
||||
await OpenFangAPI.post('/api/comms/task', body);
|
||||
OpenFangToast.success('Task posted');
|
||||
this.showTaskModal = false;
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Task failed');
|
||||
}
|
||||
this.taskLoading = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -14,9 +14,18 @@ function settingsPage() {
|
||||
modelSearch: '',
|
||||
modelProviderFilter: '',
|
||||
modelTierFilter: '',
|
||||
showCustomModelForm: false,
|
||||
customModelId: '',
|
||||
customModelProvider: 'openrouter',
|
||||
customModelContext: 128000,
|
||||
customModelMaxOutput: 8192,
|
||||
customModelStatus: '',
|
||||
providerKeyInputs: {},
|
||||
providerUrlInputs: {},
|
||||
providerUrlSaving: {},
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
@@ -208,6 +217,17 @@ 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) {
|
||||
if (!this.providerUrlInputs[p.id]) {
|
||||
this.providerUrlInputs[p.id] = p.base_url || '';
|
||||
}
|
||||
if (this.providerUrlSaving[p.id] === undefined) {
|
||||
this.providerUrlSaving[p.id] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) { this.providers = []; }
|
||||
},
|
||||
|
||||
@@ -218,6 +238,26 @@ function settingsPage() {
|
||||
} catch(e) { this.models = []; }
|
||||
},
|
||||
|
||||
async addCustomModel() {
|
||||
var id = this.customModelId.trim();
|
||||
if (!id) return;
|
||||
this.customModelStatus = 'Adding...';
|
||||
try {
|
||||
await OpenFangAPI.post('/api/models/custom', {
|
||||
id: id,
|
||||
provider: this.customModelProvider || 'openrouter',
|
||||
context_window: this.customModelContext || 128000,
|
||||
max_output_tokens: this.customModelMaxOutput || 8192,
|
||||
});
|
||||
this.customModelStatus = 'Added!';
|
||||
this.customModelId = '';
|
||||
this.showCustomModelForm = false;
|
||||
await this.loadModels();
|
||||
} catch(e) {
|
||||
this.customModelStatus = 'Error: ' + (e.message || 'Failed');
|
||||
}
|
||||
},
|
||||
|
||||
async loadConfigSchema() {
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
@@ -360,6 +400,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 +466,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 — 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;
|
||||
|
||||
@@ -75,6 +75,7 @@ async fn start_test_server_with_provider(
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -700,6 +701,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
});
|
||||
|
||||
let api_key_state = state.kernel.config.api_key.clone();
|
||||
|
||||
@@ -112,6 +112,7 @@ async fn test_full_daemon_lifecycle() {
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -234,6 +235,7 @@ async fn test_server_immediate_responsiveness() {
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -56,6 +56,7 @@ async fn start_test_server() -> TestServer {
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.trim()
|
||||
.to_string()
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ");
|
||||
|
||||
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("'hello'"), "'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!({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Compile-time embedded agent templates.
|
||||
//!
|
||||
//! All 30 bundled agent templates are embedded into the binary via `include_str!`.
|
||||
//! This ensures `openfang agent new` works immediately after install — no filesystem
|
||||
//! discovery needed.
|
||||
|
||||
/// Returns all bundled agent templates as `(name, toml_content)` pairs.
|
||||
pub fn bundled_agents() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("analyst", include_str!("../../../agents/analyst/agent.toml")),
|
||||
("architect", include_str!("../../../agents/architect/agent.toml")),
|
||||
("assistant", include_str!("../../../agents/assistant/agent.toml")),
|
||||
("coder", include_str!("../../../agents/coder/agent.toml")),
|
||||
("code-reviewer", include_str!("../../../agents/code-reviewer/agent.toml")),
|
||||
("customer-support", include_str!("../../../agents/customer-support/agent.toml")),
|
||||
("data-scientist", include_str!("../../../agents/data-scientist/agent.toml")),
|
||||
("debugger", include_str!("../../../agents/debugger/agent.toml")),
|
||||
("devops-lead", include_str!("../../../agents/devops-lead/agent.toml")),
|
||||
("doc-writer", include_str!("../../../agents/doc-writer/agent.toml")),
|
||||
("email-assistant", include_str!("../../../agents/email-assistant/agent.toml")),
|
||||
("health-tracker", include_str!("../../../agents/health-tracker/agent.toml")),
|
||||
("hello-world", include_str!("../../../agents/hello-world/agent.toml")),
|
||||
("home-automation", include_str!("../../../agents/home-automation/agent.toml")),
|
||||
("legal-assistant", include_str!("../../../agents/legal-assistant/agent.toml")),
|
||||
("meeting-assistant", include_str!("../../../agents/meeting-assistant/agent.toml")),
|
||||
("ops", include_str!("../../../agents/ops/agent.toml")),
|
||||
("orchestrator", include_str!("../../../agents/orchestrator/agent.toml")),
|
||||
("personal-finance", include_str!("../../../agents/personal-finance/agent.toml")),
|
||||
("planner", include_str!("../../../agents/planner/agent.toml")),
|
||||
("recruiter", include_str!("../../../agents/recruiter/agent.toml")),
|
||||
("researcher", include_str!("../../../agents/researcher/agent.toml")),
|
||||
("sales-assistant", include_str!("../../../agents/sales-assistant/agent.toml")),
|
||||
("security-auditor", include_str!("../../../agents/security-auditor/agent.toml")),
|
||||
("social-media", include_str!("../../../agents/social-media/agent.toml")),
|
||||
("test-engineer", include_str!("../../../agents/test-engineer/agent.toml")),
|
||||
("translator", include_str!("../../../agents/translator/agent.toml")),
|
||||
("travel-planner", include_str!("../../../agents/travel-planner/agent.toml")),
|
||||
("tutor", include_str!("../../../agents/tutor/agent.toml")),
|
||||
("writer", include_str!("../../../agents/writer/agent.toml")),
|
||||
]
|
||||
}
|
||||
|
||||
/// Install bundled agent templates to `~/.openfang/agents/`.
|
||||
/// Skips any template that already exists on disk (user customization preserved).
|
||||
pub fn install_bundled_agents(agents_dir: &std::path::Path) {
|
||||
for (name, content) in bundled_agents() {
|
||||
let dest_dir = agents_dir.join(name);
|
||||
let dest_file = dest_dir.join("agent.toml");
|
||||
if dest_file.exists() {
|
||||
continue; // Preserve user customization
|
||||
}
|
||||
if std::fs::create_dir_all(&dest_dir).is_ok() {
|
||||
let _ = std::fs::write(&dest_file, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,19 +11,32 @@ pub fn env_file_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".openfang").join(".env"))
|
||||
}
|
||||
|
||||
/// Load `~/.openfang/.env` into `std::env`.
|
||||
/// Load `~/.openfang/.env` and `~/.openfang/secrets.env` into `std::env`.
|
||||
///
|
||||
/// System env vars take priority — existing vars are NOT overridden.
|
||||
/// Silently does nothing if the file doesn't exist.
|
||||
/// `secrets.env` is loaded second so `.env` values take priority over secrets
|
||||
/// (but both yield to system env vars).
|
||||
/// Silently does nothing if the files don't exist.
|
||||
pub fn load_dotenv() {
|
||||
let path = match env_file_path() {
|
||||
load_env_file(env_file_path());
|
||||
// Also load secrets.env (written by dashboard "Set API Key" button)
|
||||
load_env_file(secrets_env_path());
|
||||
}
|
||||
|
||||
/// Return the path to `~/.openfang/secrets.env`.
|
||||
pub fn secrets_env_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".openfang").join("secrets.env"))
|
||||
}
|
||||
|
||||
fn load_env_file(path: Option<PathBuf>) {
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return, // file doesn't exist or unreadable — that's fine
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for line in content.lines() {
|
||||
@@ -33,7 +46,6 @@ pub fn load_dotenv() {
|
||||
}
|
||||
|
||||
if let Some((key, value)) = parse_env_line(trimmed) {
|
||||
// Only set if not already in environment (system env takes priority)
|
||||
if std::env::var(&key).is_err() {
|
||||
std::env::set_var(&key, &value);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! When a daemon is running (`openfang start`), the CLI talks to it over HTTP.
|
||||
//! Otherwise, commands boot an in-process kernel (single-shot mode).
|
||||
|
||||
mod bundled_agents;
|
||||
mod dotenv;
|
||||
mod launcher;
|
||||
mod mcp;
|
||||
@@ -72,6 +73,7 @@ const AFTER_HELP: &str = "\
|
||||
openfang doctor Run diagnostic health checks
|
||||
openfang channel setup Interactive channel setup wizard
|
||||
openfang cron list List scheduled jobs
|
||||
openfang uninstall Completely remove OpenFang from your system
|
||||
|
||||
\x1b[1;36mQuick Start:\x1b[0m
|
||||
1. openfang init Set up config + API key
|
||||
@@ -131,6 +133,9 @@ enum Commands {
|
||||
/// Manage channel integrations (setup, test, enable, disable) [*].
|
||||
#[command(subcommand)]
|
||||
Channel(ChannelCommands),
|
||||
/// Manage hands (list, activate, deactivate, info) [*].
|
||||
#[command(subcommand)]
|
||||
Hand(HandCommands),
|
||||
/// Show or edit configuration (show, edit, get, set, keys) [*].
|
||||
#[command(subcommand)]
|
||||
Config(ConfigCommands),
|
||||
@@ -275,6 +280,15 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
confirm: bool,
|
||||
},
|
||||
/// Completely uninstall OpenFang from your system.
|
||||
Uninstall {
|
||||
/// Skip confirmation prompt (also --yes).
|
||||
#[arg(long, alias = "yes")]
|
||||
confirm: bool,
|
||||
/// Keep config files (config.toml, .env, secrets.env).
|
||||
#[arg(long)]
|
||||
keep_config: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -370,6 +384,29 @@ enum ChannelCommands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum HandCommands {
|
||||
/// List all available hands.
|
||||
List,
|
||||
/// Show currently active hand instances.
|
||||
Active,
|
||||
/// Activate a hand by ID.
|
||||
Activate {
|
||||
/// Hand ID (e.g. "clip", "lead", "researcher").
|
||||
id: String,
|
||||
},
|
||||
/// Deactivate an active hand instance.
|
||||
Deactivate {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Show detailed info about a hand.
|
||||
Info {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ConfigCommands {
|
||||
/// Show the current configuration.
|
||||
@@ -438,6 +475,15 @@ enum AgentCommands {
|
||||
/// Agent ID (UUID).
|
||||
agent_id: String,
|
||||
},
|
||||
/// Set an agent property (e.g., model).
|
||||
Set {
|
||||
/// Agent ID (UUID).
|
||||
agent_id: String,
|
||||
/// Field to set (model).
|
||||
field: String,
|
||||
/// New value.
|
||||
value: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -802,6 +848,11 @@ fn main() {
|
||||
AgentCommands::List { json } => cmd_agent_list(cli.config, json),
|
||||
AgentCommands::Chat { agent_id } => cmd_agent_chat(cli.config, &agent_id),
|
||||
AgentCommands::Kill { agent_id } => cmd_agent_kill(cli.config, &agent_id),
|
||||
AgentCommands::Set {
|
||||
agent_id,
|
||||
field,
|
||||
value,
|
||||
} => cmd_agent_set(&agent_id, &field, &value),
|
||||
},
|
||||
Some(Commands::Workflow(sub)) => match sub {
|
||||
WorkflowCommands::List => cmd_workflow_list(),
|
||||
@@ -833,6 +884,13 @@ fn main() {
|
||||
ChannelCommands::Enable { channel } => cmd_channel_toggle(&channel, true),
|
||||
ChannelCommands::Disable { channel } => cmd_channel_toggle(&channel, false),
|
||||
},
|
||||
Some(Commands::Hand(sub)) => match sub {
|
||||
HandCommands::List => cmd_hand_list(),
|
||||
HandCommands::Active => cmd_hand_active(),
|
||||
HandCommands::Activate { id } => cmd_hand_activate(&id),
|
||||
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
|
||||
HandCommands::Info { id } => cmd_hand_info(&id),
|
||||
},
|
||||
Some(Commands::Config(sub)) => match sub {
|
||||
ConfigCommands::Show => cmd_config_show(),
|
||||
ConfigCommands::Edit => cmd_config_edit(),
|
||||
@@ -921,6 +979,7 @@ fn main() {
|
||||
SystemCommands::Version { json } => cmd_system_version(json),
|
||||
},
|
||||
Some(Commands::Reset { confirm }) => cmd_reset(confirm),
|
||||
Some(Commands::Uninstall { confirm, keep_config }) => cmd_uninstall(confirm, keep_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,14 +1011,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
|
||||
}
|
||||
@@ -1051,6 +1116,9 @@ fn cmd_init(quick: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// Install bundled agent templates (skips existing ones to preserve user edits)
|
||||
bundled_agents::install_bundled_agents(&openfang_dir.join("agents"));
|
||||
|
||||
if quick {
|
||||
cmd_init_quick(&openfang_dir);
|
||||
} else {
|
||||
@@ -1238,6 +1306,7 @@ fn write_config_if_missing(
|
||||
r#"# OpenFang Agent OS configuration
|
||||
# See https://github.com/RightNow-AI/openfang for documentation
|
||||
|
||||
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
|
||||
api_listen = "127.0.0.1:4200"
|
||||
|
||||
[default_model]
|
||||
@@ -1328,7 +1397,23 @@ fn cmd_stop() {
|
||||
let client = daemon_client();
|
||||
match client.post(format!("{base}/api/shutdown")).send() {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
ui::success("Daemon is shutting down");
|
||||
// Wait for daemon to actually stop (up to 5 seconds)
|
||||
for _ in 0..10 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
if find_daemon().is_none() {
|
||||
ui::success("Daemon stopped");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Still alive — force kill via PID
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let of_dir = home.join(".openfang");
|
||||
if let Some(info) = read_daemon_info(&of_dir) {
|
||||
force_kill_pid(info.pid);
|
||||
let _ = std::fs::remove_file(of_dir.join("daemon.json"));
|
||||
}
|
||||
}
|
||||
ui::success("Daemon stopped (forced)");
|
||||
}
|
||||
Ok(r) => {
|
||||
ui::error(&format!("Shutdown request failed ({})", r.status()));
|
||||
@@ -1347,6 +1432,21 @@ fn cmd_stop() {
|
||||
}
|
||||
}
|
||||
|
||||
fn force_kill_pid(pid: u32) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = std::process::Command::new("kill")
|
||||
.args(["-9", &pid.to_string()])
|
||||
.output();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
/// Show context-aware error for kernel boot failures.
|
||||
fn boot_kernel_error(e: &openfang_kernel::error::KernelError) {
|
||||
let msg = e.to_string();
|
||||
@@ -1542,6 +1642,38 @@ fn cmd_agent_kill(config: Option<PathBuf>, agent_id_str: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_agent_set(agent_id_str: &str, field: &str, value: &str) {
|
||||
match field {
|
||||
"model" => {
|
||||
if let Some(base) = find_daemon() {
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.put(format!("{base}/api/agents/{agent_id_str}/model"))
|
||||
.json(&serde_json::json!({"model": value}))
|
||||
.send(),
|
||||
);
|
||||
if body.get("status").is_some() {
|
||||
println!("Agent {agent_id_str} model set to {value}.");
|
||||
} else {
|
||||
eprintln!(
|
||||
"Failed to set model: {}",
|
||||
body["error"].as_str().unwrap_or("Unknown error")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else {
|
||||
eprintln!("No running daemon found. Start one with: openfang start");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
eprintln!("Unknown field: {field}. Supported fields: model");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_agent_new(config: Option<PathBuf>, template_name: Option<String>) {
|
||||
let all_templates = templates::load_all_templates();
|
||||
if all_templates.is_empty() {
|
||||
@@ -1872,6 +2004,7 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
let default_config = r#"# OpenFang Agent OS configuration
|
||||
# See https://github.com/RightNow-AI/openfang for documentation
|
||||
|
||||
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
|
||||
api_listen = "127.0.0.1:4200"
|
||||
|
||||
[default_model]
|
||||
@@ -3657,6 +3790,168 @@ fn cmd_channel_toggle(channel: &str, enable: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hand commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cmd_hand_list() {
|
||||
let base = require_daemon("hand list");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(client.get(format!("{base}/api/hands")).send());
|
||||
// API returns {"hands": [...]} or a bare array
|
||||
let arr_val;
|
||||
if let Some(arr) = body.get("hands").and_then(|v| v.as_array()) {
|
||||
arr_val = arr.clone();
|
||||
} else if let Some(arr) = body.as_array() {
|
||||
arr_val = arr.clone();
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&body).unwrap_or_default()
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(arr) = Some(&arr_val) {
|
||||
if arr.is_empty() {
|
||||
println!("No hands available.");
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
"{:<14} {:<20} {:<10} DESCRIPTION",
|
||||
"ID", "NAME", "CATEGORY"
|
||||
);
|
||||
println!("{}", "-".repeat(72));
|
||||
for h in arr {
|
||||
println!(
|
||||
"{:<14} {:<20} {:<10} {}",
|
||||
h["id"].as_str().unwrap_or("?"),
|
||||
h["name"].as_str().unwrap_or("?"),
|
||||
h["category"].as_str().unwrap_or("?"),
|
||||
h["description"].as_str().unwrap_or("").chars().take(40).collect::<String>(),
|
||||
);
|
||||
}
|
||||
println!("\nUse `openfang hand activate <id>` to activate a hand.");
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_active() {
|
||||
let base = require_daemon("hand active");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(client.get(format!("{base}/api/hands/active")).send());
|
||||
// API returns {"instances": [...]} or bare array
|
||||
let arr = body
|
||||
.get("instances")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| body.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if arr.is_empty() {
|
||||
println!("No active hands.");
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
"{:<38} {:<14} {:<10} AGENT",
|
||||
"INSTANCE", "HAND", "STATUS"
|
||||
);
|
||||
println!("{}", "-".repeat(72));
|
||||
for i in &arr {
|
||||
println!(
|
||||
"{:<38} {:<14} {:<10} {}",
|
||||
i["instance_id"].as_str().unwrap_or("?"),
|
||||
i["hand_id"].as_str().unwrap_or("?"),
|
||||
i["status"].as_str().unwrap_or("?"),
|
||||
i["agent_name"].as_str().unwrap_or("?"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_activate(id: &str) {
|
||||
let base = require_daemon("hand activate");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/activate"))
|
||||
.header("content-type", "application/json")
|
||||
.body("{}")
|
||||
.send(),
|
||||
);
|
||||
if body.get("instance_id").is_some() {
|
||||
println!(
|
||||
"Hand '{}' activated (instance: {}, agent: {})",
|
||||
id,
|
||||
body["instance_id"].as_str().unwrap_or("?"),
|
||||
body["agent_name"].as_str().unwrap_or("?"),
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"Failed to activate hand '{}': {}",
|
||||
id,
|
||||
body["error"].as_str().unwrap_or("Unknown error")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_deactivate(id: &str) {
|
||||
let base = require_daemon("hand deactivate");
|
||||
let client = daemon_client();
|
||||
// First find the instance ID for this hand
|
||||
let active = daemon_json(client.get(format!("{base}/api/hands/active")).send());
|
||||
let arr = active
|
||||
.get("instances")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| active.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let instance_id = arr.iter().find_map(|i| {
|
||||
if i["hand_id"].as_str() == Some(id) {
|
||||
i["instance_id"].as_str().map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
match instance_id {
|
||||
Some(iid) => {
|
||||
let body = daemon_json(
|
||||
client
|
||||
.delete(format!("{base}/api/hands/instances/{iid}"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("status").is_some() {
|
||||
println!("Hand '{id}' deactivated.");
|
||||
} else {
|
||||
eprintln!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("Unknown error")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
eprintln!("No active instance found for hand '{id}'.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_info(id: &str) {
|
||||
let base = require_daemon("hand info");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(client.get(format!("{base}/api/hands/{id}")).send());
|
||||
if body.get("error").is_some() {
|
||||
eprintln!(
|
||||
"Hand not found: {}",
|
||||
body["error"].as_str().unwrap_or(id)
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&body).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider / API key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -5493,6 +5788,358 @@ fn cmd_reset(confirm: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uninstall
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
ui::error("Could not determine home directory");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let openfang_dir = home.join(".openfang");
|
||||
let exe_path = std::env::current_exe().ok();
|
||||
|
||||
// Step 1: Show what will be removed
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
"This will completely uninstall OpenFang from your system."
|
||||
.bold()
|
||||
.red()
|
||||
);
|
||||
println!();
|
||||
if openfang_dir.exists() {
|
||||
if keep_config {
|
||||
println!(
|
||||
" • Remove data in {} (keeping config files)",
|
||||
openfang_dir.display()
|
||||
);
|
||||
} else {
|
||||
println!(" • Remove {}", openfang_dir.display());
|
||||
}
|
||||
}
|
||||
if let Some(ref exe) = exe_path {
|
||||
println!(" • Remove binary: {}", exe.display());
|
||||
}
|
||||
// Check cargo bin path
|
||||
let cargo_bin = home.join(".cargo").join("bin").join(if cfg!(windows) {
|
||||
"openfang.exe"
|
||||
} else {
|
||||
"openfang"
|
||||
});
|
||||
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
|
||||
println!(" • Remove cargo binary: {}", cargo_bin.display());
|
||||
}
|
||||
println!(" • Remove auto-start entries (if any)");
|
||||
println!(" • Clean PATH from shell configs (if any)");
|
||||
println!();
|
||||
|
||||
// Step 2: Confirm
|
||||
if !confirm {
|
||||
let answer = prompt_input(" Type 'uninstall' to confirm: ");
|
||||
if answer.trim() != "uninstall" {
|
||||
println!(" Cancelled.");
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// Step 3: Stop running daemon
|
||||
if find_daemon().is_some() {
|
||||
println!(" Stopping running daemon...");
|
||||
cmd_stop();
|
||||
// Give it a moment
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
// Force kill if still alive
|
||||
if find_daemon().is_some() {
|
||||
if let Some(info) = read_daemon_info(&openfang_dir) {
|
||||
force_kill_pid(info.pid);
|
||||
let _ = std::fs::remove_file(openfang_dir.join("daemon.json"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Remove auto-start entries
|
||||
remove_autostart_entries(&home);
|
||||
|
||||
// Step 5: Clean PATH from shell configs
|
||||
if let Some(ref exe) = exe_path {
|
||||
if let Some(bin_dir) = exe.parent() {
|
||||
clean_path_entries(&home, &bin_dir.to_string_lossy());
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Remove ~/.openfang/ data
|
||||
if openfang_dir.exists() {
|
||||
if keep_config {
|
||||
remove_dir_except_config(&openfang_dir);
|
||||
ui::success("Removed data (kept config files)");
|
||||
} else {
|
||||
match std::fs::remove_dir_all(&openfang_dir) {
|
||||
Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())),
|
||||
Err(e) => ui::error(&format!(
|
||||
"Failed to remove {}: {e}",
|
||||
openfang_dir.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Remove cargo bin copy if it exists and is separate from current exe
|
||||
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
|
||||
match std::fs::remove_file(&cargo_bin) {
|
||||
Ok(()) => ui::success(&format!("Removed {}", cargo_bin.display())),
|
||||
Err(e) => ui::error(&format!(
|
||||
"Failed to remove {}: {e}",
|
||||
cargo_bin.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: Remove the binary itself (must be last)
|
||||
if let Some(exe) = exe_path {
|
||||
remove_self_binary(&exe);
|
||||
}
|
||||
|
||||
println!();
|
||||
ui::success("OpenFang has been uninstalled. Goodbye!");
|
||||
}
|
||||
|
||||
/// Remove auto-start / launch-agent / systemd entries.
|
||||
#[allow(unused_variables)]
|
||||
fn remove_autostart_entries(home: &std::path::Path) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows: remove from HKCU\Software\Microsoft\Windows\CurrentVersion\Run
|
||||
let output = std::process::Command::new("reg")
|
||||
.args([
|
||||
"delete",
|
||||
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
"/v",
|
||||
"OpenFang",
|
||||
"/f",
|
||||
])
|
||||
.output();
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
ui::success("Removed Windows auto-start registry entry");
|
||||
}
|
||||
_ => {} // Entry didn't exist — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let plist = home.join("Library/LaunchAgents/ai.openfang.desktop.plist");
|
||||
if plist.exists() {
|
||||
// Unload first
|
||||
let _ = std::process::Command::new("launchctl")
|
||||
.args(["unload", &plist.to_string_lossy()])
|
||||
.output();
|
||||
match std::fs::remove_file(&plist) {
|
||||
Ok(()) => ui::success("Removed macOS launch agent"),
|
||||
Err(e) => ui::error(&format!("Failed to remove launch agent: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let desktop_file = home.join(".config/autostart/OpenFang.desktop");
|
||||
if desktop_file.exists() {
|
||||
match std::fs::remove_file(&desktop_file) {
|
||||
Ok(()) => ui::success("Removed Linux autostart entry"),
|
||||
Err(e) => ui::error(&format!("Failed to remove autostart entry: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for systemd user service
|
||||
let service_file = home.join(".config/systemd/user/openfang.service");
|
||||
if service_file.exists() {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "disable", "--now", "openfang.service"])
|
||||
.output();
|
||||
match std::fs::remove_file(&service_file) {
|
||||
Ok(()) => {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "daemon-reload"])
|
||||
.output();
|
||||
ui::success("Removed systemd user service");
|
||||
}
|
||||
Err(e) => ui::error(&format!("Failed to remove systemd service: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove lines from shell config files that add openfang to PATH.
|
||||
#[allow(unused_variables)]
|
||||
fn clean_path_entries(home: &std::path::Path, openfang_dir: &str) {
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let shell_files = [
|
||||
home.join(".bashrc"),
|
||||
home.join(".bash_profile"),
|
||||
home.join(".profile"),
|
||||
home.join(".zshrc"),
|
||||
home.join(".config/fish/config.fish"),
|
||||
];
|
||||
|
||||
for path in &shell_files {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = std::fs::read_to_string(path) else {
|
||||
continue;
|
||||
};
|
||||
let filtered: Vec<&str> = content
|
||||
.lines()
|
||||
.filter(|line| !is_openfang_path_line(line, openfang_dir))
|
||||
.collect();
|
||||
if filtered.len() < content.lines().count() {
|
||||
let new_content = filtered.join("\n");
|
||||
// Preserve trailing newline if original had one
|
||||
let new_content = if content.ends_with('\n') {
|
||||
format!("{new_content}\n")
|
||||
} else {
|
||||
new_content
|
||||
};
|
||||
if std::fs::write(path, &new_content).is_ok() {
|
||||
ui::success(&format!("Cleaned PATH from {}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Read User PATH via PowerShell, filter out openfang entries, write back
|
||||
let output = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Environment]::GetEnvironmentVariable('PATH', 'User')",
|
||||
])
|
||||
.output();
|
||||
if let Ok(out) = output {
|
||||
if out.status.success() {
|
||||
let current = String::from_utf8_lossy(&out.stdout);
|
||||
let current = current.trim();
|
||||
if !current.is_empty() {
|
||||
let dir_lower = openfang_dir.to_lowercase();
|
||||
let filtered: Vec<&str> = current
|
||||
.split(';')
|
||||
.filter(|entry| {
|
||||
let e = entry.trim().to_lowercase();
|
||||
!e.is_empty() && !e.contains("openfang") && !e.contains(&dir_lower)
|
||||
})
|
||||
.collect();
|
||||
if filtered.len() < current.split(';').count() {
|
||||
let new_path = filtered.join(";");
|
||||
let ps_cmd = format!(
|
||||
"[Environment]::SetEnvironmentVariable('PATH', '{}', 'User')",
|
||||
new_path.replace('\'', "''")
|
||||
);
|
||||
let result = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", &ps_cmd])
|
||||
.output();
|
||||
if result.is_ok_and(|o| o.status.success()) {
|
||||
ui::success("Cleaned PATH from Windows user environment");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if a shell config line is an openfang PATH export.
|
||||
/// Must match BOTH an openfang reference AND a PATH-setting pattern.
|
||||
#[cfg(any(not(windows), test))]
|
||||
fn is_openfang_path_line(line: &str, openfang_dir: &str) -> bool {
|
||||
let lower = line.to_lowercase();
|
||||
let has_openfang = lower.contains("openfang") || lower.contains(&openfang_dir.to_lowercase());
|
||||
if !has_openfang {
|
||||
return false;
|
||||
}
|
||||
// Match common PATH-setting patterns
|
||||
lower.contains("export path=")
|
||||
|| lower.contains("export path =")
|
||||
|| lower.starts_with("path=")
|
||||
|| lower.contains("set -gx path")
|
||||
|| lower.contains("fish_add_path")
|
||||
}
|
||||
|
||||
/// Remove everything in ~/.openfang/ except config files.
|
||||
fn remove_dir_except_config(openfang_dir: &std::path::Path) {
|
||||
let keep = ["config.toml", ".env", "secrets.env"];
|
||||
let Ok(entries) = std::fs::read_dir(openfang_dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if keep.contains(&name_str.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the currently-running binary.
|
||||
fn remove_self_binary(exe_path: &std::path::Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// On Unix, running binaries can be unlinked — the OS keeps the inode
|
||||
// alive until the process exits.
|
||||
match std::fs::remove_file(exe_path) {
|
||||
Ok(()) => ui::success(&format!("Removed {}", exe_path.display())),
|
||||
Err(e) => ui::error(&format!(
|
||||
"Failed to remove binary {}: {e}",
|
||||
exe_path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows locks running executables. Rename first, then spawn a
|
||||
// detached process that waits briefly and deletes the renamed file.
|
||||
let old_path = exe_path.with_extension("exe.old");
|
||||
if std::fs::rename(exe_path, &old_path).is_err() {
|
||||
ui::error(&format!(
|
||||
"Could not rename binary for deferred deletion: {}",
|
||||
exe_path.display()
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
|
||||
let del_cmd = format!(
|
||||
"ping -n 3 127.0.0.1 >nul & del /f /q \"{}\"",
|
||||
old_path.display()
|
||||
);
|
||||
let _ = std::process::Command::new("cmd.exe")
|
||||
.args(["/C", &del_cmd])
|
||||
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.spawn();
|
||||
|
||||
ui::success(&format!("Removed {} (deferred cleanup)", exe_path.display()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -5617,4 +6264,44 @@ args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
];
|
||||
assert_eq!(events.len(), 4);
|
||||
}
|
||||
|
||||
// --- Uninstall command unit tests ---
|
||||
|
||||
#[test]
|
||||
fn test_uninstall_path_line_filter() {
|
||||
use super::is_openfang_path_line;
|
||||
let dir = "/home/user/.openfang/bin";
|
||||
|
||||
// Should match: openfang PATH exports
|
||||
assert!(is_openfang_path_line(
|
||||
r#"export PATH="$HOME/.openfang/bin:$PATH""#,
|
||||
dir
|
||||
));
|
||||
assert!(is_openfang_path_line(
|
||||
r#"export PATH="/home/user/.openfang/bin:$PATH""#,
|
||||
dir
|
||||
));
|
||||
assert!(is_openfang_path_line(
|
||||
"set -gx PATH $HOME/.openfang/bin $PATH",
|
||||
dir
|
||||
));
|
||||
assert!(is_openfang_path_line(
|
||||
"fish_add_path $HOME/.openfang/bin",
|
||||
dir
|
||||
));
|
||||
|
||||
// Should NOT match: unrelated PATH exports
|
||||
assert!(!is_openfang_path_line(
|
||||
r#"export PATH="$HOME/.cargo/bin:$PATH""#,
|
||||
dir
|
||||
));
|
||||
assert!(!is_openfang_path_line(
|
||||
r#"export PATH="/usr/local/bin:$PATH""#,
|
||||
dir
|
||||
));
|
||||
|
||||
// Should NOT match: openfang lines that aren't PATH-related
|
||||
assert!(!is_openfang_path_line("# openfang config", dir));
|
||||
assert!(!is_openfang_path_line("alias of=openfang", dir));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,11 +54,12 @@ pub fn discover_template_dirs() -> Vec<PathBuf> {
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Load all templates from discovered directories.
|
||||
/// Load all templates from discovered directories, falling back to bundled templates.
|
||||
pub fn load_all_templates() -> Vec<AgentTemplate> {
|
||||
let mut templates = Vec::new();
|
||||
let mut seen_names = std::collections::HashSet::new();
|
||||
|
||||
// First: load from filesystem (user-installed or dev repo)
|
||||
for dir in discover_template_dirs() {
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
@@ -86,6 +87,18 @@ pub fn load_all_templates() -> Vec<AgentTemplate> {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: load bundled templates for any not found on disk
|
||||
for (name, content) in crate::bundled_agents::bundled_agents() {
|
||||
if seen_names.insert(name.to_string()) {
|
||||
let description = extract_description(content);
|
||||
templates.push(AgentTemplate {
|
||||
name: name.to_string(),
|
||||
description,
|
||||
content: content.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
templates.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
templates
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -189,6 +189,17 @@ pub enum AppEvent {
|
||||
AgentSkillsUpdated(String),
|
||||
/// Agent MCP servers updated.
|
||||
AgentMcpServersUpdated(String),
|
||||
/// Comms topology loaded.
|
||||
CommsTopologyLoaded {
|
||||
nodes: Vec<super::screens::comms::CommsNode>,
|
||||
edges: Vec<super::screens::comms::CommsEdge>,
|
||||
},
|
||||
/// Comms events loaded.
|
||||
CommsEventsLoaded(Vec<super::screens::comms::CommsEventItem>),
|
||||
/// Comms send result.
|
||||
CommsSendResult(String),
|
||||
/// Comms task post result.
|
||||
CommsTaskResult(String),
|
||||
}
|
||||
|
||||
/// Spawn the crossterm polling + tick thread. Returns sender + receiver.
|
||||
@@ -1481,7 +1492,13 @@ pub fn spawn_browse_clawhub(backend: BackendRef, sort: String, tx: mpsc::Sender<
|
||||
}
|
||||
|
||||
fn parse_clawhub_results(body: &serde_json::Value) -> Vec<ClawHubResult> {
|
||||
body.as_array()
|
||||
// API returns {"items": [...]} wrapper, fall back to bare array for compat
|
||||
let items = body
|
||||
.get("items")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| body.as_array());
|
||||
|
||||
items
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|r| ClawHubResult {
|
||||
@@ -2592,3 +2609,176 @@ pub fn spawn_reconnect_extension(backend: BackendRef, id: String, tx: mpsc::Send
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch comms topology + events.
|
||||
pub fn spawn_fetch_comms(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
|
||||
use super::screens::comms::{CommsEdge, CommsEventItem, CommsNode};
|
||||
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
// Fetch topology
|
||||
if let Ok(resp) = client.get(format!("{base_url}/api/comms/topology")).send() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let nodes: Vec<CommsNode> = body["nodes"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|n| CommsNode {
|
||||
id: n["id"].as_str().unwrap_or("").to_string(),
|
||||
name: n["name"].as_str().unwrap_or("").to_string(),
|
||||
state: n["state"].as_str().unwrap_or("").to_string(),
|
||||
model: n["model"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let edges: Vec<CommsEdge> = body["edges"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|e| CommsEdge {
|
||||
from: e["from"].as_str().unwrap_or("").to_string(),
|
||||
to: e["to"].as_str().unwrap_or("").to_string(),
|
||||
kind: e["kind"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = tx.send(AppEvent::CommsTopologyLoaded { nodes, edges });
|
||||
}
|
||||
}
|
||||
// Fetch events
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/comms/events?limit=100"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let events: Vec<CommsEventItem> = body
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|e| CommsEventItem {
|
||||
id: e["id"].as_str().unwrap_or("").to_string(),
|
||||
timestamp: e["timestamp"].as_str().unwrap_or("").to_string(),
|
||||
kind: e["kind"].as_str().unwrap_or("").to_string(),
|
||||
source_name: e["source_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
target_name: e["target_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
detail: e["detail"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = tx.send(AppEvent::CommsEventsLoaded(events));
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::CommsTopologyLoaded {
|
||||
nodes: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
});
|
||||
let _ = tx.send(AppEvent::CommsEventsLoaded(Vec::new()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Send a message between agents via comms endpoint.
|
||||
pub fn spawn_comms_send(
|
||||
backend: BackendRef,
|
||||
from: String,
|
||||
to: String,
|
||||
msg: String,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let body = serde_json::json!({
|
||||
"from_agent_id": from,
|
||||
"to_agent_id": to,
|
||||
"message": msg,
|
||||
});
|
||||
match client
|
||||
.post(format!("{base_url}/api/comms/send"))
|
||||
.json(&body)
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
let _ = tx.send(AppEvent::CommsSendResult("Message sent".to_string()));
|
||||
} else {
|
||||
let err = resp
|
||||
.json::<serde_json::Value>()
|
||||
.ok()
|
||||
.and_then(|v| v["error"].as_str().map(String::from))
|
||||
.unwrap_or_else(|| "Send failed".to_string());
|
||||
let _ = tx.send(AppEvent::CommsSendResult(err));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(AppEvent::CommsSendResult(format!("Error: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::CommsSendResult(
|
||||
"Send not supported in-process".to_string(),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Post a task via comms endpoint.
|
||||
pub fn spawn_comms_task(
|
||||
backend: BackendRef,
|
||||
title: String,
|
||||
desc: String,
|
||||
assign: String,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let mut body = serde_json::json!({
|
||||
"title": title,
|
||||
"description": desc,
|
||||
});
|
||||
if !assign.is_empty() {
|
||||
body["assigned_to"] = serde_json::Value::String(assign);
|
||||
}
|
||||
match client
|
||||
.post(format!("{base_url}/api/comms/task"))
|
||||
.json(&body)
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
let _ = tx.send(AppEvent::CommsTaskResult("Task posted".to_string()));
|
||||
} else {
|
||||
let err = resp
|
||||
.json::<serde_json::Value>()
|
||||
.ok()
|
||||
.and_then(|v| v["error"].as_str().map(String::from))
|
||||
.unwrap_or_else(|| "Post failed".to_string());
|
||||
let _ = tx.send(AppEvent::CommsTaskResult(err));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(AppEvent::CommsTaskResult(format!("Error: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::CommsTaskResult(
|
||||
"Task post not supported in-process".to_string(),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_types::agent::AgentId;
|
||||
use screens::{
|
||||
agents, audit, channels, chat, dashboard, extensions, hands, logs, memory, peers, security,
|
||||
sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
|
||||
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{mpsc, Arc};
|
||||
@@ -53,6 +53,7 @@ enum Tab {
|
||||
Extensions,
|
||||
Templates,
|
||||
Peers,
|
||||
Comms,
|
||||
Security,
|
||||
Audit,
|
||||
Usage,
|
||||
@@ -74,6 +75,7 @@ const TABS: &[Tab] = &[
|
||||
Tab::Extensions,
|
||||
Tab::Templates,
|
||||
Tab::Peers,
|
||||
Tab::Comms,
|
||||
Tab::Security,
|
||||
Tab::Audit,
|
||||
Tab::Usage,
|
||||
@@ -97,6 +99,7 @@ impl Tab {
|
||||
Tab::Extensions => "Extensions",
|
||||
Tab::Templates => "Templates",
|
||||
Tab::Peers => "Peers",
|
||||
Tab::Comms => "Comms",
|
||||
Tab::Security => "Security",
|
||||
Tab::Audit => "Audit",
|
||||
Tab::Usage => "Usage",
|
||||
@@ -169,6 +172,7 @@ struct App {
|
||||
usage: usage::UsageState,
|
||||
settings: settings::SettingsState,
|
||||
peers: peers::PeersState,
|
||||
comms: comms::CommsState,
|
||||
logs: logs::LogsState,
|
||||
|
||||
kernel_booting: bool,
|
||||
@@ -207,6 +211,7 @@ impl App {
|
||||
usage: usage::UsageState::new(),
|
||||
settings: settings::SettingsState::new(),
|
||||
peers: peers::PeersState::new(),
|
||||
comms: comms::CommsState::new(),
|
||||
logs: logs::LogsState::new(),
|
||||
kernel_booting: false,
|
||||
kernel_boot_error: None,
|
||||
@@ -504,6 +509,26 @@ impl App {
|
||||
}
|
||||
self.peers.loading = false;
|
||||
}
|
||||
AppEvent::CommsTopologyLoaded { nodes, edges } => {
|
||||
self.comms.nodes = nodes;
|
||||
self.comms.edges = edges;
|
||||
self.comms.loading = false;
|
||||
}
|
||||
AppEvent::CommsEventsLoaded(events) => {
|
||||
self.comms.events = events;
|
||||
if !self.comms.events.is_empty()
|
||||
&& self.comms.event_list_state.selected().is_none()
|
||||
{
|
||||
self.comms.event_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
AppEvent::CommsSendResult(msg) => {
|
||||
self.comms.status_msg = msg;
|
||||
self.refresh_comms();
|
||||
}
|
||||
AppEvent::CommsTaskResult(msg) => {
|
||||
self.comms.status_msg = msg;
|
||||
}
|
||||
AppEvent::LogsLoaded(entries) => {
|
||||
self.logs.entries = entries;
|
||||
self.logs.refilter();
|
||||
@@ -845,6 +870,10 @@ impl App {
|
||||
let action = self.peers.handle_key(key);
|
||||
self.handle_peers_action(action);
|
||||
}
|
||||
Tab::Comms => {
|
||||
let action = self.comms.handle_key(key);
|
||||
self.handle_comms_action(action);
|
||||
}
|
||||
Tab::Logs => {
|
||||
let action = self.logs.handle_key(key);
|
||||
self.handle_logs_action(action);
|
||||
@@ -876,6 +905,7 @@ impl App {
|
||||
self.usage.tick();
|
||||
self.settings.tick();
|
||||
self.peers.tick();
|
||||
self.comms.tick();
|
||||
self.logs.tick();
|
||||
|
||||
// Auto-poll for active tabs
|
||||
@@ -883,6 +913,7 @@ impl App {
|
||||
match self.active_tab {
|
||||
Tab::Logs if self.logs.should_poll() => self.refresh_logs(),
|
||||
Tab::Peers if self.peers.should_poll() => self.refresh_peers(),
|
||||
Tab::Comms if self.comms.should_poll() => self.refresh_comms(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -932,6 +963,7 @@ impl App {
|
||||
Tab::Usage => self.refresh_usage(),
|
||||
Tab::Settings => self.refresh_settings_providers(),
|
||||
Tab::Peers => self.refresh_peers(),
|
||||
Tab::Comms => self.refresh_comms(),
|
||||
Tab::Logs => self.refresh_logs(),
|
||||
Tab::Chat => {} // Chat doesn't need refresh on enter
|
||||
}
|
||||
@@ -1089,6 +1121,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_comms(&mut self) {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
self.comms.loading = true;
|
||||
event::spawn_fetch_comms(backend, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_logs(&mut self) {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
self.logs.loading = true;
|
||||
@@ -1660,6 +1699,27 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_comms_action(&mut self, action: comms::CommsAction) {
|
||||
match action {
|
||||
comms::CommsAction::Continue => {}
|
||||
comms::CommsAction::Refresh => self.refresh_comms(),
|
||||
comms::CommsAction::SendMessage { from, to, msg } => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_comms_send(backend, from, to, msg, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
comms::CommsAction::PostTask {
|
||||
title,
|
||||
desc,
|
||||
assign,
|
||||
} => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_comms_task(backend, title, desc, assign, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_logs_action(&mut self, action: logs::LogsAction) {
|
||||
match action {
|
||||
logs::LogsAction::Continue => {}
|
||||
@@ -2029,6 +2089,7 @@ impl App {
|
||||
Tab::Usage => usage::draw(frame, chunks[1], &mut self.usage),
|
||||
Tab::Settings => settings::draw(frame, chunks[1], &mut self.settings),
|
||||
Tab::Peers => peers::draw(frame, chunks[1], &mut self.peers),
|
||||
Tab::Comms => comms::draw(frame, chunks[1], &mut self.comms),
|
||||
Tab::Logs => logs::draw(frame, chunks[1], &mut self.logs),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
//! Comms screen: Agent communication topology + live event feed.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub state: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub kind: String, // "parent_child" or "peer"
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsEventItem {
|
||||
/// Event ID — used by the dashboard for dedup, kept for wire compat.
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub timestamp: String,
|
||||
pub kind: String,
|
||||
pub source_name: String,
|
||||
pub target_name: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommsFocus {
|
||||
Topology,
|
||||
EventList,
|
||||
}
|
||||
|
||||
pub struct CommsState {
|
||||
pub nodes: Vec<CommsNode>,
|
||||
pub edges: Vec<CommsEdge>,
|
||||
pub events: Vec<CommsEventItem>,
|
||||
pub event_list_state: ListState,
|
||||
pub focus: CommsFocus,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub poll_tick: usize,
|
||||
// Send modal
|
||||
pub show_send_modal: bool,
|
||||
pub send_from: String,
|
||||
pub send_to: String,
|
||||
pub send_msg: String,
|
||||
pub send_field: usize,
|
||||
// Task modal
|
||||
pub show_task_modal: bool,
|
||||
pub task_title: String,
|
||||
pub task_desc: String,
|
||||
pub task_assign: String,
|
||||
pub task_field: usize,
|
||||
// Status
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum CommsAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
SendMessage {
|
||||
from: String,
|
||||
to: String,
|
||||
msg: String,
|
||||
},
|
||||
PostTask {
|
||||
title: String,
|
||||
desc: String,
|
||||
assign: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CommsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nodes: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
events: Vec::new(),
|
||||
event_list_state: ListState::default(),
|
||||
focus: CommsFocus::Topology,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
poll_tick: 0,
|
||||
show_send_modal: false,
|
||||
send_from: String::new(),
|
||||
send_to: String::new(),
|
||||
send_msg: String::new(),
|
||||
send_field: 0,
|
||||
show_task_modal: false,
|
||||
task_title: String::new(),
|
||||
task_desc: String::new(),
|
||||
task_assign: String::new(),
|
||||
task_field: 0,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
self.poll_tick = self.poll_tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Auto-refresh every ~5s at 20fps tick rate.
|
||||
pub fn should_poll(&self) -> bool {
|
||||
self.poll_tick > 0 && self.poll_tick.is_multiple_of(100)
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return CommsAction::Continue;
|
||||
}
|
||||
|
||||
// Modal key handling
|
||||
if self.show_send_modal {
|
||||
return self.handle_send_modal_key(key);
|
||||
}
|
||||
if self.show_task_modal {
|
||||
return self.handle_task_modal_key(key);
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Tab => {
|
||||
self.focus = match self.focus {
|
||||
CommsFocus::Topology => CommsFocus::EventList,
|
||||
CommsFocus::EventList => CommsFocus::Topology,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
self.show_send_modal = true;
|
||||
self.send_from.clear();
|
||||
self.send_to.clear();
|
||||
self.send_msg.clear();
|
||||
self.send_field = 0;
|
||||
}
|
||||
KeyCode::Char('t') => {
|
||||
self.show_task_modal = true;
|
||||
self.task_title.clear();
|
||||
self.task_desc.clear();
|
||||
self.task_assign.clear();
|
||||
self.task_field = 0;
|
||||
}
|
||||
KeyCode::Char('r') => return CommsAction::Refresh,
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 {
|
||||
self.events.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
};
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.events.len();
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_send_modal_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_send_modal = false;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.send_field = (self.send_field + 1) % 3;
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.send_field = if self.send_field == 0 {
|
||||
2
|
||||
} else {
|
||||
self.send_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.send_from.is_empty()
|
||||
&& !self.send_to.is_empty()
|
||||
&& !self.send_msg.is_empty()
|
||||
{
|
||||
self.show_send_modal = false;
|
||||
return CommsAction::SendMessage {
|
||||
from: self.send_from.clone(),
|
||||
to: self.send_to.clone(),
|
||||
msg: self.send_msg.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => match self.send_field {
|
||||
0 => self.send_from.push(c),
|
||||
1 => self.send_to.push(c),
|
||||
_ => self.send_msg.push(c),
|
||||
},
|
||||
KeyCode::Backspace => match self.send_field {
|
||||
0 => {
|
||||
self.send_from.pop();
|
||||
}
|
||||
1 => {
|
||||
self.send_to.pop();
|
||||
}
|
||||
_ => {
|
||||
self.send_msg.pop();
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_task_modal_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_task_modal = false;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.task_field = (self.task_field + 1) % 3;
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.task_field = if self.task_field == 0 {
|
||||
2
|
||||
} else {
|
||||
self.task_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.task_title.is_empty() {
|
||||
self.show_task_modal = false;
|
||||
return CommsAction::PostTask {
|
||||
title: self.task_title.clone(),
|
||||
desc: self.task_desc.clone(),
|
||||
assign: self.task_assign.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => match self.task_field {
|
||||
0 => self.task_title.push(c),
|
||||
1 => self.task_desc.push(c),
|
||||
_ => self.task_assign.push(c),
|
||||
},
|
||||
KeyCode::Backspace => match self.task_field {
|
||||
0 => {
|
||||
self.task_title.pop();
|
||||
}
|
||||
1 => {
|
||||
self.task_desc.pop();
|
||||
}
|
||||
_ => {
|
||||
self.task_assign.pop();
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
// ── Topology helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn root_nodes(&self) -> Vec<&CommsNode> {
|
||||
let child_ids: std::collections::HashSet<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "parent_child")
|
||||
.map(|e| e.to.as_str())
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| !child_ids.contains(n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn children_of(&self, id: &str) -> Vec<&CommsNode> {
|
||||
let child_ids: Vec<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "parent_child" && e.from == id)
|
||||
.map(|e| e.to.as_str())
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| child_ids.contains(&n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn peers_of(&self, id: &str) -> Vec<&CommsNode> {
|
||||
let peer_ids: std::collections::HashSet<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "peer")
|
||||
.filter_map(|e| {
|
||||
if e.from == id {
|
||||
Some(e.to.as_str())
|
||||
} else if e.to == id {
|
||||
Some(e.from.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| peer_ids.contains(n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Comms ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Percentage(35), // topology
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(4), // event list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Header
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" Agent Topology ({} agents, {} edges)",
|
||||
state.nodes.len(),
|
||||
state.edges.len()
|
||||
),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(""),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// Separator
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
"\u{2500}".repeat(inner.width as usize),
|
||||
theme::dim_style(),
|
||||
))),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
// Topology tree
|
||||
draw_topology(f, chunks[2], state);
|
||||
|
||||
// Separator
|
||||
let event_label = if state.focus == CommsFocus::EventList {
|
||||
" \u{25b6} Live Event Feed"
|
||||
} else {
|
||||
" Live Event Feed"
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
event_label,
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({} events)", state.events.len()),
|
||||
theme::dim_style(),
|
||||
),
|
||||
])),
|
||||
chunks[3],
|
||||
);
|
||||
|
||||
// Event list
|
||||
draw_event_list(f, chunks[4], state);
|
||||
|
||||
// Status message or hints
|
||||
let hint_text = if !state.status_msg.is_empty() {
|
||||
format!(
|
||||
" {} | [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll",
|
||||
state.status_msg
|
||||
)
|
||||
} else {
|
||||
" [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll".to_string()
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(hint_text, theme::hint_style()))),
|
||||
chunks[5],
|
||||
);
|
||||
|
||||
// Modal overlays
|
||||
if state.show_send_modal {
|
||||
draw_send_modal(f, area, state);
|
||||
}
|
||||
if state.show_task_modal {
|
||||
draw_task_modal(f, area, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
if state.loading && state.nodes.is_empty() {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading topology\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if state.nodes.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No agents running.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let focus_highlight = state.focus == CommsFocus::Topology;
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for root in state.root_nodes() {
|
||||
let state_style = state_color(&root.state);
|
||||
let mut spans = vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(format!("[{}]", &root.state), state_style),
|
||||
Span::styled(
|
||||
format!(" {} ", root.name),
|
||||
Style::default()
|
||||
.fg(if focus_highlight {
|
||||
theme::CYAN
|
||||
} else {
|
||||
theme::TEXT
|
||||
})
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!("({})", root.model), theme::dim_style()),
|
||||
];
|
||||
// Peer annotations
|
||||
for peer in state.peers_of(&root.id) {
|
||||
spans.push(Span::styled(
|
||||
format!(" \u{2194} {}", peer.name),
|
||||
Style::default().fg(theme::PURPLE),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
|
||||
// Children
|
||||
let children = state.children_of(&root.id);
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
let branch = if i < children.len() - 1 {
|
||||
"\u{251c}\u{2500}\u{2500} "
|
||||
} else {
|
||||
"\u{2514}\u{2500}\u{2500} "
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(branch, theme::dim_style()),
|
||||
Span::styled(format!("[{}]", child.state), state_color(&child.state)),
|
||||
Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)),
|
||||
Span::styled(format!("({})", child.model), theme::dim_style()),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
fn draw_event_list(f: &mut Frame, area: Rect, state: &mut CommsState) {
|
||||
if state.events.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No inter-agent events yet.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = state
|
||||
.events
|
||||
.iter()
|
||||
.map(|ev| {
|
||||
let kind_style = kind_color(&ev.kind);
|
||||
let kind_label = kind_short(&ev.kind);
|
||||
let target_part = if ev.target_name.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" \u{2192} {}", ev.target_name)
|
||||
};
|
||||
let detail = truncate(&ev.detail, 50);
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<8}", short_time(&ev.timestamp)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(format!(" {:<10}", kind_label), kind_style),
|
||||
Span::styled(
|
||||
format!(" {}", ev.source_name),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(target_part, Style::default().fg(theme::PURPLE)),
|
||||
Span::styled(format!(" {detail}"), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, area, &mut state.event_list_state);
|
||||
}
|
||||
|
||||
fn draw_send_modal(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
let modal = centered_rect(50, 12, area);
|
||||
f.render_widget(Clear, modal);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(" Send Message ", theme::title_style()))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::uniform(1));
|
||||
let inner = block.inner(modal);
|
||||
f.render_widget(block, modal);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let field_style = |idx: usize| {
|
||||
if state.send_field == idx {
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
theme::dim_style()
|
||||
}
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("From (agent ID):", field_style(0))),
|
||||
rows[0],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_from),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("To (agent ID):", field_style(1))),
|
||||
rows[2],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_to),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[3],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Message:", field_style(2))),
|
||||
rows[4],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_msg),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[5],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
"[Tab] field [Enter] send [Esc] cancel",
|
||||
theme::hint_style(),
|
||||
)),
|
||||
rows[6],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
let modal = centered_rect(50, 12, area);
|
||||
f.render_widget(Clear, modal);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(" Post Task ", theme::title_style()))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::uniform(1));
|
||||
let inner = block.inner(modal);
|
||||
f.render_widget(block, modal);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let field_style = |idx: usize| {
|
||||
if state.task_field == idx {
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
theme::dim_style()
|
||||
}
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Title:", field_style(0))),
|
||||
rows[0],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_title),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Description:", field_style(1))),
|
||||
rows[2],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_desc),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[3],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))),
|
||||
rows[4],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_assign),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[5],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
"[Tab] field [Enter] post [Esc] cancel",
|
||||
theme::hint_style(),
|
||||
)),
|
||||
rows[6],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn state_color(state: &str) -> Style {
|
||||
match state {
|
||||
"Running" => Style::default().fg(theme::GREEN),
|
||||
"Suspended" => Style::default().fg(theme::YELLOW),
|
||||
"Terminated" | "Crashed" => Style::default().fg(theme::RED),
|
||||
_ => theme::dim_style(),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_color(kind: &str) -> Style {
|
||||
match kind {
|
||||
"agent_message" => Style::default().fg(theme::CYAN),
|
||||
"agent_spawned" => Style::default().fg(theme::GREEN),
|
||||
"agent_terminated" => Style::default().fg(theme::RED),
|
||||
"task_posted" => Style::default().fg(theme::YELLOW),
|
||||
"task_claimed" => Style::default().fg(theme::CYAN),
|
||||
"task_completed" => Style::default().fg(theme::GREEN),
|
||||
_ => theme::dim_style(),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_short(kind: &str) -> &str {
|
||||
match kind {
|
||||
"agent_message" => "MSG",
|
||||
"agent_spawned" => "SPAWNED",
|
||||
"agent_terminated" => "KILLED",
|
||||
"task_posted" => "TASK+",
|
||||
"task_claimed" => "CLAIM",
|
||||
"task_completed" => "DONE",
|
||||
_ => kind,
|
||||
}
|
||||
}
|
||||
|
||||
fn short_time(ts: &str) -> String {
|
||||
// Extract HH:MM:SS from ISO-8601
|
||||
if let Some(t_pos) = ts.find('T') {
|
||||
let time_part = &ts[t_pos + 1..];
|
||||
if time_part.len() >= 8 {
|
||||
return time_part[..8].to_string();
|
||||
}
|
||||
}
|
||||
ts.chars().take(8).collect()
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
|
||||
let w = area.width * percent_x / 100;
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||
Rect::new(x, y, w, height.min(area.height))
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ impl State {
|
||||
|
||||
for m in &models {
|
||||
match m.tier {
|
||||
ModelTier::Fast | ModelTier::Local => {
|
||||
ModelTier::Fast | ModelTier::Local | ModelTier::Custom => {
|
||||
if fast.is_none() {
|
||||
fast = Some(&m.id);
|
||||
}
|
||||
@@ -461,6 +461,7 @@ fn tier_label(tier: ModelTier) -> &'static str {
|
||||
ModelTier::Balanced => "balanced",
|
||||
ModelTier::Fast => "fast",
|
||||
ModelTier::Local => "local",
|
||||
ModelTier::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
pub mod agents;
|
||||
pub mod audit;
|
||||
pub mod channels;
|
||||
pub mod comms;
|
||||
pub mod chat;
|
||||
pub mod dashboard;
|
||||
pub mod extensions;
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub const PURPLE: Color = Color::Rgb(168, 85, 247); // #A855F7 — decorators
|
||||
|
||||
pub const CYAN: Color = BLUE;
|
||||
pub const DIM: Color = TEXT_SECONDARY;
|
||||
pub const TEXT: Color = TEXT_PRIMARY;
|
||||
|
||||
// ── Reusable styles ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,21 @@ impl HandRegistry {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Update config for an active hand instance.
|
||||
pub fn update_config(
|
||||
&self,
|
||||
instance_id: Uuid,
|
||||
config: HashMap<String, serde_json::Value>,
|
||||
) -> HandResult<()> {
|
||||
let mut entry = self
|
||||
.instances
|
||||
.get_mut(&instance_id)
|
||||
.ok_or(HandError::InstanceNotFound(instance_id))?;
|
||||
entry.config = config;
|
||||
entry.updated_at = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark an instance as errored.
|
||||
pub fn set_error(&self, instance_id: Uuid, message: String) -> HandResult<()> {
|
||||
let mut entry = self
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -181,6 +181,7 @@ mod tests {
|
||||
require_approval: vec!["file_write".to_string(), "file_delete".to_string()],
|
||||
timeout_secs: 30,
|
||||
auto_approve_autonomous: false,
|
||||
auto_approve: false,
|
||||
};
|
||||
let mgr = ApprovalManager::new(policy);
|
||||
assert!(mgr.requires_approval("file_write"));
|
||||
@@ -258,6 +259,7 @@ mod tests {
|
||||
require_approval: vec!["file_write".to_string()],
|
||||
timeout_secs: 120,
|
||||
auto_approve_autonomous: true,
|
||||
auto_approve: false,
|
||||
};
|
||||
mgr.update_policy(new_policy);
|
||||
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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. Use `now` as the base so the
|
||||
// next fire time is computed strictly after the current moment.
|
||||
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, now));
|
||||
}
|
||||
}
|
||||
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!(
|
||||
@@ -279,7 +287,8 @@ impl CronScheduler {
|
||||
);
|
||||
meta.job.enabled = false;
|
||||
} else {
|
||||
meta.job.next_run = Some(compute_next_run(&meta.job.schedule));
|
||||
meta.job.next_run =
|
||||
Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,20 +298,58 @@ impl CronScheduler {
|
||||
// compute_next_run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the next fire time for a schedule.
|
||||
/// Compute the next fire time for a schedule, based on `now`.
|
||||
///
|
||||
/// - `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> {
|
||||
compute_next_run_after(schedule, Utc::now())
|
||||
}
|
||||
|
||||
/// Compute the next fire time for a schedule, strictly after `after`.
|
||||
///
|
||||
/// Uses `after + 1 second` as the base time so the `cron` crate's
|
||||
/// inclusive `.after()` always returns a strictly future time. Without
|
||||
/// this offset, calling `compute_next_run` right after a job fires can
|
||||
/// return the same minute (or even the same second), causing the
|
||||
/// scheduler to re-fire immediately.
|
||||
pub fn compute_next_run_after(
|
||||
schedule: &CronSchedule,
|
||||
after: chrono::DateTime<Utc>,
|
||||
) -> 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::Every { every_secs } => after + Duration::seconds(*every_secs as i64),
|
||||
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(),
|
||||
};
|
||||
|
||||
// Add 1 second so `.after()` (inclusive) skips the current second.
|
||||
let base = after + Duration::seconds(1);
|
||||
|
||||
match seven_field.parse::<cron::Schedule>() {
|
||||
Ok(sched) => sched
|
||||
.after(&base)
|
||||
.next()
|
||||
.unwrap_or_else(|| after + Duration::hours(1)),
|
||||
Err(e) => {
|
||||
warn!("Failed to parse cron expression '{}': {}", expr, e);
|
||||
after + Duration::hours(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,22 +702,72 @@ 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 -------------------------
|
||||
|
||||
#[test]
|
||||
fn test_compute_next_run_after_skips_current_second() {
|
||||
// A "every 4 hours" cron: next_run should be >= 4 hours from now,
|
||||
// not in the same minute (the bug from #55).
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "0 */4 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let now = Utc::now();
|
||||
let next = compute_next_run_after(&schedule, now);
|
||||
// Must be strictly after `now` and at least ~1 hour away
|
||||
// (the closest 4-hourly boundary is at least minutes away).
|
||||
assert!(next > now, "next_run should be strictly after now");
|
||||
let diff = next - now;
|
||||
assert!(
|
||||
diff.num_minutes() >= 1,
|
||||
"Expected next_run at least 1 min away, got {} seconds",
|
||||
diff.num_seconds()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_failure_truncates_long_error() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
|
||||
@@ -14,7 +14,9 @@ use crate::triggers::{TriggerEngine, TriggerId, TriggerPattern};
|
||||
use crate::workflow::{StepAgent, Workflow, WorkflowEngine, WorkflowId, WorkflowRunId};
|
||||
|
||||
use openfang_memory::MemorySubstrate;
|
||||
use openfang_runtime::agent_loop::{run_agent_loop, run_agent_loop_streaming, AgentLoopResult};
|
||||
use openfang_runtime::agent_loop::{
|
||||
run_agent_loop, run_agent_loop_streaming, strip_provider_prefix, AgentLoopResult,
|
||||
};
|
||||
use openfang_runtime::audit::AuditLog;
|
||||
use openfang_runtime::drivers;
|
||||
use openfang_runtime::kernel_handle::{self, KernelHandle};
|
||||
@@ -127,6 +129,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 +415,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)
|
||||
@@ -448,7 +448,7 @@ fn read_identity_file(workspace: &Path, filename: &str) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
if content.len() > MAX_IDENTITY_FILE_BYTES {
|
||||
Some(content[..MAX_IDENTITY_FILE_BYTES].to_string())
|
||||
Some(openfang_types::truncate_str(&content, MAX_IDENTITY_FILE_BYTES).to_string())
|
||||
} else {
|
||||
Some(content)
|
||||
}
|
||||
@@ -485,6 +485,11 @@ impl OpenFangKernel {
|
||||
pub fn boot_with_config(mut config: KernelConfig) -> KernelResult<Self> {
|
||||
use openfang_types::config::KernelMode;
|
||||
|
||||
// Env var overrides — useful for Docker where config.toml is baked in.
|
||||
if let Ok(listen) = std::env::var("OPENFANG_LISTEN") {
|
||||
config.api_listen = listen;
|
||||
}
|
||||
|
||||
// Clamp configuration bounds to prevent zero-value or unbounded misconfigs
|
||||
config.clamp_bounds();
|
||||
|
||||
@@ -590,9 +595,19 @@ 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()
|
||||
);
|
||||
}
|
||||
// Load user's custom models from ~/.openfang/custom_models.json
|
||||
let custom_models_path = config.home_dir.join("custom_models.json");
|
||||
model_catalog.load_custom_models(&custom_models_path);
|
||||
let available_count = model_catalog.available_models().len();
|
||||
let total_count = model_catalog.list_models().len();
|
||||
let local_count = model_catalog
|
||||
@@ -867,6 +882,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(),
|
||||
};
|
||||
|
||||
@@ -896,6 +912,29 @@ impl OpenFangKernel {
|
||||
restored_entry.manifest.exec_policy =
|
||||
Some(kernel.config.exec_policy.clone());
|
||||
}
|
||||
|
||||
// Apply global budget defaults to restored agents
|
||||
apply_budget_defaults(
|
||||
&kernel.config.budget,
|
||||
&mut restored_entry.manifest.resources,
|
||||
);
|
||||
|
||||
// Apply default_model to restored agents (same logic as spawn)
|
||||
if restored_entry.manifest.model.api_key_env.is_none()
|
||||
&& restored_entry.manifest.model.base_url.is_none()
|
||||
{
|
||||
let dm = &kernel.config.default_model;
|
||||
if !dm.provider.is_empty() {
|
||||
restored_entry.manifest.model.provider = dm.provider.clone();
|
||||
}
|
||||
if !dm.model.is_empty() {
|
||||
restored_entry.manifest.model.model = dm.model.clone();
|
||||
}
|
||||
if dm.base_url.is_some() {
|
||||
restored_entry.manifest.model.base_url = dm.base_url.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = kernel.registry.register(restored_entry) {
|
||||
tracing::warn!(agent = %name, "Failed to restore agent: {e}");
|
||||
} else {
|
||||
@@ -958,13 +997,33 @@ impl OpenFangKernel {
|
||||
manifest.exec_policy = Some(self.config.exec_policy.clone());
|
||||
}
|
||||
|
||||
// Create workspace directory for the agent
|
||||
// Overlay kernel default_model onto agent if no custom key/url is set.
|
||||
// This ensures agents respect the user's configured provider from `openfang init`.
|
||||
if manifest.model.api_key_env.is_none() && manifest.model.base_url.is_none() {
|
||||
let dm = &self.config.default_model;
|
||||
if !dm.provider.is_empty() {
|
||||
manifest.model.provider = dm.provider.clone();
|
||||
}
|
||||
if !dm.model.is_empty() {
|
||||
manifest.model.model = dm.model.clone();
|
||||
}
|
||||
if dm.base_url.is_some() {
|
||||
manifest.model.base_url = dm.base_url.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize: strip provider prefix from model name if present
|
||||
let normalized = strip_provider_prefix(&manifest.model.model, &manifest.model.provider);
|
||||
if normalized != manifest.model.model {
|
||||
manifest.model.model = normalized;
|
||||
}
|
||||
|
||||
// Apply global budget defaults to agent resource quotas
|
||||
apply_budget_defaults(&self.config.budget, &mut manifest.resources);
|
||||
|
||||
// Create workspace directory for the agent (name-based, so SOUL.md survives recreation)
|
||||
let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| {
|
||||
self.config.effective_workspaces_dir().join(format!(
|
||||
"{}-{}",
|
||||
&name,
|
||||
&agent_id.0.to_string()[..8]
|
||||
))
|
||||
self.config.effective_workspaces_dir().join(&name)
|
||||
});
|
||||
ensure_workspace(&workspace_dir)?;
|
||||
if manifest.generate_identity_files {
|
||||
@@ -1071,12 +1130,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.
|
||||
@@ -1278,11 +1347,7 @@ impl OpenFangKernel {
|
||||
|
||||
// Lazy backfill: create workspace for existing agents spawned before workspaces
|
||||
if manifest.workspace.is_none() {
|
||||
let workspace_dir = self.config.effective_workspaces_dir().join(format!(
|
||||
"{}-{}",
|
||||
&manifest.name,
|
||||
&agent_id.0.to_string()[..8]
|
||||
));
|
||||
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
|
||||
if let Err(e) = ensure_workspace(&workspace_dir) {
|
||||
warn!(agent_id = %agent_id, "Failed to backfill workspace (streaming): {e}");
|
||||
} else {
|
||||
@@ -1304,6 +1369,19 @@ impl OpenFangKernel {
|
||||
.flatten()
|
||||
.and_then(|v| v.as_str().map(String::from));
|
||||
|
||||
let peer_agents: Vec<(String, String, String)> = self
|
||||
.registry
|
||||
.list()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
(
|
||||
a.name.clone(),
|
||||
format!("{:?}", a.state),
|
||||
a.manifest.model.model.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let prompt_ctx = openfang_runtime::prompt_builder::PromptContext {
|
||||
agent_name: manifest.name.clone(),
|
||||
agent_description: manifest.description.clone(),
|
||||
@@ -1312,7 +1390,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()
|
||||
@@ -1368,9 +1446,20 @@ impl OpenFangKernel {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
peer_agents,
|
||||
};
|
||||
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);
|
||||
@@ -1725,11 +1814,7 @@ impl OpenFangKernel {
|
||||
|
||||
// Lazy backfill: create workspace for existing agents spawned before workspaces
|
||||
if manifest.workspace.is_none() {
|
||||
let workspace_dir = self.config.effective_workspaces_dir().join(format!(
|
||||
"{}-{}",
|
||||
&manifest.name,
|
||||
&agent_id.0.to_string()[..8]
|
||||
));
|
||||
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
|
||||
if let Err(e) = ensure_workspace(&workspace_dir) {
|
||||
warn!(agent_id = %agent_id, "Failed to backfill workspace: {e}");
|
||||
} else {
|
||||
@@ -1752,6 +1837,19 @@ impl OpenFangKernel {
|
||||
.flatten()
|
||||
.and_then(|v| v.as_str().map(String::from));
|
||||
|
||||
let peer_agents: Vec<(String, String, String)> = self
|
||||
.registry
|
||||
.list()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
(
|
||||
a.name.clone(),
|
||||
format!("{:?}", a.state),
|
||||
a.manifest.model.model.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let prompt_ctx = openfang_runtime::prompt_builder::PromptContext {
|
||||
agent_name: manifest.name.clone(),
|
||||
agent_description: manifest.description.clone(),
|
||||
@@ -1760,7 +1858,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()
|
||||
@@ -1816,9 +1914,20 @@ impl OpenFangKernel {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
peer_agents,
|
||||
};
|
||||
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;
|
||||
@@ -1839,7 +1948,7 @@ impl OpenFangKernel {
|
||||
router.resolve_aliases(&self.model_catalog.read().unwrap_or_else(|e| e.into_inner()));
|
||||
// Build a probe request to score complexity
|
||||
let probe = CompletionRequest {
|
||||
model: manifest.model.model.clone(),
|
||||
model: strip_provider_prefix(&manifest.model.model, &manifest.model.provider),
|
||||
messages: vec![openfang_types::message::Message::user(message)],
|
||||
tools: tools.clone(),
|
||||
max_tokens: manifest.model.max_tokens,
|
||||
@@ -2025,6 +2134,35 @@ impl OpenFangKernel {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear ALL conversation history for an agent (sessions + canonical).
|
||||
///
|
||||
/// Creates a fresh empty session afterward so the agent is still usable.
|
||||
pub fn clear_agent_history(&self, agent_id: AgentId) -> KernelResult<()> {
|
||||
let _entry = self.registry.get(agent_id).ok_or_else(|| {
|
||||
KernelError::OpenFang(OpenFangError::AgentNotFound(agent_id.to_string()))
|
||||
})?;
|
||||
|
||||
// Delete all regular sessions
|
||||
let _ = self.memory.delete_agent_sessions(agent_id);
|
||||
|
||||
// Delete canonical (cross-channel) session
|
||||
let _ = self.memory.delete_canonical_session(agent_id);
|
||||
|
||||
// Create a fresh session
|
||||
let new_session = self
|
||||
.memory
|
||||
.create_session(agent_id)
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
|
||||
// Update registry with new session ID
|
||||
self.registry
|
||||
.update_session_id(agent_id, new_session.id)
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
|
||||
info!(agent_id = %agent_id, "All agent history cleared");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all sessions for a specific agent.
|
||||
pub fn list_agent_sessions(&self, agent_id: AgentId) -> KernelResult<Vec<serde_json::Value>> {
|
||||
// Verify agent exists
|
||||
@@ -2190,16 +2328,48 @@ impl OpenFangKernel {
|
||||
|
||||
/// Switch an agent's model.
|
||||
pub fn set_agent_model(&self, agent_id: AgentId, model: &str) -> KernelResult<()> {
|
||||
self.registry
|
||||
.update_model(agent_id, model.to_string())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
// Resolve provider from model catalog so switching models also switches provider
|
||||
let resolved_provider = self
|
||||
.model_catalog
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|catalog| {
|
||||
catalog
|
||||
.find_model(model)
|
||||
.map(|entry| entry.provider.clone())
|
||||
});
|
||||
|
||||
// If catalog lookup failed, try to infer provider from model name prefix
|
||||
let provider = resolved_provider.or_else(|| infer_provider_from_model(model));
|
||||
|
||||
// Strip the provider prefix from the model name (e.g. "openrouter/deepseek/deepseek-chat" → "deepseek/deepseek-chat")
|
||||
let normalized_model = if let Some(ref prov) = provider {
|
||||
strip_provider_prefix(model, prov)
|
||||
} else {
|
||||
model.to_string()
|
||||
};
|
||||
|
||||
if let Some(provider) = provider {
|
||||
self.registry
|
||||
.update_model_and_provider(agent_id, normalized_model.clone(), provider.clone())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
info!(agent_id = %agent_id, model = %normalized_model, provider = %provider, "Agent model+provider updated");
|
||||
} else {
|
||||
self.registry
|
||||
.update_model(agent_id, normalized_model.clone())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
info!(agent_id = %agent_id, model = %normalized_model, "Agent model updated (provider unchanged)");
|
||||
}
|
||||
|
||||
// Persist the updated entry
|
||||
if let Some(entry) = self.registry.get(agent_id) {
|
||||
let _ = self.memory.save_agent(&entry);
|
||||
}
|
||||
|
||||
info!(agent_id = %agent_id, model = %model, "Agent model updated");
|
||||
// Clear canonical session to prevent memory poisoning from old model's responses
|
||||
let _ = self.memory.delete_canonical_session(agent_id);
|
||||
debug!(agent_id = %agent_id, "Cleared canonical session after model switch");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2272,6 +2442,30 @@ impl OpenFangKernel {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's tool allowlist and/or blocklist.
|
||||
pub fn set_agent_tool_filters(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
allowlist: Option<Vec<String>>,
|
||||
blocklist: Option<Vec<String>>,
|
||||
) -> KernelResult<()> {
|
||||
self.registry
|
||||
.update_tool_filters(agent_id, allowlist.clone(), blocklist.clone())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
|
||||
if let Some(entry) = self.registry.get(agent_id) {
|
||||
let _ = self.memory.save_agent(&entry);
|
||||
}
|
||||
|
||||
info!(
|
||||
agent_id = %agent_id,
|
||||
allowlist = ?allowlist,
|
||||
blocklist = ?blocklist,
|
||||
"Agent tool filters updated"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get session token usage and estimated cost for an agent.
|
||||
pub fn session_usage_cost(&self, agent_id: AgentId) -> KernelResult<(u64, u64, f64)> {
|
||||
let entry = self.registry.get(agent_id).ok_or_else(|| {
|
||||
@@ -2722,6 +2916,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
|
||||
@@ -3034,6 +3236,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 {
|
||||
@@ -3075,15 +3279,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}");
|
||||
@@ -3403,39 +3616,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| {
|
||||
@@ -3864,6 +4088,19 @@ impl OpenFangKernel {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply per-agent tool allowlist/blocklist (manifest-level filtering)
|
||||
let (tool_allowlist, tool_blocklist) = entry
|
||||
.as_ref()
|
||||
.map(|e| (e.manifest.tool_allowlist.clone(), e.manifest.tool_blocklist.clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
if !tool_allowlist.is_empty() {
|
||||
all_tools.retain(|t| tool_allowlist.iter().any(|a| a == &t.name));
|
||||
}
|
||||
if !tool_blocklist.is_empty() {
|
||||
all_tools.retain(|t| !tool_blocklist.iter().any(|b| b == &t.name));
|
||||
}
|
||||
|
||||
let caps = self.capabilities.list(agent_id);
|
||||
|
||||
// If agent has ToolAll, return all tools
|
||||
@@ -4001,7 +4238,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
|
||||
}
|
||||
|
||||
@@ -4128,6 +4376,83 @@ fn manifest_to_capabilities(manifest: &AgentManifest) -> Vec<Capability> {
|
||||
caps
|
||||
}
|
||||
|
||||
/// Apply global budget defaults to an agent's resource quota.
|
||||
///
|
||||
/// When the global budget config specifies limits and the agent still has
|
||||
/// the built-in defaults, override them so agents respect the user's config.
|
||||
fn apply_budget_defaults(
|
||||
budget: &openfang_types::config::BudgetConfig,
|
||||
resources: &mut ResourceQuota,
|
||||
) {
|
||||
// Only override hourly if agent has the built-in default (1.0) and global is set
|
||||
if budget.max_hourly_usd > 0.0 && resources.max_cost_per_hour_usd == 1.0 {
|
||||
resources.max_cost_per_hour_usd = budget.max_hourly_usd;
|
||||
}
|
||||
// Only override daily/monthly if agent has unlimited (0.0) and global is set
|
||||
if budget.max_daily_usd > 0.0 && resources.max_cost_per_day_usd == 0.0 {
|
||||
resources.max_cost_per_day_usd = budget.max_daily_usd;
|
||||
}
|
||||
if budget.max_monthly_usd > 0.0 && resources.max_cost_per_month_usd == 0.0 {
|
||||
resources.max_cost_per_month_usd = budget.max_monthly_usd;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -4137,6 +4462,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(
|
||||
@@ -4562,6 +4955,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,
|
||||
@@ -4693,6 +5122,8 @@ mod tests {
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
};
|
||||
manifest.capabilities.tools = vec!["file_read".to_string(), "web_fetch".to_string()];
|
||||
manifest.capabilities.agent_spawn = true;
|
||||
@@ -4728,6 +5159,8 @@ mod tests {
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -160,6 +160,23 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's model AND provider together.
|
||||
pub fn update_model_and_provider(
|
||||
&self,
|
||||
id: AgentId,
|
||||
new_model: String,
|
||||
new_provider: String,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
entry.manifest.model.model = new_model;
|
||||
entry.manifest.model.provider = new_provider;
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's skill allowlist.
|
||||
pub fn update_skills(&self, id: AgentId, skills: Vec<String>) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
@@ -182,6 +199,27 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's tool allowlist and blocklist.
|
||||
pub fn update_tool_filters(
|
||||
&self,
|
||||
id: AgentId,
|
||||
allowlist: Option<Vec<String>>,
|
||||
blocklist: Option<Vec<String>>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
if let Some(al) = allowlist {
|
||||
entry.manifest.tool_allowlist = al;
|
||||
}
|
||||
if let Some(bl) = blocklist {
|
||||
entry.manifest.tool_blocklist = bl;
|
||||
}
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's system prompt (hot-swap, takes effect on next message).
|
||||
pub fn update_system_prompt(&self, id: AgentId, new_prompt: String) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
@@ -224,6 +262,31 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's resource quota (budget limits).
|
||||
pub fn update_resources(
|
||||
&self,
|
||||
id: AgentId,
|
||||
hourly: Option<f64>,
|
||||
daily: Option<f64>,
|
||||
monthly: Option<f64>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
if let Some(v) = hourly {
|
||||
entry.manifest.resources.max_cost_per_hour_usd = v;
|
||||
}
|
||||
if let Some(v) = daily {
|
||||
entry.manifest.resources.max_cost_per_day_usd = v;
|
||||
}
|
||||
if let Some(v) = monthly {
|
||||
entry.manifest.resources.max_cost_per_month_usd = v;
|
||||
}
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark an agent's onboarding as complete.
|
||||
pub fn mark_onboarding_complete(&self, id: AgentId) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
@@ -278,6 +341,8 @@ mod tests {
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
},
|
||||
state: AgentState::Created,
|
||||
mode: AgentMode::default(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -179,6 +179,8 @@ impl SetupWizard {
|
||||
profile: None,
|
||||
fallback_models: vec![],
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
};
|
||||
|
||||
let skills_to_install: Vec<String> = intent
|
||||
|
||||
@@ -128,6 +128,20 @@ impl SessionStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the canonical (cross-channel) session for an agent.
|
||||
pub fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM canonical_sessions WHERE agent_id = ?1",
|
||||
rusqlite::params![agent_id.0.to_string()],
|
||||
)
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all sessions with metadata (session_id, agent_id, message_count, created_at).
|
||||
pub fn list_sessions(&self) -> OpenFangResult<Vec<serde_json::Value>> {
|
||||
let conn = self
|
||||
@@ -428,19 +442,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;
|
||||
@@ -551,6 +570,7 @@ impl SessionStore {
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name: _,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
|
||||
@@ -164,6 +164,16 @@ impl MemorySubstrate {
|
||||
self.sessions.delete_session(session_id)
|
||||
}
|
||||
|
||||
/// Delete all sessions belonging to an agent.
|
||||
pub fn delete_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult<()> {
|
||||
self.sessions.delete_agent_sessions(agent_id)
|
||||
}
|
||||
|
||||
/// Delete the canonical (cross-channel) session for an agent.
|
||||
pub fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> {
|
||||
self.sessions.delete_canonical_session(agent_id)
|
||||
}
|
||||
|
||||
/// Set or clear a session label.
|
||||
pub fn set_session_label(
|
||||
&self,
|
||||
|
||||
@@ -51,6 +51,20 @@ const MAX_CONTINUATIONS: u32 = 5;
|
||||
/// Maximum message history size before auto-trimming to prevent context overflow.
|
||||
const MAX_HISTORY_MESSAGES: usize = 20;
|
||||
|
||||
/// Strip a provider prefix from a model ID before sending to the API.
|
||||
///
|
||||
/// Many models are stored as `provider/org/model` (e.g. `openrouter/google/gemini-2.5-flash`)
|
||||
/// but the upstream API expects just `org/model`. This also handles special routers
|
||||
/// like `openrouter/auto` → `auto`.
|
||||
pub fn strip_provider_prefix(model: &str, provider: &str) -> String {
|
||||
let prefix = format!("{}/", provider);
|
||||
if model.starts_with(&prefix) {
|
||||
model[prefix.len()..].to_string()
|
||||
} else {
|
||||
model.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Default context window size (tokens) for token-based trimming.
|
||||
const DEFAULT_CONTEXT_WINDOW: usize = 200_000;
|
||||
|
||||
@@ -215,6 +229,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 +280,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");
|
||||
@@ -267,8 +295,11 @@ pub async fn run_agent_loop(
|
||||
// Context guard: compact oversized tool results before LLM call
|
||||
apply_context_guard(&mut messages, &context_budget, available_tools);
|
||||
|
||||
// Strip provider prefix: "openrouter/google/gemini-2.5-flash" → "google/gemini-2.5-flash"
|
||||
let api_model = strip_provider_prefix(&manifest.model.model, &manifest.model.provider);
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: manifest.model.model.clone(),
|
||||
model: api_model,
|
||||
messages: messages.clone(),
|
||||
tools: available_tools.to_vec(),
|
||||
max_tokens: manifest.model.max_tokens,
|
||||
@@ -370,7 +401,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 +502,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();
|
||||
@@ -521,6 +553,7 @@ pub async fn run_agent_loop(
|
||||
warn!(tool = %tool_call.name, "Tool call blocked by loop guard");
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: msg.clone(),
|
||||
is_error: true,
|
||||
});
|
||||
@@ -558,6 +591,7 @@ pub async fn run_agent_loop(
|
||||
if let Err(reason) = hook_reg.fire(&ctx) {
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: format!(
|
||||
"Hook blocked tool '{}': {}",
|
||||
tool_call.name, reason
|
||||
@@ -641,11 +675,28 @@ pub async fn run_agent_loop(
|
||||
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: result.tool_use_id,
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: final_content,
|
||||
is_error: result.is_error,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 +1126,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 +1175,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");
|
||||
@@ -1141,8 +1206,11 @@ pub async fn run_agent_loop_streaming(
|
||||
// Context guard: compact oversized tool results before LLM call
|
||||
apply_context_guard(&mut messages, &context_budget, available_tools);
|
||||
|
||||
// Strip provider prefix: "openrouter/google/gemini-2.5-flash" → "google/gemini-2.5-flash"
|
||||
let api_model = strip_provider_prefix(&manifest.model.model, &manifest.model.provider);
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: manifest.model.model.clone(),
|
||||
model: api_model,
|
||||
messages: messages.clone(),
|
||||
tools: available_tools.to_vec(),
|
||||
max_tokens: manifest.model.max_tokens,
|
||||
@@ -1247,7 +1315,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 +1415,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();
|
||||
|
||||
@@ -1393,6 +1462,7 @@ pub async fn run_agent_loop_streaming(
|
||||
warn!(tool = %tool_call.name, "Tool call blocked by loop guard (streaming)");
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: msg.clone(),
|
||||
is_error: true,
|
||||
});
|
||||
@@ -1430,6 +1500,7 @@ pub async fn run_agent_loop_streaming(
|
||||
if let Err(reason) = hook_reg.fire(&ctx) {
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: format!(
|
||||
"Hook blocked tool '{}': {}",
|
||||
tool_call.name, reason
|
||||
@@ -1527,11 +1598,28 @@ pub async fn run_agent_loop_streaming(
|
||||
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: result.tool_use_id,
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: final_content,
|
||||
is_error: result.is_error,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 +2367,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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -425,8 +426,14 @@ async fn summarize_messages(
|
||||
let effective_max = (config.max_chunk_chars as f64 / config.safety_margin) as usize;
|
||||
if conversation_text.len() > effective_max {
|
||||
// Keep the tail (most recent) which is usually more important
|
||||
conversation_text =
|
||||
conversation_text[conversation_text.len() - effective_max..].to_string();
|
||||
let start = conversation_text.len() - effective_max;
|
||||
// Find valid char boundary at or after start
|
||||
let safe_start = if conversation_text.is_char_boundary(start) {
|
||||
start
|
||||
} else {
|
||||
conversation_text[start..].char_indices().next().map(|(i, _)| start + i).unwrap_or(conversation_text.len())
|
||||
};
|
||||
conversation_text = conversation_text[safe_start..].to_string();
|
||||
}
|
||||
|
||||
let summarize_prompt = format!(
|
||||
@@ -849,6 +856,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "Search results here".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -886,7 +894,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()
|
||||
};
|
||||
@@ -1183,6 +1191,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "Results found".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1323,6 +1332,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: tool_content,
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1342,6 +1352,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t2".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: large_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1365,6 +1376,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t3".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: short_result.to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
|
||||
@@ -64,12 +64,23 @@ pub fn truncate_tool_result_dynamic(content: &str, budget: &ContextBudget) -> St
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
// Find last newline before the cap to break cleanly
|
||||
let search_start = cap.saturating_sub(200);
|
||||
let break_point = content[search_start..cap]
|
||||
// Find last newline before the cap to break cleanly (char-boundary safe)
|
||||
let safe_cap = if content.is_char_boundary(cap) {
|
||||
cap
|
||||
} else {
|
||||
content[..cap].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
let search_start = safe_cap.saturating_sub(200);
|
||||
let break_point = content[search_start..safe_cap]
|
||||
.rfind('\n')
|
||||
.map(|pos| search_start + pos)
|
||||
.unwrap_or(cap.saturating_sub(100));
|
||||
.unwrap_or(safe_cap.saturating_sub(100));
|
||||
// Ensure break_point is also a char boundary
|
||||
let break_point = if content.is_char_boundary(break_point) {
|
||||
break_point
|
||||
} else {
|
||||
content[..break_point].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
|
||||
format!(
|
||||
"{}\n\n[TRUNCATED: result was {} chars, showing first {} (budget: {}% of {}K context window)]",
|
||||
@@ -248,6 +259,7 @@ mod tests {
|
||||
role: openfang_types::message::Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: big_result.clone(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -256,6 +268,7 @@ mod tests {
|
||||
role: openfang_types::message::Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t2".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: big_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
|
||||
@@ -103,11 +103,17 @@ pub fn recover_from_overflow(
|
||||
if let ContentBlock::ToolResult { content, .. } = block {
|
||||
if content.len() > tool_truncation_limit {
|
||||
let keep = tool_truncation_limit.saturating_sub(80);
|
||||
// Find a valid char boundary at or before `keep`
|
||||
let safe_keep = if content.is_char_boundary(keep) {
|
||||
keep
|
||||
} else {
|
||||
content[..keep].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
*content = format!(
|
||||
"{}\n\n[OVERFLOW RECOVERY: truncated from {} to {} chars]",
|
||||
&content[..keep],
|
||||
&content[..safe_keep],
|
||||
content.len(),
|
||||
keep
|
||||
safe_keep
|
||||
);
|
||||
truncated += 1;
|
||||
}
|
||||
@@ -202,6 +208,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: big_result.clone(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -210,6 +217,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t2".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: big_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -576,6 +576,7 @@ fn convert_message(msg: &Message) -> ApiMessage {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} => Some(ApiContentBlock::ToolResult {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
content: content.clone(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ use tracing::warn;
|
||||
|
||||
/// A driver that wraps multiple LLM drivers and tries each in order.
|
||||
///
|
||||
/// On failure, moves to the next driver. Rate-limit and overload errors
|
||||
/// are bubbled up for retry logic to handle.
|
||||
/// On failure (including rate-limit and overload), moves to the next driver.
|
||||
/// Only returns an error when ALL drivers in the chain are exhausted.
|
||||
pub struct FallbackDriver {
|
||||
drivers: Vec<Arc<dyn LlmDriver>>,
|
||||
}
|
||||
@@ -34,8 +34,12 @@ impl LlmDriver for FallbackDriver {
|
||||
match driver.complete(request.clone()).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(e @ LlmError::RateLimited { .. }) | Err(e @ LlmError::Overloaded { .. }) => {
|
||||
// Retryable errors — bubble up for the retry loop to handle
|
||||
return Err(e);
|
||||
warn!(
|
||||
driver_index = i,
|
||||
error = %e,
|
||||
"Driver rate-limited/overloaded, trying next fallback"
|
||||
);
|
||||
last_error = Some(e);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -65,7 +69,12 @@ impl LlmDriver for FallbackDriver {
|
||||
match driver.stream(request.clone(), tx.clone()).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(e @ LlmError::RateLimited { .. }) | Err(e @ LlmError::Overloaded { .. }) => {
|
||||
return Err(e);
|
||||
warn!(
|
||||
driver_index = i,
|
||||
error = %e,
|
||||
"Driver rate-limited/overloaded (stream), trying next fallback"
|
||||
);
|
||||
last_error = Some(e);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -166,7 +175,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rate_limit_bubbles_up() {
|
||||
async fn test_rate_limit_falls_through() {
|
||||
struct RateLimitDriver;
|
||||
|
||||
#[async_trait]
|
||||
@@ -186,7 +195,33 @@ mod tests {
|
||||
Arc::new(OkDriver) as Arc<dyn LlmDriver>,
|
||||
]);
|
||||
let result = driver.complete(test_request()).await;
|
||||
// Rate limit should NOT fall through to next driver
|
||||
// Rate limit should fall through to the OkDriver fallback
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().text(), "OK");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rate_limit_all_fail() {
|
||||
struct RateLimitDriver;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for RateLimitDriver {
|
||||
async fn complete(
|
||||
&self,
|
||||
_req: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
Err(LlmError::RateLimited {
|
||||
retry_after_ms: 5000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let driver = FallbackDriver::new(vec![
|
||||
Arc::new(RateLimitDriver) as Arc<dyn LlmDriver>,
|
||||
Arc::new(RateLimitDriver) as Arc<dyn LlmDriver>,
|
||||
]);
|
||||
let result = driver.complete(test_request()).await;
|
||||
// All drivers rate-limited — error should bubble up
|
||||
assert!(matches!(result, Err(LlmError::RateLimited { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,10 +213,17 @@ fn convert_messages(
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::ToolResult { content, .. } => {
|
||||
ContentBlock::ToolResult {
|
||||
content, tool_name, ..
|
||||
} => {
|
||||
let fn_name = if tool_name.is_empty() {
|
||||
"unknown_function".to_string()
|
||||
} else {
|
||||
tool_name.clone()
|
||||
};
|
||||
parts.push(GeminiPart::FunctionResponse {
|
||||
function_response: GeminiFunctionResponseData {
|
||||
name: String::new(),
|
||||
name: fn_name,
|
||||
response: serde_json::json!({ "result": content }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -14,8 +15,10 @@ use crate::llm_driver::{DriverConfig, LlmDriver, LlmError};
|
||||
use openfang_types::model_catalog::{
|
||||
AI21_BASE_URL, ANTHROPIC_BASE_URL, CEREBRAS_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL,
|
||||
FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL,
|
||||
MISTRAL_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL,
|
||||
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
|
||||
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, XAI_BASE_URL,
|
||||
ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -130,6 +133,46 @@ 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",
|
||||
key_required: true,
|
||||
}),
|
||||
"qwen" | "dashscope" => Some(ProviderDefaults {
|
||||
base_url: QWEN_BASE_URL,
|
||||
api_key_env: "DASHSCOPE_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"minimax" => Some(ProviderDefaults {
|
||||
base_url: MINIMAX_BASE_URL,
|
||||
api_key_env: "MINIMAX_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"zhipu" | "glm" => Some(ProviderDefaults {
|
||||
base_url: ZHIPU_BASE_URL,
|
||||
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",
|
||||
key_required: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -195,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.
|
||||
@@ -255,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
|
||||
),
|
||||
})
|
||||
@@ -286,6 +354,14 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
"xai",
|
||||
"replicate",
|
||||
"github-copilot",
|
||||
"moonshot",
|
||||
"qwen",
|
||||
"minimax",
|
||||
"zhipu",
|
||||
"zhipu_coding",
|
||||
"qianfan",
|
||||
"codex",
|
||||
"claude-code",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -373,7 +449,15 @@ mod tests {
|
||||
assert!(providers.contains(&"xai"));
|
||||
assert!(providers.contains(&"replicate"));
|
||||
assert!(providers.contains(&"github-copilot"));
|
||||
assert_eq!(providers.len(), 21);
|
||||
assert!(providers.contains(&"moonshot"));
|
||||
assert!(providers.contains(&"qwen"));
|
||||
assert!(providers.contains(&"minimax"));
|
||||
assert!(providers.contains(&"zhipu"));
|
||||
assert!(providers.contains(&"zhipu_coding"));
|
||||
assert!(providers.contains(&"qianfan"));
|
||||
assert!(providers.contains(&"codex"));
|
||||
assert!(providers.contains(&"claude-code"));
|
||||
assert_eq!(providers.len(), 29);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,8 @@ pub struct PromptContext {
|
||||
pub identity_md: Option<String>,
|
||||
/// HEARTBEAT.md content (autonomous agent checklist).
|
||||
pub heartbeat_md: Option<String>,
|
||||
/// Peer agents visible to this agent: (name, state, model).
|
||||
pub peer_agents: Vec<(String, String, String)>,
|
||||
}
|
||||
|
||||
/// Build the complete system prompt from a `PromptContext`.
|
||||
@@ -139,6 +141,11 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Section 9.5 — Peer Agent Awareness (skip for subagents)
|
||||
if !ctx.is_subagent && !ctx.peer_agents.is_empty() {
|
||||
sections.push(build_peer_agents_section(&ctx.agent_name, &ctx.peer_agents));
|
||||
}
|
||||
|
||||
// Section 10 — Safety & Oversight (skip for subagents)
|
||||
if !ctx.is_subagent {
|
||||
sections.push(SAFETY_SECTION.to_string());
|
||||
@@ -147,17 +154,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 {
|
||||
@@ -210,7 +208,10 @@ const TOOL_CALL_BEHAVIOR: &str = "\
|
||||
- Prefer action over narration. If you can answer by using a tool, do it.
|
||||
- When executing multiple sequential tool calls, batch them — don't output reasoning between each call.
|
||||
- If a tool returns useful results, present the KEY information, not the raw output.
|
||||
- Start with the answer, not meta-commentary about how you'll help.";
|
||||
- Start with the answer, not meta-commentary about how you'll help.
|
||||
- IMPORTANT: If your instructions or persona mention a shell command, script path, or code snippet, \
|
||||
execute it via the appropriate tool call (shell_exec, file_write, etc.). Never output commands as \
|
||||
code blocks — always call the tool instead.";
|
||||
|
||||
/// Build the grouped tools section (Section 3).
|
||||
pub fn build_tools_section(granted_tools: &[String]) -> String {
|
||||
@@ -245,6 +246,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.
|
||||
@@ -309,9 +325,10 @@ fn build_persona_section(
|
||||
|
||||
if let Some(soul) = soul_md {
|
||||
if !soul.trim().is_empty() {
|
||||
let sanitized = strip_code_blocks(soul);
|
||||
parts.push(format!(
|
||||
"## Persona\nEmbody this identity in your tone and communication style. Be natural, not stiff or generic.\n{}",
|
||||
cap_str(soul, 1000)
|
||||
cap_str(&sanitized, 1000)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -386,6 +403,24 @@ fn build_channel_section(channel: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn build_peer_agents_section(self_name: &str, peers: &[(String, String, String)]) -> String {
|
||||
let mut out = String::from(
|
||||
"## Peer Agents\n\
|
||||
You are part of a multi-agent system. These agents are running alongside you:\n",
|
||||
);
|
||||
for (name, state, model) in peers {
|
||||
if name == self_name {
|
||||
continue; // Don't list yourself
|
||||
}
|
||||
out.push_str(&format!("- **{}** ({}) — model: {}\n", name, state, model));
|
||||
}
|
||||
out.push_str(
|
||||
"\nYou can communicate with them using `agent_send` (by name) and see all agents with `agent_list`. \
|
||||
Delegate tasks to specialized agents when appropriate.",
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Static safety section.
|
||||
const SAFETY_SECTION: &str = "\
|
||||
## Safety
|
||||
@@ -521,11 +556,40 @@ pub fn tool_hint(name: &str) -> &'static str {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Cap a string to `max_chars`, appending "..." if truncated.
|
||||
/// Strip markdown triple-backtick code blocks from content.
|
||||
///
|
||||
/// Prevents LLMs from copying code blocks as text output instead of making
|
||||
/// tool calls when SOUL.md contains command examples.
|
||||
fn strip_code_blocks(content: &str) -> String {
|
||||
let mut result = String::with_capacity(content.len());
|
||||
let mut in_block = false;
|
||||
for line in content.lines() {
|
||||
if line.trim_start().starts_with("```") {
|
||||
in_block = !in_block;
|
||||
continue;
|
||||
}
|
||||
if !in_block {
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
}
|
||||
}
|
||||
// Collapse multiple blank lines left by stripped blocks
|
||||
while result.contains("\n\n\n") {
|
||||
result = result.replace("\n\n\n", "\n\n");
|
||||
}
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
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 +850,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 +870,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 +907,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");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -370,6 +370,7 @@ fn insert_synthetic_results(messages: &mut Vec<Message>) -> usize {
|
||||
.or_default()
|
||||
.push(ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name: String::new(),
|
||||
content: "[Tool execution was interrupted or lost]".to_string(),
|
||||
is_error: true,
|
||||
});
|
||||
@@ -520,7 +521,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()
|
||||
)
|
||||
}
|
||||
@@ -702,6 +703,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "orphan-id".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "some result".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -762,6 +764,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "Results found".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -793,6 +796,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-reorder".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "Search results".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -881,6 +885,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-dup".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "First result".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -889,6 +894,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-dup".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "Duplicate result".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -978,6 +984,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "orphan".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "lost".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1057,6 +1064,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-a".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "search result".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1066,6 +1074,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-ghost".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "ghost result".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1114,11 +1123,13 @@ mod tests {
|
||||
content: MessageContent::Blocks(vec![
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id: "orphan-1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "lost 1".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id: "orphan-2".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "lost 2".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
|
||||
@@ -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), "");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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}"
|
||||
));
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ pub enum SkillError {
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SkillRuntime {
|
||||
/// Python script executed in subprocess.
|
||||
#[default]
|
||||
Python,
|
||||
/// WASM module executed in sandbox.
|
||||
Wasm,
|
||||
@@ -58,6 +57,7 @@ pub enum SkillRuntime {
|
||||
Builtin,
|
||||
/// Prompt-only skill: injects context into the LLM system prompt.
|
||||
/// No executable code — the Markdown body teaches the LLM.
|
||||
#[default]
|
||||
PromptOnly,
|
||||
}
|
||||
|
||||
@@ -101,7 +101,8 @@ pub struct SkillRequirements {
|
||||
pub struct SkillManifest {
|
||||
/// Skill metadata.
|
||||
pub skill: SkillMeta,
|
||||
/// Runtime configuration.
|
||||
/// Runtime configuration (defaults to PromptOnly if omitted).
|
||||
#[serde(default)]
|
||||
pub runtime: SkillRuntimeConfig,
|
||||
/// Tools provided by this skill.
|
||||
#[serde(default)]
|
||||
@@ -144,7 +145,7 @@ fn default_version() -> String {
|
||||
}
|
||||
|
||||
/// Runtime configuration section.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SkillRuntimeConfig {
|
||||
/// Runtime type.
|
||||
#[serde(rename = "type", default)]
|
||||
|
||||
@@ -476,6 +476,12 @@ pub struct AgentManifest {
|
||||
/// Per-agent exec policy override. If None, uses global exec_policy.
|
||||
#[serde(default)]
|
||||
pub exec_policy: Option<crate::config::ExecPolicy>,
|
||||
/// Tool allowlist — only these tools are available (empty = all tools).
|
||||
#[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")]
|
||||
pub tool_allowlist: Vec<String>,
|
||||
/// Tool blocklist — these tools are excluded (applied after allowlist).
|
||||
#[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")]
|
||||
pub tool_blocklist: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -508,6 +514,8 @@ impl Default for AgentManifest {
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: Vec::new(),
|
||||
tool_blocklist: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -763,6 +771,8 @@ mod tests {
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: Vec::new(),
|
||||
tool_blocklist: Vec::new(),
|
||||
};
|
||||
let json = serde_json::to_string(&manifest).unwrap();
|
||||
let deserialized: AgentManifest = serde_json::from_str(&json).unwrap();
|
||||
|
||||
@@ -167,11 +167,19 @@ pub struct ApprovalResponse {
|
||||
#[serde(default)]
|
||||
pub struct ApprovalPolicy {
|
||||
/// Tools that always require approval. Default: `["shell_exec"]`.
|
||||
///
|
||||
/// Accepts either a list of tool names or a boolean shorthand:
|
||||
/// - `require_approval = false` → empty list (no tools require approval)
|
||||
/// - `require_approval = true` → `["shell_exec"]` (the default set)
|
||||
#[serde(deserialize_with = "deserialize_require_approval")]
|
||||
pub require_approval: Vec<String>,
|
||||
/// Timeout in seconds. Default: 60, range: 10..=300.
|
||||
pub timeout_secs: u64,
|
||||
/// Auto-approve in autonomous mode. Default: `false`.
|
||||
pub auto_approve_autonomous: bool,
|
||||
/// Alias: if `auto_approve = true`, clears the require list at boot.
|
||||
#[serde(default, alias = "auto_approve")]
|
||||
pub auto_approve: bool,
|
||||
}
|
||||
|
||||
impl Default for ApprovalPolicy {
|
||||
@@ -180,11 +188,57 @@ impl Default for ApprovalPolicy {
|
||||
require_approval: vec!["shell_exec".to_string()],
|
||||
timeout_secs: 60,
|
||||
auto_approve_autonomous: false,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom deserializer that accepts:
|
||||
/// - A list of strings: `["shell_exec", "file_write"]`
|
||||
/// - A boolean: `false` → `[]`, `true` → `["shell_exec"]`
|
||||
fn deserialize_require_approval<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct RequireApprovalVisitor;
|
||||
|
||||
impl<'de> de::Visitor<'de> for RequireApprovalVisitor {
|
||||
type Value = Vec<String>;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a list of tool names or a boolean")
|
||||
}
|
||||
|
||||
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
|
||||
Ok(if v {
|
||||
vec!["shell_exec".to_string()]
|
||||
} else {
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
|
||||
let mut v = Vec::new();
|
||||
while let Some(s) = seq.next_element::<String>()? {
|
||||
v.push(s);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(RequireApprovalVisitor)
|
||||
}
|
||||
|
||||
impl ApprovalPolicy {
|
||||
/// Apply the `auto_approve` shorthand: if true, clears the require list.
|
||||
pub fn apply_shorthands(&mut self) {
|
||||
if self.auto_approve {
|
||||
self.require_approval.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate this policy's fields.
|
||||
///
|
||||
/// Returns `Ok(())` or an error message describing the first validation failure.
|
||||
@@ -485,6 +539,7 @@ mod tests {
|
||||
assert_eq!(policy.require_approval, vec!["shell_exec".to_string()]);
|
||||
assert_eq!(policy.timeout_secs, 60);
|
||||
assert!(!policy.auto_approve_autonomous);
|
||||
assert!(!policy.auto_approve);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -496,6 +551,31 @@ mod tests {
|
||||
assert!(!policy.auto_approve_autonomous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_require_approval_bool_false() {
|
||||
// require_approval = false → empty list
|
||||
let policy: ApprovalPolicy =
|
||||
serde_json::from_str(r#"{"require_approval": false}"#).unwrap();
|
||||
assert!(policy.require_approval.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_require_approval_bool_true() {
|
||||
// require_approval = true → ["shell_exec"]
|
||||
let policy: ApprovalPolicy =
|
||||
serde_json::from_str(r#"{"require_approval": true}"#).unwrap();
|
||||
assert_eq!(policy.require_approval, vec!["shell_exec"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_auto_approve_clears_list() {
|
||||
let mut policy = ApprovalPolicy::default();
|
||||
assert!(!policy.require_approval.is_empty());
|
||||
policy.auto_approve = true;
|
||||
policy.apply_shorthands();
|
||||
assert!(policy.require_approval.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ApprovalPolicy — timeout_secs
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -608,6 +688,7 @@ mod tests {
|
||||
require_approval: vec!["shell_exec".into(), "file_delete".into()],
|
||||
timeout_secs: 120,
|
||||
auto_approve_autonomous: true,
|
||||
auto_approve: false,
|
||||
};
|
||||
let json = serde_json::to_string(&policy).unwrap();
|
||||
let back: ApprovalPolicy = serde_json::from_str(&json).unwrap();
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Shared wire types for the Agent Communication UI.
|
||||
//!
|
||||
//! These types are used by both the REST API and the TUI to represent
|
||||
//! agent topology graphs, inter-agent communication events, and
|
||||
//! request payloads for sending messages / posting tasks.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A node in the agent topology graph.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TopoNode {
|
||||
/// Agent ID.
|
||||
pub id: String,
|
||||
/// Human-readable agent name.
|
||||
pub name: String,
|
||||
/// Current lifecycle state (e.g. "Running", "Suspended").
|
||||
pub state: String,
|
||||
/// Model name the agent is using.
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// An edge in the agent topology graph.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TopoEdge {
|
||||
/// Source agent ID.
|
||||
pub from: String,
|
||||
/// Target agent ID.
|
||||
pub to: String,
|
||||
/// Relationship kind.
|
||||
pub kind: EdgeKind,
|
||||
}
|
||||
|
||||
/// The kind of relationship between two agents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EdgeKind {
|
||||
/// Parent spawned child.
|
||||
ParentChild,
|
||||
/// Peer-to-peer message exchange.
|
||||
Peer,
|
||||
}
|
||||
|
||||
/// The full agent topology: nodes + edges.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Topology {
|
||||
pub nodes: Vec<TopoNode>,
|
||||
pub edges: Vec<TopoEdge>,
|
||||
}
|
||||
|
||||
/// A communication event between agents.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommsEvent {
|
||||
/// Unique event ID.
|
||||
pub id: String,
|
||||
/// ISO-8601 timestamp.
|
||||
pub timestamp: String,
|
||||
/// Event kind.
|
||||
pub kind: CommsEventKind,
|
||||
/// Source agent ID.
|
||||
pub source_id: String,
|
||||
/// Source agent name.
|
||||
pub source_name: String,
|
||||
/// Target agent ID (empty for lifecycle events without a target).
|
||||
pub target_id: String,
|
||||
/// Target agent name.
|
||||
pub target_name: String,
|
||||
/// Human-readable detail text.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// The kind of inter-agent communication event.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommsEventKind {
|
||||
/// Agent-to-agent message.
|
||||
AgentMessage,
|
||||
/// A new agent was spawned.
|
||||
AgentSpawned,
|
||||
/// An agent was terminated.
|
||||
AgentTerminated,
|
||||
/// A task was posted to the queue.
|
||||
TaskPosted,
|
||||
/// A task was claimed by an agent.
|
||||
TaskClaimed,
|
||||
/// A task was completed.
|
||||
TaskCompleted,
|
||||
}
|
||||
|
||||
/// Request body for POST /api/comms/send.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CommsSendRequest {
|
||||
pub from_agent_id: String,
|
||||
pub to_agent_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Request body for POST /api/comms/task.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CommsTaskRequest {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub assigned_to: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn comms_event_kind_roundtrip() {
|
||||
let kind = CommsEventKind::AgentMessage;
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
assert_eq!(json, "\"agent_message\"");
|
||||
let parsed: CommsEventKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_kind_roundtrip() {
|
||||
let kind = EdgeKind::ParentChild;
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
assert_eq!(json, "\"parent_child\"");
|
||||
let parsed: EdgeKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_serialization() {
|
||||
let topo = Topology {
|
||||
nodes: vec![TopoNode {
|
||||
id: "a1".into(),
|
||||
name: "agent-1".into(),
|
||||
state: "Running".into(),
|
||||
model: "gpt-4".into(),
|
||||
}],
|
||||
edges: vec![TopoEdge {
|
||||
from: "a1".into(),
|
||||
to: "a2".into(),
|
||||
kind: EdgeKind::Peer,
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&topo).unwrap();
|
||||
assert!(json.contains("\"agent-1\""));
|
||||
assert!(json.contains("\"peer\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comms_send_request_deser() {
|
||||
let json = r#"{"from_agent_id":"a","to_agent_id":"b","message":"hello"}"#;
|
||||
let req: CommsSendRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.from_agent_id, "a");
|
||||
assert_eq!(req.message, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comms_task_request_deser() {
|
||||
let json = r#"{"title":"t","description":"d"}"#;
|
||||
let req: CommsTaskRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.title, "t");
|
||||
assert!(req.assigned_to.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comms_task_request_with_assign() {
|
||||
let json = r#"{"title":"t","description":"d","assigned_to":"agent-x"}"#;
|
||||
let req: CommsTaskRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.assigned_to.as_deref(), Some("agent-x"));
|
||||
}
|
||||
}
|
||||
@@ -929,7 +929,8 @@ pub struct KernelConfig {
|
||||
pub data_dir: PathBuf,
|
||||
/// Log level (trace, debug, info, warn, error).
|
||||
pub log_level: String,
|
||||
/// gRPC API listen address.
|
||||
/// API listen address (e.g., "0.0.0.0:4200").
|
||||
#[serde(alias = "listen_addr")]
|
||||
pub api_listen: String,
|
||||
/// Whether to enable the OFP network layer.
|
||||
pub network_enabled: bool,
|
||||
@@ -995,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")]
|
||||
@@ -1038,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.
|
||||
@@ -1182,6 +1211,8 @@ impl Default for KernelConfig {
|
||||
auth_profiles: HashMap::new(),
|
||||
thinking: None,
|
||||
budget: BudgetConfig::default(),
|
||||
provider_urls: HashMap::new(),
|
||||
oauth: OAuthConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod agent;
|
||||
pub mod approval;
|
||||
pub mod capability;
|
||||
pub mod comms;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
@@ -20,3 +21,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), "");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user