Compare commits

...
34 Commits
Author SHA1 Message Date
jaberjaber23 3cce1eb3fb bump v0.6.4 2026-05-01 13:48:18 +03:00
jaberjaber23 c89958b66d firefox sidebar 2026-05-01 13:28:26 +03:00
jaberjaber23 b0a92456bf openrouter free 2026-05-01 13:19:50 +03:00
jaberjaber23 67bbcc623d cachyos build 2026-05-01 13:18:19 +03:00
jaberjaber23 a91bfc0e9c dashboard bind 2026-05-01 13:17:32 +03:00
jaberjaber23 948117d5de bump v0.6.3 2026-05-01 13:12:25 +03:00
jaberjaber23 2dedab2a8b think persist 2026-05-01 13:01:55 +03:00
jaberjaber23 8642c4d442 timeout reload 2026-05-01 12:57:05 +03:00
jaberjaber23 46a6eb33d9 slack dedup 2026-05-01 12:50:38 +03:00
jaberjaber23 99b4ce2931 telegram cache 2026-05-01 12:50:38 +03:00
Jaber Jaber 87932f5da0 Merge pull request #1061 from RightNow-AI/dependabot/github_actions/softprops/action-gh-release-3
build(deps): bump softprops/action-gh-release from 2 to 3
2026-05-01 12:49:07 +03:00
jaberjaber23 15ed29c667 bump v0.6.2 2026-04-29 20:39:59 +03:00
jaberjaber23 1d1bf0fb09 exec full 2026-04-29 16:19:23 +03:00
jaberjaber23 d3363142b2 binding rule 2026-04-29 16:19:23 +03:00
Hypn0sis 76929a41aa fix(clippy): resolve upstream warnings breaking CI
Mechanical clippy fixes for collapsible_match, unnecessary_sort_by, and redundant into_iter. Resolves the 5 errors blocking openfang-runtime in CI.
2026-04-29 16:17:48 +03:00
Jaber Jaber fe34a37e6f Merge pull request #1112 from RightNow-AI/dependabot/cargo/lettre-0.11.21
build(deps): bump lettre from 0.11.20 to 0.11.21
2026-04-29 16:10:44 +03:00
Jaber Jaber 4b63eb18cc Merge pull request #1111 from RightNow-AI/dependabot/cargo/libc-0.2.185
build(deps): bump libc from 0.2.183 to 0.2.185
2026-04-29 16:10:31 +03:00
Jaber Jaber fe21d4b4df Merge pull request #1110 from RightNow-AI/dependabot/cargo/rustls-0.23.39
build(deps): bump rustls from 0.23.37 to 0.23.39
2026-04-29 16:10:19 +03:00
Jaber Jaber f52bc53e47 Merge pull request #1109 from RightNow-AI/dependabot/cargo/uuid-1.23.1
build(deps): bump uuid from 1.23.0 to 1.23.1
2026-04-29 16:10:05 +03:00
Jaber Jaber 10f7ee1885 Merge pull request #1060 from ferr079/fix/unify-ssrf-protection
fix(security): unify SSRF protection for WASM host calls
2026-04-29 16:02:51 +03:00
Jaber Jaber 7bc6591338 Merge pull request #1058 from lc-soft/fix/trader-dashboard-style
fix(hands): correct trader dashboard style
2026-04-29 15:45:05 +03:00
jack-wzandjack-wz01 53f2066945 chore: add health stack and stabilize provider env tests (#1126)
Co-authored-by: jack-wz01 <15474862+jack-wz01@user.noreply.gitee.com>
2026-04-29 15:45:01 +03:00
Jaber Jaber c69dd84184 Merge pull request #1095 from Streamweaver/fix/mcp-stdio-env-passthrough-linux
fix(runtime): pass HOME/TMP/TEMP to stdio MCP servers on all platforms
2026-04-29 15:44:05 +03:00
Jaber Jaber c1356fc95d Merge pull request #1100 from Streamweaver/fix/telegram-silent-failures
channels/telegram: propagate send failures and cache terminal reaction errors
2026-04-29 15:43:42 +03:00
dependabot[bot] 40bd7e2c11 build(deps): bump lettre from 0.11.20 to 0.11.21
Bumps [lettre](https://github.com/lettre/lettre) from 0.11.20 to 0.11.21.
- [Release notes](https://github.com/lettre/lettre/releases)
- [Changelog](https://github.com/lettre/lettre/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lettre/lettre/compare/v0.11.20...v0.11.21)

---
updated-dependencies:
- dependency-name: lettre
  dependency-version: 0.11.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:24:47 +00:00
dependabot[bot] a7197d7b97 build(deps): bump libc from 0.2.183 to 0.2.185
Bumps [libc](https://github.com/rust-lang/libc) from 0.2.183 to 0.2.185.
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.185/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.183...0.2.185)

---
updated-dependencies:
- dependency-name: libc
  dependency-version: 0.2.185
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:24:21 +00:00
dependabot[bot] bc26d5e8c3 build(deps): bump rustls from 0.23.37 to 0.23.39
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.37 to 0.23.39.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.37...v/0.23.39)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.39
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:23:42 +00:00
dependabot[bot] 84d90ad342 build(deps): bump uuid from 1.23.0 to 1.23.1
Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.23.0 to 1.23.1.
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.0...v1.23.1)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.23.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:23:16 +00:00
Scott Turnbull 9fee63d58c channels/telegram: cache terminal setMessageReaction errors per (chat, emoji)
`fire_reaction` calls `setMessageReaction` fire-and-forget on every
agent lifecycle event. When Telegram returns a terminal error like
`REACTION_INVALID` (emoji not in the bot's free-reaction allowlist),
`REACTION_NOT_AVAILABLE` (chat admin restricted this emoji), or
`REACTION_TOO_MANY` (per-message cap), retrying on every subsequent
turn is pointless log spam and wasted API quota.

This adds a per-bot-instance `HashSet<(i64, String)>` keyed by
`(chat_id, emoji)` that records terminal rejections and short-circuits
future calls for the same pair. Keyed by chat, not just emoji, because
`Chat.available_reactions` varies across chats and is admin-mutable
(https://core.telegram.org/bots/api#setmessagereaction) — an emoji
rejected in chat A may still be valid in chat B. Cache is
per-process; on restart it rebuilds naturally, which handles any
runtime allowlist change without needing persistence.

The terminal-error match uses a small private helper
`is_terminal_reaction_error` that substring-matches the three
permanent errors. Transient errors (429, 5xx, `MESSAGE_NOT_MODIFIED`,
unrelated 400s) are deliberately NOT cached.

Concurrency: the cache uses `std::sync::Mutex` — critical section is
two `HashSet` ops (contains + insert), never held across `.await`.
Endorsed by the Tokio shared-state tutorial
(https://tokio.rs/tokio/tutorial/shared-state) for exactly this shape.
Two concurrent `fire_reaction` calls for the same (chat, emoji) can
both pass the cache check before either rejection lands, producing up
to N duplicate API calls on the first rejection; the duplicate
`insert` is idempotent so this is benign and self-limits on the
second turn. Documented in-code.

Tests: 6 new tests covering terminal-error matching, cache insertion,
per-chat key isolation, and non-caching of transient and successful
responses. Total 47 telegram tests pass (41 existing + 6 new). No new
clippy warnings.
2026-04-21 18:03:23 -04:00
Scott Turnbull 40903cceee channels/telegram: propagate send failures from api_send_* helpers
The six outbound helpers in the Telegram adapter (sendMessage, sendPhoto,
sendDocument, sendDocument_upload, sendVoice, sendLocation) previously
logged a `warn!` on HTTP non-success and still returned `Ok(())`. Callers
interpreted that as successful delivery and told the agent "Message sent"
even when Telegram had rejected the request (e.g. 400 Bad Request from
malformed HTML entities with parse_mode=HTML). The agent recorded phantom
success in its session history, corrupting subsequent behavior.

The fix returns `Err(format!(...).into())` on HTTP non-success in all six
helpers, matching the error-handling convention documented in
CONTRIBUTING.md.

`api_send_message` is slightly different because it splits long messages
into chunks via `split_message(4096)`. Naively returning `Err` on any
chunk failure would create a partial-delivery-then-error regression —
worse than the original silent success. The function now tracks
`delivered_any` across chunks:

- First-chunk failure (nothing delivered yet) → return `Err` to surface
  the failure. This is where the motivating HTML-parse-error bug lives,
  so the fix is fully effective.
- Subsequent-chunk failure (user already received preceding chunks) →
  log `warn!` and continue with best-effort delivery, matching the
  convention used by every other adapter in the crate that calls
  `split_message` (Discord, Gitter, Mattermost, Nextcloud, Twitch,
  Pumble, etc.).

Tests: 4 new tests using a small in-crate stub server (axum on an
ephemeral port, reached via the existing `api_url` constructor seam —
zero new dependencies). 41 telegram tests pass (37 existing + 4 new).
2026-04-21 18:01:24 -04:00
Scott Turnbull e97eb6fff3 fix(runtime): pass HOME/TMP/TEMP to stdio MCP servers on all platforms
Node/npx-backed stdio MCP servers (Gmail, AgentMail, Exa, etc.) need a
usable HOME directory for npm cache and temp-file scratch space. Without
it, npm errors with EACCES on /nonexistent or silently falls over when
trying to write cache entries.

Previously these three variables were only passed on Windows. Linux and
macOS hosts launching stdio MCP servers through npx would get an empty
env for HOME/TMP/TEMP, breaking most community MCP servers.

Move the HOME/TMP/TEMP passthrough above the cfg!(windows) block so it
applies to every platform. Remove the now-redundant entries from the
Windows-only list.
2026-04-20 12:24:33 -04:00
Stephane de8a692036 fix(security): unify SSRF protection for WASM host calls
The WASM sandbox host_net_fetch() had its own SSRF implementation
(is_ssrf_target) that was incomplete compared to the canonical
check_ssrf() in web_fetch.rs:

- Missing 6 blocked hostnames (ip6-localhost, Alibaba/Azure IMDS,
  0.0.0.0, ::1, [::1])
- Missing cloud metadata IP detection (is_metadata_ip)
- Missing IPv6 bracket notation support
- Ignoring ssrf_allowed_hosts from config.toml entirely
- Duplicate is_private_ip() and extract_host_from_url() functions

This meant a WASM agent could bypass SSRF protections that the
builtin web_fetch tool correctly enforced.

Changes:
- Remove duplicated is_ssrf_target(), is_private_ip(), and
  extract_host_from_url() from host_functions.rs
- Delegate to web_fetch::check_ssrf() which has the complete
  implementation with allowlist, CIDR matching, and metadata
  IP detection
- Add ssrf_allowed_hosts to SandboxConfig and GuestState so the
  config propagates to WASM host calls
- Make extract_host() pub(crate) for reuse
- Update tests to exercise the unified code path, including new
  coverage for IPv6 and cloud metadata endpoints

All 908 runtime tests pass. Zero clippy warnings.
2026-04-18 14:22:12 +02:00
dependabot[bot] c9701627a9 build(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-16 08:21:33 +00:00
Liu 643a22b295 fix(hands): correct trader dashboard style 2026-04-15 16:59:52 +08:00
51 changed files with 3043 additions and 844 deletions
+1 -1
View File
@@ -204,7 +204,7 @@ jobs:
$hash = (Get-FileHash "openfang-${{ matrix.target }}.zip" -Algorithm SHA256).Hash.ToLower()
"$hash openfang-${{ matrix.target }}.zip" | Out-File -Encoding ASCII "openfang-${{ matrix.target }}.zip.sha256"
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: openfang-${{ matrix.target }}.*
env:
+6
View File
@@ -0,0 +1,6 @@
## Health Stack
- typecheck: cargo build --workspace --lib
- lint: cargo clippy --workspace --all-targets -- -D warnings
- test: cargo test --workspace
- shell: shellcheck scripts/install.sh
Generated
+39 -39
View File
@@ -139,7 +139,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -893,7 +893,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1555,7 +1555,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1805,7 +1805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2743,7 +2743,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@@ -3255,9 +3255,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lettre"
version = "0.11.20"
version = "0.11.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "471816f3e24b85e820dee02cde962379ea1a669e5242f19c61bcbcffedf4c4fb"
checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -3320,9 +3320,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.183"
version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "libloading"
@@ -3739,7 +3739,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3959,7 +3959,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"argon2",
"async-trait",
@@ -4001,7 +4001,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"aes",
"async-trait",
@@ -4040,7 +4040,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"clap",
"clap_complete",
@@ -4068,7 +4068,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"axum",
"open",
@@ -4094,7 +4094,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"aes-gcm",
"argon2",
@@ -4122,7 +4122,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"chrono",
"dashmap",
@@ -4140,7 +4140,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"async-trait",
"chrono",
@@ -4179,7 +4179,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"async-trait",
"chrono",
@@ -4199,7 +4199,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4218,7 +4218,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"anyhow",
"async-trait",
@@ -4254,7 +4254,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"chrono",
"hex",
@@ -4277,7 +4277,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"async-trait",
"bitflags 2.11.0",
@@ -4297,7 +4297,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.6.1"
version = "0.6.4"
dependencies = [
"async-trait",
"chrono",
@@ -4394,7 +4394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5100,7 +5100,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5138,7 +5138,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
@@ -5704,14 +5704,14 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
dependencies = [
"aws-lc-rs",
"log",
@@ -5763,7 +5763,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6309,7 +6309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7042,10 +7042,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7617,7 +7617,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7781,9 +7781,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.0"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -8490,7 +8490,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9231,7 +9231,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.6.1"
version = "0.6.4"
[[package]]
name = "yoke"
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.6.1"
version = "0.6.4"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
+2 -2
View File
@@ -19,8 +19,8 @@
<p align="center">
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
<img src="https://img.shields.io/badge/version-0.6.1-green?style=flat-square" alt="v0.6.1" />
<img src="https://img.shields.io/badge/tests-2,497%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/version-0.6.4-green?style=flat-square" alt="v0.6.4" />
<img src="https://img.shields.io/badge/tests-2,543%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>
+1 -1
View File
@@ -6287,7 +6287,7 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
// Index probe results by provider list position for O(1) lookup
let mut probe_map: HashMap<usize, openfang_runtime::provider_health::ProbeResult> =
HashMap::with_capacity(local_providers.len());
for ((idx, _, _), result) in local_providers.iter().zip(probe_results.into_iter()) {
for ((idx, _, _), result) in local_providers.iter().zip(probe_results) {
probe_map.insert(*idx, result);
}
@@ -3253,7 +3253,7 @@ mark.search-highlight {
═══════════════════════════════════════════════════════════════════════════ */
.trader-dashboard {
background: var(--bg-card);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
width: 96vw;
@@ -3270,7 +3270,7 @@ mark.search-highlight {
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-card);
background: var(--surface);
z-index: 10;
border-radius: 12px 12px 0 0;
}
@@ -3330,6 +3330,7 @@ mark.search-highlight {
border-radius: 8px;
padding: 14px 16px;
min-width: 0;
position: relative;
}
.trader-chart-title {
font-size: 0.75rem;
@@ -1,5 +1,10 @@
/* OpenFang Layout — Grid + Sidebar + Responsive */
/* Firefox compat: hide x-cloak elements until Alpine.js initializes.
Without this, the sidebar flashes hidden in Firefox while Alpine
processes the nested x-data scopes for nav sections. */
[x-cloak] { display: none !important; }
.app-layout {
display: flex;
height: 100vh;
+14 -23
View File
@@ -27,8 +27,8 @@
</div>
<div class="app-layout" :class="{ 'focus-mode': $store.app.focusMode }">
<!-- Sidebar -->
<nav class="sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
<!-- Sidebar — x-cloak prevents Firefox flash-hidden during Alpine init -->
<nav class="sidebar" x-cloak :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
<div class="sidebar-header">
<div class="sidebar-header-text">
<div class="sidebar-logo">
@@ -68,8 +68,8 @@
<span class="nav-label">Agents</span>
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">&rsaquo;</span>
</div>
<template x-if="!collapsed">
<div x-transition>
<!-- x-show + x-cloak: Firefox-safe replacement for nested <template x-if> which has render quirks. -->
<div x-show="!collapsed" x-cloak x-transition>
<a class="nav-item" :class="{ active: page === 'agents' }" @click="navigate('agents')" :aria-current="page === 'agents' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
<span class="nav-label">Chat</span>
@@ -87,8 +87,7 @@
<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>
</div>
<!-- Automation -->
@@ -97,8 +96,7 @@
<span class="nav-label">Automation</span>
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">&rsaquo;</span>
</div>
<template x-if="!collapsed">
<div x-transition>
<div x-show="!collapsed" x-cloak x-transition>
<a class="nav-item" :class="{ active: page === 'workflows' }" @click="navigate('workflows')" :aria-current="page === 'workflows' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M6 3v12M18 9a9 9 0 0 1-9 9"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/></svg></span>
<span class="nav-label">Workflows</span>
@@ -107,8 +105,7 @@
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></span>
<span class="nav-label">Scheduler</span>
</a>
</div>
</template>
</div>
</div>
<!-- Extensions -->
@@ -117,8 +114,7 @@
<span class="nav-label">Extensions</span>
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">&rsaquo;</span>
</div>
<template x-if="!collapsed">
<div x-transition>
<div x-show="!collapsed" x-cloak x-transition>
<a class="nav-item" :class="{ active: page === 'channels' }" @click="navigate('channels')" :aria-current="page === 'channels' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 9h16M4 15h16M10 3l-2 18M16 3l-2 18"/></svg></span>
<span class="nav-label">Channels</span>
@@ -131,8 +127,7 @@
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M18 11V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2 2 2 0 0 0-2 2v6"/><path d="M10 10.5V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.9-5.7-2.4L3.4 16a2 2 0 0 1 3.2-2.4L8 15"/></svg></span>
<span class="nav-label">Hands</span>
</a>
</div>
</template>
</div>
</div>
<!-- Monitor -->
@@ -141,8 +136,7 @@
<span class="nav-label">Monitor</span>
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">&rsaquo;</span>
</div>
<template x-if="!collapsed">
<div x-transition>
<div x-show="!collapsed" x-cloak x-transition>
<a class="nav-item" :class="{ active: page === 'analytics' }" @click="navigate('analytics')" :aria-current="page === 'analytics' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M18 20V10M12 20V4M6 20v-6"/></svg></span>
<span class="nav-label">Analytics</span>
@@ -151,8 +145,7 @@
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg></span>
<span class="nav-label">Logs</span>
</a>
</div>
</template>
</div>
</div>
<!-- System -->
@@ -161,8 +154,7 @@
<span class="nav-label">System</span>
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">&rsaquo;</span>
</div>
<template x-if="!collapsed">
<div x-transition>
<div x-show="!collapsed" x-cloak x-transition>
<a class="nav-item" :class="{ active: page === 'runtime' }" @click="navigate('runtime')" :aria-current="page === 'runtime' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></span>
<span class="nav-label">Runtime</span>
@@ -171,8 +163,7 @@
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg></span>
<span class="nav-label">Settings</span>
</a>
</div>
</template>
</div>
</div>
</div>
@@ -186,7 +177,7 @@
<div class="sidebar-toggle" @click="toggleSidebar()" x-text="sidebarCollapsed ? '\u276F' : '\u276E'"></div>
</nav>
<div class="sidebar-overlay" @click="mobileMenuOpen = false"></div>
<div class="sidebar-overlay" x-cloak @click="mobileMenuOpen = false"></div>
<!-- Main Content -->
<main class="main-content">
+36 -3
View File
@@ -679,6 +679,34 @@ fn sender_user_id(message: &ChannelMessage) -> &str {
.unwrap_or(&message.sender.platform_id)
}
/// Extract the channel/conversation ID from a message, for bindings whose
/// `match_rule.channel_id` is set.
///
/// On Discord and Slack, `sender.platform_id` already holds the channel/
/// conversation ID (per `discord.rs` and `slack.rs`, where the user ID lives
/// in metadata under `sender_user_id`). On other adapters where the platform
/// ID is the user, callers can opt-in by stashing the channel ID under the
/// `sender_channel_id` metadata key.
fn sender_channel_id(message: &ChannelMessage) -> Option<&str> {
if let Some(v) = message
.metadata
.get("sender_channel_id")
.and_then(|v| v.as_str())
{
return Some(v);
}
// On Discord/Slack, the metadata `sender_user_id` is set and differs from
// `sender.platform_id` — in that case, platform_id IS the channel ID.
let user_in_meta = message
.metadata
.get("sender_user_id")
.and_then(|v| v.as_str());
match user_in_meta {
Some(uid) if uid != message.sender.platform_id => Some(&message.sender.platform_id),
_ => None,
}
}
/// If an error contains "Agent not found", try to re-resolve the channel's default agent
/// by name (the name stored at bridge startup). Returns `Some(new_id)` on success.
async fn try_reresolution(
@@ -1001,10 +1029,13 @@ async fn dispatch_message(
// Route to agent (standard path).
// Use sender_user_id() so user-keyed bindings (peer_id) match for adapters like
// Discord/Slack where sender.platform_id is the channel ID, not the user ID.
let agent_id = router.resolve(
// Pass the channel/conversation ID separately so bindings with `channel_id`
// can match (e.g. "messages in Discord channel X → agent Y").
let agent_id = router.resolve_with_channel_id(
&message.channel,
sender_user_id(message),
message.sender.openfang_user.as_deref(),
sender_channel_id(message),
);
let agent_id = match agent_id {
@@ -1444,11 +1475,13 @@ async fn dispatch_with_blocks(
prefix_style: PrefixStyle,
) {
// Route to agent (same logic as text path).
// Use sender_user_id() so user-keyed bindings match for Discord/Slack.
let agent_id = router.resolve(
// Use sender_user_id() so user-keyed bindings match for Discord/Slack;
// pass channel_id so per-room bindings match too.
let agent_id = router.resolve_with_channel_id(
&message.channel,
sender_user_id(message),
message.sender.openfang_user.as_deref(),
sender_channel_id(message),
);
let agent_id = match agent_id {
+9 -11
View File
@@ -326,19 +326,17 @@ impl ChannelAdapter for IrcAdapter {
}
// RPL_WELCOME (001) — registration complete, join channels
"001" => {
if !joined {
info!("IRC registered as {nick_clone}");
for ch in &channels_clone {
let join_cmd = format!("JOIN {ch}\r\n");
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
warn!("IRC JOIN send failed: {e}");
break 'inner true;
}
info!("IRC joining {ch}");
"001" if !joined => {
info!("IRC registered as {nick_clone}");
for ch in &channels_clone {
let join_cmd = format!("JOIN {ch}\r\n");
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
warn!("IRC JOIN send failed: {e}");
break 'inner true;
}
joined = true;
info!("IRC joining {ch}");
}
joined = true;
}
// PRIVMSG — incoming message
+145 -1
View File
@@ -18,6 +18,10 @@ pub struct BindingContext {
pub peer_id: String,
/// Guild/server ID.
pub guild_id: Option<String>,
/// Channel/conversation ID (e.g. Discord channel, Slack conversation,
/// Telegram chat, IRC channel name). Populated by bridges so bindings can
/// route by room independent of which user posted.
pub channel_id: Option<String>,
/// User's roles.
pub roles: Vec<String>,
}
@@ -143,6 +147,19 @@ impl AgentRouter {
channel_type: &ChannelType,
platform_user_id: &str,
user_key: Option<&str>,
) -> Option<AgentId> {
self.resolve_with_channel_id(channel_type, platform_user_id, user_key, None)
}
/// Resolve with an explicit channel/conversation ID, so bindings whose
/// `match_rule.channel_id` is set can match. Used by bridges that know the
/// room/conversation the message arrived in (Discord/Slack/Telegram/IRC).
pub fn resolve_with_channel_id(
&self,
channel_type: &ChannelType,
platform_user_id: &str,
user_key: Option<&str>,
channel_id: Option<&str>,
) -> Option<AgentId> {
let channel_key = format!("{channel_type:?}");
@@ -152,6 +169,7 @@ impl AgentRouter {
account_id: None,
peer_id: platform_user_id.to_string(),
guild_id: None,
channel_id: channel_id.map(|s| s.to_string()),
roles: Vec::new(),
};
if let Some(agent_id) = self.resolve_binding(&ctx) {
@@ -329,6 +347,11 @@ impl AgentRouter {
return false;
}
}
if let Some(ref cid) = rule.channel_id {
if ctx.channel_id.as_ref() != Some(cid) {
return false;
}
}
if !rule.roles.is_empty() {
// User must have at least one of the specified roles
let has_role = rule.roles.iter().any(|r| ctx.roles.contains(r));
@@ -639,7 +662,128 @@ mod tests {
guild_id: Some("guild".to_string()),
roles: vec!["admin".to_string()],
account_id: Some("bot".to_string()),
channel_id: Some("ch_42".to_string()),
};
assert_eq!(full.specificity(), 17); // 8+4+2+2+1
assert_eq!(full.specificity(), 25); // 8+8+4+2+2+1
// peer_id alone vs channel_id alone — both worth 8.
let peer_only = BindingMatchRule {
peer_id: Some("u".to_string()),
..Default::default()
};
let channel_id_only = BindingMatchRule {
channel_id: Some("c".to_string()),
..Default::default()
};
assert_eq!(peer_only.specificity(), 8);
assert_eq!(channel_id_only.specificity(), 8);
// Combined peer_id + channel_id (16) outranks either alone (8).
let peer_and_channel = BindingMatchRule {
peer_id: Some("u".to_string()),
channel_id: Some("c".to_string()),
..Default::default()
};
assert_eq!(peer_and_channel.specificity(), 16);
}
#[test]
fn test_binding_channel_id_match() {
// A binding scoped to a specific Discord channel should match messages
// from that channel and reject messages from other channels.
let router = AgentRouter::new();
let agent_id = AgentId::new();
router.register_agent("ops-bot".to_string(), agent_id);
router.load_bindings(&[AgentBinding {
agent: "ops-bot".to_string(),
match_rule: openfang_types::config::BindingMatchRule {
channel: Some("discord".to_string()),
channel_id: Some("1477803840265781391".to_string()),
..Default::default()
},
}]);
// Same channel, any user — matches.
let resolved = router.resolve_with_channel_id(
&ChannelType::Discord,
"any-user",
None,
Some("1477803840265781391"),
);
assert_eq!(resolved, Some(agent_id));
// Different channel — no match.
let resolved = router.resolve_with_channel_id(
&ChannelType::Discord,
"any-user",
None,
Some("9999999999999999999"),
);
assert_eq!(resolved, None);
// Missing channel_id on the wire — no match (the binding is restrictive).
let resolved = router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None);
assert_eq!(resolved, None);
}
#[test]
fn test_binding_channel_id_plus_peer_outranks_channel_id_alone() {
// user A in #medical → researcher; anyone else in #medical → general.
let router = AgentRouter::new();
let researcher = AgentId::new();
let general = AgentId::new();
router.register_agent("researcher".to_string(), researcher);
router.register_agent("general".to_string(), general);
router.load_bindings(&[
AgentBinding {
agent: "general".to_string(),
match_rule: openfang_types::config::BindingMatchRule {
channel_id: Some("ch-medical".to_string()),
..Default::default()
},
},
AgentBinding {
agent: "researcher".to_string(),
match_rule: openfang_types::config::BindingMatchRule {
channel_id: Some("ch-medical".to_string()),
peer_id: Some("user-a".to_string()),
..Default::default()
},
},
]);
// user-a in #medical → researcher (more specific wins)
let r = router.resolve_with_channel_id(
&ChannelType::Discord,
"user-a",
None,
Some("ch-medical"),
);
assert_eq!(r, Some(researcher));
// user-b in #medical → general (channel_id alone matches)
let r = router.resolve_with_channel_id(
&ChannelType::Discord,
"user-b",
None,
Some("ch-medical"),
);
assert_eq!(r, Some(general));
}
#[test]
fn test_binding_match_rule_unknown_field_rejected() {
// Typos like `channnel_id` must fail loudly at deserialization rather
// than silently producing a wide-open binding. This is the highest-
// leverage line in the patch from issue #1127.
let bad = r#"{ "channnel_id": "ch-1" }"#;
let r: Result<openfang_types::config::BindingMatchRule, _> = serde_json::from_str(bad);
assert!(r.is_err(), "unknown field must be rejected by serde");
// Sanity: known fields still parse.
let good = r#"{ "channel_id": "ch-1", "channel": "discord" }"#;
let r: openfang_types::config::BindingMatchRule = serde_json::from_str(good).unwrap();
assert_eq!(r.channel_id.as_deref(), Some("ch-1"));
assert_eq!(r.channel.as_deref(), Some("discord"));
}
}
+101
View File
@@ -21,6 +21,38 @@ const SLACK_API_BASE: &str = "https://slack.com/api";
const MAX_BACKOFF: Duration = Duration::from_secs(60);
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
const SLACK_MSG_LIMIT: usize = 3000;
/// TTL for envelope_id dedup entries. Well above the typical Slack
/// connection-rotation overlap window (< 10s).
const ENVELOPE_TTL: Duration = Duration::from_secs(60);
/// Soft cap on the dedup cache size. When exceeded we GC expired entries.
/// Recent envelope IDs are not reused by Slack, so 10k is more than enough.
const ENVELOPE_CACHE_CAP: usize = 10_000;
/// Returns true if `envelope_id` was already seen within `ENVELOPE_TTL`.
/// On first sight, records the timestamp and returns false. Performs
/// opportunistic GC of expired entries when the cache grows large.
///
/// Slack Socket Mode delivers the same event to multiple active WebSocket
/// connections during connection rotation. Apps must dedupe on `envelope_id`
/// to avoid double-processing.
fn is_duplicate_envelope(cache: &DashMap<String, Instant>, envelope_id: &str) -> bool {
if envelope_id.is_empty() {
return false;
}
// Opportunistic GC: bound growth without per-call work.
if cache.len() > ENVELOPE_CACHE_CAP {
cache.retain(|_, ts| ts.elapsed() < ENVELOPE_TTL);
}
if let Some(prev) = cache.get(envelope_id) {
if prev.elapsed() < ENVELOPE_TTL {
return true;
}
}
cache.insert(envelope_id.to_string(), Instant::now());
false
}
/// Slack Socket Mode adapter.
pub struct SlackAdapter {
@@ -41,6 +73,9 @@ pub struct SlackAdapter {
auto_thread_reply: bool,
/// Whether to unfurl (expand previews for) links in posted messages.
unfurl_links: bool,
/// Recently-seen envelope_ids. Slack Socket Mode redelivers the same event
/// across rotated WebSocket connections; this prevents double-processing.
seen_envelopes: Arc<DashMap<String, Instant>>,
}
impl SlackAdapter {
@@ -65,6 +100,7 @@ impl SlackAdapter {
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
auto_thread_reply,
unfurl_links,
seen_envelopes: Arc::new(DashMap::new()),
}
}
@@ -161,6 +197,7 @@ impl ChannelAdapter for SlackAdapter {
let mut shutdown = self.shutdown_rx.clone();
let active_threads = self.active_threads.clone();
let auto_thread_reply = self.auto_thread_reply;
let seen_envelopes = self.seen_envelopes.clone();
// Spawn periodic cleanup of expired thread entries.
{
@@ -288,6 +325,16 @@ impl ChannelAdapter for SlackAdapter {
}
}
// Dedup: Slack redelivers the same event on the new
// connection during the rotation overlap. Ack on
// both, but only forward to the agent once.
if is_duplicate_envelope(&seen_envelopes, envelope_id) {
debug!(
"Slack: skipping duplicate envelope_id {envelope_id}"
);
continue;
}
// Extract the event
let event = &payload["payload"]["event"];
if let Some(msg) = parse_slack_event(
@@ -745,4 +792,58 @@ mod tests {
);
assert!(!adapter.unfurl_links);
}
#[test]
fn test_envelope_dedup_skips_second_delivery() {
// Simulates Slack redelivering the same event across a connection
// rotation: the envelope is acked on both connections but the agent
// must only see it once.
let cache: DashMap<String, Instant> = DashMap::new();
let envelope_id = "8d2e1c5a-4f3b-49a1-b6e2-7c0a9f1234ab";
// First delivery on the old connection: not a duplicate, forward.
assert!(
!is_duplicate_envelope(&cache, envelope_id),
"first sight of envelope must not be flagged as duplicate"
);
// Second delivery on the new connection: duplicate, skip.
assert!(
is_duplicate_envelope(&cache, envelope_id),
"second sight of same envelope must be flagged as duplicate"
);
// Simulate the receive-loop pattern: count how many times the agent
// would actually be invoked across two deliveries.
let mut agent_invocations = 0;
for _delivery in 0..2 {
if !is_duplicate_envelope(&cache, envelope_id) {
agent_invocations += 1;
}
}
assert_eq!(
agent_invocations, 0,
"after initial double-delivery, no further invocations should occur within TTL"
);
}
#[test]
fn test_envelope_dedup_distinct_ids_pass_through() {
let cache: DashMap<String, Instant> = DashMap::new();
assert!(!is_duplicate_envelope(&cache, "envelope-a"));
assert!(!is_duplicate_envelope(&cache, "envelope-b"));
assert!(!is_duplicate_envelope(&cache, "envelope-c"));
// Each unique envelope_id should be seen exactly once.
assert_eq!(cache.len(), 3);
}
#[test]
fn test_envelope_dedup_empty_id_never_dedupes() {
// Defensive: malformed payloads with no envelope_id should not poison
// the cache or short-circuit forwarding.
let cache: DashMap<String, Instant> = DashMap::new();
assert!(!is_duplicate_envelope(&cache, ""));
assert!(!is_duplicate_envelope(&cache, ""));
assert_eq!(cache.len(), 0);
}
}
+447 -13
View File
@@ -11,9 +11,9 @@ use crate::types::{
use async_trait::async_trait;
use futures::Stream;
use serde::Serialize;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tracing::{debug, info, warn};
@@ -49,6 +49,21 @@ pub struct TelegramAdapter {
/// Bot username (without @), populated from `getMe` during `start()`.
/// Used for @mention detection in group messages.
bot_username: Arc<tokio::sync::RwLock<Option<String>>>,
/// `(chat_id, emoji)` pairs that Telegram has rejected with a terminal
/// `setMessageReaction` error for this bot instance. Checked before
/// issuing the API call so we don't keep retrying reactions that will
/// never succeed in that chat. Keyed by chat so that an emoji restricted
/// in one chat can still be attempted in another (`Chat.available_reactions`
/// can differ per chat and is settable by admins).
///
/// Cached errors: `REACTION_INVALID` (emoji not in the free-reaction
/// allowlist, or not a valid reaction at all) and `REACTION_NOT_AVAILABLE`
/// (chat admin restricted this emoji). Transient errors (429, 5xx,
/// `REACTION_TOO_MANY` per-message rate-limit, unrelated 400s) are NOT
/// cached. Grows monotonically over process lifetime; cache resets on
/// restart, which is fine because admins can change allowed reactions at
/// any time.
rejected_reactions: Arc<Mutex<HashSet<(i64, String)>>>,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
}
@@ -77,6 +92,7 @@ impl TelegramAdapter {
poll_interval,
api_base_url,
bot_username: Arc::new(tokio::sync::RwLock::new(None)),
rejected_reactions: Arc::new(Mutex::new(HashSet::new())),
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
}
@@ -158,8 +174,20 @@ impl TelegramAdapter {
// Any other tag (e.g. <name>, <thinking>) causes a 400 Bad Request.
let sanitized = sanitize_telegram_html(text);
// Telegram has a 4096 character limit per message — split if needed
// Telegram has a 4096 character limit per message — split if needed.
//
// Error semantics for multi-chunk sends match the convention used by
// sibling adapters that also call `split_message` (Discord, Gitter,
// Mattermost, Nextcloud, Twitch, Pumble): fail loudly if NOTHING was
// delivered (first-chunk failure → return Err so the caller knows), but
// treat a mid-stream failure as best-effort — warn and continue — so the
// user isn't told "send failed" after they've already received
// preceding chunks. The motivating bug (HTML parse errors) is always a
// first-chunk failure anyway (sanitization/parse_mode applies to the
// whole text), so this keeps the fix effective while avoiding a
// partial-delivery-then-error regression.
let chunks = split_message(&sanitized, 4096);
let mut delivered_any = false;
for chunk in chunks {
let mut body = serde_json::json!({
"chat_id": chat_id,
@@ -175,7 +203,15 @@ impl TelegramAdapter {
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendMessage failed ({status}): {body_text}");
if !delivered_any {
return Err(
format!("Telegram sendMessage failed ({status}): {body_text}").into(),
);
}
// Partial delivery already happened; continue on best-effort.
continue;
}
delivered_any = true;
}
Ok(())
}
@@ -201,9 +237,11 @@ impl TelegramAdapter {
body["message_thread_id"] = serde_json::json!(tid);
}
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendPhoto failed: {body_text}");
warn!("Telegram sendPhoto failed ({status}): {body_text}");
return Err(format!("Telegram sendPhoto failed ({status}): {body_text}").into());
}
Ok(())
}
@@ -230,9 +268,11 @@ impl TelegramAdapter {
body["message_thread_id"] = serde_json::json!(tid);
}
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendDocument failed: {body_text}");
warn!("Telegram sendDocument failed ({status}): {body_text}");
return Err(format!("Telegram sendDocument failed ({status}): {body_text}").into());
}
Ok(())
}
@@ -268,9 +308,13 @@ impl TelegramAdapter {
}
let resp = self.client.post(&url).multipart(form).send().await?;
if !resp.status().is_success() {
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendDocument upload failed: {body_text}");
warn!("Telegram sendDocument upload failed ({status}): {body_text}");
return Err(
format!("Telegram sendDocument upload failed ({status}): {body_text}").into(),
);
}
Ok(())
}
@@ -291,9 +335,11 @@ impl TelegramAdapter {
body["message_thread_id"] = serde_json::json!(tid);
}
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendVoice failed: {body_text}");
warn!("Telegram sendVoice failed ({status}): {body_text}");
return Err(format!("Telegram sendVoice failed ({status}): {body_text}").into());
}
Ok(())
}
@@ -320,9 +366,11 @@ impl TelegramAdapter {
body["message_thread_id"] = serde_json::json!(tid);
}
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendLocation failed: {body_text}");
warn!("Telegram sendLocation failed ({status}): {body_text}");
return Err(format!("Telegram sendLocation failed ({status}): {body_text}").into());
}
Ok(())
}
@@ -356,7 +404,27 @@ impl TelegramAdapter {
/// Sets or replaces the bot's emoji reaction on a message. Each new call
/// automatically replaces the previous reaction, so there is no need to
/// explicitly remove old ones.
///
/// Telegram restricts non-premium bots to a free-reaction allowlist, and
/// chat admins can further restrict allowed reactions per chat via
/// `Chat.available_reactions`. Terminal errors
/// (`REACTION_INVALID`, `REACTION_NOT_AVAILABLE`) are cached per
/// `(chat_id, emoji)` so we don't keep calling the API with reactions
/// that will never succeed in that chat. `REACTION_TOO_MANY` is a
/// transient per-message rate-limit and is NOT cached. Because two
/// concurrent `fire_reaction` calls for the same `(chat_id, emoji)` can
/// both pass the cache check before either rejection lands, the first
/// rejection may produce up to N duplicate API calls where N is the
/// concurrency — this is benign (insert is idempotent) and self-limits
/// on the second turn.
fn fire_reaction(&self, chat_id: i64, message_id: i64, emoji: &str) {
// Short-circuit: (chat_id, emoji) previously rejected for this bot.
if let Ok(rejected) = self.rejected_reactions.lock() {
if rejected.contains(&(chat_id, emoji.to_string())) {
return;
}
}
let url = format!(
"{}/bot{}/setMessageReaction",
self.api_base_url,
@@ -368,11 +436,23 @@ impl TelegramAdapter {
"reaction": [{"type": "emoji", "emoji": emoji}],
});
let client = self.client.clone();
let rejected_cache = self.rejected_reactions.clone();
let emoji = emoji.to_string();
tokio::spawn(async move {
match client.post(&url).json(&body).send().await {
Ok(resp) if !resp.status().is_success() => {
let body_text = resp.text().await.unwrap_or_default();
debug!("Telegram setMessageReaction failed: {body_text}");
if is_terminal_reaction_error(&body_text) {
if let Ok(mut rejected) = rejected_cache.lock() {
if rejected.insert((chat_id, emoji.clone())) {
debug!(
"Telegram: caching rejected reaction (chat={chat_id}, emoji={emoji:?}); \
further setMessageReaction calls with this pair will be skipped"
);
}
}
}
}
Err(e) => {
debug!("Telegram setMessageReaction error: {e}");
@@ -383,6 +463,16 @@ impl TelegramAdapter {
}
}
/// Terminal errors for `setMessageReaction` — retrying with the same
/// `(chat, emoji)` pair will not succeed without an outside change (chat
/// admin updating `Chat.available_reactions`, bot getting Premium, etc.).
/// Callers cache these and stop retrying. Transient errors (429, 5xx,
/// `RETRY_AFTER`, `REACTION_TOO_MANY` per-message rate-limit, unrelated
/// 400s like `MESSAGE_NOT_MODIFIED`) are NOT included here.
fn is_terminal_reaction_error(body_text: &str) -> bool {
body_text.contains("REACTION_INVALID") || body_text.contains("REACTION_NOT_AVAILABLE")
}
impl TelegramAdapter {
/// Internal helper: send content with optional forum-topic thread_id.
///
@@ -1909,4 +1999,348 @@ mod tests {
}
assert!(!msg.metadata.contains_key("reply_to_message_id"));
}
// -----------------------------------------------------------------------
// Stub Telegram Bot API server for send-path and reaction-cache tests.
//
// Binds an axum app to an ephemeral port, returns a base URL that the
// `TelegramAdapter` can be pointed at via the `api_url` constructor
// parameter, and records per-call response fixtures + hit count.
// -----------------------------------------------------------------------
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Default)]
struct StubServer {
hits: AtomicUsize,
responses: std::sync::Mutex<Vec<(u16, String)>>,
}
impl StubServer {
fn new(responses: Vec<(u16, &str)>) -> Arc<Self> {
Arc::new(Self {
hits: AtomicUsize::new(0),
responses: std::sync::Mutex::new(
responses
.into_iter()
.map(|(s, b)| (s, b.to_string()))
.collect(),
),
})
}
fn hit_count(&self) -> usize {
self.hits.load(Ordering::SeqCst)
}
}
async fn spawn_stub_server(stub: Arc<StubServer>) -> String {
use axum::{http::StatusCode, routing::any, Router};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let stub_for_handler = stub.clone();
let app = Router::new().fallback(any(move || {
let stub = stub_for_handler.clone();
async move {
let i = stub.hits.fetch_add(1, Ordering::SeqCst);
let responses = stub.responses.lock().unwrap();
if i < responses.len() {
let (status, body) = responses[i].clone();
(
StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
body,
)
} else {
(
StatusCode::OK,
r#"{"ok":true,"result":true}"#.to_string(),
)
}
}
}));
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
format!("http://{}", addr)
}
/// Build an adapter pointed at a stub server, bypassing `start()` (which
/// would call `getMe` / `setMyCommands` against the real API).
fn test_adapter(api_url: String) -> TelegramAdapter {
TelegramAdapter::new(
"test:token".to_string(),
vec![],
Duration::from_millis(10),
Some(api_url),
)
}
async fn wait_for<F>(mut cond: F, timeout_ms: u64) -> bool
where
F: FnMut() -> bool,
{
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
while std::time::Instant::now() < deadline {
if cond() {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
cond()
}
// -----------------------------------------------------------------------
// send-path error propagation (api_send_message)
// -----------------------------------------------------------------------
#[tokio::test]
async fn test_api_send_message_single_chunk_400_returns_err() {
let stub = StubServer::new(vec![(
400,
r#"{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities"}"#,
)]);
let base = spawn_stub_server(stub.clone()).await;
let adapter = test_adapter(base);
let result = adapter.api_send_message(12345, "hello", None).await;
assert!(result.is_err(), "expected Err on single-chunk 400");
let err = result.unwrap_err().to_string();
assert!(err.contains("400"), "err should include status: {err}");
assert!(
err.contains("can't parse entities"),
"err should include body: {err}"
);
assert_eq!(stub.hit_count(), 1, "expected exactly one POST");
}
#[tokio::test]
async fn test_api_send_message_single_chunk_200_returns_ok() {
let stub = StubServer::new(vec![(200, r#"{"ok":true,"result":{}}"#)]);
let base = spawn_stub_server(stub.clone()).await;
let adapter = test_adapter(base);
let result = adapter.api_send_message(12345, "hello", None).await;
assert!(result.is_ok(), "expected Ok on 200: {result:?}");
assert_eq!(stub.hit_count(), 1);
}
#[tokio::test]
async fn test_api_send_message_first_chunk_fail_returns_err() {
// Two-chunk message; first POST fails. Nothing delivered → Err.
let big = "a".repeat(5000); // > 4096 → split into two chunks
let stub = StubServer::new(vec![
(500, r#"{"ok":false,"error_code":500,"description":"server"}"#),
(200, r#"{"ok":true,"result":{}}"#),
]);
let base = spawn_stub_server(stub.clone()).await;
let adapter = test_adapter(base);
let result = adapter.api_send_message(12345, &big, None).await;
assert!(
result.is_err(),
"first-chunk failure must return Err, got Ok"
);
// Must have stopped after the failing first chunk — no partial send.
assert_eq!(
stub.hit_count(),
1,
"expected adapter to abort after first-chunk failure"
);
}
#[tokio::test]
async fn test_api_send_message_partial_delivery_returns_ok() {
// Two-chunk message; first POST succeeds (user sees chunk 1), second
// fails. Match sibling-adapter best-effort convention: warn + continue,
// return Ok so the agent isn't told total failure after partial success.
let big = "a".repeat(5000);
let stub = StubServer::new(vec![
(200, r#"{"ok":true,"result":{}}"#),
(400, r#"{"ok":false,"error_code":400,"description":"some err"}"#),
]);
let base = spawn_stub_server(stub.clone()).await;
let adapter = test_adapter(base);
let result = adapter.api_send_message(12345, &big, None).await;
assert!(
result.is_ok(),
"partial delivery must return Ok (best-effort), got {result:?}"
);
assert_eq!(stub.hit_count(), 2, "both chunks should have been attempted");
}
// -----------------------------------------------------------------------
// reaction cache (fire_reaction + is_terminal_reaction_error)
// -----------------------------------------------------------------------
#[test]
fn test_is_terminal_reaction_error_matches() {
assert!(is_terminal_reaction_error(
r#"{"ok":false,"description":"Bad Request: REACTION_INVALID"}"#
));
assert!(is_terminal_reaction_error(
r#"{"description":"Bad Request: REACTION_NOT_AVAILABLE"}"#
));
}
#[test]
fn test_is_terminal_reaction_error_rejects_transient() {
// REACTION_TOO_MANY is a per-message rate-limit, not permanent.
// Caching it would suppress valid future reactions on that emoji
// for the lifetime of the process — see issue #1133.
assert!(!is_terminal_reaction_error(
r#"{"description":"Bad Request: REACTION_TOO_MANY"}"#
));
assert!(!is_terminal_reaction_error(
r#"{"description":"Too Many Requests: retry after 5"}"#
));
assert!(!is_terminal_reaction_error(
r#"{"description":"Bad Request: MESSAGE_NOT_MODIFIED"}"#
));
assert!(!is_terminal_reaction_error(r#"{"ok":true}"#));
assert!(!is_terminal_reaction_error(""));
}
#[tokio::test]
async fn test_fire_reaction_does_not_cache_reaction_too_many() {
// Regression test for #1133: REACTION_TOO_MANY is a transient
// per-message rate-limit and must NOT be cached as a permanent
// rejection. Caching it would suppress valid future reactions on
// that (chat_id, emoji) pair for the lifetime of the process.
let stub = StubServer::new(vec![(
400,
r#"{"ok":false,"error_code":400,"description":"Bad Request: REACTION_TOO_MANY"}"#,
)]);
let base = spawn_stub_server(stub.clone()).await;
let adapter = test_adapter(base);
adapter.fire_reaction(999, 1, "");
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(stub.hit_count(), 1);
let cached = adapter
.rejected_reactions
.lock()
.map(|s| s.contains(&(999_i64, "".to_string())))
.unwrap_or(true);
assert!(
!cached,
"REACTION_TOO_MANY is transient and must NOT populate the cache"
);
}
#[tokio::test]
async fn test_fire_reaction_caches_on_reaction_invalid() {
let stub = StubServer::new(vec![(
400,
r#"{"ok":false,"error_code":400,"description":"Bad Request: REACTION_INVALID"}"#,
)]);
let base = spawn_stub_server(stub.clone()).await;
let adapter = test_adapter(base);
adapter.fire_reaction(999, 1, "");
let cached = wait_for(
|| {
adapter
.rejected_reactions
.lock()
.map(|s| s.contains(&(999_i64, "".to_string())))
.unwrap_or(false)
},
1000,
)
.await;
assert!(cached, "emoji should be cached after REACTION_INVALID");
assert_eq!(stub.hit_count(), 1);
// Second call with same (chat, emoji) must short-circuit.
adapter.fire_reaction(999, 2, "");
// Give any rogue task time to fire.
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
stub.hit_count(),
1,
"short-circuit should have prevented second POST"
);
}
#[tokio::test]
async fn test_fire_reaction_cache_is_per_chat() {
// Same emoji rejected in chat A should NOT short-circuit in chat B.
let stub = StubServer::new(vec![
(
400,
r#"{"ok":false,"error_code":400,"description":"Bad Request: REACTION_INVALID"}"#,
),
(200, r#"{"ok":true,"result":true}"#),
]);
let base = spawn_stub_server(stub.clone()).await;
let adapter = test_adapter(base);
adapter.fire_reaction(111, 1, "");
wait_for(
|| {
adapter
.rejected_reactions
.lock()
.map(|s| s.contains(&(111_i64, "".to_string())))
.unwrap_or(false)
},
1000,
)
.await;
assert_eq!(stub.hit_count(), 1);
adapter.fire_reaction(222, 1, "");
wait_for(|| stub.hit_count() >= 2, 1000).await;
assert_eq!(
stub.hit_count(),
2,
"different chat_id must still fire even when same emoji was cached"
);
}
#[tokio::test]
async fn test_fire_reaction_does_not_cache_non_terminal() {
let stub = StubServer::new(vec![(
400,
r#"{"ok":false,"error_code":400,"description":"Bad Request: MESSAGE_NOT_MODIFIED"}"#,
)]);
let base = spawn_stub_server(stub.clone()).await;
let adapter = test_adapter(base);
adapter.fire_reaction(999, 1, "");
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(stub.hit_count(), 1);
let cached = adapter
.rejected_reactions
.lock()
.map(|s| s.contains(&(999_i64, "".to_string())))
.unwrap_or(true);
assert!(!cached, "non-terminal 400 must NOT populate the cache");
}
#[tokio::test]
async fn test_fire_reaction_does_not_cache_on_success() {
let stub = StubServer::new(vec![(200, r#"{"ok":true,"result":true}"#)]);
let base = spawn_stub_server(stub.clone()).await;
let adapter = test_adapter(base);
adapter.fire_reaction(999, 1, "🤔");
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(stub.hit_count(), 1);
let cached = adapter
.rejected_reactions
.lock()
.map(|s| s.contains(&(999_i64, "🤔".to_string())))
.unwrap_or(true);
assert!(!cached, "successful reaction must NOT populate the cache");
}
}
+36 -66
View File
@@ -576,13 +576,11 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::CreateMethod;
}
KeyCode::Enter => {
if !self.custom_name.is_empty() {
if self.custom_desc.is_empty() {
self.custom_desc = format!("A custom {} agent", self.custom_name);
}
self.sub = AgentSubScreen::CustomDesc;
KeyCode::Enter if !self.custom_name.is_empty() => {
if self.custom_desc.is_empty() {
self.custom_desc = format!("A custom {} agent", self.custom_name);
}
self.sub = AgentSubScreen::CustomDesc;
}
KeyCode::Char(c) => {
self.custom_name.push(c);
@@ -641,15 +639,11 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::CustomPrompt;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.tool_cursor > 0 {
self.tool_cursor -= 1;
}
KeyCode::Up | KeyCode::Char('k') if self.tool_cursor > 0 => {
self.tool_cursor -= 1;
}
KeyCode::Down | KeyCode::Char('j') => {
if self.tool_cursor < TOOL_OPTIONS.len() - 1 {
self.tool_cursor += 1;
}
KeyCode::Down | KeyCode::Char('j') if self.tool_cursor < TOOL_OPTIONS.len() - 1 => {
self.tool_cursor += 1;
}
KeyCode::Char(' ') => {
self.tool_checks[self.tool_cursor] = !self.tool_checks[self.tool_cursor];
@@ -674,21 +668,15 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::CustomTools;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.skill_cursor > 0 {
self.skill_cursor -= 1;
}
KeyCode::Up | KeyCode::Char('k') if self.skill_cursor > 0 => {
self.skill_cursor -= 1;
}
KeyCode::Down | KeyCode::Char('j') => {
if len > 0 && self.skill_cursor < len - 1 {
self.skill_cursor += 1;
}
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.skill_cursor < len - 1 => {
self.skill_cursor += 1;
}
KeyCode::Char(' ') => {
if len > 0 {
let checked = &mut self.available_skills[self.skill_cursor].1;
*checked = !*checked;
}
KeyCode::Char(' ') if len > 0 => {
let checked = &mut self.available_skills[self.skill_cursor].1;
*checked = !*checked;
}
KeyCode::Enter => {
// Advance to MCP server selection
@@ -706,21 +694,15 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::CustomSkills;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.mcp_cursor > 0 {
self.mcp_cursor -= 1;
}
KeyCode::Up | KeyCode::Char('k') if self.mcp_cursor > 0 => {
self.mcp_cursor -= 1;
}
KeyCode::Down | KeyCode::Char('j') => {
if len > 0 && self.mcp_cursor < len - 1 {
self.mcp_cursor += 1;
}
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.mcp_cursor < len - 1 => {
self.mcp_cursor += 1;
}
KeyCode::Char(' ') => {
if len > 0 {
let checked = &mut self.available_mcp[self.mcp_cursor].1;
*checked = !*checked;
}
KeyCode::Char(' ') if len > 0 => {
let checked = &mut self.available_mcp[self.mcp_cursor].1;
*checked = !*checked;
}
KeyCode::Enter => {
let toml = self.build_custom_toml();
@@ -737,21 +719,15 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::AgentDetail;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.skill_cursor > 0 {
self.skill_cursor -= 1;
}
KeyCode::Up | KeyCode::Char('k') if self.skill_cursor > 0 => {
self.skill_cursor -= 1;
}
KeyCode::Down | KeyCode::Char('j') => {
if len > 0 && self.skill_cursor < len - 1 {
self.skill_cursor += 1;
}
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.skill_cursor < len - 1 => {
self.skill_cursor += 1;
}
KeyCode::Char(' ') => {
if len > 0 {
let checked = &mut self.available_skills[self.skill_cursor].1;
*checked = !*checked;
}
KeyCode::Char(' ') if len > 0 => {
let checked = &mut self.available_skills[self.skill_cursor].1;
*checked = !*checked;
}
KeyCode::Enter => {
// Save — collect checked skill names (none checked = "all")
@@ -780,21 +756,15 @@ impl AgentSelectState {
KeyCode::Esc => {
self.sub = AgentSubScreen::AgentDetail;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.mcp_cursor > 0 {
self.mcp_cursor -= 1;
}
KeyCode::Up | KeyCode::Char('k') if self.mcp_cursor > 0 => {
self.mcp_cursor -= 1;
}
KeyCode::Down | KeyCode::Char('j') => {
if len > 0 && self.mcp_cursor < len - 1 {
self.mcp_cursor += 1;
}
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.mcp_cursor < len - 1 => {
self.mcp_cursor += 1;
}
KeyCode::Char(' ') => {
if len > 0 {
let checked = &mut self.available_mcp[self.mcp_cursor].1;
*checked = !*checked;
}
KeyCode::Char(' ') if len > 0 => {
let checked = &mut self.available_mcp[self.mcp_cursor].1;
*checked = !*checked;
}
KeyCode::Enter => {
// Save — collect checked server names (none checked = "all")
+8 -12
View File
@@ -164,19 +164,15 @@ impl AuditState {
let total = self.filtered.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Char('f') => {
self.action_filter = self.action_filter.next();
+28 -31
View File
@@ -155,19 +155,19 @@ impl CommsState {
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::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));
}
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));
}
_ => {}
}
@@ -189,18 +189,17 @@ impl CommsState {
self.send_field - 1
};
}
KeyCode::Enter => {
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(),
};
}
&& !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),
@@ -238,15 +237,13 @@ impl CommsState {
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::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),
@@ -152,12 +152,10 @@ impl ExtensionsState {
self.sub = ExtSub::Health;
return ExtensionsAction::RefreshHealth;
}
KeyCode::Char('/') => {
if self.sub == ExtSub::Browse {
self.searching = true;
self.search_query.clear();
return ExtensionsAction::Continue;
}
KeyCode::Char('/') if self.sub == ExtSub::Browse => {
self.searching = true;
self.search_query.clear();
return ExtensionsAction::Continue;
}
_ => {}
}
@@ -172,19 +170,15 @@ impl ExtensionsState {
fn handle_browse(&mut self, key: KeyEvent) -> ExtensionsAction {
let total = self.filtered().len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.browse_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.browse_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.browse_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.browse_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.browse_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.browse_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.browse_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.browse_list.select(Some(next));
}
KeyCode::Enter => {
let filtered = self.filtered();
@@ -222,24 +216,18 @@ impl ExtensionsState {
let total = self.installed_list_data().len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.installed_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.installed_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.installed_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.installed_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.installed_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.installed_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.installed_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.installed_list.select(Some(next));
}
KeyCode::Char('d') | KeyCode::Delete => {
if self.installed_list.selected().is_some() {
self.confirm_remove = true;
}
KeyCode::Char('d') | KeyCode::Delete if self.installed_list.selected().is_some() => {
self.confirm_remove = true;
}
KeyCode::Char('r') => return ExtensionsAction::RefreshAll,
_ => {}
@@ -250,19 +238,15 @@ impl ExtensionsState {
fn handle_health(&mut self, key: KeyEvent) -> ExtensionsAction {
let total = self.health_entries.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.health_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.health_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.health_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.health_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.health_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.health_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.health_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.health_list.select(Some(next));
}
KeyCode::Char('r') | KeyCode::Enter => {
if let Some(sel) = self.health_list.selected() {
+18 -28
View File
@@ -109,19 +109,15 @@ impl HandsState {
fn handle_marketplace(&mut self, key: KeyEvent) -> HandsAction {
let total = self.definitions.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.marketplace_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.marketplace_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.marketplace_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.marketplace_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.marketplace_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.marketplace_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.marketplace_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.marketplace_list.select(Some(next));
}
KeyCode::Enter | KeyCode::Char('a') => {
if let Some(sel) = self.marketplace_list.selected() {
@@ -157,24 +153,18 @@ impl HandsState {
let total = self.instances.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.active_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.active_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.active_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.active_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.active_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.active_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.active_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.active_list.select(Some(next));
}
KeyCode::Char('d') | KeyCode::Delete => {
if self.active_list.selected().is_some() {
self.confirm_deactivate = true;
}
KeyCode::Char('d') | KeyCode::Delete if self.active_list.selected().is_some() => {
self.confirm_deactivate = true;
}
KeyCode::Char('p') => {
if let Some(sel) = self.active_list.selected() {
@@ -991,16 +991,15 @@ pub fn run() -> InitResult {
state.step = Step::Provider;
}
}
KeyCode::Enter => {
KeyCode::Enter
if matches!(
state.copilot_auth_status,
CopilotAuthStatus::WaitingForUser
) && !state.copilot_verification_uri.is_empty()
{
let _ = openfang_runtime::drivers::copilot::open_verification_url(
&state.copilot_verification_uri,
);
}
) && !state.copilot_verification_uri.is_empty() =>
{
let _ = openfang_runtime::drivers::copilot::open_verification_url(
&state.copilot_verification_uri,
);
}
_ => {}
},
@@ -1015,41 +1014,36 @@ pub fn run() -> InitResult {
state.key_test = KeyTestState::Idle;
state.step = Step::Provider;
}
KeyCode::Enter => {
KeyCode::Enter
if !state.api_key_input.is_empty()
&& state.key_test == KeyTestState::Idle
{
if let Some(p) = state.provider() {
let _ = crate::dotenv::save_env_key(
p.env_var,
&state.api_key_input,
);
}
state.key_test = KeyTestState::Testing;
let provider_name = state
.provider()
.map(|p| p.name.to_string())
.unwrap_or_default();
let env_var = state
.provider()
.map(|p| p.env_var.to_string())
.unwrap_or_default();
let tx = test_tx.clone();
std::thread::spawn(move || {
let ok = crate::test_api_key(&provider_name, &env_var);
let _ = tx.send(ok);
});
&& state.key_test == KeyTestState::Idle =>
{
if let Some(p) = state.provider() {
let _ = crate::dotenv::save_env_key(
p.env_var,
&state.api_key_input,
);
}
state.key_test = KeyTestState::Testing;
let provider_name = state
.provider()
.map(|p| p.name.to_string())
.unwrap_or_default();
let env_var = state
.provider()
.map(|p| p.env_var.to_string())
.unwrap_or_default();
let tx = test_tx.clone();
std::thread::spawn(move || {
let ok = crate::test_api_key(&provider_name, &env_var);
let _ = tx.send(ok);
});
}
KeyCode::Char(c) => {
if state.key_test == KeyTestState::Idle {
state.api_key_input.push(c);
}
KeyCode::Char(c) if state.key_test == KeyTestState::Idle => {
state.api_key_input.push(c);
}
KeyCode::Backspace => {
if state.key_test == KeyTestState::Idle {
state.api_key_input.pop();
}
KeyCode::Backspace if state.key_test == KeyTestState::Idle => {
state.api_key_input.pop();
}
_ => {}
}
+12 -20
View File
@@ -211,19 +211,15 @@ impl LogsState {
let total = self.filtered.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Char('f') => {
self.level_filter = self.level_filter.next();
@@ -237,15 +233,11 @@ impl LogsState {
self.auto_refresh = !self.auto_refresh;
}
KeyCode::Char('r') => return LogsAction::Refresh,
KeyCode::End => {
if total > 0 {
self.list_state.select(Some(total - 1));
}
KeyCode::End if total > 0 => {
self.list_state.select(Some(total - 1));
}
KeyCode::Home => {
if total > 0 {
self.list_state.select(Some(0));
}
KeyCode::Home if total > 0 => {
self.list_state.select(Some(0));
}
_ => {}
}
+18 -28
View File
@@ -106,19 +106,15 @@ impl MemoryState {
fn handle_agent_select(&mut self, key: KeyEvent) -> MemoryAction {
let total = self.agents.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.agent_list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.agent_list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.agent_list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.agent_list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.agent_list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.agent_list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.agent_list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.agent_list_state.select(Some(next));
}
KeyCode::Enter => {
if let Some(sel) = self.agent_list_state.selected() {
@@ -166,19 +162,15 @@ impl MemoryState {
self.kv_pairs.clear();
self.selected_agent = None;
}
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.kv_list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.kv_list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.kv_list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.kv_list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.kv_list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.kv_list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.kv_list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.kv_list_state.select(Some(next));
}
KeyCode::Char('a') => {
self.sub = MemorySub::AddKey;
@@ -196,10 +188,8 @@ impl MemoryState {
}
}
}
KeyCode::Char('d') => {
if self.kv_list_state.selected().is_some() {
self.confirm_delete = true;
}
KeyCode::Char('d') if self.kv_list_state.selected().is_some() => {
self.confirm_delete = true;
}
KeyCode::Char('r') => {
if let Some(agent) = &self.selected_agent {
+8 -12
View File
@@ -62,19 +62,15 @@ impl PeersState {
}
let total = self.peers.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Char('r') => return PeersAction::Refresh,
_ => {}
+10 -16
View File
@@ -130,19 +130,15 @@ impl SessionsState {
let total = self.filtered.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Enter => {
if let Some(sel) = self.list_state.selected() {
@@ -155,10 +151,8 @@ impl SessionsState {
}
}
}
KeyCode::Char('d') => {
if self.list_state.selected().is_some() {
self.confirm_delete = true;
}
KeyCode::Char('d') if self.list_state.selected().is_some() => {
self.confirm_delete = true;
}
KeyCode::Char('/') => {
self.search_mode = true;
+26 -38
View File
@@ -174,21 +174,17 @@ impl SettingsState {
fn handle_providers(&mut self, key: KeyEvent) -> SettingsAction {
let total = self.providers.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.provider_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.provider_list.select(Some(next));
self.test_result = None;
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.provider_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.provider_list.select(Some(next));
self.test_result = None;
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.provider_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.provider_list.select(Some(next));
self.test_result = None;
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.provider_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.provider_list.select(Some(next));
self.test_result = None;
}
KeyCode::Char('e') => {
if let Some(sel) = self.provider_list.selected() {
@@ -223,19 +219,15 @@ impl SettingsState {
fn handle_models(&mut self, key: KeyEvent) -> SettingsAction {
let total = self.models.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.model_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.model_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.model_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.model_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.model_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.model_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.model_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.model_list.select(Some(next));
}
KeyCode::Char('r') => return SettingsAction::RefreshModels,
_ => {}
@@ -246,19 +238,15 @@ impl SettingsState {
fn handle_tools(&mut self, key: KeyEvent) -> SettingsAction {
let total = self.tools.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.tool_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.tool_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.tool_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.tool_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.tool_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.tool_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.tool_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.tool_list.select(Some(next));
}
KeyCode::Char('r') => return SettingsAction::RefreshTools,
_ => {}
+26 -40
View File
@@ -192,24 +192,18 @@ impl SkillsState {
let total = self.installed.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.installed_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.installed_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.installed_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.installed_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.installed_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.installed_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.installed_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.installed_list.select(Some(next));
}
KeyCode::Char('u') => {
if self.installed_list.selected().is_some() {
self.confirm_uninstall = true;
}
KeyCode::Char('u') if self.installed_list.selected().is_some() => {
self.confirm_uninstall = true;
}
KeyCode::Char('c') => {
if let Some(sel) = self.installed_list.selected() {
@@ -255,19 +249,15 @@ impl SkillsState {
let total = self.clawhub_results.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.clawhub_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.clawhub_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.clawhub_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.clawhub_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.clawhub_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.clawhub_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.clawhub_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.clawhub_list.select(Some(next));
}
KeyCode::Char('i') => {
if let Some(sel) = self.clawhub_list.selected() {
@@ -295,19 +285,15 @@ impl SkillsState {
fn handle_mcp(&mut self, key: KeyEvent) -> SkillsAction {
let total = self.mcp_servers.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.mcp_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.mcp_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.mcp_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.mcp_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.mcp_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.mcp_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.mcp_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.mcp_list.select(Some(next));
}
KeyCode::Char('r') => return SkillsAction::RefreshMcp,
_ => {}
@@ -194,19 +194,15 @@ impl TemplatesState {
let total = self.filtered.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Enter => {
if let Some(sel) = self.list_state.selected() {
@@ -156,19 +156,15 @@ impl TriggerState {
self.create_step -= 1;
}
}
KeyCode::Enter => {
if self.create_step < 5 {
self.create_step += 1;
}
KeyCode::Enter if self.create_step < 5 => {
self.create_step += 1;
}
KeyCode::Char(c) => match self.create_step {
0 => self.create_agent_id.push(c),
2 => self.create_pattern_param.push(c),
3 => self.create_prompt.push(c),
4 => {
if c.is_ascii_digit() {
self.create_max_fires.push(c);
}
4 if c.is_ascii_digit() => {
self.create_max_fires.push(c);
}
_ => {}
},
+16 -24
View File
@@ -111,19 +111,15 @@ impl UsageState {
UsageSub::ByModel => {
let total = self.by_model.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.model_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.model_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.model_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.model_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.model_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.model_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.model_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.model_list.select(Some(next));
}
KeyCode::Char('r') => return UsageAction::Refresh,
_ => {}
@@ -132,19 +128,15 @@ impl UsageState {
UsageSub::ByAgent => {
let total = self.by_agent.len();
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if total > 0 {
let i = self.agent_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.agent_list.select(Some(next));
}
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
let i = self.agent_list.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.agent_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
if total > 0 {
let i = self.agent_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.agent_list.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
let i = self.agent_list.selected().unwrap_or(0);
let next = (i + 1) % total;
self.agent_list.select(Some(next));
}
KeyCode::Char('r') => return UsageAction::Refresh,
_ => {}
@@ -328,13 +328,11 @@ impl WizardState {
KeyCode::Esc => {
self.step = WizardStep::Provider;
}
KeyCode::Enter => {
if !self.api_key_input.is_empty() {
if let Some(p) = self.selected_provider_info() {
self.model_input = p.default_model.to_string();
}
self.step = WizardStep::Model;
KeyCode::Enter if !self.api_key_input.is_empty() => {
if let Some(p) = self.selected_provider_info() {
self.model_input = p.default_model.to_string();
}
self.step = WizardStep::Model;
}
KeyCode::Char(c) => {
self.api_key_input.push(c);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenFang",
"version": "0.6.1",
"version": "0.6.4",
"identifier": "ai.openfang.desktop",
"build": {},
"app": {
@@ -483,6 +483,79 @@ mod tests {
assert!(plan.hot_actions.contains(&HotAction::ReloadProviderUrls));
}
/// #1129: editing `[default_model].subprocess_timeout_secs` must produce
/// a hot-reload action so cross-message timeout retunes don't require
/// a daemon bounce. The whole `default_model` block round-trips through
/// `UpdateDefaultModel`, which carries the new timeout into the override
/// slot read by `resolve_driver`.
#[test]
fn test_default_model_subprocess_timeout_hot_reload() {
let a = default_cfg();
let mut b = default_cfg();
b.default_model.subprocess_timeout_secs = Some(900);
let plan = build_reload_plan(&a, &b);
assert!(
!plan.restart_required,
"subprocess_timeout_secs edits on default_model must be hot-reloadable"
);
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
}
/// #1129: editing `[[fallback_providers]]` (including
/// `subprocess_timeout_secs` on a non-default provider) must produce a
/// `ReloadFallbackProviders` hot-action. Without this, mixed-fleet
/// operators have no live tuning knob for their non-default driver.
#[test]
fn test_fallback_providers_subprocess_timeout_hot_reload() {
use openfang_types::config::FallbackProviderConfig;
let mut a = default_cfg();
let mut b = default_cfg();
a.fallback_providers.push(FallbackProviderConfig {
provider: "codex".to_string(),
model: "gpt-5-codex".to_string(),
api_key_env: String::new(),
base_url: None,
subprocess_timeout_secs: Some(120),
});
b.fallback_providers.push(FallbackProviderConfig {
provider: "codex".to_string(),
model: "gpt-5-codex".to_string(),
api_key_env: String::new(),
base_url: None,
// Operator raises the ceiling for slow Codex turns.
subprocess_timeout_secs: Some(900),
});
let plan = build_reload_plan(&a, &b);
assert!(
!plan.restart_required,
"[[fallback_providers]] edits must be hot-reloadable"
);
assert!(plan
.hot_actions
.contains(&HotAction::ReloadFallbackProviders));
}
/// #1129: adding a brand-new `[[fallback_providers]]` entry on reload also
/// emits the hot-action so the new provider is picked up without bounce.
#[test]
fn test_fallback_providers_add_entry_hot_reload() {
use openfang_types::config::FallbackProviderConfig;
let a = default_cfg();
let mut b = default_cfg();
b.fallback_providers.push(FallbackProviderConfig {
provider: "ollama".to_string(),
model: "llama3.2:latest".to_string(),
api_key_env: String::new(),
base_url: None,
subprocess_timeout_secs: Some(300),
});
let plan = build_reload_plan(&a, &b);
assert!(!plan.restart_required);
assert!(plan
.hot_actions
.contains(&HotAction::ReloadFallbackProviders));
}
// -----------------------------------------------------------------------
// Mixed changes
// -----------------------------------------------------------------------
+425 -20
View File
@@ -164,6 +164,16 @@ pub struct OpenFangKernel {
/// Hot-reloadable default model override (set via config hot-reload, read at agent spawn).
pub default_model_override:
std::sync::RwLock<Option<openfang_types::config::DefaultModelConfig>>,
/// Hot-reloadable fallback provider chain override.
///
/// Set by `apply_hot_actions(ReloadFallbackProviders)` when
/// `[[fallback_providers]]` changes in `config.toml`. `resolve_driver`
/// reads this in preference to `self.config.fallback_providers`, so
/// timeout edits and provider list mutations take effect on the next
/// driver build without a daemon bounce. `None` means "fall back to the
/// boot-time `self.config.fallback_providers`". (#1129)
pub fallback_providers_override:
std::sync::RwLock<Option<Vec<openfang_types::config::FallbackProviderConfig>>>,
/// Per-agent message locks — serializes LLM calls for the same agent to prevent
/// session corruption when multiple messages arrive concurrently (e.g. rapid voice
/// messages via Telegram). Different agents can still run in parallel.
@@ -1174,6 +1184,7 @@ impl OpenFangKernel {
whatsapp_gateway_pid: Arc::new(std::sync::Mutex::new(None)),
channel_adapters: dashmap::DashMap::new(),
default_model_override: std::sync::RwLock::new(None),
fallback_providers_override: std::sync::RwLock::new(None),
agent_msg_locks: dashmap::DashMap::new(),
self_handle: OnceLock::new(),
};
@@ -1186,6 +1197,15 @@ impl OpenFangKernel {
let agent_id = entry.id;
let name = entry.name.clone();
// Track whether on-disk agent.toml explicitly defines an
// exec_policy override. If it does, that's the per-agent
// setting. If not, the kernel's current config.exec_policy
// is authoritative and must overwrite the stale DB value
// (fixes #1132: changing config.toml exec_policy.mode = "full"
// had no effect on agents whose manifests cached the older
// inherited Allowlist policy at spawn time).
let mut disk_has_exec_policy_override = false;
// Check if TOML on disk is newer/different — if so, update from file
let mut entry = entry;
let toml_path = kernel
@@ -1201,6 +1221,12 @@ impl OpenFangKernel {
&toml_str,
) {
Ok(disk_manifest) => {
// Capture whether agent.toml defines exec_policy
// explicitly (so we don't blow it away with the
// kernel default below).
if disk_manifest.exec_policy.is_some() {
disk_has_exec_policy_override = true;
}
// Compare key fields to detect changes.
// IMPORTANT: keep this list in sync with AgentManifest
// fields that users may legitimately edit in agent.toml.
@@ -1290,8 +1316,24 @@ impl OpenFangKernel {
restored_entry.state = AgentState::Running;
restored_entry.last_active = chrono::Utc::now();
// Inherit kernel exec_policy for agents that lack one
if restored_entry.manifest.exec_policy.is_none() {
// Resolve exec_policy on every restart so that edits to
// config.toml's [exec_policy] take effect (fixes #1132).
//
// Precedence:
// 1. agent.toml on disk explicitly sets [exec_policy] →
// keep the per-agent override.
// 2. otherwise → always re-inherit the kernel's current
// config.exec_policy, even if the DB has a cached
// value from an earlier boot. The cached value would
// otherwise pin the agent to the inherited mode at
// first spawn (typically Allowlist) regardless of
// later config edits.
if !disk_has_exec_policy_override {
restored_entry.manifest.exec_policy =
Some(kernel.config.exec_policy.clone());
} else if restored_entry.manifest.exec_policy.is_none() {
// Defensive: should not happen given the flag, but keep
// the manifest non-None for the runtime check.
restored_entry.manifest.exec_policy =
Some(kernel.config.exec_policy.clone());
}
@@ -2291,6 +2333,7 @@ impl OpenFangKernel {
max_memory_bytes: entry.manifest.resources.max_memory_bytes as usize,
capabilities: caps,
timeout_secs: Some(30),
ssrf_allowed_hosts: self.config.web.fetch.ssrf_allowed_hosts.clone(),
};
let input = serde_json::json!({
@@ -3045,7 +3088,19 @@ impl OpenFangKernel {
if let Some(entry) = self.registry.get(agent_id) {
let dir = self.config.home_dir.join("agents").join(&entry.name);
let toml_path = dir.join("agent.toml");
match toml::to_string_pretty(&entry.manifest) {
// Strip exec_policy from the on-disk copy when it matches the
// current kernel default (i.e. the agent inherited it). This way,
// a later edit to config.toml's [exec_policy] is not silently
// shadowed by a stale snapshot we wrote here (#1132).
let mut manifest_for_disk = entry.manifest.clone();
if manifest_for_disk
.exec_policy
.as_ref()
.is_some_and(|p| p == &self.config.exec_policy)
{
manifest_for_disk.exec_policy = None;
}
match toml::to_string_pretty(&manifest_for_disk) {
Ok(toml_str) => {
if let Err(e) = std::fs::create_dir_all(&dir) {
warn!(agent = %entry.name, "Failed to create agent dir for manifest persist: {e}");
@@ -3590,10 +3645,10 @@ impl OpenFangKernel {
for req in &def.requires {
match req.requirement_type {
openfang_hands::RequirementType::ApiKey
| openfang_hands::RequirementType::EnvVar => {
if !req.check_value.is_empty() && !allowed_env.contains(&req.check_value) {
allowed_env.push(req.check_value.clone());
}
| openfang_hands::RequirementType::EnvVar
if !req.check_value.is_empty() && !allowed_env.contains(&req.check_value) =>
{
allowed_env.push(req.check_value.clone());
}
_ => {}
}
@@ -3800,7 +3855,7 @@ impl OpenFangKernel {
let mut bindings = self.bindings.lock().unwrap_or_else(|e| e.into_inner());
bindings.push(binding);
// Sort by specificity descending
bindings.sort_by(|a, b| b.match_rule.specificity().cmp(&a.match_rule.specificity()));
bindings.sort_by_key(|b| std::cmp::Reverse(b.match_rule.specificity()));
}
/// Remove a binding by index, returns the removed binding if valid.
@@ -3878,8 +3933,10 @@ impl OpenFangKernel {
}
HotAction::UpdateDefaultModel => {
info!(
"Hot-reload: updating default model to {}/{}",
new_config.default_model.provider, new_config.default_model.model
"Hot-reload: updating default model to {}/{} (subprocess_timeout_secs={:?})",
new_config.default_model.provider,
new_config.default_model.model,
new_config.default_model.subprocess_timeout_secs,
);
let mut guard = self
.default_model_override
@@ -3887,6 +3944,23 @@ impl OpenFangKernel {
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
*guard = Some(new_config.default_model.clone());
}
HotAction::ReloadFallbackProviders => {
info!(
"Hot-reload: applying fallback provider chain ({} provider(s))",
new_config.fallback_providers.len()
);
for fb in &new_config.fallback_providers {
info!(
"Hot-reload: fallback provider '{}' subprocess_timeout_secs={:?}",
fb.provider, fb.subprocess_timeout_secs,
);
}
let mut guard = self
.fallback_providers_override
.write()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
*guard = Some(new_config.fallback_providers.clone());
}
_ => {
// Other hot actions (channels, web, browser, extensions, etc.)
// are logged but not applied here — they require subsystem-specific
@@ -5025,6 +5099,19 @@ impl OpenFangKernel {
.unwrap_or(&self.config.default_model);
let default_provider = &effective_default.provider;
// Effective fallback provider chain: hot-reloaded override takes priority
// over the boot-time `[[fallback_providers]]`. Lets operators retune
// `subprocess_timeout_secs` on a non-default provider via
// `POST /api/config/reload` without bouncing the daemon (#1129).
let fb_override_guard = self
.fallback_providers_override
.read()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
let effective_fallbacks: &[openfang_types::config::FallbackProviderConfig] =
fb_override_guard
.as_deref()
.unwrap_or(&self.config.fallback_providers);
let has_custom_key = manifest.model.api_key_env.is_some();
let has_custom_url = manifest.model.base_url.is_some();
@@ -5066,20 +5153,30 @@ impl OpenFangKernel {
self.lookup_provider_url(agent_provider)
};
// Per-provider timeout resolution for the primary driver:
// - Default-provider agent: inherit `[default_model].subprocess_timeout_secs`.
// - Cross-provider agent: look up `[[fallback_providers]]` keyed on
// `agent_provider` (override-aware) and inherit its timeout. This
// closes #1129 Gap 1 — a `codex` agent on a `claude-code`-default
// daemon now picks up a `[[fallback_providers]] provider = "codex"`
// timeout instead of being silently dropped to `None`.
// - No matching fallback entry: leave unset (env var still wins, then
// driver default).
let primary_timeout = if agent_provider == default_provider {
effective_default.subprocess_timeout_secs
} else {
effective_fallbacks
.iter()
.find(|fb| &fb.provider == agent_provider)
.and_then(|fb| fb.subprocess_timeout_secs)
};
let driver_config = DriverConfig {
provider: agent_provider.clone(),
api_key,
base_url,
skip_permissions: true,
// Inherit the default-model timeout only when the agent is using the
// default provider. If the agent overrides to a different provider,
// we have no per-provider config in scope today, so leave it unset
// (env var still applies, then driver default).
subprocess_timeout_secs: if agent_provider == default_provider {
effective_default.subprocess_timeout_secs
} else {
None
},
subprocess_timeout_secs: primary_timeout,
};
match drivers::create_driver(&driver_config) {
@@ -5178,7 +5275,11 @@ impl OpenFangKernel {
// These apply to every agent so that when the primary provider becomes
// unreachable at runtime (network failure, daemon shutdown, etc.) the
// agent loop fails over to the next provider in the chain. (#1003)
for fb in &self.config.fallback_providers {
//
// Reads from `effective_fallbacks` so that hot-reloaded mutations to
// `[[fallback_providers]]` (including `subprocess_timeout_secs`) take
// effect on the next driver build without a daemon bounce (#1129).
for fb in effective_fallbacks {
let fb_api_key = {
let env_var = if !fb.api_key_env.is_empty() {
fb.api_key_env.clone()
@@ -7468,6 +7569,136 @@ mod tests {
assert_eq!(merged.workspace, Some(std::path::PathBuf::from("/new")));
}
/// Regression for #1132: editing `[exec_policy] mode = "full"` in
/// config.toml must take effect for agents whose persisted manifests
/// captured an older inherited policy.
///
/// Scenario: agent was first spawned when kernel default was `Allowlist`,
/// so its DB-cached manifest has `exec_policy = Some(Allowlist)`. The user
/// later sets `exec_policy.mode = "full"` in config.toml. On the next
/// boot we must replace the cached value with the kernel's current
/// `config.exec_policy` unless the user wrote a per-agent override into
/// the on-disk `agent.toml`.
#[test]
fn test_exec_policy_reinherits_from_kernel_config_on_restart() {
use openfang_types::config::ExecSecurityMode;
// Cached manifest from an earlier boot — still Allowlist.
let cached_policy = ExecPolicy {
mode: ExecSecurityMode::Allowlist,
..Default::default()
};
let mut restored_manifest = AgentManifest {
name: "demo".to_string(),
version: "0.1.0".to_string(),
description: "x".to_string(),
author: "test".to_string(),
module: "builtin:chat".to_string(),
schedule: ScheduleMode::default(),
model: ModelConfig::default(),
fallback_models: vec![],
resources: ResourceQuota::default(),
priority: Priority::default(),
capabilities: ManifestCapabilities::default(),
profile: None,
tools: HashMap::new(),
skills: vec![],
mcp_servers: vec![],
metadata: HashMap::new(),
tags: vec![],
routing: None,
autonomous: None,
pinned_model: None,
workspace: None,
generate_identity_files: true,
exec_policy: Some(cached_policy.clone()),
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
};
// Current kernel config now says mode = Full.
let current_kernel_policy = ExecPolicy {
mode: ExecSecurityMode::Full,
..Default::default()
};
// Simulate the restoration branch in start_background_agents:
// disk had no exec_policy override → re-inherit current config.
let disk_has_exec_policy_override = false;
if !disk_has_exec_policy_override {
restored_manifest.exec_policy = Some(current_kernel_policy.clone());
}
assert_eq!(
restored_manifest.exec_policy.as_ref().map(|p| p.mode),
Some(ExecSecurityMode::Full),
"config.toml exec_policy.mode='full' must override stale cached value"
);
// And: if the user *did* set a per-agent override on disk, that wins.
let mut with_override = restored_manifest.clone();
with_override.exec_policy = Some(ExecPolicy {
mode: ExecSecurityMode::Deny,
..Default::default()
});
let disk_has_override = true;
if !disk_has_override {
with_override.exec_policy = Some(current_kernel_policy.clone());
}
assert_eq!(
with_override.exec_policy.as_ref().map(|p| p.mode),
Some(ExecSecurityMode::Deny),
"per-agent override in agent.toml must win over kernel config"
);
}
/// Regression for #1132: persist_manifest_to_disk must not bake an
/// inherited exec_policy into agent.toml. If the agent's policy equals
/// the kernel's current config, we strip it before writing so future
/// config.toml edits take effect.
#[test]
fn test_persist_strips_inherited_exec_policy() {
use openfang_types::config::ExecSecurityMode;
let kernel_policy = ExecPolicy {
mode: ExecSecurityMode::Full,
..Default::default()
};
// Agent inherited the kernel default → its policy equals kernel_policy.
let inherited = Some(kernel_policy.clone());
let mut for_disk_inherited: Option<ExecPolicy> = inherited.clone();
if for_disk_inherited
.as_ref()
.is_some_and(|p| p == &kernel_policy)
{
for_disk_inherited = None;
}
assert!(
for_disk_inherited.is_none(),
"inherited policy should be stripped from on-disk copy"
);
// Agent has a per-agent override → must survive.
let custom = Some(ExecPolicy {
mode: ExecSecurityMode::Deny,
..Default::default()
});
let mut for_disk_custom = custom.clone();
if for_disk_custom
.as_ref()
.is_some_and(|p| p == &kernel_policy)
{
for_disk_custom = None;
}
assert_eq!(
for_disk_custom.as_ref().map(|p| p.mode),
Some(ExecSecurityMode::Deny),
"per-agent override must survive disk persistence"
);
}
fn test_manifest(name: &str, description: &str, tags: Vec<String>) -> AgentManifest {
AgentManifest {
name: name.to_string(),
@@ -7896,4 +8127,178 @@ mod tests {
kernel.shutdown();
}
// -----------------------------------------------------------------------
// Issue #1129: per-provider hot-reloadable subprocess timeout.
// -----------------------------------------------------------------------
/// Editing `subprocess_timeout_secs` on a `[[fallback_providers]]` entry
/// and calling `apply_hot_actions(ReloadFallbackProviders)` must populate
/// the kernel's `fallback_providers_override` slot with the new value.
/// `resolve_driver` reads from this slot so cross-provider agents pick up
/// the new timeout on their next driver build, with no daemon restart.
#[test]
fn test_subprocess_timeout_hot_reload_fallback_providers() {
use crate::config_reload::{build_reload_plan, HotAction};
use openfang_types::config::FallbackProviderConfig;
let tmp = tempfile::tempdir().unwrap();
let home_dir = tmp.path().join("openfang-1129-fallback-timeout");
std::fs::create_dir_all(&home_dir).unwrap();
// Boot with one fallback provider configured at 120s.
let mut config = KernelConfig {
home_dir: home_dir.clone(),
data_dir: home_dir.join("data"),
..KernelConfig::default()
};
config.fallback_providers.push(FallbackProviderConfig {
provider: "codex".to_string(),
model: "gpt-5-codex".to_string(),
api_key_env: String::new(),
base_url: None,
subprocess_timeout_secs: Some(120),
});
let kernel = OpenFangKernel::boot_with_config(config.clone()).expect("kernel boots");
// Pre-condition: nothing has been hot-reloaded yet — override slot is empty.
{
let guard = kernel.fallback_providers_override.read().unwrap();
assert!(
guard.is_none(),
"fallback_providers_override should start as None"
);
}
// Operator edits config.toml, raising the codex timeout to 900s.
let mut new_config = config.clone();
new_config.fallback_providers[0].subprocess_timeout_secs = Some(900);
// The reload-plan diff must spot the change and emit
// ReloadFallbackProviders.
let plan = build_reload_plan(&kernel.config, &new_config);
assert!(
!plan.restart_required,
"fallback timeout edits must be hot-reloadable"
);
assert!(
plan.hot_actions.contains(&HotAction::ReloadFallbackProviders),
"ReloadFallbackProviders must be present in the plan"
);
// Apply the plan and verify the override slot now carries the new
// timeout. Drivers built after this point will see 900s.
kernel.apply_hot_actions(&plan, &new_config);
{
let guard = kernel.fallback_providers_override.read().unwrap();
let slot = guard
.as_ref()
.expect("ReloadFallbackProviders must populate override slot");
assert_eq!(slot.len(), 1, "exactly one fallback provider expected");
assert_eq!(slot[0].provider, "codex");
assert_eq!(
slot[0].subprocess_timeout_secs,
Some(900),
"drivers built after reload must see 900s, not 120s"
);
}
kernel.shutdown();
}
/// Editing `[default_model].subprocess_timeout_secs` produces an
/// `UpdateDefaultModel` hot-action that populates `default_model_override`.
/// This is the path agents on the default provider use to pick up a new
/// timeout without a daemon restart.
#[test]
fn test_subprocess_timeout_hot_reload_default_model() {
use crate::config_reload::{build_reload_plan, HotAction};
let tmp = tempfile::tempdir().unwrap();
let home_dir = tmp.path().join("openfang-1129-default-timeout");
std::fs::create_dir_all(&home_dir).unwrap();
let mut config = KernelConfig {
home_dir: home_dir.clone(),
data_dir: home_dir.join("data"),
..KernelConfig::default()
};
config.default_model.subprocess_timeout_secs = Some(180);
let kernel = OpenFangKernel::boot_with_config(config.clone()).expect("kernel boots");
// Operator raises the timeout to 1200s.
let mut new_config = config.clone();
new_config.default_model.subprocess_timeout_secs = Some(1200);
let plan = build_reload_plan(&kernel.config, &new_config);
assert!(
!plan.restart_required,
"default_model timeout edits must be hot-reloadable"
);
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
kernel.apply_hot_actions(&plan, &new_config);
{
let guard = kernel.default_model_override.read().unwrap();
let dm = guard
.as_ref()
.expect("UpdateDefaultModel must populate override slot");
assert_eq!(
dm.subprocess_timeout_secs,
Some(1200),
"default-provider drivers built after reload must see 1200s"
);
}
kernel.shutdown();
}
/// Adding a `[[fallback_providers]]` entry on reload (no prior entry)
/// must produce `ReloadFallbackProviders` and populate the override slot.
/// Mirrors the operator workflow of "I want to add a Codex fallback to my
/// Claude-default daemon mid-flight."
#[test]
fn test_subprocess_timeout_hot_reload_adds_new_fallback() {
use crate::config_reload::{build_reload_plan, HotAction};
use openfang_types::config::FallbackProviderConfig;
let tmp = tempfile::tempdir().unwrap();
let home_dir = tmp.path().join("openfang-1129-add-fallback");
std::fs::create_dir_all(&home_dir).unwrap();
let config = KernelConfig {
home_dir: home_dir.clone(),
data_dir: home_dir.join("data"),
..KernelConfig::default()
};
let kernel = OpenFangKernel::boot_with_config(config.clone()).expect("kernel boots");
// Operator adds a codex fallback with a 600s timeout.
let mut new_config = config.clone();
new_config.fallback_providers.push(FallbackProviderConfig {
provider: "codex".to_string(),
model: "gpt-5-codex".to_string(),
api_key_env: String::new(),
base_url: None,
subprocess_timeout_secs: Some(600),
});
let plan = build_reload_plan(&kernel.config, &new_config);
assert!(plan
.hot_actions
.contains(&HotAction::ReloadFallbackProviders));
kernel.apply_hot_actions(&plan, &new_config);
{
let guard = kernel.fallback_providers_override.read().unwrap();
let slot = guard.as_ref().expect("override populated");
assert_eq!(slot.len(), 1);
assert_eq!(slot[0].provider, "codex");
assert_eq!(slot[0].subprocess_timeout_secs, Some(600));
}
kernel.shutdown();
}
}
+1 -1
View File
@@ -586,7 +586,7 @@ impl SessionStore {
ContentBlock::Image { media_type, .. } => {
text_parts.push(format!("[image: {media_type}]"));
}
ContentBlock::Thinking { thinking } => {
ContentBlock::Thinking { thinking, .. } => {
text_parts.push(format!(
"[thinking: {}]",
openfang_types::truncate_str(thinking, 200)
+169 -2
View File
@@ -109,6 +109,70 @@ fn append_tool_error_guidance(tool_result_blocks: &mut Vec<ContentBlock>) {
}
}
/// Build an assistant message that preserves Thinking blocks alongside the
/// final visible text.
///
/// Issue #1098 — thinking-model state preservation. When the LLM response
/// contains `ContentBlock::Thinking` (Anthropic extended thinking with
/// signatures, Gemini 2.5+ thoughts, OpenAI-compat reasoning_content,
/// MiniMax/Qwen inline `<think>` blocks), the prior code stored only the
/// final text via `Message::assistant(text)` — discarding all reasoning
/// state. On the next turn the model re-derived its answer from scratch
/// and quality degraded.
///
/// This helper preserves the full block list whenever any Thinking block is
/// present, otherwise returns the legacy `Message::assistant(text)` form so
/// downstream consumers (channel formatters, JSONL mirrors, embeddings) keep
/// working without changes.
///
/// Note: we deliberately replace any visible Text blocks in `response_blocks`
/// with `final_text` so that any post-processing the agent loop applied
/// (phantom-action recovery, accumulated_text fallback, EmptyResponse guard
/// stub) is reflected in the persisted message.
fn build_assistant_message_preserving_thinking(
response_blocks: &[ContentBlock],
final_text: &str,
) -> Message {
let has_thinking = response_blocks
.iter()
.any(|b| matches!(b, ContentBlock::Thinking { .. }));
if !has_thinking {
return Message::assistant(final_text.to_string());
}
// Preserve order: Thinking blocks first (in original order), then a
// single Text block carrying `final_text`. Tool blocks aren't expected
// here (StopReason::EndTurn path), but copy them through if present so
// we don't drop information.
let mut blocks: Vec<ContentBlock> = Vec::with_capacity(response_blocks.len() + 1);
let mut emitted_text = false;
for b in response_blocks {
match b {
ContentBlock::Thinking { .. } => blocks.push(b.clone()),
ContentBlock::Text { .. } if !emitted_text => {
blocks.push(ContentBlock::Text {
text: final_text.to_string(),
provider_metadata: None,
});
emitted_text = true;
}
ContentBlock::Text { .. } => {
// Drop additional text blocks — final_text already captures
// the canonical visible message.
}
other => blocks.push(other.clone()),
}
}
if !emitted_text && !final_text.is_empty() {
blocks.push(ContentBlock::Text {
text: final_text.to_string(),
provider_metadata: None,
});
}
Message::assistant_with_blocks(blocks)
}
/// 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`)
@@ -605,7 +669,16 @@ pub async fn run_agent_loop(
};
final_response = text.clone();
session.messages.push(Message::assistant(text));
// Issue #1098: persist Thinking blocks alongside the text so
// reasoning models retain state across turns. When the
// response carries any Thinking content (Anthropic extended
// thinking, Gemini 2.5 thought signatures, DeepSeek-R1/Qwen3
// `reasoning_content`, MiniMax inline `<think>`), save the
// full content blocks; otherwise fall back to the legacy
// Text shape so existing sessions/snapshots stay readable.
let assistant_msg =
build_assistant_message_preserving_thinking(&response.content, &text);
session.messages.push(assistant_msg);
// Prune NO_REPLY heartbeat turns to save context budget
crate::session_repair::prune_heartbeat_turns(&mut session.messages, 10);
@@ -1798,7 +1871,13 @@ pub async fn run_agent_loop_streaming(
text
};
final_response = text.clone();
session.messages.push(Message::assistant(text));
// Issue #1098: preserve Thinking blocks (with Anthropic
// signatures / Gemini thought signatures / inline-think /
// reasoning_content) on the persisted assistant turn. See
// build_assistant_message_preserving_thinking for details.
let assistant_msg =
build_assistant_message_preserving_thinking(&response.content, &text);
session.messages.push(assistant_msg);
// Prune NO_REPLY heartbeat turns to save context budget
crate::session_repair::prune_heartbeat_turns(&mut session.messages, 10);
@@ -3092,6 +3171,94 @@ mod tests {
assert_eq!(MAX_ITERATIONS, 50);
}
/// Issue #1098: when a response carries Thinking blocks, the persisted
/// assistant turn must keep them so the next turn round-trips reasoning
/// state to the model.
#[test]
fn test_build_assistant_message_preserves_thinking() {
let response_blocks = vec![
ContentBlock::Thinking {
thinking: "Let me reason carefully...".to_string(),
signature: Some("sig_anthropic_xyz".to_string()),
provider_metadata: Some(serde_json::json!({
"format": "anthropic_extended_thinking"
})),
},
ContentBlock::Text {
text: "Initial response text".to_string(),
provider_metadata: None,
},
];
// Final text might differ from the original Text block (phantom-action
// recovery / synthesis fallback rewrites it). The helper should adopt
// final_text into the persisted Text block.
let final_text = "Initial response text";
let msg = build_assistant_message_preserving_thinking(&response_blocks, final_text);
assert_eq!(msg.role, Role::Assistant);
let blocks = match &msg.content {
MessageContent::Blocks(b) => b,
other => panic!("expected blocks, got {other:?}"),
};
assert_eq!(blocks.len(), 2, "must preserve thinking + text");
match &blocks[0] {
ContentBlock::Thinking {
thinking, signature, ..
} => {
assert_eq!(thinking, "Let me reason carefully...");
assert_eq!(signature.as_deref(), Some("sig_anthropic_xyz"));
}
_ => panic!("expected Thinking first"),
}
match &blocks[1] {
ContentBlock::Text { text, .. } => assert_eq!(text, "Initial response text"),
_ => panic!("expected Text second"),
}
}
/// Without thinking, fall back to the legacy `Message::assistant(text)`
/// shape so existing JSONL mirrors and embeddings keep working.
#[test]
fn test_build_assistant_message_no_thinking_is_plain_text() {
let response_blocks = vec![ContentBlock::Text {
text: "Hi.".to_string(),
provider_metadata: None,
}];
let msg = build_assistant_message_preserving_thinking(&response_blocks, "Hi.");
match msg.content {
MessageContent::Text(t) => assert_eq!(t, "Hi."),
_ => panic!("expected plain text content for non-thinking responses"),
}
}
/// Final text supplied by the loop (e.g. recovery stub) must replace
/// the original text part — the persisted message reflects what was
/// actually returned to the user, not the raw LLM output.
#[test]
fn test_build_assistant_message_final_text_replaces_original_text() {
let response_blocks = vec![
ContentBlock::Thinking {
thinking: "deliberation".to_string(),
signature: None,
provider_metadata: Some(serde_json::json!({"format": "inline_think"})),
},
ContentBlock::Text {
text: "raw LLM output".to_string(),
provider_metadata: None,
},
];
let final_text = "[Task completed — recovered after empty response.]";
let msg = build_assistant_message_preserving_thinking(&response_blocks, final_text);
let blocks = match &msg.content {
MessageContent::Blocks(b) => b,
_ => panic!("expected blocks"),
};
let saved_text = blocks.iter().find_map(|b| match b {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
});
assert_eq!(saved_text, Some(final_text));
}
#[test]
fn test_retry_constants() {
assert_eq!(MAX_RETRIES, 3);
+259 -12
View File
@@ -84,6 +84,15 @@ enum ApiContentBlock {
#[serde(skip_serializing_if = "std::ops::Not::not")]
is_error: bool,
},
/// Extended-thinking block echoed back to the API.
///
/// Anthropic requires the original `signature` to be returned verbatim
/// alongside the `thinking` text on subsequent turns; otherwise the
/// model loses its prior reasoning state. Without `signature` the API
/// rejects the block, so we omit thinking blocks that arrive without
/// one (e.g. legacy sessions saved before this field was tracked).
#[serde(rename = "thinking")]
Thinking { thinking: String, signature: String },
}
#[derive(Debug, Serialize)]
@@ -120,8 +129,16 @@ enum ResponseContentBlock {
name: String,
input: serde_json::Value,
},
/// Extended-thinking block from Anthropic. The `signature` is opaque
/// to us but MUST be persisted and echoed back on the next request,
/// otherwise the API rejects the resubmitted thinking block and the
/// model loses its reasoning state.
#[serde(rename = "thinking")]
Thinking { thinking: String },
Thinking {
thinking: String,
#[serde(default)]
signature: Option<String>,
},
}
#[derive(Debug, Deserialize)]
@@ -144,7 +161,14 @@ struct ApiErrorDetail {
/// Accumulator for content blocks during streaming.
enum ContentBlockAccum {
Text(String),
Thinking(String),
/// Extended thinking — text plus an opaque signature delivered as
/// `signature_delta` events (or as a single field on `content_block_stop`
/// for older API versions). The signature is required to round-trip
/// thinking blocks on subsequent turns.
Thinking {
thinking: String,
signature: String,
},
ToolUse {
id: String,
name: String,
@@ -412,7 +436,16 @@ impl LlmDriver for AnthropicDriver {
});
}
"thinking" => {
blocks.push(ContentBlockAccum::Thinking(String::new()));
// Some API versions ship the signature on
// content_block_start instead of as a delta.
let initial_sig = block["signature"]
.as_str()
.unwrap_or("")
.to_string();
blocks.push(ContentBlockAccum::Thinking {
thinking: String::new(),
signature: initial_sig,
});
}
_ => {}
}
@@ -452,11 +485,33 @@ impl LlmDriver for AnthropicDriver {
}
}
"thinking_delta" => {
if let Some(thinking) = delta["thinking"].as_str() {
if let Some(ContentBlockAccum::Thinking(ref mut t)) =
blocks.get_mut(block_idx)
if let Some(t) = delta["thinking"].as_str() {
if let Some(ContentBlockAccum::Thinking {
thinking: ref mut buf,
..
}) = blocks.get_mut(block_idx)
{
t.push_str(thinking);
buf.push_str(t);
}
// Forward to UI as ThinkingDelta event so dashboards can show reasoning.
let _ = tx
.send(StreamEvent::ThinkingDelta {
text: t.to_string(),
})
.await;
}
}
"signature_delta" => {
// Anthropic streams the thinking signature
// as its own delta type; concatenate any
// partial pieces into the accumulator.
if let Some(sig) = delta["signature"].as_str() {
if let Some(ContentBlockAccum::Thinking {
ref mut signature,
..
}) = blocks.get_mut(block_idx)
{
signature.push_str(sig);
}
}
}
@@ -512,8 +567,26 @@ impl LlmDriver for AnthropicDriver {
provider_metadata: None,
});
}
ContentBlockAccum::Thinking(thinking) => {
content.push(ContentBlock::Thinking { thinking });
ContentBlockAccum::Thinking {
thinking,
signature,
} => {
// Drop empty thinking blocks (rare, but happens if the
// stream is interrupted mid-block). Always keep the
// signature when present — it's required to round-trip.
if !thinking.is_empty() || !signature.is_empty() {
content.push(ContentBlock::Thinking {
thinking,
signature: if signature.is_empty() {
None
} else {
Some(signature)
},
provider_metadata: Some(serde_json::json!({
"format": "anthropic_extended_thinking"
})),
});
}
}
ContentBlockAccum::ToolUse {
id,
@@ -615,7 +688,28 @@ fn convert_message(msg: &Message) -> ApiMessage {
content: content.clone(),
is_error: *is_error,
}),
ContentBlock::Thinking { .. } => None,
ContentBlock::Thinking {
thinking,
signature,
..
} => {
// Anthropic's extended-thinking spec requires the
// verbatim `signature` to accompany any thinking block
// resubmitted in conversation history. Without one,
// the API rejects the request, so we silently drop
// legacy thinking blocks (saved before signature
// tracking) instead of round-tripping them.
signature.as_ref().and_then(|sig| {
if sig.is_empty() {
None
} else {
Some(ApiContentBlock::Thinking {
thinking: thinking.clone(),
signature: sig.clone(),
})
}
})
}
ContentBlock::Unknown => None,
})
.collect();
@@ -651,8 +745,17 @@ fn convert_response(api: ApiResponse) -> CompletionResponse {
});
tool_calls.push(ToolCall { id, name, input });
}
ResponseContentBlock::Thinking { thinking } => {
content.push(ContentBlock::Thinking { thinking });
ResponseContentBlock::Thinking {
thinking,
signature,
} => {
content.push(ContentBlock::Thinking {
thinking,
signature,
provider_metadata: Some(serde_json::json!({
"format": "anthropic_extended_thinking"
})),
});
}
}
}
@@ -772,4 +875,148 @@ mod tests {
panic!("Expected Blocks content");
}
}
/// Issue #1098: Anthropic extended-thinking blocks must round-trip
/// through the driver — the inbound response carries a `signature` that
/// MUST be echoed verbatim on the next request, otherwise the API
/// rejects the resubmitted thinking block and the model loses prior
/// reasoning state.
#[test]
fn test_thinking_block_signature_round_trip() {
// Step 1: API delivers a thinking block with signature
let api_response = ApiResponse {
content: vec![
ResponseContentBlock::Thinking {
thinking: "Let me carefully consider this problem...".to_string(),
signature: Some("WaUjzkypQ2mUEVM36O2TxuC".to_string()),
},
ResponseContentBlock::Text {
text: "The answer is 42.".to_string(),
},
],
stop_reason: "end_turn".to_string(),
usage: ApiUsage {
input_tokens: 100,
output_tokens: 50,
},
};
let response = convert_response(api_response);
assert_eq!(response.content.len(), 2);
// Step 2: Verify the signature reached the ContentBlock
let thinking_block = &response.content[0];
match thinking_block {
ContentBlock::Thinking {
thinking,
signature,
..
} => {
assert_eq!(thinking, "Let me carefully consider this problem...");
assert_eq!(signature.as_deref(), Some("WaUjzkypQ2mUEVM36O2TxuC"));
}
_ => panic!("expected Thinking content block"),
}
// Step 3: Now feed the assistant turn back into the driver as if
// it were prior conversation history (next user turn). The signature
// must survive into the outbound API request.
let assistant_msg = Message {
role: Role::Assistant,
content: MessageContent::Blocks(response.content.clone()),
};
let api_msg = convert_message(&assistant_msg);
let blocks = match api_msg.content {
ApiContent::Blocks(b) => b,
_ => panic!("expected Blocks content"),
};
// The Thinking block must appear in the outbound payload with its signature.
let mut found_thinking = false;
for block in &blocks {
if let ApiContentBlock::Thinking {
thinking,
signature,
} = block
{
assert_eq!(thinking, "Let me carefully consider this problem...");
assert_eq!(signature, "WaUjzkypQ2mUEVM36O2TxuC");
found_thinking = true;
}
}
assert!(
found_thinking,
"outbound API request must include the thinking block with signature"
);
// Step 4: Verify on-the-wire JSON shape (`type=thinking`, `signature` present).
let outbound_json = serde_json::to_value(&blocks).unwrap();
let arr = outbound_json.as_array().unwrap();
let thinking_json = arr
.iter()
.find(|v| v["type"] == "thinking")
.expect("thinking block in JSON");
assert_eq!(thinking_json["signature"], "WaUjzkypQ2mUEVM36O2TxuC");
assert_eq!(
thinking_json["thinking"],
"Let me carefully consider this problem..."
);
}
/// Legacy thinking blocks saved before signature tracking should NOT
/// be replayed — Anthropic rejects thinking blocks without signatures.
#[test]
fn test_thinking_block_without_signature_dropped_outbound() {
let assistant_msg = Message {
role: Role::Assistant,
content: MessageContent::Blocks(vec![
ContentBlock::Thinking {
thinking: "old reasoning from before sig tracking".to_string(),
signature: None,
provider_metadata: None,
},
ContentBlock::Text {
text: "Hello.".to_string(),
provider_metadata: None,
},
]),
};
let api_msg = convert_message(&assistant_msg);
let blocks = match api_msg.content {
ApiContent::Blocks(b) => b,
_ => panic!("expected Blocks content"),
};
// The legacy thinking block must be dropped (no sig = API would 400).
for block in &blocks {
assert!(
!matches!(block, ApiContentBlock::Thinking { .. }),
"thinking block without signature must be dropped"
);
}
// The text part is still preserved.
assert!(blocks
.iter()
.any(|b| matches!(b, ApiContentBlock::Text { .. })));
}
/// Streaming path: signature_delta events accumulate into the final block.
#[test]
fn test_thinking_block_serde_with_signature_field() {
// Verify the API response wire format is parsed correctly.
let json = serde_json::json!({
"type": "thinking",
"thinking": "step 1, step 2",
"signature": "abc123"
});
let block: ResponseContentBlock = serde_json::from_value(json).unwrap();
match block {
ResponseContentBlock::Thinking {
thinking,
signature,
} => {
assert_eq!(thinking, "step 1, step 2");
assert_eq!(signature.as_deref(), Some("abc123"));
}
_ => panic!("expected Thinking response block"),
}
}
}
+99 -8
View File
@@ -321,7 +321,39 @@ fn convert_messages(
},
});
}
ContentBlock::Thinking { .. } => {}
ContentBlock::Thinking {
thinking,
provider_metadata,
..
} => {
// Issue #1098: preserve Gemini 2.5+ thought parts
// when the upstream model originally emitted them.
// Most Gemini state actually rides on the
// thoughtSignature attached to text/tool_use
// parts above, but we round-trip the visible
// thinking text + sig as a `Thought` part too
// so the model's internal state is fully
// preserved. Other providers' thinking blocks
// are dropped here (they have their own
// outbound paths in the OpenAI/Anthropic
// drivers).
let format = provider_metadata
.as_ref()
.and_then(|m| m.get("format"))
.and_then(|v| v.as_str());
if format == Some("gemini_thought") && !thinking.is_empty() {
let sig = provider_metadata
.as_ref()
.and_then(|m| m.get("thought_signature"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
parts.push(GeminiPart::Thought {
text: thinking.clone(),
thought: true,
thought_signature: sig,
});
}
}
_ => {}
}
}
@@ -561,12 +593,29 @@ fn convert_response(resp: GeminiResponse) -> Result<CompletionResponse, LlmError
input: function_call.args,
});
}
GeminiPart::Thought { text, .. } => {
GeminiPart::Thought {
text,
thought_signature,
..
} => {
// Gemini 2.5+ thinking parts — internal reasoning.
// Store as Thinking content block so the UI can
// optionally display it (like <think> blocks).
// optionally display it. Issue #1098: preserve the
// part-level `thoughtSignature` in `provider_metadata`
// (and on subsequent text/tool_use parts) so the
// model retains state across turns.
if !text.is_empty() {
content.push(ContentBlock::Thinking { thinking: text });
let provider_metadata = thought_signature.map(|sig| {
serde_json::json!({
"format": "gemini_thought",
"thought_signature": sig,
})
});
content.push(ContentBlock::Thinking {
thinking: text,
signature: None,
provider_metadata,
});
}
}
GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => {
@@ -788,6 +837,10 @@ impl LlmDriver for GeminiDriver {
let mut text_content = String::new();
// Thought signature for accumulated text content (last one wins)
let mut text_thought_sig: Option<String> = None;
// Accumulated thought (Gemini 2.5+) text + signature, for
// round-tripping reasoning state across turns (issue #1098).
let mut thought_text = String::new();
let mut thought_sig: Option<String> = None;
// Track function calls: (name, args_json, thought_signature)
let mut fn_calls: Vec<(String, serde_json::Value, Option<String>)> = Vec::new();
let mut finish_reason: Option<String> = None;
@@ -894,17 +947,26 @@ impl LlmDriver for GeminiDriver {
thought_signature.clone(),
));
}
GeminiPart::Thought { ref text, .. } => {
GeminiPart::Thought {
ref text,
ref thought_signature,
..
} => {
// Gemini 2.5+ thinking chunk — emit as
// thinking delta so UIs can optionally
// show it; do NOT mix into text_content.
// show it; accumulate the text + sig
// for later persistence (issue #1098).
if !text.is_empty() {
thought_text.push_str(text);
let _ = tx
.send(StreamEvent::ThinkingDelta {
text: text.clone(),
})
.await;
}
if thought_signature.is_some() {
thought_sig = thought_signature.clone();
}
}
GeminiPart::InlineData { .. }
| GeminiPart::FunctionResponse { .. } => {}
@@ -985,14 +1047,24 @@ impl LlmDriver for GeminiDriver {
thought_signature.clone(),
));
}
GeminiPart::Thought { ref text, .. } => {
GeminiPart::Thought {
ref text,
ref thought_signature,
..
} if !text.is_empty()
|| thought_signature.is_some() =>
{
if !text.is_empty() {
thought_text.push_str(text);
let _ = tx
.send(StreamEvent::ThinkingDelta {
text: text.clone(),
})
.await;
}
if thought_signature.is_some() {
thought_sig = thought_signature.clone();
}
}
_ => {}
}
@@ -1034,6 +1106,25 @@ impl LlmDriver for GeminiDriver {
let mut content = Vec::new();
let mut tool_calls = Vec::new();
// Issue #1098: persist any accumulated Thought parts (Gemini
// 2.5+ thinking) so reasoning state round-trips on the next
// turn. The thoughtSignature also rides on text/tool_use
// parts below; this Thinking block carries the human-readable
// reasoning text for UI display + audit.
if !thought_text.is_empty() || thought_sig.is_some() {
let provider_metadata = thought_sig.as_ref().map(|sig| {
serde_json::json!({
"format": "gemini_thought",
"thought_signature": sig,
})
});
content.push(ContentBlock::Thinking {
thinking: thought_text,
signature: None,
provider_metadata,
});
}
if !text_content.is_empty() {
let provider_metadata =
text_thought_sig.map(|sig| serde_json::json!({ "thought_signature": sig }));
@@ -1994,7 +2085,7 @@ mod tests {
// Should have a Thinking block and a Text block
assert_eq!(completion.content.len(), 2);
match &completion.content[0] {
ContentBlock::Thinking { thinking } => {
ContentBlock::Thinking { thinking, .. } => {
assert_eq!(thinking, "Let me reason...");
}
_ => panic!("Expected Thinking block, got {:?}", completion.content[0]),
+43 -6
View File
@@ -632,6 +632,39 @@ pub fn known_providers() -> &'static [&'static str] {
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::{LazyLock, Mutex};
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let original = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, original }
}
fn remove(key: &'static str) -> Self {
let original = std::env::var_os(key);
std::env::remove_var(key);
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(value) = &self.original {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
#[test]
fn test_provider_defaults_groq() {
@@ -792,8 +825,9 @@ mod tests {
#[test]
fn test_novita_provider_with_env_key() {
let _env_lock = ENV_LOCK.lock().unwrap();
let unique_key = "test-novita-key-12345";
std::env::set_var("NOVITA_API_KEY", unique_key);
let _env = EnvVarGuard::set("NOVITA_API_KEY", unique_key);
let config = DriverConfig {
provider: "novita".to_string(),
api_key: None,
@@ -806,11 +840,12 @@ mod tests {
driver.is_ok(),
"Novita provider with env var should succeed"
);
std::env::remove_var("NOVITA_API_KEY");
}
#[test]
fn test_novita_provider_no_key_errors() {
let _env_lock = ENV_LOCK.lock().unwrap();
let _env = EnvVarGuard::remove("NOVITA_API_KEY");
let config = DriverConfig {
provider: "novita".to_string(),
api_key: None,
@@ -825,8 +860,9 @@ mod tests {
#[test]
fn test_nvidia_provider_with_env_key() {
// NVIDIA NIM is a known provider — set API key and verify driver creation succeeds.
let _env_lock = ENV_LOCK.lock().unwrap();
let unique_key = "test-nvidia-key-12345";
std::env::set_var("NVIDIA_API_KEY", unique_key);
let _env = EnvVarGuard::set("NVIDIA_API_KEY", unique_key);
let config = DriverConfig {
provider: "nvidia".to_string(),
api_key: None, // picked up from env via provider_defaults
@@ -839,12 +875,13 @@ mod tests {
driver.is_ok(),
"NVIDIA provider with env var should succeed"
);
std::env::remove_var("NVIDIA_API_KEY");
}
#[test]
fn test_nvidia_provider_no_key_errors() {
// NVIDIA NIM provider with no API key should error.
let _env_lock = ENV_LOCK.lock().unwrap();
let _env = EnvVarGuard::remove("NVIDIA_API_KEY");
let config = DriverConfig {
provider: "nvidia".to_string(),
api_key: None,
@@ -859,8 +896,9 @@ mod tests {
#[test]
fn test_custom_provider_key_no_url_helpful_error() {
// Custom provider with key set (via env) but no base_url should give helpful error.
let _env_lock = ENV_LOCK.lock().unwrap();
let unique_key = "test-custom-key-67890";
std::env::set_var("MYCUSTOM_API_KEY", unique_key);
let _env = EnvVarGuard::set("MYCUSTOM_API_KEY", unique_key);
let config = DriverConfig {
provider: "mycustom".to_string(),
api_key: None,
@@ -876,7 +914,6 @@ mod tests {
"Error should mention base_url: {}",
err
);
std::env::remove_var("MYCUSTOM_API_KEY");
}
#[test]
+310 -128
View File
@@ -289,6 +289,135 @@ fn strip_trailing_empty_assistant(messages: &mut Vec<OaiMessage>) {
}
}
/// Assemble an outbound assistant `OaiMessage` from `ContentBlock`s, replaying
/// any `Thinking` blocks in the format the upstream model originally emitted.
///
/// This is the fix for issue #1098 — thinking-model state preservation.
/// Without this, `<think>...</think>` and `reasoning_content` are stripped on
/// the next turn so the model loses its prior reasoning trace and re-derives
/// the answer (degrading quality). We honour `provider_metadata.format`:
///
/// - `"reasoning_content"` → emitted on the OpenAI `reasoning_content` field
/// (DeepSeek-R1, Qwen3, MiniMax M2 via LM Studio/Ollama)
/// - `"inline_think"` → wrapped in `<think>...</think>` and prepended to
/// the visible content (MiniMax M2.5, Llama-3.3-think variants)
/// - missing/other → fall back to the legacy Moonshot/Kimi behaviour
/// (only emit `reasoning_content` when `needs_reasoning_content()` is true)
fn assemble_assistant_message(
blocks: &[ContentBlock],
model: &str,
driver: &OpenAIDriver,
) -> OaiMessage {
let mut text_parts: Vec<String> = Vec::new();
let mut tool_calls: Vec<OaiToolCall> = Vec::new();
let mut reasoning_field: Option<String> = None;
let mut inline_think: Option<String> = None;
for block in blocks {
match block {
ContentBlock::Text { text, .. } => text_parts.push(text.clone()),
ContentBlock::ToolUse {
id, name, input, ..
} => {
tool_calls.push(OaiToolCall {
id: id.clone(),
call_type: "function".to_string(),
function: OaiFunction {
name: name.clone(),
arguments: serde_json::to_string(input).unwrap_or_default(),
},
});
}
ContentBlock::Thinking {
thinking,
provider_metadata,
..
} => {
if thinking.is_empty() {
continue;
}
let format = provider_metadata
.as_ref()
.and_then(|m| m.get("format"))
.and_then(|v| v.as_str());
match format {
Some("inline_think") => {
// MiniMax / models trained to expect `<think>` in
// historical assistant messages. Concatenate
// multiple thinking blocks if present.
let entry = format!("<think>{thinking}</think>");
match &mut inline_think {
Some(existing) => existing.push_str(&entry),
None => inline_think = Some(entry),
}
}
Some("reasoning_content") => {
// DeepSeek-R1 / Qwen3 / OpenAI-compat servers that
// expose a separate `reasoning_content` field.
match &mut reasoning_field {
Some(existing) => existing.push_str(thinking),
None => reasoning_field = Some(thinking.clone()),
}
}
_ => {
// Unknown format — preserve as inline_think since it's
// safe (visible to the model as ordinary text). The
// legacy Moonshot path overrides this below.
let entry = format!("<think>{thinking}</think>");
match &mut inline_think {
Some(existing) => existing.push_str(&entry),
None => inline_think = Some(entry),
}
}
}
}
_ => {}
}
}
// Build the visible content by prepending inline_think (if any).
let mut visible = String::new();
if let Some(it) = inline_think.as_ref() {
visible.push_str(it);
}
if !text_parts.is_empty() {
visible.push_str(&text_parts.join(""));
}
let has_tool_calls = !tool_calls.is_empty();
let needs_reasoning = driver.needs_reasoning_content(model);
// Final reasoning_content field: the per-block format hint wins; otherwise
// fall back to legacy Moonshot/Kimi behaviour (empty string when needed).
let reasoning_content = if reasoning_field.is_some() {
reasoning_field
} else if needs_reasoning {
Some(String::new())
} else {
None
};
OaiMessage {
role: "assistant".to_string(),
content: if visible.is_empty() {
if has_tool_calls {
Some(OaiMessageContent::Text(String::new()))
} else {
None
}
} else {
Some(OaiMessageContent::Text(visible))
},
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
tool_call_id: None,
reasoning_content,
}
}
#[async_trait]
impl LlmDriver for OpenAIDriver {
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
@@ -308,16 +437,14 @@ impl LlmDriver for OpenAIDriver {
// Convert messages
for msg in &request.messages {
match (&msg.role, &msg.content) {
(Role::System, MessageContent::Text(text)) => {
if request.system.is_none() {
oai_messages.push(OaiMessage {
role: "system".to_string(),
content: Some(OaiMessageContent::Text(text.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
(Role::System, MessageContent::Text(text)) if request.system.is_none() => {
oai_messages.push(OaiMessage {
role: "system".to_string(),
content: Some(OaiMessageContent::Text(text.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
(Role::User, MessageContent::Text(text)) => {
oai_messages.push(OaiMessage {
@@ -386,59 +513,8 @@ impl LlmDriver for OpenAIDriver {
}
}
(Role::Assistant, MessageContent::Blocks(blocks)) => {
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
let mut reasoning_text = String::new();
for block in blocks {
match block {
ContentBlock::Text { text, .. } => text_parts.push(text.clone()),
ContentBlock::ToolUse {
id, name, input, ..
} => {
tool_calls.push(OaiToolCall {
id: id.clone(),
call_type: "function".to_string(),
function: OaiFunction {
name: name.clone(),
arguments: serde_json::to_string(input).unwrap_or_default(),
},
});
}
ContentBlock::Thinking { thinking, .. } => {
reasoning_text = thinking.clone();
}
_ => {}
}
}
let has_tool_calls = !tool_calls.is_empty();
let needs_reasoning = self.needs_reasoning_content(&request.model);
oai_messages.push(OaiMessage {
role: "assistant".to_string(),
content: if text_parts.is_empty() {
if has_tool_calls {
Some(OaiMessageContent::Text(String::new()))
} else {
None
}
} else {
Some(OaiMessageContent::Text(text_parts.join("")))
},
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
tool_call_id: None,
reasoning_content: if needs_reasoning {
Some(if reasoning_text.is_empty() {
String::new()
} else {
reasoning_text
})
} else {
None
},
});
let assembled = assemble_assistant_message(blocks, &request.model, self);
oai_messages.push(assembled);
}
_ => {}
}
@@ -652,8 +728,15 @@ impl LlmDriver for OpenAIDriver {
len = reasoning.len(),
"Captured reasoning_content from response"
);
// Mark the format so the outbound path knows to re-emit
// this as a `reasoning_content` field rather than as
// inline `<think>` tags. Issue #1098.
content.push(ContentBlock::Thinking {
thinking: reasoning.clone(),
signature: None,
provider_metadata: Some(serde_json::json!({
"format": "reasoning_content"
})),
});
}
}
@@ -666,8 +749,14 @@ impl LlmDriver for OpenAIDriver {
if let Some(think_text) = thinking {
// Only add if we didn't already get reasoning_content
if choice.message.reasoning_content.is_none() {
// Mark the format so we re-emit as inline `<think>`
// tags on the next turn (MiniMax/M2.5 style).
content.push(ContentBlock::Thinking {
thinking: think_text,
signature: None,
provider_metadata: Some(serde_json::json!({
"format": "inline_think"
})),
});
}
}
@@ -694,7 +783,7 @@ impl LlmDriver for OpenAIDriver {
let thinking_text = content
.iter()
.find_map(|b| match b {
ContentBlock::Thinking { thinking } => Some(thinking.as_str()),
ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
_ => None,
})
.unwrap_or("");
@@ -793,16 +882,14 @@ impl LlmDriver for OpenAIDriver {
for msg in &request.messages {
match (&msg.role, &msg.content) {
(Role::System, MessageContent::Text(text)) => {
if request.system.is_none() {
oai_messages.push(OaiMessage {
role: "system".to_string(),
content: Some(OaiMessageContent::Text(text.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
(Role::System, MessageContent::Text(text)) if request.system.is_none() => {
oai_messages.push(OaiMessage {
role: "system".to_string(),
content: Some(OaiMessageContent::Text(text.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
(Role::User, MessageContent::Text(text)) => {
oai_messages.push(OaiMessage {
@@ -845,59 +932,8 @@ impl LlmDriver for OpenAIDriver {
}
}
(Role::Assistant, MessageContent::Blocks(blocks)) => {
let mut text_parts = Vec::new();
let mut tool_calls_out = Vec::new();
let mut reasoning_text = String::new();
for block in blocks {
match block {
ContentBlock::Text { text, .. } => text_parts.push(text.clone()),
ContentBlock::ToolUse {
id, name, input, ..
} => {
tool_calls_out.push(OaiToolCall {
id: id.clone(),
call_type: "function".to_string(),
function: OaiFunction {
name: name.clone(),
arguments: serde_json::to_string(input).unwrap_or_default(),
},
});
}
ContentBlock::Thinking { thinking, .. } => {
reasoning_text = thinking.clone();
}
_ => {}
}
}
let has_tool_calls = !tool_calls_out.is_empty();
let needs_reasoning = self.needs_reasoning_content(&request.model);
oai_messages.push(OaiMessage {
role: "assistant".to_string(),
content: if text_parts.is_empty() {
if has_tool_calls {
Some(OaiMessageContent::Text(String::new()))
} else {
None
}
} else {
Some(OaiMessageContent::Text(text_parts.join("")))
},
tool_calls: if tool_calls_out.is_empty() {
None
} else {
Some(tool_calls_out)
},
tool_call_id: None,
reasoning_content: if needs_reasoning {
Some(if reasoning_text.is_empty() {
String::new()
} else {
reasoning_text
})
} else {
None
},
});
let assembled = assemble_assistant_message(blocks, &request.model, self);
oai_messages.push(assembled);
}
_ => {}
}
@@ -1296,8 +1332,15 @@ impl LlmDriver for OpenAIDriver {
// Add reasoning/thinking content if present
if !reasoning_content.is_empty() {
// Mark format so outbound path replays this as
// `reasoning_content` (DeepSeek-R1, Qwen3, MiniMax via
// LM Studio/Ollama). Issue #1098.
content.push(ContentBlock::Thinking {
thinking: reasoning_content.clone(),
signature: None,
provider_metadata: Some(serde_json::json!({
"format": "reasoning_content"
})),
});
}
@@ -1307,8 +1350,14 @@ impl LlmDriver for OpenAIDriver {
if let Some(think_text) = thinking {
// Only add if we didn't already get reasoning_content
if reasoning_content.is_empty() {
// Mark as inline-think so the next outbound turn
// re-emits the content wrapped in `<think>...</think>`.
content.push(ContentBlock::Thinking {
thinking: think_text,
signature: None,
provider_metadata: Some(serde_json::json!({
"format": "inline_think"
})),
});
}
}
@@ -1333,7 +1382,7 @@ impl LlmDriver for OpenAIDriver {
let thinking_text = content
.iter()
.find_map(|b| match b {
ContentBlock::Thinking { thinking } => Some(thinking.as_str()),
ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
_ => None,
})
.unwrap_or("");
@@ -1898,4 +1947,137 @@ mod tests {
let url = driver.chat_url("moonshot-v1-128k");
assert_eq!(url, "https://api.moonshot.ai/v1/chat/completions");
}
// ── issue #1098: thinking-block round-trip ────────────────────────
/// Inline `<think>` blocks captured on ingress must be re-emitted in
/// historical assistant turns so MiniMax-style models retain reasoning
/// state across turns.
#[test]
fn test_assemble_assistant_replays_inline_think() {
let driver = OpenAIDriver::new(
"test".to_string(),
"https://api.minimax.chat/v1".to_string(),
);
let blocks = vec![
ContentBlock::Thinking {
thinking: "step-by-step reasoning".to_string(),
signature: None,
provider_metadata: Some(serde_json::json!({"format": "inline_think"})),
},
ContentBlock::Text {
text: "Hello, user.".to_string(),
provider_metadata: None,
},
];
let msg = assemble_assistant_message(&blocks, "minimax-m2.5", &driver);
let content = match msg.content {
Some(OaiMessageContent::Text(t)) => t,
_ => panic!("expected text content"),
};
assert_eq!(
content, "<think>step-by-step reasoning</think>Hello, user.",
"inline_think must be re-emitted as <think> wrapping prepended to text"
);
// No reasoning_content field should be set for non-Moonshot models.
assert!(msg.reasoning_content.is_none());
}
/// `reasoning_content`-flavoured Thinking blocks must re-emit on the
/// `reasoning_content` field, NOT inline (DeepSeek-R1, Qwen3, MiniMax M2
/// via LM Studio/Ollama).
#[test]
fn test_assemble_assistant_replays_reasoning_content_field() {
let driver = OpenAIDriver::new(
"test".to_string(),
"https://api.deepseek.com/v1".to_string(),
);
let blocks = vec![
ContentBlock::Thinking {
thinking: "internal chain-of-thought".to_string(),
signature: None,
provider_metadata: Some(serde_json::json!({"format": "reasoning_content"})),
},
ContentBlock::Text {
text: "answer".to_string(),
provider_metadata: None,
},
];
let msg = assemble_assistant_message(&blocks, "deepseek-reasoner", &driver);
let content = match msg.content {
Some(OaiMessageContent::Text(t)) => t,
_ => panic!("expected text content"),
};
assert_eq!(content, "answer", "visible content must not include <think>");
assert_eq!(
msg.reasoning_content.as_deref(),
Some("internal chain-of-thought"),
"reasoning_content field must carry the reasoning text"
);
}
/// Without thinking blocks, the outbound message should be a plain
/// assistant message — preserve the legacy shape.
#[test]
fn test_assemble_assistant_no_thinking_is_plain() {
let driver =
OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
let blocks = vec![ContentBlock::Text {
text: "Hi.".to_string(),
provider_metadata: None,
}];
let msg = assemble_assistant_message(&blocks, "gpt-4o", &driver);
match msg.content {
Some(OaiMessageContent::Text(t)) => assert_eq!(t, "Hi."),
_ => panic!("expected text content"),
}
assert!(msg.reasoning_content.is_none());
}
/// Issue #1098 round-trip: parse a wire response with `reasoning_content`,
/// then feed the parsed assistant turn back through the outbound path
/// and confirm the reasoning is replayed.
#[test]
fn test_reasoning_content_full_round_trip() {
// Step 1: parse server response shape.
let json = serde_json::json!({
"content": "Final answer.",
"reasoning_content": "I considered options A, B, and C…",
"tool_calls": null
});
let server_msg: OaiResponseMessage = serde_json::from_value(json).unwrap();
assert_eq!(server_msg.content.as_deref(), Some("Final answer."));
assert_eq!(
server_msg.reasoning_content.as_deref(),
Some("I considered options A, B, and C…")
);
// Step 2: simulate the driver building blocks (mirrors the live
// path in `complete()`).
let mut content = Vec::new();
if let Some(ref reasoning) = server_msg.reasoning_content {
content.push(ContentBlock::Thinking {
thinking: reasoning.clone(),
signature: None,
provider_metadata: Some(serde_json::json!({"format": "reasoning_content"})),
});
}
content.push(ContentBlock::Text {
text: server_msg.content.unwrap(),
provider_metadata: None,
});
// Step 3: replay through the outbound path.
let driver = OpenAIDriver::new(
"test".to_string(),
"https://api.deepseek.com/v1".to_string(),
);
let outbound = assemble_assistant_message(&content, "deepseek-reasoner", &driver);
// The reasoning_content field must round-trip verbatim.
assert_eq!(
outbound.reasoning_content.as_deref(),
Some("I considered options A, B, and C…"),
"issue #1098 regression: reasoning was stripped on resubmission"
);
}
}
+44 -97
View File
@@ -7,9 +7,9 @@
//! They receive `&GuestState` (not `&mut`) and return JSON values.
use crate::sandbox::GuestState;
use crate::web_fetch;
use openfang_types::capability::{capability_matches, Capability};
use serde_json::json;
use std::net::ToSocketAddrs;
use std::path::{Component, Path};
use tracing::debug;
@@ -117,64 +117,9 @@ fn safe_resolve_parent(path: &str) -> Result<std::path::PathBuf, serde_json::Val
}
// ---------------------------------------------------------------------------
// SSRF protection
// SSRF protection — delegates to the canonical implementation in web_fetch.rs
// ---------------------------------------------------------------------------
/// SSRF protection: check if a hostname resolves to a private/internal IP.
/// This defeats DNS rebinding by checking the RESOLVED address, not the hostname.
fn is_ssrf_target(url: &str) -> Result<(), serde_json::Value> {
// Only allow http:// and https:// schemes (block file://, gopher://, ftp://)
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(json!({"error": "Only http:// and https:// URLs are allowed"}));
}
let host = extract_host_from_url(url);
let hostname = host.split(':').next().unwrap_or(&host);
// Check hostname-based blocklist first (catches metadata endpoints)
let blocked_hostnames = [
"localhost",
"metadata.google.internal",
"metadata.aws.internal",
"instance-data",
"169.254.169.254",
];
if blocked_hostnames.contains(&hostname) {
return Err(json!({"error": format!("SSRF blocked: {hostname} is a restricted hostname")}));
}
// Resolve DNS and check every returned IP
let port = if url.starts_with("https") { 443 } else { 80 };
let socket_addr = format!("{hostname}:{port}");
if let Ok(addrs) = socket_addr.to_socket_addrs() {
for addr in addrs {
let ip = addr.ip();
if ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) {
return Err(json!({"error": format!(
"SSRF blocked: {hostname} resolves to private IP {ip}"
)}));
}
}
}
Ok(())
}
fn is_private_ip(ip: &std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
let octets = v4.octets();
matches!(
octets,
[10, ..] | [172, 16..=31, ..] | [192, 168, ..] | [169, 254, ..]
)
}
std::net::IpAddr::V6(v6) => {
let segments = v6.segments();
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
}
}
}
// ---------------------------------------------------------------------------
// Always-allowed functions
// ---------------------------------------------------------------------------
@@ -279,13 +224,15 @@ fn host_net_fetch(state: &GuestState, params: &serde_json::Value) -> serde_json:
.unwrap_or("GET");
let body = params.get("body").and_then(|b| b.as_str()).unwrap_or("");
// SECURITY: SSRF protection — check resolved IP against private ranges
if let Err(e) = is_ssrf_target(url) {
return e;
// SECURITY: SSRF protection — delegates to the canonical check in web_fetch
// which includes the full blocklist, metadata IP detection, IPv6 support,
// and respects the ssrf_allowed_hosts configuration.
if let Err(msg) = web_fetch::check_ssrf(url, &state.ssrf_allowed_hosts) {
return json!({"error": msg});
}
// Extract host:port from URL for capability check
let host = extract_host_from_url(url);
let host = web_fetch::extract_host(url);
if let Err(e) = check_capability(&state.capabilities, &Capability::NetConnect(host)) {
return e;
}
@@ -311,21 +258,6 @@ fn host_net_fetch(state: &GuestState, params: &serde_json::Value) -> serde_json:
})
}
/// Extract host:port from a URL for capability checking.
fn extract_host_from_url(url: &str) -> String {
if let Some(after_scheme) = url.split("://").nth(1) {
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
if host_port.contains(':') {
host_port.to_string()
} else if url.starts_with("https") {
format!("{host_port}:443")
} else {
format!("{host_port}:80")
}
} else {
url.to_string()
}
}
// ---------------------------------------------------------------------------
// Shell (capability-checked)
@@ -501,6 +433,7 @@ mod tests {
kernel: None,
agent_id: "test-agent".to_string(),
tokio_handle: tokio::runtime::Handle::current(),
ssrf_allowed_hosts: Vec::new(),
}
}
@@ -618,51 +551,65 @@ mod tests {
assert!(safe_resolve_parent("/tmp/../../etc/shadow").is_err());
}
// SSRF tests now exercise the canonical implementation in web_fetch.rs,
// which is the same code path used by host_net_fetch at runtime.
// This verifies the integration works end-to-end for WASM host calls.
#[test]
fn test_ssrf_private_ips_blocked() {
assert!(is_ssrf_target("http://127.0.0.1:8080/secret").is_err());
assert!(is_ssrf_target("http://localhost:3000/api").is_err());
assert!(is_ssrf_target("http://169.254.169.254/metadata").is_err());
assert!(is_ssrf_target("http://metadata.google.internal/v1/instance").is_err());
let no_allow: Vec<String> = vec![];
assert!(web_fetch::check_ssrf("http://127.0.0.1:8080/secret", &no_allow).is_err());
assert!(web_fetch::check_ssrf("http://localhost:3000/api", &no_allow).is_err());
assert!(web_fetch::check_ssrf("http://169.254.169.254/metadata", &no_allow).is_err());
assert!(web_fetch::check_ssrf("http://metadata.google.internal/v1/instance", &no_allow).is_err());
// These were previously missing from host_functions — now covered:
assert!(web_fetch::check_ssrf("http://[::1]:8080/secret", &no_allow).is_err());
assert!(web_fetch::check_ssrf("http://100.100.100.200/metadata", &no_allow).is_err());
}
#[test]
fn test_ssrf_public_ips_allowed() {
assert!(is_ssrf_target("https://api.openai.com/v1/chat").is_ok());
assert!(is_ssrf_target("https://google.com").is_ok());
let no_allow: Vec<String> = vec![];
assert!(web_fetch::check_ssrf("https://api.openai.com/v1/chat", &no_allow).is_ok());
assert!(web_fetch::check_ssrf("https://google.com", &no_allow).is_ok());
}
#[test]
fn test_ssrf_scheme_validation() {
assert!(is_ssrf_target("file:///etc/passwd").is_err());
assert!(is_ssrf_target("gopher://evil.com").is_err());
assert!(is_ssrf_target("ftp://example.com").is_err());
let no_allow: Vec<String> = vec![];
assert!(web_fetch::check_ssrf("file:///etc/passwd", &no_allow).is_err());
assert!(web_fetch::check_ssrf("gopher://evil.com", &no_allow).is_err());
assert!(web_fetch::check_ssrf("ftp://example.com", &no_allow).is_err());
}
#[test]
fn test_is_private_ip() {
use std::net::IpAddr;
assert!(is_private_ip(&"10.0.0.1".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(&"172.16.0.1".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(&"192.168.1.1".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(&"169.254.169.254".parse::<IpAddr>().unwrap()));
assert!(!is_private_ip(&"8.8.8.8".parse::<IpAddr>().unwrap()));
assert!(!is_private_ip(&"1.1.1.1".parse::<IpAddr>().unwrap()));
fn test_ssrf_allowlist_respected() {
let allowed = vec!["192.168.1.0/24".to_string()];
// Private IP that matches allowlist — should pass
assert!(web_fetch::check_ssrf("http://192.168.1.100:8080/api", &allowed).is_ok());
// Private IP outside allowlist — should still block
let no_allow: Vec<String> = vec![];
assert!(web_fetch::check_ssrf("http://192.168.1.100:8080/api", &no_allow).is_err());
}
#[test]
fn test_extract_host_from_url() {
fn test_extract_host_delegates_to_web_fetch() {
assert_eq!(
extract_host_from_url("https://api.openai.com/v1/chat"),
web_fetch::extract_host("https://api.openai.com/v1/chat"),
"api.openai.com:443"
);
assert_eq!(
extract_host_from_url("http://localhost:8080/api"),
web_fetch::extract_host("http://localhost:8080/api"),
"localhost:8080"
);
assert_eq!(
extract_host_from_url("http://example.com"),
web_fetch::extract_host("http://example.com"),
"example.com:80"
);
// IPv6 — previously not handled by host_functions
assert_eq!(
web_fetch::extract_host("http://[::1]:9090/test"),
"[::1]:9090"
);
}
}
+7 -3
View File
@@ -247,6 +247,13 @@ impl McpConnection {
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
// Some stdio MCP servers launched via node/npx require a usable home
// directory even when they do not declare any explicit secret env vars.
for var in &["HOME", "TMP", "TEMP"] {
if let Ok(val) = std::env::var(var) {
cmd.env(var, val);
}
}
// On Windows, npm/node need extra vars
if cfg!(windows) {
for var in &[
@@ -254,9 +261,6 @@ impl McpConnection {
"LOCALAPPDATA",
"USERPROFILE",
"SystemRoot",
"TEMP",
"TMP",
"HOME",
"HOMEDRIVE",
"HOMEPATH",
] {
+201 -9
View File
@@ -1012,13 +1012,24 @@ fn builtin_aliases() -> HashMap<String, String> {
("qwen-coder", "qwen-code/qwen3-coder"),
("qwen-coder-plus", "qwen-code/qwen-coder-plus"),
("qwq", "qwen-code/qwq-32b"),
// OpenRouter free-tier aliases
// OpenRouter free-tier aliases. Point to free models that actually support
// tool calling on OpenRouter's free endpoints — agents send tool definitions
// by default, so a non-tool model returns "No endpoints found that support
// tool use" (issue #1032).
(
"openrouter/free",
"openrouter/meta-llama/llama-3.1-8b-instruct:free",
"openrouter/meta-llama/llama-3.3-70b-instruct:free",
),
("free", "openrouter/meta-llama/llama-3.1-8b-instruct:free"),
("free", "openrouter/meta-llama/llama-3.3-70b-instruct:free"),
("free-reasoning", "openrouter/deepseek/deepseek-r1:free"),
(
"openrouter/free-coder",
"openrouter/qwen/qwen3-coder:free",
),
(
"openrouter/free-large",
"openrouter/openai/gpt-oss-120b:free",
),
];
pairs
.into_iter()
@@ -1721,7 +1732,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// OpenRouter (10) — pass-through models using real upstream IDs
// OpenRouter (15+) — pass-through models using real upstream IDs
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "openrouter/google/gemini-2.5-flash".into(),
@@ -1879,6 +1890,10 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
ModelCatalogEntry {
// NOTE: OpenRouter's free endpoint for this model rejects tool-use
// requests ("No endpoints found that support tool use"), so we mark
// it as no-tool to keep agents from sending tool definitions to it.
// The paid version of llama-3.1-8b-instruct does support tools.
id: "openrouter/meta-llama/llama-3.1-8b-instruct:free".into(),
display_name: "Llama 3.1 8B Free (OpenRouter)".into(),
provider: "openrouter".into(),
@@ -1887,18 +1902,93 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
max_output_tokens: 4_096,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
// Same caveat as above — OpenRouter's free 7B endpoint has no tool
// support; use qwen3-coder:free for tool-using free workloads.
id: "openrouter/qwen/qwen-2.5-7b-instruct:free".into(),
display_name: "Qwen 2.5 7B Free (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Fast,
context_window: 32_768,
max_output_tokens: 4_096,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
// Free models that DO support tool calling on OpenRouter's free tier.
// Verified against `GET https://openrouter.ai/api/v1/models` —
// `supported_parameters` includes "tools" for these IDs.
ModelCatalogEntry {
id: "openrouter/meta-llama/llama-3.3-70b-instruct:free".into(),
display_name: "Llama 3.3 70B Free (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Balanced,
context_window: 65_536,
max_output_tokens: 4_096,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/qwen/qwen3-coder:free".into(),
display_name: "Qwen3 Coder Free (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Smart,
context_window: 262_000,
max_output_tokens: 8_192,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/openai/gpt-oss-120b:free".into(),
display_name: "GPT-OSS 120B Free (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 8_192,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/openai/gpt-oss-20b:free".into(),
display_name: "GPT-OSS 20B Free (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Fast,
context_window: 131_072,
max_output_tokens: 4_096,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/qwen/qwen-2.5-7b-instruct:free".into(),
display_name: "Qwen 2.5 7B Free (OpenRouter)".into(),
id: "openrouter/z-ai/glm-4.5-air:free".into(),
display_name: "GLM 4.5 Air Free (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Fast,
context_window: 32_768,
max_output_tokens: 4_096,
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 8_192,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
@@ -4510,4 +4600,106 @@ mod tests {
assert_eq!(found.provider, "custom_provider");
assert_eq!(found.id, "My-Custom-LLM");
}
// ── OpenRouter free-tier fixes (issue #1032) ──────────────────────────
/// `openrouter/free` and `free` aliases must point to a free model that
/// actually supports tool calling on OpenRouter's free endpoints.
/// Previously they pointed to `llama-3.1-8b-instruct:free`, which OpenRouter
/// rejects with "No endpoints found that support tool use" when agents
/// send tool definitions.
#[test]
fn test_openrouter_free_alias_supports_tools() {
let catalog = ModelCatalog::new();
let entry = catalog.find_model("openrouter/free").expect(
"openrouter/free alias must resolve to a known model",
);
assert_eq!(entry.provider, "openrouter");
assert!(
entry.supports_tools,
"openrouter/free must resolve to a tool-capable model (issue #1032). \
Resolved to {} which has supports_tools=false",
entry.id
);
}
#[test]
fn test_openrouter_free_short_alias_supports_tools() {
let catalog = ModelCatalog::new();
let entry = catalog
.find_model("free")
.expect("free alias must resolve");
assert_eq!(entry.provider, "openrouter");
assert!(
entry.supports_tools,
"`free` alias must resolve to a tool-capable model"
);
}
/// Confirm the resolved free model's ID is one of the verified
/// tool-supporting free endpoints on OpenRouter.
#[test]
fn test_openrouter_free_alias_target() {
let catalog = ModelCatalog::new();
let resolved = catalog
.resolve_alias("openrouter/free")
.expect("alias must exist");
// Must be one of the known-good free models with tool support.
let known_good = [
"openrouter/meta-llama/llama-3.3-70b-instruct:free",
"openrouter/qwen/qwen3-coder:free",
"openrouter/openai/gpt-oss-120b:free",
"openrouter/openai/gpt-oss-20b:free",
"openrouter/z-ai/glm-4.5-air:free",
];
assert!(
known_good.contains(&resolved),
"openrouter/free resolves to {}, expected one of: {:?}",
resolved,
known_good
);
}
/// New free-tier tool-using models are present in the catalog.
#[test]
fn test_openrouter_free_tool_models_present() {
let catalog = ModelCatalog::new();
for id in [
"openrouter/meta-llama/llama-3.3-70b-instruct:free",
"openrouter/qwen/qwen3-coder:free",
"openrouter/openai/gpt-oss-120b:free",
"openrouter/openai/gpt-oss-20b:free",
"openrouter/z-ai/glm-4.5-air:free",
] {
let entry = catalog
.find_model(id)
.unwrap_or_else(|| panic!("missing free model {}", id));
assert_eq!(entry.provider, "openrouter");
assert!(entry.supports_tools, "{} must support tools", id);
assert_eq!(entry.input_cost_per_m, 0.0, "{} must be free", id);
assert_eq!(entry.output_cost_per_m, 0.0, "{} must be free", id);
}
}
/// Free models that OpenRouter's free endpoint does NOT route to a
/// tool-supporting backend must be marked `supports_tools=false` so
/// agents don't send tool defs that get rejected.
#[test]
fn test_openrouter_free_no_tool_models_marked() {
let catalog = ModelCatalog::new();
let llama8b = catalog
.find_model("openrouter/meta-llama/llama-3.1-8b-instruct:free")
.expect("model must exist");
assert!(
!llama8b.supports_tools,
"llama-3.1-8b-instruct:free has no tool-supporting free endpoint"
);
let qwen7b = catalog
.find_model("openrouter/qwen/qwen-2.5-7b-instruct:free")
.expect("model must exist");
assert!(
!qwen7b.supports_tools,
"qwen-2.5-7b-instruct:free has no tool-supporting free endpoint"
);
}
}
+7
View File
@@ -42,6 +42,9 @@ pub struct SandboxConfig {
/// Wall-clock timeout in seconds for epoch-based interruption.
/// Defaults to 30 seconds if None.
pub timeout_secs: Option<u64>,
/// Hosts allowed to bypass SSRF private-IP checks.
/// Forwarded from `[web.fetch] ssrf_allowed_hosts` in config.toml.
pub ssrf_allowed_hosts: Vec<String>,
}
impl Default for SandboxConfig {
@@ -51,6 +54,7 @@ impl Default for SandboxConfig {
max_memory_bytes: 16 * 1024 * 1024,
capabilities: Vec::new(),
timeout_secs: None,
ssrf_allowed_hosts: Vec::new(),
}
}
}
@@ -65,6 +69,8 @@ pub struct GuestState {
pub agent_id: String,
/// Tokio runtime handle for async operations in sync host functions.
pub tokio_handle: tokio::runtime::Handle,
/// Hosts allowed to bypass SSRF private-IP checks (from config).
pub ssrf_allowed_hosts: Vec<String>,
}
/// Result of executing a WASM module.
@@ -164,6 +170,7 @@ impl WasmSandbox {
kernel,
agent_id: agent_id.to_string(),
tokio_handle,
ssrf_allowed_hosts: config.ssrf_allowed_hosts.clone(),
},
);
@@ -331,7 +331,7 @@ fn reorder_tool_results(messages: &mut Vec<Message>) -> usize {
// Insert in reverse order so indices remain valid
let mut sorted_insertions: Vec<(usize, Vec<ContentBlock>)> = insertions.into_iter().collect();
sorted_insertions.sort_by(|a, b| b.0.cmp(&a.0));
sorted_insertions.sort_by_key(|b| std::cmp::Reverse(b.0));
for (orig_assistant_idx, blocks) in sorted_insertions {
if let Some(&current_idx) = current_assistant_positions.get(&orig_assistant_idx) {
@@ -433,7 +433,7 @@ fn insert_synthetic_results(messages: &mut Vec<Message>) -> usize {
// Insert in reverse order so indices stay valid
let mut sorted: Vec<(usize, Vec<ContentBlock>)> = grouped.into_iter().collect();
sorted.sort_by(|a, b| b.0.cmp(&a.0));
sorted.sort_by_key(|b| std::cmp::Reverse(b.0));
for (assistant_idx, blocks) in sorted {
let insert_pos = assistant_idx + 1;
+4 -1
View File
@@ -366,7 +366,10 @@ fn is_private_ip(ip: &IpAddr) -> bool {
}
/// Extract host:port from a URL.
fn extract_host(url: &str) -> String {
///
/// Handles IPv6 bracket notation (`[::1]:8080`), and infers default
/// ports (80 for HTTP, 443 for HTTPS) when no explicit port is given.
pub(crate) 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
+22
View File
@@ -742,16 +742,32 @@ pub struct AgentBinding {
}
/// Match rule for agent bindings. All specified (non-None) fields must match.
///
/// `#[serde(deny_unknown_fields)]` is intentional: a typo like `channnel_id` or
/// `chan_id` would otherwise be silently dropped, producing a wide-open binding
/// that matches every message. Failing loudly at config load is the safer default.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BindingMatchRule {
/// Channel type (e.g., "discord", "telegram", "slack").
#[serde(default)]
pub channel: Option<String>,
/// Specific account/bot ID within the channel.
#[serde(default)]
pub account_id: Option<String>,
/// Peer/user ID for DM routing.
#[serde(default)]
pub peer_id: Option<String>,
/// Guild/server ID (Discord/Slack).
#[serde(default)]
pub guild_id: Option<String>,
/// Channel/conversation ID — the per-channel routing dimension.
/// On Discord this is the channel/thread ID; on Slack it is the conversation
/// ID (`C…`/`D…`/`G…`); on Telegram it is the chat ID; on IRC it is the
/// channel name. Bridges populate this from the message's channel/conversation
/// identifier so bindings can route by room independent of which user posted.
#[serde(default)]
pub channel_id: Option<String>,
/// Role-based routing (user must have at least one).
#[serde(default)]
pub roles: Vec<String>,
@@ -760,11 +776,17 @@ pub struct BindingMatchRule {
impl BindingMatchRule {
/// Calculate specificity score for binding priority ordering.
/// Higher = more specific = checked first.
///
/// Weights: peer_id and channel_id are both 8 so a binding that combines
/// both (a specific user in a specific room) cleanly outranks either alone.
pub fn specificity(&self) -> u32 {
let mut score = 0u32;
if self.peer_id.is_some() {
score += 8;
}
if self.channel_id.is_some() {
score += 8;
}
if self.guild_id.is_some() {
score += 4;
}
+141 -1
View File
@@ -85,10 +85,26 @@ pub enum ContentBlock {
is_error: bool,
},
/// Extended thinking content block (model's reasoning trace).
///
/// Preserved across turns so reasoning models retain state. Anthropic's
/// extended thinking requires the `signature` to be echoed on resubmission;
/// other providers (Gemini thought signatures, DeepSeek/Qwen
/// `reasoning_content`, MiniMax inline `<think>`) round-trip via
/// `provider_metadata` or by inlining into the assistant message body.
#[serde(rename = "thinking")]
Thinking {
/// The thinking/reasoning text.
thinking: String,
/// Provider-issued signature required to resubmit thinking blocks
/// (Anthropic extended thinking). `None` for providers that don't
/// emit a signature.
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
/// Provider-specific metadata (e.g. `{"format": "reasoning_content"}`
/// or `{"format": "inline_think"}` so the outbound driver knows how
/// the upstream model originally delivered the reasoning).
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_metadata: Option<serde_json::Value>,
},
/// Catch-all for unrecognized content block types (forward compatibility).
#[serde(other)]
@@ -141,7 +157,7 @@ impl MessageContent {
.map(|b| match b {
ContentBlock::Text { text, .. } => text.len(),
ContentBlock::ToolResult { content, .. } => content.len(),
ContentBlock::Thinking { thinking } => thinking.len(),
ContentBlock::Thinking { thinking, .. } => thinking.len(),
ContentBlock::ToolUse { name, input, .. } => {
name.len() + input.to_string().len()
}
@@ -199,6 +215,17 @@ impl Message {
content: MessageContent::Text(content.into()),
}
}
/// Create an assistant message with structured content blocks.
///
/// Used to preserve `Thinking` blocks (with signatures and reasoning text)
/// across persistence so reasoning models retain state between turns.
pub fn assistant_with_blocks(blocks: Vec<ContentBlock>) -> Self {
Self {
role: Role::Assistant,
content: MessageContent::Blocks(blocks),
}
}
}
/// Why the LLM stopped generating.
@@ -311,6 +338,119 @@ mod tests {
assert!(matches!(block, ContentBlock::Unknown));
}
#[test]
fn test_thinking_block_roundtrip_preserves_signature() {
// Anthropic extended thinking — the signature MUST round-trip through
// serde so it can be echoed on the next request.
let block = ContentBlock::Thinking {
thinking: "Let me reason about this carefully...".to_string(),
signature: Some("sig_abc123_anthropic_extended_thinking".to_string()),
provider_metadata: None,
};
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "thinking");
assert_eq!(json["signature"], "sig_abc123_anthropic_extended_thinking");
// Round-trip through serialize → deserialize (e.g. SQLite session blob)
let serialized = serde_json::to_string(&block).unwrap();
let restored: ContentBlock = serde_json::from_str(&serialized).unwrap();
match restored {
ContentBlock::Thinking {
thinking, signature, ..
} => {
assert_eq!(thinking, "Let me reason about this carefully...");
assert_eq!(
signature.as_deref(),
Some("sig_abc123_anthropic_extended_thinking")
);
}
_ => panic!("expected Thinking block"),
}
}
#[test]
fn test_thinking_block_roundtrip_with_provider_metadata() {
// OpenAI-compat models (DeepSeek-R1, Qwen3, MiniMax) — record the
// wire format so the outbound driver knows whether to re-emit as
// `reasoning_content` or inline `<think>` tags.
let block = ContentBlock::Thinking {
thinking: "step-by-step analysis".to_string(),
signature: None,
provider_metadata: Some(serde_json::json!({"format": "inline_think"})),
};
let serialized = serde_json::to_string(&block).unwrap();
let restored: ContentBlock = serde_json::from_str(&serialized).unwrap();
match restored {
ContentBlock::Thinking {
thinking,
signature,
provider_metadata,
} => {
assert_eq!(thinking, "step-by-step analysis");
assert!(signature.is_none());
let meta = provider_metadata.expect("provider_metadata preserved");
assert_eq!(meta["format"], "inline_think");
}
_ => panic!("expected Thinking block"),
}
}
#[test]
fn test_thinking_block_legacy_deser() {
// Existing sessions on disk only have `{"type": "thinking", "thinking": "..."}`.
// The new fields must be optional so old payloads still load.
let json = serde_json::json!({"type": "thinking", "thinking": "old session reasoning"});
let block: ContentBlock = serde_json::from_value(json).unwrap();
match block {
ContentBlock::Thinking {
thinking,
signature,
provider_metadata,
} => {
assert_eq!(thinking, "old session reasoning");
assert!(signature.is_none());
assert!(provider_metadata.is_none());
}
_ => panic!("expected Thinking block"),
}
}
#[test]
fn test_assistant_with_blocks_preserves_thinking() {
// A complete round-trip: build an assistant turn that mixes Thinking +
// Text (the shape we'll store after fix) and confirm the Thinking
// block survives serialization (msgpack is what session.rs uses; JSON
// exercises the same serde path).
let msg = Message::assistant_with_blocks(vec![
ContentBlock::Thinking {
thinking: "Internal reasoning".to_string(),
signature: Some("sig_xyz".to_string()),
provider_metadata: None,
},
ContentBlock::Text {
text: "Hello!".to_string(),
provider_metadata: None,
},
]);
let bytes = rmp_serde::to_vec_named(&msg).expect("msgpack encode");
let restored: Message = rmp_serde::from_slice(&bytes).expect("msgpack decode");
match restored.content {
MessageContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 2);
match &blocks[0] {
ContentBlock::Thinking {
thinking, signature, ..
} => {
assert_eq!(thinking, "Internal reasoning");
assert_eq!(signature.as_deref(), Some("sig_xyz"));
}
_ => panic!("expected Thinking first"),
}
}
_ => panic!("expected Blocks content"),
}
}
#[test]
fn test_user_with_blocks() {
let blocks = vec![
+62 -1
View File
@@ -11,6 +11,7 @@ Complete reference for `config.toml`, covering every configurable field in the O
- [Full Example](#full-example)
- [Section Reference](#section-reference)
- [Top-Level Fields](#top-level-fields)
- [Exposing the Dashboard](#exposing-the-dashboard)
- [\[default\_model\]](#default_model)
- [\[memory\]](#memory)
- [\[network\]](#network)
@@ -36,6 +37,13 @@ OpenFang reads its configuration from a single TOML file:
On Windows, `~` resolves to `C:\Users\<username>`. If the home directory cannot be determined, the system temp directory is used as a fallback.
The home directory is resolved with the following priority:
1. `OPENFANG_HOME` environment variable (e.g. `OPENFANG_HOME=/data` in the official Docker image).
2. `~/.openfang` (default).
So inside the Docker container the config file must live at `/data/config.toml` (because the image sets `ENV OPENFANG_HOME=/data`). Placing it anywhere else (for example `/opt/openfang/config.toml`) will be silently ignored.
**Key behaviors:**
- Every struct in the configuration uses `#[serde(default)]`, which means **all fields are optional**. Omitted fields receive their documented default values.
@@ -227,7 +235,7 @@ These fields sit at the root of `config.toml` (not inside any `[section]`).
| `home_dir` | path | `~/.openfang` | OpenFang home directory. Stores config, agents, skills. |
| `data_dir` | path | `~/.openfang/data` | Directory for SQLite databases and persistent data. |
| `log_level` | string | `"info"` | Log verbosity. One of: `trace`, `debug`, `info`, `warn`, `error`. |
| `api_listen` | string | `"127.0.0.1:50051"` | Bind address for the HTTP/WebSocket/SSE API server. |
| `api_listen` | string | `"127.0.0.1:50051"` | Bind address for the HTTP/WebSocket/SSE API server. Use `0.0.0.0:<port>` to accept connections from outside the host (LAN, Docker, remote clients). See [Exposing the Dashboard](#exposing-the-dashboard) below before doing so. Can be overridden at runtime with the `OPENFANG_LISTEN` environment variable. |
| `network_enabled` | bool | `false` | Enable the OFP peer-to-peer network layer. |
| `api_key` | string | `""` (empty) | API authentication key. When set, all endpoints except `/api/health` require `Authorization: Bearer <key>`. Empty means unauthenticated (local development only). |
| `mode` | string | `"default"` | Kernel operating mode. See below. |
@@ -253,6 +261,59 @@ These fields sit at the root of `config.toml` (not inside any `[section]`).
---
### Exposing the Dashboard
By default OpenFang binds the API and dashboard to `127.0.0.1` (loopback only) so the daemon is unreachable from anywhere except the local machine. To accept connections from your LAN, Docker host, or a remote client you must explicitly opt in to non-loopback binding.
**Two ways to change the bind address:**
1. Edit `config.toml`:
```toml
api_listen = "0.0.0.0:4200"
```
2. Or set the `OPENFANG_LISTEN` environment variable (overrides `config.toml`):
```bash
export OPENFANG_LISTEN=0.0.0.0:4200
```
The env var route is the recommended path for Docker because it does not require mounting a config file.
**Docker example.** The official image sets `OPENFANG_HOME=/data` and exposes port `4200`. The simplest end-to-end setup is:
```yaml
services:
openfang:
image: ghcr.io/rightnow-ai/openfang:latest
ports:
- "4200:4200"
volumes:
- openfang-data:/data
environment:
- OPENFANG_LISTEN=0.0.0.0:4200 # required: bind to all interfaces inside the container
- OPENFANG_API_KEY=${OPENFANG_API_KEY} # strongly recommended when exposing
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
volumes:
openfang-data:
```
If you prefer mounting `config.toml`, the file must live at `/data/config.toml` inside the container (because of `OPENFANG_HOME=/data`). Placing it at `/opt/openfang/config.toml` or any other path will not be picked up. The port in `api_listen` must also match the port published in `ports:` — the example config in `openfang.toml.example` ships with port `50051` to be safe; change it to `4200` (or whatever port you publish) when running in Docker.
**Security warning.** Once you bind to a non-loopback address, anyone reachable at that address can talk to the API. OpenFang's middleware enforces a fail-closed default:
- If `api_key` is empty AND dashboard auth is disabled AND the bind address is not loopback, all non-loopback requests are rejected with `401 Unauthorized`.
- To run in this configuration anyway (not recommended), set `OPENFANG_ALLOW_NO_AUTH=1`. This will be loudly logged.
The supported ways to expose the dashboard safely:
- Set `api_key = "..."` in `config.toml` (or `OPENFANG_API_KEY=...`) and send `Authorization: Bearer <key>` on every request.
- Or enable the [`[auth]`](#auth) section to require username/password login on the dashboard UI.
- Or keep `api_listen` on `127.0.0.1` and reach the dashboard through an SSH tunnel or reverse proxy that handles authentication for you.
---
### `[default_model]`
Configures the primary LLM provider used when agents do not specify their own model.
+15
View File
@@ -88,6 +88,21 @@ export PATH="$HOME/.cargo/bin:$PATH"
# Add to ~/.bashrc or ~/.zshrc to persist
```
### Black screen on login after install (Arch / CachyOS / fish users)
**Cause**: Older OpenFang installers (`<v0.6.4`) appended a PATH line directly to `~/.config/fish/config.fish`. On Arch derivatives like CachyOS, the desktop session can source fish on login — a malformed or invalid PATH line then prevents the session from finishing, leaving you on a black screen.
**Fix**: Boot to a TTY (`Ctrl+Alt+F2`) and remove any OpenFang PATH lines from `config.fish`:
```bash
sed -i '/openfang/d' ~/.config/fish/config.fish
```
Then re-run the installer — current versions write to `~/.config/fish/conf.d/openfang.fish` (a drop-in directory) instead, and guard the path with `test -d` so a missing install dir can never wedge fish startup.
To remove OpenFang's PATH entry cleanly:
```bash
rm ~/.config/fish/conf.d/openfang.fish
```
### Docker container won't start
**Common causes**:
+6 -2
View File
@@ -2,8 +2,12 @@
# Copy to ~/.openfang/config.toml and customize.
# API server settings
# api_key = "" # Set to enable Bearer auth (recommended)
# api_listen = "127.0.0.1:50051" # HTTP API bind address (use 0.0.0.0 for public)
# api_key = "" # Set to enable Bearer auth (strongly recommended when exposing)
# api_listen = "127.0.0.1:50051" # HTTP API bind address.
# Use "0.0.0.0:<port>" to expose on LAN / Docker. The port must
# match the port you publish (Docker image exposes 4200). You can
# also override at runtime with OPENFANG_LISTEN=0.0.0.0:4200.
# See docs/configuration.md "Exposing the Dashboard".
[default_model]
provider = "anthropic" # "anthropic", "gemini", "openai", "groq", "ollama", etc.
+58 -28
View File
@@ -149,39 +149,69 @@ install() {
USER_SHELL=$(grep "^$(id -un):" /etc/passwd 2>/dev/null | cut -d: -f7)
fi
SHELL_RC=""
# Fish shell: write to ~/.config/fish/conf.d/openfang.fish (drop-in dir).
# This keeps the user's config.fish completely untouched, so a broken
# PATH entry can never wedge the user's main shell config — critical
# on Arch/CachyOS where the desktop session sources fish on login.
USE_FISH_DROPIN=0
case "$USER_SHELL" in
*/zsh) SHELL_RC="$HOME/.zshrc" ;;
*/bash) SHELL_RC="$HOME/.bashrc" ;;
*/fish) SHELL_RC="$HOME/.config/fish/config.fish" ;;
*/fish) USE_FISH_DROPIN=1 ;;
esac
# Also check for config files if shell detection failed.
# Check bash/zsh first (more common defaults), fish last — avoids
# writing to config.fish for users who merely have Fish installed.
if [ -z "$SHELL_RC" ]; then
if [ -f "$HOME/.bashrc" ]; then
SHELL_RC="$HOME/.bashrc"
elif [ -f "$HOME/.zshrc" ]; then
SHELL_RC="$HOME/.zshrc"
elif [ -f "$HOME/.config/fish/config.fish" ]; then
SHELL_RC="$HOME/.config/fish/config.fish"
fi
# If $USER_SHELL didn't match fish but config.fish exists AND no other
# rc files exist, the user is likely a fish user — use the drop-in too.
if [ "$USE_FISH_DROPIN" -eq 0 ] \
&& [ -f "$HOME/.config/fish/config.fish" ] \
&& [ ! -f "$HOME/.bashrc" ] \
&& [ ! -f "$HOME/.zshrc" ]; then
USE_FISH_DROPIN=1
fi
if [ -n "$SHELL_RC" ] && ! grep -q "openfang" "$SHELL_RC" 2>/dev/null; then
# Determine syntax from the TARGET FILE, not $USER_SHELL — this
# prevents Bash syntax from ever being written to config.fish even
# when shell detection mis-identifies the user's shell.
case "$SHELL_RC" in
*/config.fish)
mkdir -p "$(dirname "$SHELL_RC")"
echo "fish_add_path \"$INSTALL_DIR\"" >> "$SHELL_RC"
;;
*)
echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$SHELL_RC"
;;
if [ "$USE_FISH_DROPIN" -eq 1 ]; then
FISH_CONF_DIR="$HOME/.config/fish/conf.d"
FISH_DROPIN="$FISH_CONF_DIR/openfang.fish"
mkdir -p "$FISH_CONF_DIR"
if [ ! -f "$FISH_DROPIN" ]; then
# Guarded with `test -d` so a missing/broken install dir never
# breaks fish startup (which would black-screen Arch/CachyOS
# desktop sessions that source fish at login).
cat > "$FISH_DROPIN" <<EOF
# OpenFang PATH — auto-generated by installer
if test -d "$INSTALL_DIR"
fish_add_path -g "$INSTALL_DIR"
end
EOF
echo " Added $INSTALL_DIR to PATH via $FISH_DROPIN"
fi
# Best-effort: clean up legacy bash-syntax export from config.fish
# written by older OpenFang installers (<v0.5.0). Harmless if absent.
OLD_FISH_RC="$HOME/.config/fish/config.fish"
if [ -f "$OLD_FISH_RC" ] && grep -q "openfang/bin" "$OLD_FISH_RC" 2>/dev/null; then
# Remove any line containing .openfang/bin (covers both bash
# `export PATH=` syntax and old fish `set -gx PATH` lines).
TMPFILE=$(mktemp)
grep -v "openfang/bin" "$OLD_FISH_RC" > "$TMPFILE" || true
mv "$TMPFILE" "$OLD_FISH_RC"
echo " Cleaned legacy openfang PATH entry from $OLD_FISH_RC"
fi
else
SHELL_RC=""
case "$USER_SHELL" in
*/zsh) SHELL_RC="$HOME/.zshrc" ;;
*/bash) SHELL_RC="$HOME/.bashrc" ;;
esac
echo " Added $INSTALL_DIR to PATH in $SHELL_RC"
# Fall back to existing rc files when shell detection failed.
if [ -z "$SHELL_RC" ]; then
if [ -f "$HOME/.bashrc" ]; then
SHELL_RC="$HOME/.bashrc"
elif [ -f "$HOME/.zshrc" ]; then
SHELL_RC="$HOME/.zshrc"
fi
fi
if [ -n "$SHELL_RC" ] && ! grep -q "openfang" "$SHELL_RC" 2>/dev/null; then
echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$SHELL_RC"
echo " Added $INSTALL_DIR to PATH in $SHELL_RC"
fi
fi
# Verify installation