* fix(channels): key router on user, not channel (Discord/Slack)
Discord and Slack adapters set sender.platform_id to the channel/conversation
ID (needed for the send path), so router.resolve(channel, sender.platform_id, ..)
was matching peer_id bindings against the channel ID and never finding the
user-keyed binding. The sender_user_id() helper already existed but only the
rate-limit/authz paths used it; the routing reads did not.
Read-path fix:
- discord.rs / slack.rs: stash author/user ID in metadata["sender_user_id"]
- bridge.rs: route the text and audio paths through sender_user_id(message)
- bridge.rs: thread user_id through handle_command() so the 6 CLI slash-command
resolves (/new, /compact, /model, /stop, /usage, /think) also key on user.
Tests updated for the new signature.
Write-path follow-up (set_user_default + broadcast routing) deferred to a
separate commit so this change can be validated in isolation.
* fix(channels): close write-path keying gap; broadcast user-scoped
Completes the router keying fix started in 6a90aa0. The read path
resolves on user_id, but four write sites and the broadcast lookup
were still keyed on sender.platform_id (channel ID on Discord/Slack),
producing the split-keying state that surfaced in GAP-008.
- 4 x set_user_default writes (text/audio fallback, /agent existing,
/agent spawned) now key on sender_user_id(message)
- 2 x broadcast lookups (has_broadcast / resolve_broadcast) switched
to sender_user_id(message), matching the upstream test's intent
(router.rs:521-547 keys on "vip_user", not a channel)
- boot-time log warning on Discord/Slack adapter start: any
pre-existing /agent default may need to be re-run once
- new test test_handle_command_agent_select_keys_on_user_id_not_
platform_id locks in the round-trip
Closes#1092
Four fixes that together make `nix build .#openfang-cli` and `nix build .#openfang-desktop` work on NixOS:
1. perl / clang / pkg-config moved to nativeBuildInputs (fixes openssl-src build failure — this is the bug filed in #1092)
2. openfang-desktop nativeBuildInputs gets pkg-config + wrapGAppsHook3 (GTK runtime wrappers + webkit2gtk-4.1 .pc discovery)
3. libayatana-appindicator added to desktop buildInputs (tray.rs dlopen at runtime)
4. preFixup hook prefixes LD_LIBRARY_PATH so the dlopen-only library actually ends up in the runtime closure
Authored by @Aypex.
Supersedes #1086 (which only fixed item 1).
Adds an optional `audio_base_url` field to `MediaConfig` that overrides
the hardcoded provider URLs in `media_understanding::transcribe_audio`,
allowing the same OpenAI-compatible multipart wire format to be sent to
a local Whisper service (speaches, faster-whisper-server, LM Studio,
etc.) instead of api.openai.com / api.groq.com.
Closes#1051.
## Why
Self-hosted, sovereignty-conscious, or rate-limited deployments often
need to route audio transcription to a local Whisper backend while
keeping `media_transcribe` / `speech_to_text` working as native tools
(no helper scripts, no shell_exec workarounds). Today the URLs in
`media_understanding.rs:118-128` are literal `&'static str` so neither
`OPENAI_BASE_URL` nor `provider_urls` (which the LLM drivers do
respect) is read for audio. The same problem existed for embeddings
and was already addressable via `provider_urls`, so this change keeps
the pattern symmetric for media at the simplest possible surface area.
## Wire format
The endpoint shape and Authorization header remain identical:
POST <audio_base_url>/v1/audio/transcriptions
Authorization: Bearer $<provider>_API_KEY
Content-Type: multipart/form-data
fields: file (binary), model, response_format=text
This means **any OpenAI-compatible Whisper server is drop-in**
(Speaches, faster-whisper-server, LM Studio's Whisper server, etc.).
Local servers typically accept any non-empty bearer string, so users
can keep `OPENAI_API_KEY=anything` for the auth header.
## Configuration
```toml
[media]
audio_provider = "openai"
audio_base_url = "http://127.0.0.1:8000"
# → POST http://127.0.0.1:8000/v1/audio/transcriptions
```
Or for Groq-compatible local servers:
```toml
[media]
audio_provider = "groq"
audio_base_url = "http://127.0.0.1:9000"
# → POST http://127.0.0.1:9000/v1/audio/transcriptions
```
Trailing slash on the user-supplied base is stripped to avoid double
slashes in the final URL.
## Backward compatibility
- `MediaConfig` already uses `#[serde(default)]`, so existing
configs without `audio_base_url` deserialize as `None` and behave
exactly as before (cloud provider URLs).
- `Default` impl extended; `audio_base_url: None`.
- `parakeet-mlx` provider path unaffected (it's a separate code branch).
- No new dependencies, no breaking changes to public API.
## Tests
- `test_media_config_default` extended to assert `audio_base_url.is_none()`.
- `test_media_config_audio_base_url_serde_roundtrip` — set + JSON roundtrip.
- `test_media_config_backward_compat_no_audio_base_url` — legacy JSON
parses with the new field as None.
- `test_audio_base_url_override_logic` — pure-function test that
exercises the URL building branch (default URLs preserved when
unset, override applied for both providers, trailing-slash strip).
The runtime branch in `transcribe_audio` was kept as a straight
`if Some/else default` rather than a helper function to minimize the
diff and keep the patch obviously safe to review.
## Operational note
This change does not affect anyone running the cloud provider URLs
out of the box. The override is opt-in via a single optional config
field. Useful for users like myself running a local Speaches container
behind a reverse proxy and a chat-only LLM key (z.ai Coding Plan)
that can't satisfy openai.com's audio endpoint.
Linked: #1051 (Configurable STT/TTS/image URLs and local backends).
Co-authored-by: Miguel Guerrero <kortux@gmail.com>
Fixes#1089
run_agent_loop_streaming skipped the touch_agent() call that the
non-streaming run_agent_loop performs before every LLM request. On slow
local inference (e.g. Ollama qwen3.5:35b, multi-minute generations),
last_active went stale and the heartbeat monitor flagged the agent as
unresponsive, triggering crash recovery mid-stream. With multiple agents
sharing one Ollama instance, queued agents appeared frozen while the
active one generated.
Mirror the non-streaming behavior: stamp last_active immediately before
stream_with_retry so the heartbeat window covers the full LLM call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When using Lark international (open.larksuite.com) with WebSocket mode,
the adapter was hardcoding the Chinese Feishu endpoint URL and also
ignoring the configured region entirely when constructing the adapter.
Two bugs fixed:
1. FEISHU_WS_ENDPOINT_URL was hardcoded to open.feishu.cn — international
Lark apps could not authenticate because their credentials are only
valid on open.larksuite.com. Changed to FEISHU_WS_ENDPOINT_PATH and
compute the full URL using self.region.domain() at call time.
2. new_websocket() in FeishuAdapter always set region = FeishuRegion::Cn.
Added new_websocket_with_region() that accepts an explicit region, and
updated the call site in channel_bridge.rs to pass the parsed region.
Fixes#1081
Bump workspace version to 0.5.10 and refresh docs.
Bundles the 7 fixes merged on main since v0.5.9:
- #1034 auth fail-closed (#1071)
- #980 channel agent name prefix (#1072)
- #1043 multimodal text+images (#1073)
- #809 openfang hand config subcommand (#1074)
- #843 context.md re-read per turn (#1075)
- #905 config get default_model.base_url (#1076)
- #1069 scheduler unification and migration (#1077)
README: fix stale 0.3.30 badge and March 2026 header to 0.5.10 and April 2026,
drop em dashes throughout.
CHANGELOG: new 0.5.10 section with the above, plus notes on #818 and #819
which were closed as invalid.
The schedule_create tool, its sibling schedule_list and schedule_delete,
and the matching /api/schedules HTTP routes were all writing to a
shared-memory key that no executor ever read. Jobs registered that way
silently never fired.
Route all three tools and all /api/schedules endpoints through the real
cron scheduler in openfang-kernel. Add a one-shot idempotent migration
at kernel startup that imports legacy __openfang_schedules entries into
the cron scheduler and clears the old key.
Tests:
- Unit tests for sanitize_schedule_name and sanitize_cron_job_name
- Tool wrapper tests using a fake KernelHandle that verify
schedule_create/list/delete route into cron_create/list/cancel
- Migration tests cover the happy path, idempotency via the marker key,
and skipping entries whose target agent is not in the registry
Quality gates: cargo check + test + clippy -D warnings + fmt clean on
openfang-kernel, openfang-runtime, openfang-api.
Made-with: Cursor
When an upload supplied image content blocks alongside the user's text, the agent loop pushed only the image blocks and dropped the text. The LLM saw images with no accompanying prompt.
Build the user turn through a single helper that combines text and image blocks into one multimodal message when both are present, and keeps the existing single-mode representation when only one is supplied. Both the streaming and non-streaming paths now share the same builder.
Closes#1043
Made-with: Cursor
Adds an opt-in per-channel knob prefix_agent_name on ChannelOverrides
with styles Off (default), Bracket ([agent] text) and BoldBracket
(**[agent]** text).
The bridge wraps the final outbound text once in dispatch_message,
dispatch_with_blocks and the auto-reply path. Off is byte-identical
to pre-feature behavior so existing configs are unaffected.
Platform-native identity overrides (Slack per-message username,
Discord embed author field) are intentionally out of scope here and
tracked as a follow-up.
Made-with: Cursor
Extract the dotted-key lookup in `openfang config get` into a pure
`lookup_config_value` helper with clear outcomes (scalar value, non-scalar
section, or key not found) so the behaviour is covered by unit tests.
Previously the command only had integration coverage, so regressions where
`config get default_model.base_url` silently returned an empty string could
slip through. Tests now pin down every `[default_model]` scalar, including
`base_url`, plus unset, missing, numeric, boolean, and section cases.
Also distinguishes a section-valued key (e.g. `config get default_model`)
from a scalar instead of printing a debug-style `{}`.
Made-with: Cursor
When an agent had a context.md file updated externally (e.g. a cron job
refreshing live market data), the updated content never reached the LLM
during an active session. The file was effectively cached for the
lifetime of the conversation.
The runtime now reads context.md from the agent workspace once per turn,
right before the system prompt is built, and injects it as a dedicated
'Live Context' section. Agents that still want the old cache-at-start
behaviour can opt back in with 'cache_context = true' on the manifest.
- new openfang-runtime::agent_context module with a small per-path cache
- if a re-read fails after a previous success, fall back to the cached
content with a warning instead of dropping context mid-conversation
- new PromptContext.context_md field wired up in both kernel streaming
and non-streaming paths
- one small disk read per agent turn (not per streaming token); file
size capped at 32 KB like the other identity files
Made-with: Cursor
Docs reference `openfang hand config browser --set headless=true` but
the CLI never shipped that command. Add it as a thin wrapper over the
existing GET/PUT `/api/hands/{id}/settings` routes so the documented
example works.
- `openfang hand config <id>` prints the current settings merged with
schema defaults.
- `--get KEY` prints one value, `--set KEY=VAL` (repeatable) updates
values, `--unset KEY` removes them. Empty keys are rejected up front.
- When no instance is active, the daemon's existing 404 is surfaced
with a hint to run `openfang hand activate <id>` first.
Unit tests cover the KEY=VAL parser (including empty keys, urls with
equals signs, and blank values). Integration test runs the registry
update_config path end-to-end through a persist/load round-trip so
the CLI semantics match what the daemon stores.
Made-with: Cursor
Empty api_key used to skip auth for everyone. Now it only skips auth for
loopback origins. Non-loopback requests get 401 unless the operator opts
in with OPENFANG_ALLOW_NO_AUTH=1.
- middleware: check ConnectInfo, fail closed on LAN/public origins
- ws: same fail-closed logic for WebSocket upgrades
- server: loud startup warning when bound to non-loopback with no key
- AuthState: new allow_no_auth flag
- tests: 8 new unit tests covering loopback, LAN, public, missing info
Made-with: Cursor
fix(deps): upgrade wasmtime 41->43 and rumqttc 0.24->0.25 to resolve active CVEs
Addresses RUSTSEC advisories surfaced by cargo-audit on main. wasmtime
jump required matching adjustments in sandbox.rs error types; rumqttc
switched to `default-features = false` + `use-native-tls` to drop
the vulnerable `rustls-webpki 0.102` path. CI green across Check/
Test/Clippy/Format/Security-Audit on all three platforms.