Compare commits

...
101 Commits
Author SHA1 Message Date
jaberjaber23 6f8463fc91 bump v0.6.5 2026-05-12 15:05:53 +03:00
jaberjaber23 6b03cb2e9d test fix 2026-05-12 15:04:47 +03:00
Jaber Jaber 8cb7541678 Merge pull request #1146 from benhoverter/lenient-binding-parse
fix(kernel): lenient binding parsing — one typo no longer drops the entire bindings table
2026-05-12 14:57:09 +03:00
jaberjaber23 6b5b7674d3 server ids 2026-05-12 14:56:32 +03:00
jaberjaber23 247dca508b inferencing flag 2026-05-12 14:55:47 +03:00
Jaber Jaber 90d16e52be Merge pull request #1175 from aqilaziz/docs-fix-getting-started-links
Fix getting started documentation links
2026-05-12 14:55:27 +03:00
jaberjaber23 68bde60fac activate agents 2026-05-12 14:54:22 +03:00
Jaber Jaber a422058049 Merge pull request #1135 from RightNow-AI/dependabot/cargo/open-5.3.4
build(deps): bump open from 5.3.3 to 5.3.4
2026-05-12 14:53:44 +03:00
jaberjaber23 6ba0bfb7ef providers screen 2026-05-12 14:53:17 +03:00
jaberjaber23 7699b86037 clone agent 2026-05-12 14:53:10 +03:00
jaberjaber23 e31216d5ec docs accuracy 2026-05-12 14:51:09 +03:00
jaberjaber23 538e943d3d clippy fix 2026-05-12 14:46:00 +03:00
jaberjaber23 94fca22124 redacted thinking 2026-05-12 14:36:05 +03:00
jaberjaber23 37e2043ed7 another timeout 2026-05-12 14:35:12 +03:00
jaberjaber23 31eb833cdf agent history 2026-05-12 14:34:22 +03:00
jaberjaber23 15da248faf another timeout 2026-05-12 14:33:05 +03:00
jaberjaber23 c27a6f3609 local fallback 2026-05-12 14:26:58 +03:00
jaberjaber23 f792f1a14b ws auth 2026-05-12 14:26:27 +03:00
aqilaziz 8b10930e40 Fix getting started documentation links 2026-05-08 06:21:25 +07:00
dependabot[bot] 24aca4e31d build(deps): bump open from 5.3.3 to 5.3.4
Bumps [open](https://github.com/Byron/open-rs) from 5.3.3 to 5.3.4.
- [Release notes](https://github.com/Byron/open-rs/releases)
- [Changelog](https://github.com/Byron/open-rs/blob/main/changelog.md)
- [Commits](https://github.com/Byron/open-rs/compare/v5.3.3...v5.3.4)

---
updated-dependencies:
- dependency-name: open
  dependency-version: 5.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 10:50:27 +00:00
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
Ben HoverterandClaude Opus 4.7 325734c6aa fix(kernel): lenient binding parsing — partial-success table parse
A typo in any binding's match_rule no longer drops the entire bindings
table. Each entry is parsed independently; malformed entries log an
ERROR with index, agent name, and the underlying serde error, then are
skipped. A single WARN summarizes total dropped vs. surviving bindings.
Per-entry deny_unknown_fields is preserved so silent typos still fail
loudly — just no longer catastrophically.

Before this change, a single misspelled field anywhere in [[bindings]]
caused the whole table to fail parsing, silently unbinding every
agent — the worst possible failure mode for a routing config.

- New `lenient_extract_bindings` runs after include-merge / [api]
  migration, before `try_into::<KernelConfig>()`.
- 7 new config tests cover the reproducer, happy path, all-malformed,
  no-bindings, missing-agent, survivor-order preservation, and
  top-level field typos:
    * test_lenient_bindings_drops_typo_keeps_rest
    * test_lenient_bindings_all_valid_unchanged
    * test_lenient_bindings_all_malformed_yields_empty_but_keeps_rest_of_config
    * test_lenient_bindings_no_bindings_section_is_noop
    * test_lenient_bindings_missing_agent_field_dropped
    * test_lenient_bindings_preserves_survivor_order — locks in that
      first-match-wins routing semantics cannot silently regress when
      a middle entry is dropped
    * test_lenient_bindings_top_level_field_typo_dropped — locks in
      that deny_unknown_fields catches operator typos at the binding
      top level (e.g. \`agnt = ...\`), not just inside match_rule
- TODO marker added on the remaining \`warn!\` fallback in \`load_config\`
  for the non-binding silent-default path (follow-up work).

Tested live: typo'd \`hannel\` field on binding #2 logged as expected;
remaining 5 bindings loaded and routed correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 17:10:18 -07: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
jaberjaber23 aabf83b351 bump v0.6.1 2026-04-29 15:30:46 +03:00
jaberjaber23 da6b567ac3 manifest merge 2026-04-29 15:19:41 +03:00
jaberjaber23 ccdd7943a2 fix clippy 2026-04-29 15:14:42 +03:00
jaberjaber23 7fe87babe6 preserve workspace 2026-04-29 15:08:44 +03:00
jaberjaber23 9c0e1637a5 fmt drift 2026-04-29 15:07:22 +03:00
jaberjaber23 fd450dbfc2 fmt cleanup 2026-04-29 15:06:17 +03:00
Jaber Jaber 81176dc626 Merge pull request #1130 from benhoverter/fix/message-timeout-config
fix(runtime): add subprocess timeout config for claude-code driver
2026-04-29 15:03:10 +03:00
Octopusandocto-patch ef9096f7c5 fix(kernel): sync all agent.toml fields to DB on restart (fixes #1087) (#1118)
The TOML-vs-DB change detection at boot only checked a subset of fields,
causing edits to workspace, schedule, resources, autonomous, and exec_policy
to be silently ignored after a restart.

Add the missing fields to the changed-detection predicate so the kernel
properly reflects all agent.toml edits in the SQL database. The workspace
comparison is intentionally guarded — if the TOML omits workspace (None),
the kernel-assigned default path already stored in the DB is kept rather
than being overwritten with None.

Derive PartialEq on ScheduleMode, AutonomousConfig, ResourceQuota, and
ExecPolicy to enable the comparisons without manual field-by-field expansion.

Co-authored-by: octo-patch <octo-patch@github.com>
2026-04-29 14:50:38 +03:00
Jaber Jaber fbb5bb1ae9 Merge pull request #1099 from nimitbhardwaj/fix#1088/websocket-scheduled
fix(ws): broadcast cron job results to WebSocket clients in real-time
2026-04-29 14:47:39 +03:00
Jaber Jaber c435a6adcd Merge pull request #1114 from pandego/fix/1102-idle-heartbeat
fix(kernel): avoid crashing idle reactive agents
2026-04-29 14:46:15 +03:00
Ben Hoverter f67c4e8754 fix(channels): key router default-agent map on user_id, not channel_id (#1123)
* 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
2026-04-29 14:40:36 +03:00
Jaber Jaber 3b237ac526 Merge pull request #1090 from chrisyoung2005/fix/streaming-heartbeat-touch
Stamp last_active in streaming agent loop to prevent heartbeat false-positives
2026-04-29 14:39:28 +03:00
Jaber Jaber 96c572df32 Merge pull request #1082 from octo-patch/fix/issue-1081-lark-websocket-region
fix(feishu): respect region setting for WebSocket endpoint URL
2026-04-29 14:38:12 +03:00
Jaber Jaber 17e0d519ca Merge pull request #1080 from pandego/fix/1079-minimax-init
fix: expose MiniMax in openfang init
2026-04-29 14:30:05 +03:00
Charles Hakes 37c233d489 fix(flake): NixOS build — nativeBuildInputs, wrapGAppsHook3, libayatana-appindicator runtime closure (#1063)
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).
2026-04-29 14:29:24 +03:00
guatoc-ecohubandMiguel Guerrero 92f7e996de feat(media): add audio_base_url override for local OpenAI-compat Whisper (#1124)
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>
2026-04-29 14:28:04 +03:00
Ben Hoverter b1c4061247 fix(runtime): wire subprocess_timeout_secs through config.toml
Follow-up to 79aa34c. The previous commit added the public surface
(DriverConfig field + OPENFANG_SUBPROCESS_TIMEOUT_SECS env var) but
left every DriverConfig construction site hardcoded to None — so the
struct field was wired but had no on-disk source feeding it. The env
var was the only operator-facing knob.

This commit plumbs the missing layer: the timeout is now deserializable
from config.toml on both the primary and global-fallback providers.

Public surface
- DefaultModelConfig.subprocess_timeout_secs: Option<u64>
- FallbackProviderConfig.subprocess_timeout_secs: Option<u64>
- Both fields are #[serde(default)] — existing config.toml files
  without the field deserialize cleanly to None (no breaking change).

Placement rationale
- Per-provider on each config struct, not a top-level field or a new
  [driver] section. This matches the existing per-provider config shape
  and lets operators set different timeouts for primary vs. fallback
  (e.g. tighter timeout on a fast fallback to fail over sooner). If a
  second driver-level setting ever lands, refactoring two struct fields
  into a [driver] section is cheap; we don't pre-pay for it now.

Wiring (kernel.rs)
- L663  primary driver  ........  pulls config.default_model.subprocess_timeout_secs
- L687  auto-detect path  ......  inherits default_model intent (the swap
                                  is replacing the *provider*, not the
                                  timeout policy)
- L736  global fallback loop  ..  pulls fb.subprocess_timeout_secs
- L5031 agent primary  .........  inherits effective_default's value when
                                  agent_provider == default_provider;
                                  None for cross-provider overrides
- L5108 agent manifest fallback   inherits dm's value when the manifest
                                  fallback resolves to "default" (matching
                                  the existing fb.provider sentinel logic);
                                  None for explicit cross-provider entries
- L5139 global fallback (per-agent loop) — pulls fb.subprocess_timeout_secs

Sites kept as None (intentional)
- agent_loop.rs:1146, 1330: ModelNotFound recovery iterates over the
  agent manifest's fallback_models (FallbackModel, not the config-toml
  type) — no per-provider config in scope.
- routes.rs:7701: provider connectivity test endpoint; no config source.
- routes.rs:7529: dashboard hot-update path constructs a fresh DM with
  defaults (None) — operator sets timeout via config.toml, not via the
  set-key flow.

Tests
- test_subprocess_timeout_secs_in_toml: round-trips a TOML doc with
  default_model.subprocess_timeout_secs = 600 and one fallback at 180,
  one fallback omitted; asserts each value (or None) reaches the parsed
  config struct.
- test_subprocess_timeout_secs_omitted_defaults_to_none: asserts a
  legacy-shaped config.toml (no timeout fields) parses cleanly with
  both fields = None — backward-compat guard.
- 4 existing claude_code driver timeout tests still pass.

Mechanical pass-throughs
- 8 test fixtures across openfang-kernel/tests and openfang-api/tests
  gain subprocess_timeout_secs: None on their DefaultModelConfig
  literals.
- 1 production literal in routes.rs gains the same field.
- The existing FallbackProviderConfig serde-roundtrip test gains
  subprocess_timeout_secs: None plus an assertion.

Precedence comment in drivers/mod.rs::create_driver updated to reflect
that the config-field path is now real, with explicit pointers to the
kernel.rs wiring sites for future contributors.

Validated: cargo check --workspace --tests is clean; openfang-types
(362), openfang-runtime (933), and openfang-kernel (260) lib tests
all pass.
2026-04-27 23:40:12 -07:00
Ben Hoverter 79aa34c77a fix(runtime): add subprocess timeout config for claude-code driver
The claude-code driver hardcodes its per-message turn timeout inside
ClaudeCodeDriver and exposed no operator-facing knob, so long-running
CC subprocess turns (large prompt-caches, deep tool chains) hit the
internal default with no escape hatch. Adds a public config surface,
honored today only by the claude-code driver, designed so future
subprocess drivers can opt in without re-shaping the API.

Public surface
- DriverConfig.subprocess_timeout_secs: Option<u64> (llm_driver.rs)
- OPENFANG_SUBPROCESS_TIMEOUT_SECS env var (drivers/mod.rs)
- Precedence in create_driver(): env var > config field > driver default

Naming rationale
- Field/env are scope-flavored, not semantic, on purpose: the name
  telegraphs that HTTP providers (default/Anthropic, openai, bedrock,
  qwen-code) accept-but-silently-ignore the field today. A semantic
  name (message_timeout_secs) would have invited the same silent-no-op
  footgun on those providers.
- Driver-internal field in claude_code.rs intentionally kept as
  message_timeout_secs — it's not on the public boundary and the
  semantic name accurately describes what it stores.

Tests (drivers/mod.rs)
- default_when_unset: no env, no config -> driver default
- config_set: config field flows through
- env_overrides_config: env var wins over config (construction-only
  assertion; trait-object opacity prevents reading the value back)
- malformed_env_falls_through: unparseable env silently falls through
  to config, matching the .parse::<u64>().ok() chain in production
- All four tests scrub OPENFANG_SUBPROCESS_TIMEOUT_SECS pre/post to
  avoid cross-test pollution

Mechanical pass-throughs
- 12 x DriverConfig { .. } test fixtures in drivers/mod.rs gain
  subprocess_timeout_secs: None
- routes.rs (1), kernel.rs (6), agent_loop.rs (2): same pass-through
  fills in DriverConfig literals; no logic touched

Forward-compat note
- A NOTE block in drivers/mod.rs flags the scope-vs-implementation
  gap so the next contributor adding a subprocess driver knows
  exactly where to wire the config in.

Validated end-to-end against a live daemon: dry-run + full deploy
(deploy-local.sh, all 7 phases) + post-swap agent_send round-trip
through the claude-code dispatch path.
2026-04-27 23:17:41 -07:00
Ben Hoverter 4ae2961b1c 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
2026-04-26 16:14:17 -07:00
Ben Hoverter 6a90aa08df 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.
2026-04-26 12:41:06 -07:00
pandego 356500bb1e fix(kernel): ignore idle reactive heartbeat silence 2026-04-23 18:42:47 +02: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
Nimit Bhardwaj 5a86141677 fix(ws): broadcast cron job results to WebSocket clients in real-time
Fixes #1088 - scheduled task results now appear in web UI without page
refresh.
2026-04-21 22:34:36 +05:30
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
chris-youngandClaude Sonnet 4.6 f2587995a2 Stamp last_active in streaming agent loop to prevent heartbeat false-positives
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>
2026-04-19 20:59:47 -07:00
jaberjaber23 e6bab993ae bump v0.6.0 2026-04-19 22:57:55 +03:00
jaberjaber23 a39a675ba9 skill config ui 2026-04-19 22:27:23 +03:00
jaberjaber23 0ce390e09f cron delivery ui 2026-04-19 21:53:20 +03:00
jaberjaber23 88eeaa6a4d commands ui 2026-04-19 21:42:33 +03:00
jaberjaber23 3db5d3a825 cron delivery 2026-04-19 17:10:23 +03:00
jaberjaber23 5a1f372612 command registry 2026-04-19 17:08:11 +03:00
jaberjaber23 a9f15b2d23 skill config 2026-04-19 16:54:11 +03:00
octo-patch 45e7ea7948 fix(feishu): respect region setting for WebSocket endpoint URL
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
2026-04-19 11:23:46 +08: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
pandego 8d3d77dd99 fix: expose MiniMax in init provider lists 2026-04-18 13:26:28 +02:00
jaberjaber23 d3d9fa842d release: v0.5.10
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.
2026-04-17 22:54:56 +03:00
Jaber Jaber ff44cfbe87 fix(scheduler): route schedule_* tools and /api/schedules through kernel cron scheduler (#1069) (#1077)
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
2026-04-17 22:47:11 +03:00
Jaber Jaber ce89d05987 fix(runtime): combine text and image blocks in multimodal user messages (#1043) (#1073)
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
2026-04-17 22:47:08 +03:00
Jaber Jaber 00c0ff60de feat(channels): optional agent name prefix on outbound messages (#980) (#1072)
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
2026-04-17 22:47:04 +03:00
Jaber Jaber 07af248a07 fix(cli): config get returns base_url from default_model (#905) (#1076)
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
2026-04-17 22:46:08 +03:00
Jaber Jaber 6ab07d155e fix(runtime): re-read agent context.md per turn so external updates take effect (#843) (#1075)
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
2026-04-17 22:46:05 +03:00
Jaber Jaber e2b0a54720 feat(cli): add hand config subcommand (#809) (#1074)
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
2026-04-17 22:46:01 +03:00
Jaber Jaber 6f519b9122 fix(api): reject unauth requests from non-loopback by default (#1034) (#1071)
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
2026-04-17 22:45:58 +03:00
Jaber Jaber 983519c8e8 Merge pull request #1041 from Hypn0sis/chore/security-deps
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.
2026-04-17 21:21:17 +03: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
Matteo De Agazio 890ab8c177 style: apply cargo fmt 2026-04-14 15:11:29 +02:00
Matteo De Agazio 1b9a68dc0b fix: resolve remaining clippy warnings blocking CI (ptr_arg, dead_code, map_or, collapsible_if) 2026-04-14 15:06:54 +02:00
Matteo De Agazio 2b6286e469 fix: suppress pre-existing dead code and clippy warnings in openfang-runtime 2026-04-12 00:07:13 +02:00
Matteo De Agazio 589f32c8e5 style: apply cargo fmt 2026-04-11 23:55:38 +02:00
Matteo De Agazio 528a7b9ff7 fix(deps): upgrade wasmtime 41->43 and rumqttc 0.24->0.25 to resolve CVEs (RUSTSEC-2026-0049, 0085-0096) 2026-04-11 23:50:55 +02:00
123 changed files with 14629 additions and 1968 deletions
+3 -1
View File
@@ -91,7 +91,9 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --check
# Gate every workspace crate on rustfmt to keep `cargo fmt --all --check` clean.
# See issue #1121.
- run: cargo fmt --all -- --check
audit:
name: Security Audit
+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
+21
View File
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.10] - 2026-04-17
### Fixed
- Non-loopback requests with no `api_key` configured now return 401 by default. Opt out with `OPENFANG_ALLOW_NO_AUTH=1`. Fixes the B1/B2 authentication bypass from #1034.
- Agent `context.md` is re-read on every turn so external updates take effect mid-session. Opt out per agent with `cache_context = true` on the manifest. Fixes #843.
- `openfang config get default_model.base_url` now prints the configured URL instead of an empty string. Missing keys return a clear "not found" error. Fixes #905.
- `schedule_create`, `schedule_list`, and `schedule_delete` tools plus the `/api/schedules` routes now use the kernel cron scheduler, so scheduled jobs actually fire. One-shot idempotent migration imports legacy shared-memory entries at startup. Fixes #1069.
- Multimodal user messages now combine text and image blocks into a single message so the LLM sees both. Fixes #1043.
### Added
- `openfang hand config <id>` subcommand: get, set, unset, and list settings on an active hand instance. Fixes #809.
- Optional per-channel `prefix_agent_name` setting (`off` / `bracket` / `bold_bracket`). Wraps outbound agent responses so users in multi-agent channels can see which agent replied. Default is off, byte-identical to prior behavior. Fixes #980.
### Closed as invalid
- #818 and #819. Both reference a knowledge-domain API that does not exist on `main`. Filed against an unmerged feature branch (`plan/013-audit-remediation`). Close with a note to build the proposed validation and stale-timestamp surfacing into that feature when it lands.
## [0.5.9] - 2026-04-10
### Changed
- **BREAKING:** Dashboard password hashing switched from SHA256 to Argon2id. Existing `password_hash` values in `config.toml` must be regenerated with `openfang auth hash-password`. Only affects users with `[auth] enabled = true`.
Generated
+210 -297
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.5.9"
version = "0.6.5"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -83,7 +83,7 @@ tokio-tungstenite = { version = "0.24", default-features = false, features = ["c
url = "2"
# WASM sandbox
wasmtime = "41"
wasmtime = "43"
# HTTP server (for API daemon)
axum = { version = "0.8", features = ["ws", "multipart"] }
@@ -147,7 +147,7 @@ native-tls = { version = "0.2", features = ["vendored"] }
mailparse = "0.16"
# MQTT client
rumqttc = "0.24"
rumqttc = { version = "0.25", default-features = false, features = ["use-native-tls"] }
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
openssl = { version = "0.10", features = ["vendored"] }
+32 -32
View File
@@ -19,25 +19,25 @@
<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.3.30-green?style=flat-square" alt="v0.3.30" />
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/version-0.6.5-green?style=flat-square" alt="v0.6.5" />
<img src="https://img.shields.io/badge/tests-2,585%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>
---
> **v0.3.30 — Security Hardening Release (March 2026)**
> **v0.5.10 (April 2026)**
>
> OpenFang is feature-complete but still pre-1.0. You may encounter rough edges or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
> OpenFang is feature complete but still pre-1.0. Expect rough edges and breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
---
## What is OpenFang?
OpenFang is an **open-source Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
OpenFang is an **open-source Agent Operating System**. Not a chatbot framework. Not a Python wrapper around an LLM. Not a "multi-agent orchestrator." A full operating system for autonomous agents, built from scratch in Rust.
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you** on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you**: on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
The entire system compiles to a **single ~32MB binary**. One install, one command, your agents are live.
@@ -65,13 +65,13 @@ openfang start
<p align="center"><em>"Traditional agents wait for you to type. Hands work <strong>for</strong> you."</em></p>
**Hands** are OpenFang's core innovation — pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
**Hands** are OpenFang's core innovation. Pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
Each Hand bundles:
- **HAND.toml** — Manifest declaring tools, settings, requirements, and dashboard metrics
- **System Prompt** — Multi-phase operational playbook (not a one-liner — these are 500+ word expert procedures)
- **SKILL.md** — Domain expertise reference injected into context at runtime
- **Guardrails** — Approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase)
- **HAND.toml**: manifest declaring tools, settings, requirements, and dashboard metrics.
- **System Prompt**: multi-phase operational playbook. Not a one-liner. These are 500+ word expert procedures.
- **SKILL.md**: domain expertise reference injected into context at runtime.
- **Guardrails**: approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase).
All compiled into the binary. No downloading, no pip install, no Docker pull.
@@ -81,14 +81,14 @@ All compiled into the binary. No downloading, no pip install, no Docker pull.
|------|----------------------|
| **Clip** | Takes a YouTube URL, downloads it, identifies the best moments, cuts them into vertical shorts with captions and thumbnails, optionally adds AI voice-over, and publishes to Telegram and WhatsApp. 8-phase pipeline. FFmpeg + yt-dlp + 5 STT backends. |
| **Lead** | Runs daily. Discovers prospects matching your ICP, enriches them with web research, scores 0-100, deduplicates against your existing database, and delivers qualified leads in CSV/JSON/Markdown. Builds ICP profiles over time. |
| **Collector** | OSINT-grade intelligence. You give it a target (company, person, topic). It monitors continuously change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
| **Collector** | OSINT grade intelligence. You give it a target (company, person, topic). It monitors continuously: change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
| **Predictor** | Superforecasting engine. Collects signals from multiple sources, builds calibrated reasoning chains, makes predictions with confidence intervals, and tracks its own accuracy using Brier scores. Has a contrarian mode that deliberately argues against consensus. |
| **Researcher** | Deep autonomous researcher. Cross-references multiple sources, evaluates credibility using CRAAP criteria (Currency, Relevance, Authority, Accuracy, Purpose), generates cited reports with APA formatting, supports multiple languages. |
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue nothing posts without your OK. |
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate** it will never spend your money without explicit confirmation. |
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue, so nothing posts without your OK. |
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate**: it will never spend your money without explicit confirmation. |
```bash
# Activate the Researcher Hand — it starts working immediately
# Activate the Researcher Hand. It starts working immediately.
openfang hand activate researcher
# Check its progress anytime
@@ -116,7 +116,7 @@ openfang hand list
### Benchmarks: Measured, Not Marketed
All data from official documentation and public repositories February 2026.
All data from official documentation and public repositories, February 2026.
#### Cold Start Time (lower is better)
@@ -203,7 +203,7 @@ AutoGen ███████████░░░░░░░░░░░░
---
## 16 Security Systems Defense in Depth
## 16 Security Systems: Defense in Depth
OpenFang doesn't bolt security on after the fact. Every layer is independently testable and operates without a single point of failure.
@@ -211,19 +211,19 @@ OpenFang doesn't bolt security on after the fact. Every layer is independently t
|---|--------|-------------|
| 1 | **WASM Dual-Metered Sandbox** | Tool code runs in WebAssembly with fuel metering + epoch interruption. A watchdog thread kills runaway code. |
| 2 | **Merkle Hash-Chain Audit Trail** | Every action is cryptographically linked to the previous one. Tamper with one entry and the entire chain breaks. |
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution — secrets are tracked from source to sink. |
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution. Secrets are tracked from source to sink. |
| 4 | **Ed25519 Signed Agent Manifests** | Every agent identity and capability set is cryptographically signed. |
| 5 | **SSRF Protection** | Blocks private IPs, cloud metadata endpoints, and DNS rebinding attacks. |
| 6 | **Secret Zeroization** | `Zeroizing<String>` auto-wipes API keys from memory the instant they're no longer needed. |
| 7 | **OFP Mutual Authentication** | HMAC-SHA256 nonce-based, constant-time verification for P2P networking. |
| 8 | **Capability Gates** | Role-based access control — agents declare required tools, the kernel enforces it. |
| 8 | **Capability Gates** | Role based access control. Agents declare required tools, the kernel enforces it. |
| 9 | **Security Headers** | CSP, X-Frame-Options, HSTS, X-Content-Type-Options on every response. |
| 10 | **Health Endpoint Redaction** | Public health check returns minimal info. Full diagnostics require authentication. |
| 11 | **Subprocess Sandbox** | `env_clear()` + selective variable passthrough. Process tree isolation with cross-platform kill. |
| 12 | **Prompt Injection Scanner** | Detects override attempts, data exfiltration patterns, and shell reference injection in skills. |
| 13 | **Loop Guard** | SHA256-based tool call loop detection with circuit breaker. Handles ping-pong patterns. |
| 14 | **Session Repair** | 7-phase message history validation and automatic recovery from corruption. |
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. `../` doesn't work here. |
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. ``../`` doesn't work here. |
| 16 | **GCRA Rate Limiter** | Cost-aware token bucket rate limiting with per-IP tracking and stale cleanup. |
---
@@ -268,7 +268,7 @@ Each adapter supports per-channel model overrides, DM/group policies, rate limit
## WhatsApp Web Gateway (QR Code)
Connect your personal WhatsApp account to OpenFang via QR code just like WhatsApp Web. No Meta Business account required.
Connect your personal WhatsApp account to OpenFang via QR code, just like WhatsApp Web. No Meta Business account required.
### Prerequisites
@@ -357,7 +357,7 @@ For production workloads, use the [WhatsApp Cloud API](https://developers.facebo
---
## 27 LLM Providers 123+ Models
## 27 LLM Providers, 123+ Models
3 native drivers (Anthropic, Gemini, OpenAI-compatible) route to 27 providers:
@@ -372,7 +372,7 @@ Intelligent routing with task complexity scoring, automatic fallback, cost track
Already running OpenClaw? One command:
```bash
# Migrate everything agents, memory, skills, configs
# Migrate everything: agents, memory, skills, configs.
openfang migrate --from openclaw
# Migrate from a specific path
@@ -410,7 +410,7 @@ curl -X POST localhost:4200/v1/chat/completions \
# 1. Install (macOS/Linux)
curl -fsSL https://openfang.sh/install | sh
# 2. Initialize — walks you through provider setup
# 2. Initialize. Walks you through provider setup.
openfang init
# 3. Start the daemon
@@ -418,7 +418,7 @@ openfang start
# 4. Dashboard is live at http://localhost:4200
# 5. Activate a Hand — it starts working for you
# 5. Activate a Hand. It starts working for you.
openfang hand activate researcher
# 6. Chat with an agent
@@ -462,14 +462,14 @@ cargo fmt --all -- --check
## Stability Notice
OpenFang v0.3.30 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
OpenFang v0.5.10 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is deep. That said:
- **Breaking changes** may occur between minor versions until v1.0
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
- **Edge cases** exist — if you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues)
- **Pin to a specific commit** for production deployments until v1.0
- **Breaking changes** may occur between minor versions until v1.0.
- **Some Hands** are more mature than others. Browser and Researcher are the most battle tested.
- **Edge cases** exist. If you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues).
- **Pin to a specific commit** for production deployments until v1.0.
We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
We ship fast and fix fast. The goal is a rock solid v1.0 by mid 2026.
---
@@ -481,7 +481,7 @@ To report a security vulnerability, email **jaber@rightnowai.co**. We take all r
## License
MIT — use it however you want.
MIT. Use it however you want.
---
+21 -1
View File
@@ -55,6 +55,7 @@ use openfang_channels::ntfy::NtfyAdapter;
use openfang_channels::webhook::WebhookAdapter;
use openfang_channels::wecom::WeComAdapter;
use openfang_kernel::OpenFangKernel;
use openfang_runtime::kernel_handle::KernelHandle;
use openfang_types::agent::AgentId;
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -523,6 +524,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
timeout_secs: None,
},
delivery: openfang_types::scheduler::CronDelivery::None,
delivery_targets: Vec::new(),
created_at: chrono::Utc::now(),
last_run: None,
next_run: None,
@@ -893,6 +895,23 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
}
async fn send_channel_message(
&self,
channel_type: &str,
recipient: &str,
message: &str,
) -> Result<(), String> {
<OpenFangKernel as KernelHandle>::send_channel_message(
&self.kernel,
channel_type,
recipient,
message,
None,
)
.await
.map(|_| ())
}
async fn check_auto_reply(&self, agent_id: AgentId, message: &str) -> Option<String> {
// Check if auto-reply should fire for this message
let channel_type = "bridge"; // Generic; the bridge layer handles specifics
@@ -1458,9 +1477,10 @@ pub async fn start_channel_bridge_with_config(
encrypt_key,
fs_config.bot_names.clone(),
)),
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket(
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket_with_region(
fs_config.app_id.clone(),
secret,
region,
)),
};
adapters.push((adapter, fs_config.default_agent.clone()));
+1 -4
View File
@@ -11,10 +11,7 @@ pub(crate) fn percent_decode(input: &str) -> String {
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (
hex_val(bytes[i + 1]),
hex_val(bytes[i + 2]),
) {
if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
out.push(hi << 4 | lo);
i += 3;
continue;
+158 -32
View File
@@ -49,14 +49,23 @@ pub struct AuthState {
pub api_key: String,
pub auth_enabled: bool,
pub session_secret: String,
/// Set from `OPENFANG_ALLOW_NO_AUTH=1` to permit running without an api_key
/// on a non-loopback bind. Off by default so empty keys fail closed.
pub allow_no_auth: bool,
}
/// Bearer token authentication middleware.
///
/// When `api_key` is non-empty (after trimming), requests to non-public
/// endpoints must include `Authorization: Bearer <api_key>`.
/// If the key is empty or whitespace-only, auth is disabled entirely
/// (public/local development mode).
///
/// When `api_key` is empty (no key configured) the server defaults to
/// fail-closed for any request that does NOT originate from loopback.
/// Loopback traffic (127.0.0.1 / ::1) is always allowed through with no
/// key so single-user local setups keep zero-config UX. To explicitly
/// run a no-auth server on a LAN/WAN address, set
/// `OPENFANG_ALLOW_NO_AUTH=1`; this opts out of fail-closed and is
/// reported loudly at startup.
///
/// When dashboard auth is enabled, session cookies are also accepted.
pub async fn auth(
@@ -67,17 +76,17 @@ pub async fn auth(
// SECURITY: Capture method early for method-aware public endpoint checks.
let method = request.method().clone();
// Shutdown is loopback-only (CLI on same machine) — skip token auth
let is_loopback = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false); // SECURITY: default-deny; unknown origin is NOT loopback
// Shutdown is loopback-only (CLI on same machine). Skip token auth only
// when the request is from loopback.
let path = request.uri().path();
if path == "/api/shutdown" {
let is_loopback = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false); // SECURITY: default-deny — unknown origin is NOT loopback
if is_loopback {
return next.run(request).await;
}
if path == "/api/shutdown" && is_loopback {
return next.run(request).await;
}
// Public endpoints that don't require auth (dashboard needs these).
@@ -117,6 +126,7 @@ pub async fn auth(
|| (path == "/api/hands/active" && is_get)
|| (path.starts_with("/api/hands/") && is_get)
|| (path == "/api/skills" && is_get)
|| (path.starts_with("/api/skills/") && path.ends_with("/config") && is_get)
|| (path == "/api/sessions" && is_get)
|| (path == "/api/integrations" && is_get)
|| (path == "/api/integrations/available" && is_get)
@@ -133,12 +143,29 @@ pub async fn auth(
return next.run(request).await;
}
// If no API key configured (empty, whitespace-only, or missing), skip auth
// entirely. Users who don't set api_key accept that all endpoints are open.
// To secure the dashboard, set a non-empty api_key in config.toml.
// If no API key configured and no dashboard login is active, fail closed
// for anything that did not come from loopback. Opting out of this
// behavior requires setting `OPENFANG_ALLOW_NO_AUTH=1`, which is logged
// loudly at startup.
//
// See issue #1034 (B1/B2): empty api_key previously bypassed auth for
// all origins, exposing agent config, channel tokens, and LLM keys on
// any LAN-reachable bind.
let api_key_trimmed = auth_state.api_key.trim().to_string();
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
return next.run(request).await;
if is_loopback || auth_state.allow_no_auth {
return next.run(request).await;
}
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("www-authenticate", "Bearer")
.body(Body::from(
serde_json::json!({
"error": "API key required for non-loopback requests. Set OPENFANG_API_KEY or bind to 127.0.0.1."
})
.to_string(),
))
.unwrap_or_default();
}
let api_key = api_key_trimmed.as_str();
@@ -189,7 +216,7 @@ pub async fn auth(
// Check session cookie (dashboard login sessions)
if auth_state.auth_enabled {
if let Some(token) = extract_session_cookie(&request) {
if let Some(token) = crate::session_auth::extract_session_cookie(request.headers()) {
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
.is_some()
{
@@ -215,21 +242,6 @@ pub async fn auth(
.unwrap_or_default()
}
/// Extract the `openfang_session` cookie value from a request.
fn extract_session_cookie(request: &Request<Body>) -> Option<String> {
request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|c| {
c.trim()
.strip_prefix("openfang_session=")
.map(|v| v.to_string())
})
})
}
/// Security headers middleware — applied to ALL API responses.
pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Body> {
let mut response = next.run(request).await;
@@ -265,9 +277,123 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Method, Request};
use axum::routing::get;
use axum::Router;
use std::net::SocketAddr;
use tower::ServiceExt;
#[test]
fn test_request_id_header_constant() {
assert_eq!(REQUEST_ID_HEADER, "x-request-id");
}
fn auth_state_empty() -> AuthState {
AuthState {
api_key: String::new(),
auth_enabled: false,
session_secret: String::new(),
allow_no_auth: false,
}
}
fn auth_state_with_key(key: &str) -> AuthState {
AuthState {
api_key: key.to_string(),
auth_enabled: false,
session_secret: key.to_string(),
allow_no_auth: false,
}
}
async fn ok_handler() -> &'static str {
"ok"
}
fn router(state: AuthState) -> Router {
Router::new()
.route("/api/agents/1", get(ok_handler))
.route_layer(axum::middleware::from_fn_with_state(state, auth))
}
fn req_from(ip: &str) -> Request<Body> {
let addr: SocketAddr = format!("{ip}:40000").parse().unwrap();
let mut req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(addr));
req
}
#[tokio::test]
async fn empty_key_allows_loopback() {
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn empty_key_blocks_lan_origin() {
// Issue #1034 B1: previously 192.168/10/... could hit every non-public
// endpoint when api_key was unset. Must now be 401.
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("192.168.1.50")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_blocks_public_origin() {
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("203.0.113.5")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_blocks_unknown_connect_info() {
// Paranoia: if ConnectInfo is missing for any reason, we must fail
// closed, not open.
let app = router(auth_state_empty());
let req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_with_allow_no_auth_opens_everything() {
let mut s = auth_state_empty();
s.allow_no_auth = true;
let app = router(s);
let resp = app.oneshot(req_from("10.0.0.9")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn configured_key_rejects_missing_token_from_loopback() {
let app = router(auth_state_with_key("secret"));
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn configured_key_accepts_bearer() {
let app = router(auth_state_with_key("secret"));
let addr: SocketAddr = "127.0.0.1:40000".parse().unwrap();
let mut req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.header("authorization", "Bearer secret")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(addr));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
}
+6 -1
View File
@@ -235,7 +235,12 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
OaiContent::Null => return None,
};
Some(Message { role, content })
Some(Message {
msg_id: uuid::Uuid::new_v4().to_string(),
provider_msg_id: None,
role,
content,
})
})
.collect()
}
+9
View File
@@ -30,6 +30,15 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
("POST", "/api/skills/install") => NonZeroU32::new(50).unwrap(),
("POST", "/api/skills/uninstall") => NonZeroU32::new(10).unwrap(),
("POST", "/api/skills/reload") => NonZeroU32::new(5).unwrap(),
("GET", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
NonZeroU32::new(3).unwrap()
}
("PUT", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
NonZeroU32::new(10).unwrap()
}
("DELETE", p) if p.starts_with("/api/skills/") && p.contains("/config/") => {
NonZeroU32::new(10).unwrap()
}
("POST", "/api/migrate") => NonZeroU32::new(100).unwrap(),
("PUT", p) if p.contains("/update") => NonZeroU32::new(10).unwrap(),
_ => NonZeroU32::new(5).unwrap(),
File diff suppressed because it is too large Load Diff
+49
View File
@@ -54,6 +54,10 @@ pub async fn build_router(
budget_config: Arc::new(tokio::sync::RwLock::new(kernel.config.budget.clone())),
});
// Start WS cron broadcaster — subscribes to kernel event bus and pushes
// cron job results to all connected WebSocket clients in real-time.
ws::start_ws_cron_broadcaster(kernel.clone());
// CORS: allow localhost origins by default. If API key is set, the API
// is protected anyway. For development, permissive CORS is convenient.
let cors = if state.kernel.config.api_key.trim().is_empty() {
@@ -115,6 +119,32 @@ pub async fn build_router(
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
let api_key = state.kernel.config.api_key.trim().to_string();
let allow_no_auth = std::env::var("OPENFANG_ALLOW_NO_AUTH")
.map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on"))
.unwrap_or(false);
// Fail-closed warning: if no api_key and no dashboard auth, and the
// server is bound to a non-loopback address without an explicit opt-in,
// shout about it. The middleware will reject non-loopback traffic.
let bind_is_loopback = listen_addr.ip().is_loopback();
if api_key.is_empty() && !state.kernel.config.auth.enabled && !bind_is_loopback {
if allow_no_auth {
tracing::warn!(
"OPENFANG_ALLOW_NO_AUTH=1 is set. Running WITHOUT authentication on {}. \
Anyone reachable at this address can read/write agents, channels, and keys.",
listen_addr
);
} else {
tracing::warn!(
"No api_key configured and server is bound to {} (non-loopback). \
Non-loopback requests will be rejected with 401. \
Set OPENFANG_API_KEY (or api_key in config.toml), or bind to 127.0.0.1, \
or set OPENFANG_ALLOW_NO_AUTH=1 to explicitly run open.",
listen_addr
);
}
}
let auth_state = crate::middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
@@ -125,6 +155,7 @@ pub async fn build_router(
} else {
String::new()
},
allow_no_auth,
};
let gcra_limiter = rate_limiter::create_rate_limiter();
@@ -168,6 +199,12 @@ pub async fn build_router(
"/api/agents/{id}/start",
axum::routing::post(routes::restart_agent),
)
.route(
// Issue #890 — alias so dashboards and external orchestrators can
// wake an inactive agent via a verb that matches the agent_activate tool.
"/api/agents/{id}/activate",
axum::routing::post(routes::restart_agent),
)
.route(
"/api/agents/{id}/message",
axum::routing::post(routes::send_message),
@@ -316,6 +353,10 @@ pub async fn build_router(
"/api/schedules/{id}/run",
axum::routing::post(routes::run_schedule),
)
.route(
"/api/schedules/{id}/delivery-log",
axum::routing::get(routes::schedule_delivery_log),
)
// Workflow endpoints
.route(
"/api/workflows",
@@ -349,6 +390,14 @@ pub async fn build_router(
"/api/skills/reload",
axum::routing::post(routes::reload_skills),
)
.route(
"/api/skills/{id}/config",
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
)
.route(
"/api/skills/{id}/config/{var_name}",
axum::routing::delete(routes::delete_skill_config_var),
)
.route(
"/api/marketplace/search",
axum::routing::get(routes::marketplace_search),
+51
View File
@@ -17,6 +17,25 @@ pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> Str
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
}
/// Extract the `openfang_session` cookie value from a `Cookie` header string.
///
/// Returns `None` if the header is absent or the cookie is not present.
/// Used by both the HTTP auth middleware and the WebSocket upgrade handler so
/// that browser sessions established via `sessionLogin()` are honored on both
/// surfaces (issue #1085).
pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|c| {
c.trim()
.strip_prefix("openfang_session=")
.map(|v| v.to_string())
})
})
}
/// Verify a session token. Returns the username if valid and not expired.
pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
use base64::Engine;
@@ -141,4 +160,36 @@ mod tests {
// Starts with $argon2 but is not a valid PHC string.
assert!(!verify_password("x", "$argon2id$garbage"));
}
#[test]
fn test_extract_session_cookie_present() {
let mut h = axum::http::HeaderMap::new();
h.insert(
"cookie",
"foo=bar; openfang_session=abc.def.ghi; baz=qux"
.parse()
.unwrap(),
);
assert_eq!(extract_session_cookie(&h).as_deref(), Some("abc.def.ghi"));
}
#[test]
fn test_extract_session_cookie_absent() {
let mut h = axum::http::HeaderMap::new();
h.insert("cookie", "foo=bar; baz=qux".parse().unwrap());
assert_eq!(extract_session_cookie(&h), None);
}
#[test]
fn test_extract_session_cookie_no_header() {
let h = axum::http::HeaderMap::new();
assert_eq!(extract_session_cookie(&h), None);
}
#[test]
fn test_extract_session_cookie_only_value() {
let mut h = axum::http::HeaderMap::new();
h.insert("cookie", "openfang_session=lonely".parse().unwrap());
assert_eq!(extract_session_cookie(&h).as_deref(), Some("lonely"));
}
}
+8
View File
@@ -107,3 +107,11 @@ pub struct ClawHubInstallRequest {
/// ClawHub skill slug (e.g., "github-helper").
pub slug: String,
}
/// Query parameters for `GET /api/commands`.
#[derive(Debug, Deserialize)]
pub struct CommandsQuery {
/// Surface filter: `web` (default), `cli`, `channel`, or `all`.
#[serde(default)]
pub surface: Option<String>,
}
+560 -35
View File
@@ -19,17 +19,19 @@ use axum::response::IntoResponse;
use dashmap::DashMap;
use futures::stream::SplitSink;
use futures::{SinkExt, StreamExt};
use openfang_kernel::OpenFangKernel;
use openfang_runtime::kernel_handle::KernelHandle;
use openfang_runtime::llm_driver::StreamEvent;
use openfang_runtime::llm_errors;
use openfang_types::agent::AgentId;
use openfang_types::commands::{self, Surfaces};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn};
/// Per-IP WebSocket connection tracker.
@@ -97,6 +99,62 @@ fn ws_tracker() -> &'static DashMap<IpAddr, AtomicUsize> {
TRACKER.get_or_init(DashMap::new)
}
/// Per-agent WebSocket sender entry.
struct WsSender {
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
}
/// Global registry: agent_id → active WebSocket senders.
/// Uses RwLock for fine-grained read/write access to the sender list.
fn ws_agent_connections() -> &'static DashMap<AgentId, RwLock<Vec<WsSender>>> {
static REGISTRY: std::sync::OnceLock<DashMap<AgentId, RwLock<Vec<WsSender>>>> =
std::sync::OnceLock::new();
REGISTRY.get_or_init(DashMap::new)
}
/// Register a WebSocket connection for an agent (async).
pub async fn register_ws_connection(
agent_id: AgentId,
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
) {
let entry = ws_agent_connections().entry(agent_id).or_default();
let mut senders = entry.value().write().await;
senders.push(WsSender { sender });
}
/// Deregister a WebSocket connection for an agent.
/// Returns the number of remaining connections for this agent.
pub async fn deregister_ws_connection(
agent_id: AgentId,
sender: &Arc<Mutex<SplitSink<WebSocket, Message>>>,
) -> usize {
let entry = match ws_agent_connections().get(&agent_id) {
Some(e) => e,
None => return 0,
};
let mut senders = entry.value().write().await;
senders.retain(|s| !Arc::ptr_eq(&s.sender, sender));
senders.len()
}
/// Broadcast a JSON message to all active WebSocket connections for an agent.
/// Returns the number of connections the message was sent to.
pub async fn broadcast_to_ws(agent_id: AgentId, msg: serde_json::Value) -> usize {
let entry = match ws_agent_connections().get(&agent_id) {
Some(e) => e,
None => return 0,
};
let senders = entry.value().read().await;
let mut success_count = 0;
for ws_sender in senders.iter() {
let sender = &ws_sender.sender;
if send_json(sender, &msg).await.is_ok() {
success_count += 1;
}
}
success_count
}
/// RAII guard that decrements the connection count on drop.
struct WsConnectionGuard {
ip: IpAddr,
@@ -132,11 +190,108 @@ fn try_acquire_ws_slot(ip: IpAddr) -> Option<WsConnectionGuard> {
// WS Upgrade Handler
// ---------------------------------------------------------------------------
/// Parameters for [`check_ws_auth`]. Kept as a struct so the auth gate stays
/// pure and unit-testable without an `AppState` or live socket.
pub(crate) struct WsAuthCtx<'a> {
/// Trimmed API key from kernel config. Empty string means no key configured.
pub api_key: &'a str,
/// Whether dashboard session login is enabled in config.
pub auth_enabled: bool,
/// Secret used to verify session cookies (api_key when set, else password hash).
pub session_secret: &'a str,
/// Whether the request originated from a loopback address.
pub is_loopback: bool,
/// True iff `OPENFANG_ALLOW_NO_AUTH=1` is set (loose mode for LAN binds).
pub allow_no_auth: bool,
pub headers: &'a axum::http::HeaderMap,
pub uri: &'a axum::http::Uri,
}
/// Pure auth gate for WebSocket upgrades.
///
/// Returns `Ok(())` if the request should be allowed through, or
/// `Err(StatusCode::UNAUTHORIZED)` otherwise. Accepts:
/// 1. `Authorization: Bearer <api_key>` header
/// 2. `?token=<api_key>` query parameter
/// 3. `openfang_session=<token>` cookie when dashboard auth is enabled
/// 4. Loopback origin when no api_key is configured
/// 5. Any origin when `OPENFANG_ALLOW_NO_AUTH=1`
///
/// Fix for issue #1085: previously only (1), (2), and (4) were honored, so
/// dashboard users logged in via session cookie saw "No active connection"
/// because the WS upgrade rejected them even though HTTP requests succeeded.
pub(crate) fn check_ws_auth(ctx: &WsAuthCtx<'_>) -> Result<(), axum::http::StatusCode> {
use axum::http::StatusCode;
// No api_key configured: only allow loopback or explicit opt-in.
if ctx.api_key.is_empty() {
// A session cookie can still rescue non-loopback requests when
// dashboard auth is enabled.
if ctx.auth_enabled && !ctx.session_secret.is_empty() {
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
if crate::session_auth::verify_session_token(&token, ctx.session_secret).is_some()
{
return Ok(());
}
}
}
if ctx.is_loopback || ctx.allow_no_auth {
return Ok(());
}
return Err(StatusCode::UNAUTHORIZED);
}
// SECURITY: constant-time comparison to prevent timing attacks on API key.
let ct_eq = |token: &str, key: &str| -> bool {
use subtle::ConstantTimeEq;
if token.len() != key.len() {
return false;
}
token.as_bytes().ct_eq(key.as_bytes()).into()
};
let header_auth = ctx
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|token| ct_eq(token, ctx.api_key))
.unwrap_or(false);
if header_auth {
return Ok(());
}
let query_auth = ctx
.uri
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(crate::percent_decode)
.map(|token| ct_eq(&token, ctx.api_key))
.unwrap_or(false);
if query_auth {
return Ok(());
}
// Dashboard session cookie (issue #1085). When auth_enabled is on the
// session_secret is set by server.rs to either the api_key or the
// configured password hash, mirroring the HTTP auth middleware.
if ctx.auth_enabled && !ctx.session_secret.is_empty() {
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
if crate::session_auth::verify_session_token(&token, ctx.session_secret).is_some() {
return Ok(());
}
}
}
Err(StatusCode::UNAUTHORIZED)
}
/// GET /api/agents/:id/ws — Upgrade to WebSocket for real-time chat.
///
/// SECURITY: Authenticates via Bearer token in Authorization header
/// or `?token=` query parameter (for browser WebSocket clients that
/// cannot set custom headers).
/// SECURITY: Authenticates via Bearer token in Authorization header,
/// `?token=` query parameter (for browser WebSocket clients that cannot
/// set custom headers), or the `openfang_session` cookie set by the
/// dashboard's session login flow (issue #1085).
pub async fn agent_ws(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
@@ -145,38 +300,43 @@ pub async fn agent_ws(
headers: axum::http::HeaderMap,
uri: axum::http::Uri,
) -> impl IntoResponse {
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
// Trim whitespace so empty/whitespace-only api_key disables auth.
// SECURITY: Authenticate WebSocket upgrades (bypasses HTTP middleware).
// Trim whitespace so empty/whitespace-only api_key still triggers the
// fail-closed path for non-loopback origins (see issue #1034 B2).
let api_key_raw = &state.kernel.config.api_key;
let api_key = api_key_raw.trim();
if !api_key.is_empty() {
// SECURITY: Use constant-time comparison to prevent timing attacks on API key
let ct_eq = |token: &str, key: &str| -> bool {
use subtle::ConstantTimeEq;
if token.len() != key.len() {
return false;
}
token.as_bytes().ct_eq(key.as_bytes()).into()
};
let is_loopback = addr.ip().is_loopback();
let allow_no_auth = std::env::var("OPENFANG_ALLOW_NO_AUTH")
.map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on"))
.unwrap_or(false);
let header_auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|token| ct_eq(token, api_key))
.unwrap_or(false);
// Mirror the session_secret derivation in server.rs::AuthState so cookies
// issued by /api/auth/login verify the same way over HTTP and WS.
let auth_enabled = state.kernel.config.auth.enabled;
let session_secret_owned: String = if !api_key.is_empty() {
api_key.to_string()
} else if auth_enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
};
let query_auth = uri
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(|raw| crate::percent_decode(raw))
.map(|token| ct_eq(&token, api_key))
.unwrap_or(false);
let auth_ctx = WsAuthCtx {
api_key,
auth_enabled,
session_secret: &session_secret_owned,
is_loopback,
allow_no_auth,
headers: &headers,
uri: &uri,
};
if !header_auth && !query_auth {
warn!("WebSocket upgrade rejected: invalid auth");
return axum::http::StatusCode::UNAUTHORIZED.into_response();
}
if let Err(status) = check_ws_auth(&auth_ctx) {
warn!(
ip = %addr.ip(),
"WebSocket upgrade rejected: no valid Bearer token, ?token=, or openfang_session cookie"
);
return status.into_response();
}
// SECURITY: Enforce per-IP WebSocket connection limit
@@ -247,6 +407,9 @@ async fn handle_agent_ws(
let (sender, mut receiver) = socket.split();
let sender = Arc::new(Mutex::new(sender));
// Register this connection in the global agent-WS registry
register_ws_connection(agent_id, Arc::clone(&sender)).await;
// Per-connection verbose level (default: Full)
let verbose = Arc::new(AtomicU8::new(VerboseLevel::Full as u8));
@@ -399,7 +562,8 @@ async fn handle_agent_ws(
}
}
// Cleanup
// Cleanup: deregister from agent-WS registry and abort background tasks
deregister_ws_connection(agent_id, &sender).await;
update_handle.abort();
info!(agent_id = %id_str, "WebSocket disconnected");
}
@@ -837,8 +1001,17 @@ async fn handle_command(
args: &str,
verbose: &Arc<AtomicU8>,
) -> serde_json::Value {
match cmd {
"new" | "reset" => match state.kernel.reset_session(agent_id) {
// Canonicalise through the unified command registry. This resolves aliases
// (e.g. `reset` -> `new`) and is case-insensitive. If the command is not
// registered on the WEB surface, fall through to the existing match so any
// legacy/un-registered handlers still work byte-identically.
let canonical: &str = commands::resolve(cmd)
.filter(|def| def.surfaces.contains(Surfaces::WEB))
.map(|def| def.name)
.unwrap_or(cmd);
match canonical {
"new" => match state.kernel.reset_session(agent_id) {
Ok(()) => {
serde_json::json!({"type": "command_result", "command": cmd, "message": "Session reset. Chat history cleared."})
}
@@ -1007,7 +1180,20 @@ async fn handle_command(
};
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
}
_ => serde_json::json!({"type": "error", "content": format!("Unknown command: {cmd}")}),
"help" => {
serde_json::json!({
"type": "command_result",
"command": cmd,
"message": commands::render_help(Surfaces::WEB),
})
}
_ => serde_json::json!({
"type": "error",
"content": format!(
"Unknown command: /{cmd}\n\n{}",
commands::render_help(Surfaces::WEB)
),
}),
}
}
@@ -1294,6 +1480,110 @@ pub fn strip_think_tags(text: &str) -> String {
result
}
// ---------------------------------------------------------------------------
// Cron Job WS Broadcasting
// ---------------------------------------------------------------------------
/// Start a background task that subscribes to the kernel's event bus and
/// broadcasts cron job results to all connected WebSocket clients for the
/// relevant agent.
///
/// This runs independently of the channel bridge — it uses the kernel's
/// event bus to receive `CronJobExecuted` events and pushes them to WS.
pub fn start_ws_cron_broadcaster(kernel: Arc<OpenFangKernel>) {
tokio::spawn(async move {
let mut rx = kernel.event_bus.subscribe_all();
loop {
let event = rx.recv().await;
match event {
Ok(event) => {
if let openfang_types::event::EventPayload::System(
openfang_types::event::SystemEvent::CronJobExecuted {
agent_id,
job_id,
job_name,
trigger_message,
response,
delivered_to_channel: _,
},
) = event.payload
{
// Build the trigger message (synthetic user message from cron)
let trigger_msg = serde_json::json!({
"type": "message",
"content": trigger_message,
"source": "cron",
"job_id": job_id,
"job_name": job_name
});
let _ = broadcast_to_ws(agent_id, trigger_msg).await;
// Send typing start
let _ = broadcast_to_ws(
agent_id,
serde_json::json!({"state": "start", "type": "typing"}),
)
.await;
// Send streaming phase
let _ = broadcast_to_ws(
agent_id,
serde_json::json!({"detail": null, "phase": "streaming", "type": "phase"}),
)
.await;
// Send text delta (full response since we don't have streaming chunks)
let text_delta = serde_json::json!({
"content": response,
"type": "text_delta"
});
let _ = broadcast_to_ws(agent_id, text_delta).await;
// Send done phase
let _ = broadcast_to_ws(
agent_id,
serde_json::json!({"detail": null, "phase": "done", "type": "phase"}),
)
.await;
// Send typing stop
let _ = broadcast_to_ws(
agent_id,
serde_json::json!({"state": "stop", "type": "typing"}),
)
.await;
// Send final response (mimics the format from agent_loop)
let response_msg = serde_json::json!({
"type": "response",
"content": response,
"context_pressure": "low",
"cost_usd": null,
"input_tokens": 0,
"iterations": 0,
"output_tokens": 0
});
let _ = broadcast_to_ws(agent_id, response_msg).await;
info!(
agent_id = %agent_id,
job_id = %job_id,
"Cron job result broadcast to WS"
);
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!(lagged_messages = n, "WS cron broadcaster lagged, skipping");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
info!("WS cron broadcaster channel closed, stopping");
break;
}
}
}
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1393,4 +1683,239 @@ mod tests {
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
}
// -----------------------------------------------------------------------
// WebSocket auth gate (issue #1085)
// -----------------------------------------------------------------------
fn empty_uri() -> axum::http::Uri {
"/api/agents/x/ws".parse().unwrap()
}
fn uri_with_token(tok: &str) -> axum::http::Uri {
format!("/api/agents/x/ws?token={tok}").parse().unwrap()
}
#[test]
fn ws_auth_accepts_bearer_token() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("authorization", "Bearer secret".parse().unwrap());
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "secret",
auth_enabled: false,
session_secret: "secret",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(check_ws_auth(&ctx).is_ok());
}
#[test]
fn ws_auth_accepts_query_token() {
let headers = axum::http::HeaderMap::new();
let uri = uri_with_token("secret");
let ctx = WsAuthCtx {
api_key: "secret",
auth_enabled: false,
session_secret: "secret",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(check_ws_auth(&ctx).is_ok());
}
#[test]
fn ws_auth_accepts_session_cookie() {
// Issue #1085: the dashboard logs in via cookie, so WS must accept it.
let secret = "shared-secret";
let token = crate::session_auth::create_session_token("alice", secret, 1);
let cookie = format!("foo=bar; openfang_session={token}");
let mut headers = axum::http::HeaderMap::new();
headers.insert("cookie", cookie.parse().unwrap());
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: secret,
auth_enabled: true,
session_secret: secret,
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(
check_ws_auth(&ctx).is_ok(),
"valid session cookie should authorize WS upgrade"
);
}
#[test]
fn ws_auth_session_cookie_rejected_when_auth_disabled() {
// If dashboard auth is off, cookies must not grant access.
let secret = "shared-secret";
let token = crate::session_auth::create_session_token("alice", secret, 1);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"cookie",
format!("openfang_session={token}").parse().unwrap(),
);
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: secret,
auth_enabled: false,
session_secret: secret,
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED
);
}
#[test]
fn ws_auth_rejects_wrong_session_cookie() {
// Cookie signed with the wrong secret must fail.
let bad = crate::session_auth::create_session_token("alice", "other-secret", 1);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"cookie",
format!("openfang_session={bad}").parse().unwrap(),
);
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "secret",
auth_enabled: true,
session_secret: "secret",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED
);
}
#[test]
fn ws_auth_rejects_when_no_credentials() {
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "secret",
auth_enabled: true,
session_secret: "secret",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED
);
}
#[test]
fn ws_auth_rejects_wrong_bearer() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("authorization", "Bearer wrong".parse().unwrap());
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "secret",
auth_enabled: false,
session_secret: "secret",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED
);
}
#[test]
fn ws_auth_empty_key_loopback_ok() {
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: false,
session_secret: "",
is_loopback: true,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(check_ws_auth(&ctx).is_ok());
}
#[test]
fn ws_auth_empty_key_non_loopback_rejected() {
// Issue #1034 B2 regression guard.
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: false,
session_secret: "",
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert_eq!(
check_ws_auth(&ctx).unwrap_err(),
axum::http::StatusCode::UNAUTHORIZED
);
}
#[test]
fn ws_auth_empty_key_allow_no_auth_opens() {
let headers = axum::http::HeaderMap::new();
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: false,
session_secret: "",
is_loopback: false,
allow_no_auth: true,
headers: &headers,
uri: &uri,
};
assert!(check_ws_auth(&ctx).is_ok());
}
#[test]
fn ws_auth_empty_key_session_cookie_grants_non_loopback() {
// When only dashboard login is configured (no api_key, auth_enabled=true),
// a valid session cookie must allow non-loopback WS upgrades.
let secret = "password-hash-style-secret";
let token = crate::session_auth::create_session_token("admin", secret, 1);
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"cookie",
format!("openfang_session={token}").parse().unwrap(),
);
let uri = empty_uri();
let ctx = WsAuthCtx {
api_key: "",
auth_enabled: true,
session_secret: secret,
is_loopback: false,
allow_no_auth: false,
headers: &headers,
uri: &uri,
};
assert!(check_ws_auth(&ctx).is_ok());
}
}
@@ -312,6 +312,12 @@ tr:hover td { background: var(--surface2); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* Issue #1026: live indicator for agents currently calling the LLM */
@keyframes agent-inferencing-pulse {
0%, 100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 var(--accent); }
50% { transform: scale(1.25); opacity: 0.85; box-shadow: 0 0 0 4px rgba(255, 92, 0, 0); }
}
.message.user {
flex-direction: row-reverse;
}
@@ -3253,7 +3259,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 +3276,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 +3336,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;
+394 -33
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">
@@ -856,8 +847,15 @@
<svg x-show="!agent.identity || !agent.identity.emoji" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
</div>
<div style="min-width:0;flex:1">
<div class="font-bold" style="font-size:13px" x-text="agent.name"></div>
<div class="text-xs text-dim font-mono" style="font-size:11px" x-text="agent.model_name"></div>
<div class="font-bold" style="font-size:13px">
<span x-text="agent.name"></span>
<!-- Issue #1026: live inferencing indicator -->
<span x-show="agent.is_inferencing" class="agent-inferencing-dot" title="Agent is calling the LLM right now" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;animation:agent-inferencing-pulse 1.2s ease-in-out infinite"></span>
</div>
<div class="text-xs text-dim font-mono" style="font-size:11px">
<span x-show="!agent.is_inferencing" x-text="agent.model_name"></span>
<span x-show="agent.is_inferencing" style="color:var(--accent);font-weight:600">Inferencing…</span>
</div>
</div>
<span class="badge" :class="'badge-' + agent.state.toLowerCase()" x-text="agent.state" style="font-size:10px"></span>
<button class="agent-chip-config-btn" @click.stop="showDetail(agent)" title="Agent settings" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--text-dim);transition:all 0.15s;flex-shrink:0" @mouseenter="$el.style.borderColor='var(--accent)';$el.style.color='var(--accent)';$el.style.background='var(--surface2)'" @mouseleave="$el.style.borderColor='var(--border)';$el.style.color='var(--text-dim)';$el.style.background='transparent'">
@@ -1720,6 +1718,7 @@
<th>Schedule</th>
<th>Agent</th>
<th>Status</th>
<th>Delivery</th>
<th>Last Run</th>
<th>Next Run</th>
<th>Actions</th>
@@ -1731,6 +1730,34 @@
<td>
<span class="font-bold" x-text="job.name || job.description || '(unnamed)'"></span>
<div class="text-xs text-dim" x-show="job.message" x-text="(job.message || '').substring(0, 60) + ((job.message || '').length > 60 ? '...' : '')" :title="job.message"></div>
<div x-show="expandedJobId === job.id" style="margin-top:8px;padding:8px;background:rgba(148,163,184,0.06);border-left:3px solid var(--accent);border-radius:3px">
<div class="font-bold" style="font-size:12px;margin-bottom:6px">Delivery Log</div>
<div x-show="deliveryLogLoading" class="text-xs text-dim">Loading delivery log...</div>
<div x-show="!deliveryLogLoading && deliveryLogError" class="text-xs" style="color:var(--error)" x-text="deliveryLogError"></div>
<div x-show="!deliveryLogLoading && !deliveryLogError">
<div class="text-xs text-dim" style="margin-bottom:4px">Configured targets (<span x-text="(deliveryLog.targets || []).length"></span>):</div>
<div x-show="!(deliveryLog.targets || []).length" class="text-xs text-dim">No fan-out targets configured.</div>
<template x-for="(t, ti) in (deliveryLog.targets || [])" :key="ti">
<div class="text-xs" style="padding:2px 0">
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
<span class="text-dim" style="margin-left:6px" x-text="targetSummary(t)"></span>
</div>
</template>
<div class="text-xs text-dim" style="margin-top:8px">Recent deliveries (<span x-text="(deliveryLog.entries || []).length"></span>):</div>
<div x-show="!(deliveryLog.entries || []).length" class="text-xs text-dim">
Delivery history will appear here after the next run. Per-run results are not yet
persisted — the fan-out engine emits tracing events you can view under
<a href="#logs" style="color:var(--accent)">Logs</a>.
</div>
<template x-for="(e, ei) in (deliveryLog.entries || [])" :key="ei">
<div class="text-xs" style="padding:2px 0">
<span class="badge" :class="e.success ? 'badge-success' : 'badge-error'" x-text="e.success ? 'ok' : 'err'"></span>
<span style="margin-left:6px" x-text="e.target"></span>
<span class="text-dim" style="margin-left:6px" x-show="e.error" x-text="e.error"></span>
</div>
</template>
</div>
</div>
</td>
<td>
<code style="font-size:11px;color:var(--accent)" x-text="job.cron"></code>
@@ -1740,15 +1767,25 @@
<td>
<span class="badge" :class="job.enabled ? 'badge-success' : 'badge-dim'" x-text="job.enabled ? 'Active' : 'Paused'"></span>
</td>
<td>
<div class="flex gap-1" style="flex-wrap:wrap;max-width:240px" x-show="(job.delivery_targets || []).length">
<template x-for="(t, ti) in (job.delivery_targets || [])" :key="ti">
<span class="badge" :class="targetChipClass(t)" :title="targetSummary(t)" x-text="targetChipLabel(t)"></span>
</template>
</div>
<span class="text-xs text-dim" x-show="!(job.delivery_targets || []).length">(none)</span>
</td>
<td class="text-xs" :title="formatTime(job.last_run)" x-text="relativeTime(job.last_run)"></td>
<td class="text-xs" :title="formatTime(job.next_run)" x-text="relativeTime(job.next_run)"></td>
<td>
<div class="flex gap-1">
<div class="flex gap-1" style="flex-wrap:wrap">
<button class="btn btn-primary btn-sm" @click="runNow(job)" :disabled="runningJobId === job.id">
<span x-show="runningJobId !== job.id">Run</span>
<span x-show="runningJobId === job.id">...</span>
</button>
<button class="btn btn-ghost btn-sm" @click="toggleJob(job)" x-text="job.enabled ? 'Pause' : 'Enable'"></button>
<button class="btn btn-ghost btn-sm" @click="toggleExpand(job)" x-text="expandedJobId === job.id ? 'Hide' : 'Details'"></button>
<button class="btn btn-ghost btn-sm" @click="startEditTargets(job)">Targets</button>
<button class="btn btn-danger btn-sm" @click="deleteJob(job)">Del</button>
</div>
</td>
@@ -1820,6 +1857,104 @@
</label>
</div>
<div class="form-group">
<div class="flex items-center justify-between" style="margin-bottom:6px">
<label style="margin:0">Delivery Targets</label>
<button class="btn btn-ghost btn-sm" type="button" @click="openTargetPicker()">+ Add target</button>
</div>
<div class="text-xs text-dim mt-1" x-show="!(newJob.delivery_targets || []).length">
The job output is delivered to the agent's last channel by default. Add one or more
fan-out destinations — channels, webhooks, local files, or email — to copy the output
to extra destinations each run.
</div>
<div x-show="(newJob.delivery_targets || []).length" style="margin-top:6px">
<template x-for="(t, ti) in (newJob.delivery_targets || [])" :key="ti">
<div class="card" style="padding:8px;margin-bottom:6px">
<div class="flex items-center justify-between">
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
<button class="btn btn-ghost btn-sm" type="button" @click="removeTarget(ti)">Remove</button>
</div>
<div class="text-xs text-dim" style="margin-top:4px" x-text="targetSummary(t)"></div>
</div>
</template>
</div>
</div>
<!-- Target picker (shared by create + edit flows) -->
<div class="card" x-show="showTargetPicker && !editingTargetsJobId" style="border-left:3px solid var(--accent);margin-top:8px">
<div class="font-bold" style="font-size:12px;margin-bottom:6px">New Delivery Target</div>
<div class="form-group">
<label>Type</label>
<select class="form-select" x-model="pickerType" @change="onPickerTypeChange()">
<option value="channel">Channel</option>
<option value="webhook">Webhook</option>
<option value="local_file">Local File</option>
<option value="email">Email</option>
</select>
</div>
<template x-if="draftTarget && draftTarget.type === 'channel'">
<div>
<div class="form-group">
<label>Channel type</label>
<select class="form-select" x-model="draftTarget.channel_type" x-show="channelTypes.length">
<option value="">-- pick --</option>
<template x-for="c in channelTypes" :key="c">
<option :value="c" x-text="c"></option>
</template>
</select>
<input class="form-input" x-model="draftTarget.channel_type" placeholder="telegram" x-show="!channelTypes.length">
</div>
<div class="form-group">
<label>Recipient</label>
<input class="form-input" x-model="draftTarget.recipient" placeholder="chat_12345">
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'webhook'">
<div>
<div class="form-group">
<label>URL</label>
<input class="form-input" x-model="draftTarget.url" placeholder="https://example.com/hook">
</div>
<div class="form-group">
<label>Authorization header (optional)</label>
<input class="form-input" x-model="draftTarget.auth_header" placeholder="Bearer abc123">
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'local_file'">
<div>
<div class="form-group">
<label>Path</label>
<input class="form-input" x-model="draftTarget.path" placeholder="/var/log/openfang-cron.log">
</div>
<div class="form-group">
<label class="flex items-center gap-2">
<div class="toggle" :class="{ active: draftTarget.append }" @click="draftTarget.append = !draftTarget.append"></div>
<span x-text="draftTarget.append ? 'Append' : 'Overwrite'"></span>
</label>
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'email'">
<div>
<div class="form-group">
<label>To</label>
<input class="form-input" x-model="draftTarget.to" placeholder="alice@example.com">
</div>
<div class="form-group">
<label>Subject template (optional)</label>
<input class="form-input" x-model="draftTarget.subject_template" placeholder="Cron: {job}">
<div class="text-xs text-dim mt-1">Placeholders: <code>{job}</code></div>
</div>
</div>
</template>
<div class="flex gap-2" style="margin-top:6px">
<button class="btn btn-primary btn-sm" type="button" @click="addDraftTarget()">Add</button>
<button class="btn btn-ghost btn-sm" type="button" @click="cancelTargetPicker()">Cancel</button>
</div>
</div>
<button class="btn btn-primary btn-block mt-4" @click="createJob()" :disabled="creating">
<span x-show="!creating">Create Schedule</span>
<span x-show="creating">Creating...</span>
@@ -1827,6 +1962,116 @@
</div>
</div>
</template>
<!-- Edit Targets Modal -->
<template x-if="editingTargetsJobId">
<div class="modal-overlay" @click.self="cancelEditTargets()" @keydown.escape.window="cancelEditTargets()">
<div class="modal">
<div class="modal-header">
<h3>Edit Delivery Targets</h3>
<button class="modal-close" @click="cancelEditTargets()">x</button>
</div>
<div class="form-group">
<div class="flex items-center justify-between" style="margin-bottom:6px">
<div class="text-xs text-dim">
Targets are replaced on save. Leave empty to remove all fan-out destinations.
</div>
<button class="btn btn-ghost btn-sm" type="button" @click="addEditTarget()">+ Add target</button>
</div>
<div x-show="!editingTargets.length" class="text-xs text-dim">No targets configured.</div>
<template x-for="(t, ti) in editingTargets" :key="ti">
<div class="card" style="padding:8px;margin-bottom:6px">
<div class="flex items-center justify-between">
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
<button class="btn btn-ghost btn-sm" type="button" @click="removeEditTarget(ti)">Remove</button>
</div>
<div class="text-xs text-dim" style="margin-top:4px" x-text="targetSummary(t)"></div>
</div>
</template>
</div>
<div class="card" x-show="showTargetPicker" style="border-left:3px solid var(--accent)">
<div class="font-bold" style="font-size:12px;margin-bottom:6px">New Delivery Target</div>
<div class="form-group">
<label>Type</label>
<select class="form-select" x-model="pickerType" @change="onPickerTypeChange()">
<option value="channel">Channel</option>
<option value="webhook">Webhook</option>
<option value="local_file">Local File</option>
<option value="email">Email</option>
</select>
</div>
<template x-if="draftTarget && draftTarget.type === 'channel'">
<div>
<div class="form-group">
<label>Channel type</label>
<select class="form-select" x-model="draftTarget.channel_type" x-show="channelTypes.length">
<option value="">-- pick --</option>
<template x-for="c in channelTypes" :key="c">
<option :value="c" x-text="c"></option>
</template>
</select>
<input class="form-input" x-model="draftTarget.channel_type" placeholder="telegram" x-show="!channelTypes.length">
</div>
<div class="form-group">
<label>Recipient</label>
<input class="form-input" x-model="draftTarget.recipient" placeholder="chat_12345">
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'webhook'">
<div>
<div class="form-group">
<label>URL</label>
<input class="form-input" x-model="draftTarget.url" placeholder="https://example.com/hook">
</div>
<div class="form-group">
<label>Authorization header (optional)</label>
<input class="form-input" x-model="draftTarget.auth_header" placeholder="Bearer abc123">
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'local_file'">
<div>
<div class="form-group">
<label>Path</label>
<input class="form-input" x-model="draftTarget.path" placeholder="/var/log/openfang-cron.log">
</div>
<div class="form-group">
<label class="flex items-center gap-2">
<div class="toggle" :class="{ active: draftTarget.append }" @click="draftTarget.append = !draftTarget.append"></div>
<span x-text="draftTarget.append ? 'Append' : 'Overwrite'"></span>
</label>
</div>
</div>
</template>
<template x-if="draftTarget && draftTarget.type === 'email'">
<div>
<div class="form-group">
<label>To</label>
<input class="form-input" x-model="draftTarget.to" placeholder="alice@example.com">
</div>
<div class="form-group">
<label>Subject template (optional)</label>
<input class="form-input" x-model="draftTarget.subject_template" placeholder="Cron: {job}">
<div class="text-xs text-dim mt-1">Placeholders: <code>{job}</code></div>
</div>
</div>
</template>
<div class="flex gap-2" style="margin-top:6px">
<button class="btn btn-primary btn-sm" type="button" @click="addDraftTargetToEdit()">Add</button>
<button class="btn btn-ghost btn-sm" type="button" @click="cancelTargetPicker()">Cancel</button>
</div>
</div>
<div class="flex gap-2 mt-4">
<button class="btn btn-primary" @click="saveEditTargets()" :disabled="savingTargets">
<span x-show="!savingTargets">Save</span>
<span x-show="savingTargets">Saving...</span>
</button>
<button class="btn btn-ghost" @click="cancelEditTargets()">Cancel</button>
</div>
</div>
</div>
</template>
</div>
<!-- ── TAB: Event Triggers ── -->
@@ -2234,8 +2479,12 @@
<span class="text-xs text-dim" x-text="skill.tools_count + ' tool(s)'"></span>
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
<span class="text-xs text-dim" x-show="skill.has_prompt_context">(prompt context)</span>
<span class="text-xs text-dim" x-show="skill.config_declared_count > 0" x-text="skill.config_declared_count + ' config var(s)'"></span>
</div>
<div class="flex gap-2">
<button class="btn btn-ghost btn-sm" x-show="skill.config_declared_count > 0" @click="openSkillConfig(skill)" title="Configure skill variables">&#9881; Configure</button>
<button class="btn btn-danger btn-sm" @click="uninstallSkill(skill.name)">Uninstall</button>
</div>
<button class="btn btn-danger btn-sm" @click="uninstallSkill(skill.name)">Uninstall</button>
</div>
</div>
</template>
@@ -2509,6 +2758,80 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</template>
<!-- Skill Config Modal (SKILL.md frontmatter `config:` variables) -->
<template x-if="configSkill">
<div class="modal-overlay" @click.self="closeSkillConfig()" @keydown.escape.window="closeSkillConfig()">
<div class="modal" style="max-width:640px">
<div class="modal-header">
<div>
<h3>Configure: <span x-text="configSkill.name"></span></h3>
<div class="text-xs text-dim mt-1" x-text="configSkill.description"></div>
</div>
<button class="modal-close" @click="closeSkillConfig()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div x-show="configLoading" class="loading-state" style="padding:20px 0">
<div class="spinner"></div><span>Loading configuration&hellip;</span>
</div>
<div x-show="!configLoading && configError" class="error-state">
<span class="error-icon">!</span>
<p x-text="configError"></p>
</div>
<div x-show="!configLoading && !configError">
<div x-show="configDeclaredNames.length === 0" class="empty-state" style="padding:16px 0">
<p class="text-dim">This skill does not declare any runtime config variables.</p>
</div>
<template x-for="name in configDeclaredNames" :key="name">
<div class="form-group" style="margin-bottom:16px">
<label style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<code x-text="name" style="font-size:0.8rem"></code>
<span x-show="configDeclared[name] && configDeclared[name].required" class="badge badge-danger" style="font-size:0.6rem">required</span>
<span class="badge" :class="sourceBadgeClass(configResolved[name] && configResolved[name].source)" style="font-size:0.6rem">
<template x-if="configResolved[name] && configResolved[name].source === 'user'"><span>user override</span></template>
<template x-if="configResolved[name] && configResolved[name].source === 'env'">
<span>env<template x-if="configDeclared[name] && configDeclared[name].env"><span>:<span x-text="configDeclared[name].env"></span></span></template></span>
</template>
<template x-if="configResolved[name] && configResolved[name].source === 'default'"><span>default</span></template>
<template x-if="!configResolved[name] || configResolved[name].source === 'unresolved'"><span>unresolved &#9888;</span></template>
</span>
</label>
<div class="text-xs text-dim" x-show="configDeclared[name] && configDeclared[name].description" x-text="configDeclared[name] ? configDeclared[name].description : ''" style="margin:2px 0 6px 0"></div>
<div class="flex gap-2 items-center" style="position:relative">
<input
style="flex:1"
:type="(configResolved[name] && configResolved[name].is_secret && !configRevealed[name]) ? 'password' : 'text'"
:placeholder="(configResolved[name] && configResolved[name].source !== 'user' && configResolved[name].value != null) ? ('Currently: ' + configResolved[name].value) : ((configDeclared[name] && configDeclared[name].default) ? ('default: ' + configDeclared[name].default) : 'Enter value')"
x-model="configDraft[name]"
:class="{ 'input-error': configRowInvalid(name) }">
<button type="button" class="btn btn-ghost btn-sm" x-show="configResolved[name] && configResolved[name].is_secret" @click="toggleReveal(name)" :title="configRevealed[name] ? 'Hide' : 'Reveal'">
<svg x-show="!configRevealed[name]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
<svg x-show="configRevealed[name]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-10-8-10-8a19.77 19.77 0 0 1 5.06-5.94M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 10 8 10 8a19.77 19.77 0 0 1-2.16 3.19M1 1l22 22"/></svg>
</button>
</div>
<div class="text-xs" style="margin-top:4px;display:flex;justify-content:space-between;align-items:center">
<div class="text-dim" style="font-style:italic" x-show="configResolved[name] && configResolved[name].source === 'user'">Leaving blank and saving keeps existing override. Use Reset to remove it.</div>
<a href="#" class="text-dim" style="font-size:0.7rem" x-show="configResolved[name] && configResolved[name].source === 'user'" @click.prevent="resetSkillConfigVar(name)">Reset to env/default</a>
</div>
</div>
</template>
<div class="text-xs text-dim" style="margin-top:12px">
Values are stored in <code>~/.openfang/config.toml</code> under <code>[skills.<span x-text="configSkill.name"></span>]</code>. Secrets are redacted in this view.
</div>
<div class="flex justify-end gap-2" style="margin-top:16px">
<button class="btn btn-ghost" @click="closeSkillConfig()" :disabled="configSaving">Cancel</button>
<button class="btn btn-primary" @click="saveSkillConfig()" :disabled="configSaving || hasInvalidConfig()" x-text="configSaving ? 'Saving...' : 'Save'"></button>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
@@ -3248,11 +3571,47 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div x-show="tab === 'providers'">
<div class="info-card">
<h4>LLM Providers</h4>
<p>OpenFang supports 12 LLM providers out of the box. Configure API keys to unlock models from each provider. Set environment variables and restart, or use the form below to save keys directly.</p>
<p>OpenFang ships with <span x-text="providers.length"></span> built-in providers and you can add unlimited custom ones. <span x-text="configuredProviderCount"></span> currently configured. Filter, search, or jump to a category below &mdash; only providers with a saved key (or that need none) light up models for your agents.</p>
</div>
<div class="card-grid">
<template x-for="p in providers" :key="p.id">
<div class="card provider-card" :class="providerCardClass(p)">
<!-- Filter toolbar -->
<div class="flex gap-2 mb-4" style="flex-wrap:wrap;align-items:center">
<div class="search-input" style="flex:1;min-width:200px">
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
<input placeholder="Search providers..." x-model="providerSearch">
</div>
<select class="form-select" style="width:170px" x-model="providerStatusFilter">
<option value="">All Statuses</option>
<option value="configured">Configured</option>
<option value="unconfigured">Needs Key</option>
</select>
<select class="form-select" style="width:200px" x-model="providerCategoryFilter">
<option value="">All Categories</option>
<option value="frontier">Frontier</option>
<option value="oss">Open-Weight Hosts</option>
<option value="aggregator">Aggregators</option>
<option value="regional">Regional / China</option>
<option value="local">Local / Self-Hosted</option>
<option value="other">Other</option>
</select>
<button class="btn btn-ghost btn-sm" @click="clearProviderFilters()" x-show="providerSearch || providerStatusFilter || providerCategoryFilter">Clear</button>
</div>
<div class="text-xs text-dim mb-2" x-text="filteredProviders.length + ' of ' + providers.length + ' providers'"></div>
<!-- Empty state for filters -->
<div x-show="!filteredProviders.length && providers.length" style="text-align:center;padding:32px 16px">
<h3 style="margin:0 0 4px;font-size:14px">No providers match your filters</h3>
<p class="text-xs text-dim">Try a different search term or category.</p>
<button class="btn btn-ghost btn-sm mt-2" @click="clearProviderFilters()">Clear Filters</button>
</div>
<!-- Grouped provider sections -->
<template x-for="group in providersGrouped" :key="group.category">
<div style="margin-bottom:1.25rem">
<div class="card-header" style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)">
<span x-text="group.label"></span>
<span class="text-xs text-dim" style="font-weight:normal;text-transform:none;letter-spacing:0" x-text="'(' + group.items.length + ')'"></span>
</div>
<div class="card-grid">
<template x-for="p in group.items" :key="p.id">
<div class="card provider-card" :class="providerCardClass(p)">
<div class="flex justify-between items-center mb-2">
<div class="card-header" style="margin:0" x-text="p.display_name"></div>
<span class="badge" :class="providerAuthClass(p)" x-text="providerAuthText(p)"></span>
@@ -3311,9 +3670,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
<!-- Add Custom Provider -->
<div class="info-card mt-4" style="border:1px solid var(--border)">
<h4 style="margin-top:0">Add Custom Provider</h4>
+81 -18
View File
@@ -304,22 +304,38 @@ function chatPage() {
this._slashCommandsLoaded = true;
},
// Fetch dynamic slash commands from server
// Fetch slash commands from the unified registry (/api/commands?surface=web).
// Replaces the hardcoded initSlashCommands() list once loaded — ensures
// the help panel and autocomplete stay in sync with the backend registry.
fetchCommands: function() {
var self = this;
OpenFangAPI.get('/api/commands').then(function(data) {
if (data.commands && data.commands.length) {
// Build a set of known cmds to avoid duplicates
var existing = {};
self.slashCommands.forEach(function(c) { existing[c.cmd] = true; });
data.commands.forEach(function(c) {
if (!existing[c.cmd]) {
self.slashCommands.push({ cmd: c.cmd, desc: c.desc || '', source: c.source || 'server' });
existing[c.cmd] = true;
}
});
}
}).catch(function() { /* silent — use hardcoded list */ });
OpenFangAPI.get('/api/commands?surface=web').then(function(data) {
var cmds = (data && data.commands) || [];
if (!cmds.length) return;
self.slashCommands = cmds.map(function(c) {
// Prefer unified-registry shape { name, aliases, description, category, requires_agent }.
// Fall back to legacy { cmd, desc } shape so older shims keep working.
if (c.name) {
return {
cmd: '/' + c.name,
desc: c.description || '',
category: c.category || 'general',
aliases: c.aliases || [],
requires_agent: !!c.requires_agent,
source: 'registry'
};
}
return {
cmd: c.cmd,
desc: c.desc || '',
category: c.category || 'general',
aliases: c.aliases || [],
requires_agent: !!c.requires_agent,
source: c.source || 'server'
};
});
self._slashCommandsLoaded = true;
}).catch(function() { /* silent — keep hardcoded fallback list */ });
},
get filteredSlashCommands() {
@@ -330,6 +346,44 @@ function chatPage() {
});
},
// Render `/help` output grouped by category, mirroring the
// backend's render_help(Surfaces::WEB). Falls back to a flat list if
// categories are not populated (pre-fetch hardcoded list).
renderHelpText: function() {
var order = ['general', 'session', 'model', 'control', 'memory', 'info', 'automation', 'monitoring'];
var labels = {
general: 'General', session: 'Session', model: 'Model', control: 'Control',
memory: 'Memory', info: 'Info', automation: 'Automation', monitoring: 'Monitoring'
};
var anyCategorised = this.slashCommands.some(function(c) { return c.category; });
if (!anyCategorised) {
return this.slashCommands.map(function(c) {
return '`' + c.cmd + '` \u2014 ' + c.desc;
}).join('\n');
}
var groups = {};
this.slashCommands.forEach(function(c) {
var cat = c.category || 'general';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(c);
});
var lines = ['**Available commands:**'];
order.forEach(function(cat) {
var list = groups[cat];
if (!list || !list.length) return;
lines.push('');
lines.push('**' + (labels[cat] || cat) + '**');
list.forEach(function(c) {
var aliasText = '';
if (c.aliases && c.aliases.length) {
aliasText = ' (aliases: ' + c.aliases.map(function(a) { return '/' + a; }).join(', ') + ')';
}
lines.push('- `' + c.cmd + '`' + aliasText + ' \u2014 ' + c.desc);
});
});
return lines.join('\n');
},
// Clear any stuck typing indicator after 120s
_resetTypingTimeout: function() {
var self = this;
@@ -355,7 +409,7 @@ function chatPage() {
cmdArgs = cmdArgs || '';
switch (cmd) {
case '/help':
self.messages.push({ id: ++msgId, role: 'system', text: self.slashCommands.map(function(c) { return '`' + c.cmd + '` — ' + c.desc; }).join('\n'), meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: self.renderHelpText(), meta: '', tools: [] });
self.scrollToBottom();
break;
case '/agents':
@@ -419,7 +473,7 @@ function chatPage() {
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
OpenFangAPI.wsSend({ type: 'command', command: 'context', args: '' });
} else {
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
self.scrollToBottom();
}
break;
@@ -427,7 +481,7 @@ function chatPage() {
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
OpenFangAPI.wsSend({ type: 'command', command: 'verbose', args: cmdArgs });
} else {
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
self.scrollToBottom();
}
break;
@@ -435,7 +489,7 @@ function chatPage() {
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
OpenFangAPI.wsSend({ type: 'command', command: 'queue', args: '' });
} else {
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected.', meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + ').', meta: '', tools: [] });
self.scrollToBottom();
}
break;
@@ -641,6 +695,15 @@ function chatPage() {
switch (data.type) {
case 'connected': break;
// Incoming message from server (e.g., cron trigger) — display as user message
case 'message':
if (data.content) {
var meta = data.source === 'cron' ? '[Scheduled: ' + (data.job_name || data.job_id || '') + ']' : '';
this.messages.push({ id: ++msgId, role: 'user', text: data.content, meta: meta, tools: [], images: [], ts: Date.now() });
this.scrollToBottom();
}
break;
// Legacy thinking event (backward compat)
case 'thinking':
if (!this.messages.length || !this.messages[this.messages.length - 1].thinking) {
@@ -26,13 +26,33 @@ function schedulerPage() {
cron: '',
agent_id: '',
message: '',
enabled: true
enabled: true,
delivery_targets: []
},
creating: false,
// -- Run Now state --
runningJobId: '',
// -- Delivery targets picker (create modal) --
showTargetPicker: false,
pickerType: 'channel',
draftTarget: null,
// -- Expanded job / delivery log state --
expandedJobId: '',
deliveryLog: { targets: [], entries: [] },
deliveryLogLoading: false,
deliveryLogError: '',
// -- Edit targets state (per-existing-job) --
editingTargetsJobId: '',
editingTargets: [],
savingTargets: false,
// -- Available channel types (populated from /api/channels) --
channelTypes: [],
// Cron presets
cronPresets: [
{ label: 'Every minute', cron: '* * * * *' },
@@ -55,12 +75,32 @@ function schedulerPage() {
this.loadError = '';
try {
await this.loadJobs();
// Channels are optional — failure is non-fatal for the scheduler page.
this.loadChannelTypes();
} catch(e) {
this.loadError = e.message || 'Could not load scheduler data.';
}
this.loading = false;
},
async loadChannelTypes() {
try {
var data = await OpenFangAPI.get('/api/channels');
// /api/channels returns an array of channel descriptors; pull names.
var list = Array.isArray(data) ? data : (data && data.channels) || [];
var names = [];
for (var i = 0; i < list.length; i++) {
var ch = list[i];
var name = ch && (ch.name || ch.display_name || ch.channel_type);
if (name && names.indexOf(name) === -1) names.push(name);
}
this.channelTypes = names;
} catch(e) {
// Fall through silently — the form uses a plain input as fallback.
this.channelTypes = [];
}
},
async loadJobs() {
var data = await OpenFangAPI.get('/api/cron/jobs');
var raw = data.jobs || [];
@@ -82,6 +122,7 @@ function schedulerPage() {
last_run: j.last_run,
next_run: j.next_run,
delivery: j.delivery ? j.delivery.kind || '' : '',
delivery_targets: Array.isArray(j.delivery_targets) ? j.delivery_targets : [],
created_at: j.created_at
};
});
@@ -162,9 +203,12 @@ function schedulerPage() {
delivery: { kind: 'last_channel' },
enabled: this.newJob.enabled
};
if (this.newJob.delivery_targets && this.newJob.delivery_targets.length) {
body.delivery_targets = this.newJob.delivery_targets.map(this.sanitizeTarget);
}
await OpenFangAPI.post('/api/cron/jobs', body);
this.showCreateForm = false;
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true, delivery_targets: [] };
OpenFangToast.success('Schedule "' + jobName + '" created');
await this.loadJobs();
} catch(e) {
@@ -216,6 +260,210 @@ function schedulerPage() {
this.runningJobId = '';
},
// ── Delivery target editing (create modal) ──
openTargetPicker() {
this.pickerType = 'channel';
this.draftTarget = this.blankTarget('channel');
this.showTargetPicker = true;
},
cancelTargetPicker() {
this.showTargetPicker = false;
this.draftTarget = null;
},
onPickerTypeChange() {
this.draftTarget = this.blankTarget(this.pickerType);
},
blankTarget(type) {
if (type === 'channel') {
return { type: 'channel', channel_type: '', recipient: '' };
}
if (type === 'webhook') {
return { type: 'webhook', url: '', auth_header: '' };
}
if (type === 'local_file') {
return { type: 'local_file', path: '', append: false };
}
if (type === 'email') {
return { type: 'email', to: '', subject_template: '' };
}
return null;
},
addDraftTarget() {
var err = this.validateTarget(this.draftTarget);
if (err) {
OpenFangToast.warn(err);
return;
}
if (!Array.isArray(this.newJob.delivery_targets)) this.newJob.delivery_targets = [];
this.newJob.delivery_targets.push(this.sanitizeTarget(this.draftTarget));
this.showTargetPicker = false;
this.draftTarget = null;
},
removeTarget(idx) {
if (!Array.isArray(this.newJob.delivery_targets)) return;
this.newJob.delivery_targets.splice(idx, 1);
},
validateTarget(t) {
if (!t || !t.type) return 'Pick a target type';
if (t.type === 'channel') {
if (!t.channel_type || !t.channel_type.trim()) return 'Channel type is required';
if (!t.recipient || !t.recipient.trim()) return 'Recipient is required';
} else if (t.type === 'webhook') {
if (!t.url || !t.url.trim()) return 'Webhook URL is required';
if (t.url.indexOf('http://') !== 0 && t.url.indexOf('https://') !== 0) {
return 'Webhook URL must start with http:// or https://';
}
} else if (t.type === 'local_file') {
if (!t.path || !t.path.trim()) return 'File path is required';
} else if (t.type === 'email') {
if (!t.to || !t.to.trim()) return 'Recipient email is required';
}
return null;
},
// Strip empty-string optional fields so serde accepts the payload cleanly.
sanitizeTarget(t) {
if (!t) return null;
var out = { type: t.type };
if (t.type === 'channel') {
out.channel_type = (t.channel_type || '').trim();
out.recipient = (t.recipient || '').trim();
} else if (t.type === 'webhook') {
out.url = (t.url || '').trim();
if (t.auth_header && t.auth_header.trim()) out.auth_header = t.auth_header.trim();
} else if (t.type === 'local_file') {
out.path = (t.path || '').trim();
out.append = !!t.append;
} else if (t.type === 'email') {
out.to = (t.to || '').trim();
if (t.subject_template && t.subject_template.trim()) {
out.subject_template = t.subject_template.trim();
}
}
return out;
},
// ── Chip rendering helpers ──
targetChipLabel(t) {
if (!t || !t.type) return '?';
if (t.type === 'channel') return 'CHANNEL: ' + (t.channel_type || '?');
if (t.type === 'webhook') return 'WEBHOOK';
if (t.type === 'local_file') return 'FILE: ' + this.truncate(t.path || '', 28);
if (t.type === 'email') return 'EMAIL: ' + this.truncate(t.to || '', 24);
return t.type.toUpperCase();
},
targetChipClass(t) {
if (!t || !t.type) return 'badge-dim';
if (t.type === 'channel') return 'badge-info';
if (t.type === 'webhook') return 'badge-created';
if (t.type === 'local_file') return 'badge-muted';
if (t.type === 'email') return 'badge-warn';
return 'badge-dim';
},
targetSummary(t) {
if (!t) return '';
if (t.type === 'channel') return (t.channel_type || '?') + ' -> ' + (t.recipient || '?');
if (t.type === 'webhook') return t.url || '(no url)';
if (t.type === 'local_file') return (t.append ? 'append ' : 'overwrite ') + (t.path || '');
if (t.type === 'email') {
var base = t.to || '';
if (t.subject_template) base += ' · subject: ' + t.subject_template;
return base;
}
return JSON.stringify(t);
},
// ── Expand row / delivery log ──
async toggleExpand(job) {
if (this.expandedJobId === job.id) {
this.expandedJobId = '';
return;
}
this.expandedJobId = job.id;
this.deliveryLog = { targets: [], entries: [] };
this.deliveryLogError = '';
this.deliveryLogLoading = true;
try {
var data = await OpenFangAPI.get('/api/schedules/' + job.id + '/delivery-log');
this.deliveryLog = {
targets: Array.isArray(data.targets) ? data.targets : [],
entries: Array.isArray(data.entries) ? data.entries : []
};
} catch(e) {
this.deliveryLogError = e.message || 'Could not load delivery log.';
}
this.deliveryLogLoading = false;
},
// ── Edit targets on existing job ──
startEditTargets(job) {
this.editingTargetsJobId = job.id;
// Clone so cancel doesn't mutate the loaded list.
this.editingTargets = (job.delivery_targets || []).map(function(t) {
return JSON.parse(JSON.stringify(t));
});
this.pickerType = 'channel';
this.draftTarget = null;
this.showTargetPicker = false;
},
cancelEditTargets() {
this.editingTargetsJobId = '';
this.editingTargets = [];
this.draftTarget = null;
this.showTargetPicker = false;
},
addEditTarget() {
this.pickerType = 'channel';
this.draftTarget = this.blankTarget('channel');
this.showTargetPicker = true;
},
addDraftTargetToEdit() {
var err = this.validateTarget(this.draftTarget);
if (err) {
OpenFangToast.warn(err);
return;
}
this.editingTargets.push(this.sanitizeTarget(this.draftTarget));
this.showTargetPicker = false;
this.draftTarget = null;
},
removeEditTarget(idx) {
this.editingTargets.splice(idx, 1);
},
async saveEditTargets() {
if (!this.editingTargetsJobId) return;
this.savingTargets = true;
try {
var clean = this.editingTargets.map(this.sanitizeTarget);
await OpenFangAPI.put('/api/schedules/' + this.editingTargetsJobId, {
delivery_targets: clean
});
OpenFangToast.success('Delivery targets updated');
this.cancelEditTargets();
await this.loadJobs();
} catch(e) {
OpenFangToast.error('Failed to update targets: ' + (e.message || e));
}
this.savingTargets = false;
},
// ── Trigger helpers ──
triggerType(pattern) {
@@ -376,6 +624,12 @@ function schedulerPage() {
} catch(e) { return 'never'; }
},
truncate(s, n) {
if (!s) return '';
if (s.length <= n) return s;
return s.substring(0, n - 1) + '…';
},
jobCount() {
var enabled = 0;
for (var i = 0; i < this.jobs.length; i++) {
@@ -25,6 +25,9 @@ function settingsPage() {
providerUrlSaving: {},
providerTesting: {},
providerTestResults: {},
providerSearch: '',
providerStatusFilter: '',
providerCategoryFilter: '',
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
customProviderName: '',
customProviderUrl: '',
@@ -338,6 +341,94 @@ function settingsPage() {
return Object.keys(seen).sort();
},
/// Coarse category for a provider used to group the Providers tab.
/// Returns: 'frontier' | 'oss' | 'local' | 'aggregator' | 'regional' | 'other'.
providerCategory(p) {
if (!p) return 'other';
if (p.is_local || p.key_required === false) return 'local';
var id = (p.id || '').toLowerCase();
var FRONTIER = ['anthropic','openai','gemini','google','xai','bedrock','azure','vertex'];
var OSS = ['groq','together','fireworks','cerebras','sambanova','deepseek','mistral','perplexity','cohere','ai21','huggingface','replicate','nvidia','venice','novita','chutes'];
var AGG = ['openrouter','litellm','github-copilot','claude-code'];
var REGIONAL = ['qwen','minimax','zhipu','zai','moonshot','qianfan','volcengine','kimi'];
if (FRONTIER.indexOf(id) !== -1) return 'frontier';
if (REGIONAL.indexOf(id) !== -1) return 'regional';
if (AGG.indexOf(id) !== -1) return 'aggregator';
if (OSS.indexOf(id) !== -1) return 'oss';
return 'other';
},
providerCategoryLabel(cat) {
switch (cat) {
case 'frontier': return 'Frontier (Anthropic, OpenAI, Google, xAI, Bedrock)';
case 'oss': return 'Open-Weight Hosts (Groq, Together, Fireworks, DeepSeek, etc.)';
case 'aggregator': return 'Aggregators & Gateways (OpenRouter, GitHub Copilot)';
case 'regional': return 'Regional / China (Qwen, Zhipu, Moonshot, MiniMax)';
case 'local': return 'Local / Self-Hosted (Ollama, vLLM, LM Studio, Lemonade)';
default: return 'Other Providers';
}
},
/// Stable category order for grouped rendering.
get providerCategoriesOrdered() {
return ['frontier', 'oss', 'aggregator', 'regional', 'local', 'other'];
},
/// Returns filter-matched providers grouped by category, preserving order.
/// Each entry: { category, label, items: [...] }. Empty groups are omitted.
get providersGrouped() {
var self = this;
var filtered = this.filteredProviders;
var by = {};
filtered.forEach(function(p) {
var c = self.providerCategory(p);
if (!by[c]) by[c] = [];
by[c].push(p);
});
// Sort each group: configured first, then alphabetical
Object.keys(by).forEach(function(c) {
by[c].sort(function(a, b) {
var ac = a.auth_status === 'configured' ? 0 : 1;
var bc = b.auth_status === 'configured' ? 0 : 1;
if (ac !== bc) return ac - bc;
return (a.display_name || a.id).localeCompare(b.display_name || b.id);
});
});
var out = [];
this.providerCategoriesOrdered.forEach(function(c) {
if (by[c] && by[c].length) {
out.push({ category: c, label: self.providerCategoryLabel(c), items: by[c] });
}
});
return out;
},
get filteredProviders() {
var self = this;
return this.providers.filter(function(p) {
if (self.providerStatusFilter === 'configured' && p.auth_status !== 'configured') return false;
if (self.providerStatusFilter === 'unconfigured' && p.auth_status === 'configured') return false;
if (self.providerCategoryFilter && self.providerCategory(p) !== self.providerCategoryFilter) return false;
if (self.providerSearch) {
var q = self.providerSearch.toLowerCase();
if ((p.display_name || '').toLowerCase().indexOf(q) === -1 &&
(p.id || '').toLowerCase().indexOf(q) === -1 &&
(p.api_key_env || '').toLowerCase().indexOf(q) === -1) return false;
}
return true;
});
},
get configuredProviderCount() {
return this.providers.filter(function(p) { return p.auth_status === 'configured'; }).length;
},
clearProviderFilters() {
this.providerSearch = '';
this.providerStatusFilter = '';
this.providerCategoryFilter = '';
},
get uniqueTiers() {
var seen = {};
this.models.forEach(function(m) { if (m.tier) seen[m.tier] = true; });
+167 -1
View File
@@ -30,6 +30,16 @@ function skillsPage() {
skillCodeFilename: '',
skillCodeLoading: false,
// Skill config modal (local skill configuration from SKILL.md frontmatter)
configSkill: null, // skill object whose config is being edited
configDeclared: {}, // { var_name: { description, env, default, required } }
configResolved: {}, // { var_name: { value, source, is_secret } }
configDraft: {}, // { var_name: user-edited string value }
configRevealed: {}, // { var_name: bool } — toggle password reveal per row
configLoading: false,
configSaving: false,
configError: '',
// MCP servers
mcpServers: [],
mcpLoading: false,
@@ -101,7 +111,8 @@ function skillsPage() {
tags: s.tags || [],
enabled: s.enabled !== false,
source: s.source || { type: 'local' },
has_prompt_context: !!s.has_prompt_context
has_prompt_context: !!s.has_prompt_context,
config_declared_count: s.config_declared_count || 0
};
});
} catch(e) {
@@ -111,6 +122,161 @@ function skillsPage() {
this.loading = false;
},
// ── Skill config editing ────────────────────────────────────────────
async openSkillConfig(skill) {
this.configSkill = skill;
this.configDeclared = {};
this.configResolved = {};
this.configDraft = {};
this.configRevealed = {};
this.configError = '';
this.configLoading = true;
try {
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(skill.name) + '/config');
this.configDeclared = data.declared || {};
this.configResolved = data.resolved || {};
// Pre-populate draft values only for vars the user has already
// overridden — never copy redacted responses back into inputs or
// they'd be re-saved as "****redacted****" strings.
var names = Object.keys(this.configDeclared);
for (var i = 0; i < names.length; i++) {
var n = names[i];
var res = this.configResolved[n] || {};
if (res.source === 'user' && !res.is_secret) {
this.configDraft[n] = res.value == null ? '' : String(res.value);
} else {
this.configDraft[n] = '';
}
}
} catch(e) {
this.configError = e.message || 'Failed to load skill config.';
}
this.configLoading = false;
},
closeSkillConfig() {
this.configSkill = null;
this.configDeclared = {};
this.configResolved = {};
this.configDraft = {};
this.configRevealed = {};
this.configError = '';
},
configRowInvalid(name) {
// A required var is invalid iff the user hasn't entered anything AND
// no env/default resolves it. Source from the server tells us where
// the current value came from; if it's "unresolved" and the draft is
// blank, the save would write an empty string over the required var.
var decl = this.configDeclared[name] || {};
if (!decl.required) return false;
var draft = (this.configDraft[name] || '').trim();
if (draft) return false;
var res = this.configResolved[name] || {};
if (res.source === 'env' || res.source === 'default') return false;
// If the user has an existing secret override we don't want to force
// them to re-type it — treat that as "currently resolved".
if (res.source === 'user') return false;
return true;
},
hasInvalidConfig() {
var names = Object.keys(this.configDeclared);
for (var i = 0; i < names.length; i++) {
if (this.configRowInvalid(names[i])) return true;
}
return false;
},
toggleReveal(name) {
this.configRevealed[name] = !this.configRevealed[name];
},
sourceBadgeClass(source) {
switch (source) {
case 'user': return 'badge-success';
case 'env': return 'badge-info';
case 'default': return 'badge-dim';
default: return 'badge-danger';
}
},
sourceBadgeLabel(res) {
if (!res) return 'unresolved';
switch (res.source) {
case 'user': return 'user override';
case 'env': return 'env' + ((this.configDeclared[res.__name] && this.configDeclared[res.__name].env) ? ':' + this.configDeclared[res.__name].env : '');
case 'default': return 'default';
default: return 'unresolved';
}
},
async saveSkillConfig() {
if (!this.configSkill) return;
if (this.hasInvalidConfig()) {
OpenFangToast.error('Fill in all required variables before saving.');
return;
}
this.configSaving = true;
this.configError = '';
// Only PUT values the user actually typed. Empty strings are dropped
// so we don't silently clobber an env/default with "".
var payload = {};
var names = Object.keys(this.configDeclared);
for (var i = 0; i < names.length; i++) {
var n = names[i];
var v = (this.configDraft[n] || '').trim();
if (v.length > 0) payload[n] = v;
}
try {
await OpenFangAPI.put('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config', { values: payload });
OpenFangToast.success('Saved, reloading agents\u2026');
// Refresh the modal contents so the new source/value shows up.
var refreshed = this.configSkill;
await this.loadSkills();
this.closeSkillConfig();
// Find the possibly-refreshed skill object and reopen.
var self = this;
var updated = this.skills.find(function(s) { return s.name === refreshed.name; });
if (updated) await self.openSkillConfig(updated);
} catch(e) {
this.configError = e.message || 'Save failed.';
OpenFangToast.error('Save failed: ' + (e.message || 'unknown error'));
}
this.configSaving = false;
},
async resetSkillConfigVar(name) {
if (!this.configSkill) return;
var decl = this.configDeclared[name] || {};
var res = this.configResolved[name] || {};
// If the server is already reporting a non-user source there's nothing
// to remove; just clear the draft so the input disappears.
if (res.source !== 'user') {
this.configDraft[name] = '';
return;
}
try {
await OpenFangAPI.del('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config/' + encodeURIComponent(name));
OpenFangToast.success('Reset ' + name);
// Refresh modal state from server.
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config');
this.configResolved = data.resolved || {};
this.configDraft[name] = '';
} catch(e) {
var msg = e.message || 'Reset failed';
if (msg.indexOf('required') !== -1 || msg.indexOf('409') !== -1) {
OpenFangToast.error('Cannot reset: ' + decl.description + ' is required with no fallback.');
} else {
OpenFangToast.error('Reset failed: ' + msg);
}
}
},
get configDeclaredNames() {
return Object.keys(this.configDeclared).sort();
},
async loadData() {
await this.loadSkills();
},
@@ -61,6 +61,7 @@ async fn start_test_server_with_provider(
model: model.to_string(),
api_key_env: api_key_env.to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
@@ -101,6 +102,10 @@ async fn start_test_server_with_provider(
"/api/agents/{id}",
axum::routing::delete(routes::kill_agent),
)
.route(
"/api/agents/{id}/clone",
axum::routing::post(routes::clone_agent),
)
.route(
"/api/triggers",
axum::routing::get(routes::list_triggers).post(routes::create_trigger),
@@ -122,6 +127,23 @@ async fn start_test_server_with_provider(
axum::routing::get(routes::list_workflow_runs),
)
.route("/api/shutdown", axum::routing::post(routes::shutdown))
.route("/api/commands", axum::routing::get(routes::list_commands))
.route(
"/api/schedules",
axum::routing::get(routes::list_schedules).post(routes::create_schedule),
)
.route(
"/api/schedules/{id}",
axum::routing::delete(routes::delete_schedule).put(routes::update_schedule),
)
.route(
"/api/schedules/{id}/delivery-log",
axum::routing::get(routes::schedule_delivery_log),
)
.route(
"/api/cron/jobs",
axum::routing::get(routes::list_cron_jobs).post(routes::create_cron_job),
)
.layer(axum::middleware::from_fn(middleware::request_logging))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
@@ -284,6 +306,86 @@ async fn test_spawn_list_kill_agent() {
assert_eq!(agents[0]["name"], "assistant");
}
/// Regression test for issue #1026: GET /api/agents returns `is_inferencing`
/// reflecting whether the agent has an in-flight LLM task. This drives the
/// live dashboard indicator that shows which agents are calling the LLM.
#[tokio::test]
async fn test_list_agents_includes_inferencing_flag() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Spawn a test agent.
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let agent_id_str = body["agent_id"].as_str().unwrap().to_string();
let agent_id: openfang_types::agent::AgentId = agent_id_str.parse().unwrap();
// Baseline: idle agent must report is_inferencing = false.
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
let test_agent = agents
.iter()
.find(|a| a["id"] == agent_id_str)
.expect("spawned agent should appear in list");
assert_eq!(
test_agent["is_inferencing"], false,
"freshly spawned agent should not be inferencing"
);
// Simulate an in-flight LLM call by inserting a real AbortHandle into
// the kernel's running_tasks map. This is exactly what the agent loop
// does when it starts processing a message.
let handle = tokio::spawn(async {
// Long-lived task we will abort at end of test.
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
});
server
.state
.kernel
.running_tasks
.insert(agent_id, handle.abort_handle());
// Now list_agents should report is_inferencing = true for that agent.
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
let test_agent = agents
.iter()
.find(|a| a["id"] == agent_id_str)
.expect("spawned agent should still appear in list");
assert_eq!(
test_agent["is_inferencing"], true,
"agent with an entry in running_tasks must be flagged is_inferencing"
);
// Other agents (the default assistant) must NOT be flagged.
if let Some(other) = agents.iter().find(|a| a["id"] != agent_id_str) {
assert_eq!(
other["is_inferencing"], false,
"agents without a running task must not be flagged"
);
}
// Cleanup so the spawned future does not outlive the test.
server.state.kernel.running_tasks.remove(&agent_id);
handle.abort();
}
#[tokio::test]
async fn test_agent_session_empty() {
let server = start_test_server().await;
@@ -362,6 +464,7 @@ async fn test_agent_session_filters_system_messages() {
content: openfang_types::message::MessageContent::Text(
"INTERNAL SYSTEM PROMPT — must not leak to UI".to_string(),
),
..Default::default()
},
Message::user("hello"),
Message::assistant("hi there"),
@@ -803,6 +906,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
model: "test-model".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
@@ -834,6 +938,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
} else {
String::new()
},
allow_no_auth: true,
};
let app = Router::new()
@@ -856,6 +961,10 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
"/api/agents/{id}",
axum::routing::delete(routes::kill_agent),
)
.route(
"/api/agents/{id}/clone",
axum::routing::post(routes::clone_agent),
)
.route(
"/api/triggers",
axum::routing::get(routes::list_triggers).post(routes::create_trigger),
@@ -982,3 +1091,719 @@ async fn test_auth_disabled_when_no_key() {
.unwrap();
assert_eq!(resp.status(), 200);
}
// ---------------------------------------------------------------------------
// /api/commands — unified command registry endpoint
// ---------------------------------------------------------------------------
/// Default (no surface query) returns web-surface commands.
#[tokio::test]
async fn test_commands_default_returns_web() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "web");
let commands = body["commands"].as_array().expect("commands is array");
assert!(!commands.is_empty(), "web surface should have commands");
// Every entry has the documented shape.
for c in commands {
assert!(c["name"].is_string());
assert!(c["aliases"].is_array());
assert!(c["description"].is_string());
assert!(c["category"].is_string());
assert!(c["requires_agent"].is_boolean());
}
// Sanity: web surface must include `/help` and `/verbose` and must NOT
// include CLI-only `/kill`.
let names: Vec<&str> = commands
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"help"));
assert!(names.contains(&"verbose"));
assert!(!names.contains(&"kill"));
}
/// `?surface=cli` returns CLI-only commands and includes the alias array.
#[tokio::test]
async fn test_commands_cli_surface() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=cli", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "cli");
let commands = body["commands"].as_array().unwrap();
let names: Vec<&str> = commands
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"kill"));
assert!(names.contains(&"clear"));
assert!(names.contains(&"exit"));
// `start` is channel-only — must not appear on CLI.
assert!(!names.contains(&"start"));
// `/exit` carries the `quit` alias.
let exit = commands
.iter()
.find(|c| c["name"] == "exit")
.expect("exit command must be present on CLI");
let aliases = exit["aliases"].as_array().unwrap();
assert!(
aliases.iter().any(|a| a == "quit"),
"quit alias should be attached to /exit"
);
}
/// `?surface=all` includes commands from every surface.
#[tokio::test]
async fn test_commands_all_surface() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=all", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "all");
let names: Vec<&str> = body["commands"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
// Surface-specific probes: all three unique-per-surface commands appear.
assert!(names.contains(&"kill"), "CLI-only /kill missing from /all");
assert!(
names.contains(&"start"),
"channel-only /start missing from /all"
);
assert!(
names.contains(&"verbose"),
"web-only /verbose missing from /all"
);
}
/// `?surface=channel` returns channel commands only.
#[tokio::test]
async fn test_commands_channel_surface() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=channel", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["surface"], "channel");
let names: Vec<&str> = body["commands"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"start"));
// CLI-only must not appear here.
assert!(!names.contains(&"kill"));
}
/// Unknown surface returns 400 with a JSON error body.
#[tokio::test]
async fn test_commands_invalid_surface_400() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/commands?surface=bogus", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("bogus"),
"error should mention the bad value: {err}"
);
}
// ---------------------------------------------------------------------------
// Schedule delivery_targets round-trip tests
// ---------------------------------------------------------------------------
//
// These exercise the `/api/schedules` and `/api/cron/jobs` endpoints to
// confirm `CronDeliveryTarget` variants round-trip cleanly through create /
// list / update / delivery-log, and that bad input is rejected at the API
// layer rather than silently dropped.
async fn spawn_test_agent(server: &TestServer) -> String {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
body["agent_id"].as_str().unwrap().to_string()
}
/// POST /api/schedules with all four `CronDeliveryTarget` variants should
/// store them and return them on GET /api/schedules.
#[tokio::test]
async fn test_schedules_delivery_targets_roundtrip() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let delivery_targets = serde_json::json!([
{ "type": "channel", "channel_type": "telegram", "recipient": "chat_12345" },
{ "type": "webhook", "url": "https://example.com/hook", "auth_header": "Bearer abc" },
{ "type": "local_file", "path": "/tmp/openfang-test.log", "append": true },
{ "type": "email", "to": "alice@example.com", "subject_template": "Cron: {job}" },
]);
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "multi-destination-test",
"cron": "0 9 * * 1-5",
"agent_id": agent_id,
"message": "Generate the daily brief.",
"enabled": true,
"delivery_targets": delivery_targets,
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let sched_id = body["id"]
.as_str()
.expect("created schedule id")
.to_string();
let got = body["delivery_targets"]
.as_array()
.expect("response must include delivery_targets");
assert_eq!(got.len(), 4, "all four targets should round-trip");
assert_eq!(got[0]["type"], "channel");
assert_eq!(got[0]["channel_type"], "telegram");
assert_eq!(got[0]["recipient"], "chat_12345");
assert_eq!(got[1]["type"], "webhook");
assert_eq!(got[1]["url"], "https://example.com/hook");
assert_eq!(got[1]["auth_header"], "Bearer abc");
assert_eq!(got[2]["type"], "local_file");
assert_eq!(got[2]["append"], true);
assert_eq!(got[3]["type"], "email");
assert_eq!(got[3]["subject_template"], "Cron: {job}");
let resp = client
.get(format!("{}/api/schedules", server.base_url))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
let schedules = body["schedules"].as_array().unwrap();
let created = schedules
.iter()
.find(|s| s["id"] == sched_id)
.expect("created schedule must appear in list");
let listed = created["delivery_targets"].as_array().unwrap();
assert_eq!(listed.len(), 4);
assert_eq!(listed[0]["channel_type"], "telegram");
let _ = client
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
.send()
.await;
}
/// PUT /api/schedules/{id} with `delivery_targets` should fully replace the
/// target list.
#[tokio::test]
async fn test_schedules_delivery_targets_update() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "update-target-test",
"cron": "*/15 * * * *",
"agent_id": agent_id,
"message": "hi",
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let sched_id = body["id"].as_str().unwrap().to_string();
assert_eq!(
body["delivery_targets"].as_array().map(|a| a.len()),
Some(0)
);
let resp = client
.put(format!("{}/api/schedules/{}", server.base_url, sched_id))
.json(&serde_json::json!({
"delivery_targets": [
{ "type": "webhook", "url": "https://new.example.com/hook" },
{ "type": "local_file", "path": "/tmp/new.log" },
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "updated");
let echoed = &body["schedule"]["delivery_targets"];
let arr = echoed
.as_array()
.expect("schedule.delivery_targets must be array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["type"], "webhook");
assert_eq!(arr[1]["type"], "local_file");
let resp = client
.get(format!("{}/api/schedules", server.base_url))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let created = body["schedules"]
.as_array()
.unwrap()
.iter()
.find(|s| s["id"] == sched_id)
.unwrap();
let listed = created["delivery_targets"].as_array().unwrap();
assert_eq!(listed.len(), 2);
let resp = client
.put(format!("{}/api/schedules/{}", server.base_url, sched_id))
.json(&serde_json::json!({"delivery_targets": []}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp = client
.get(format!("{}/api/schedules", server.base_url))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let created = body["schedules"]
.as_array()
.unwrap()
.iter()
.find(|s| s["id"] == sched_id)
.unwrap();
let listed = created["delivery_targets"].as_array().unwrap();
assert_eq!(listed.len(), 0);
let _ = client
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
.send()
.await;
}
/// Malformed `delivery_targets` should return 400, not silently succeed.
#[tokio::test]
async fn test_schedules_rejects_bad_delivery_target() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "bad-target-test",
"cron": "*/10 * * * *",
"agent_id": agent_id,
"message": "hi",
"delivery_targets": [
{ "type": "channel" /* missing channel_type + recipient */ }
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("delivery_targets"),
"error should mention delivery_targets, got: {err}"
);
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "bad-array-test",
"cron": "*/10 * * * *",
"agent_id": agent_id,
"message": "hi",
"delivery_targets": "not-an-array",
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
}
/// GET /api/schedules/{id}/delivery-log returns the configured targets and an
/// empty entries array for a known schedule, and 404 for a random UUID.
#[tokio::test]
async fn test_schedules_delivery_log_endpoint() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/schedules", server.base_url))
.json(&serde_json::json!({
"name": "log-test",
"cron": "0 * * * *",
"agent_id": agent_id,
"message": "x",
"delivery_targets": [
{ "type": "webhook", "url": "https://example.com/h" }
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let sched_id = resp.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let resp = client
.get(format!(
"{}/api/schedules/{}/delivery-log",
server.base_url, sched_id
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["schedule_id"], sched_id);
let targets = body["targets"].as_array().expect("targets array");
assert_eq!(targets.len(), 1);
assert_eq!(targets[0]["type"], "webhook");
let entries = body["entries"].as_array().expect("entries array");
assert!(
entries.is_empty(),
"delivery history is not persisted yet — entries must be empty"
);
let random = "550e8400-e29b-41d4-a716-446655440000";
let resp = client
.get(format!(
"{}/api/schedules/{}/delivery-log",
server.base_url, random
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
let resp = client
.get(format!(
"{}/api/schedules/not-a-uuid/delivery-log",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let _ = client
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
.send()
.await;
}
/// POST /api/cron/jobs with `delivery_targets` should persist them and they
/// should appear on the subsequent GET.
#[tokio::test]
async fn test_cron_jobs_delivery_targets_roundtrip() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let agent_id = spawn_test_agent(&server).await;
let resp = client
.post(format!("{}/api/cron/jobs", server.base_url))
.json(&serde_json::json!({
"agent_id": agent_id,
"name": "cron-fanout",
"schedule": { "kind": "cron", "expr": "*/20 * * * *" },
"action": { "kind": "agent_turn", "message": "pulse" },
"delivery": { "kind": "none" },
"delivery_targets": [
{ "type": "local_file", "path": "/tmp/pulse.log", "append": true },
{ "type": "webhook", "url": "http://example.com/pulse" }
]
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let resp = client
.get(format!(
"{}/api/cron/jobs?agent_id={}",
server.base_url, agent_id
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
let jobs = body["jobs"].as_array().unwrap();
let job = jobs
.iter()
.find(|j| j["name"] == "cron-fanout")
.expect("created job must be listed");
let targets = job["delivery_targets"].as_array().expect("targets array");
assert_eq!(targets.len(), 2);
assert_eq!(targets[0]["type"], "local_file");
assert_eq!(targets[0]["path"], "/tmp/pulse.log");
assert_eq!(targets[0]["append"], true);
assert_eq!(targets[1]["type"], "webhook");
assert_eq!(targets[1]["url"], "http://example.com/pulse");
}
// ---------------------------------------------------------------------------
// Clone agent endpoint tests (issue #868)
// ---------------------------------------------------------------------------
/// Happy path: clone an existing template agent into a new agent with a
/// distinct name. The clone must get a fresh ID, fresh workspace path, and
/// inherit non-name manifest fields from the template.
#[tokio::test]
async fn test_clone_agent_happy_path() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Spawn a template agent.
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let template_id = body["agent_id"].as_str().unwrap().to_string();
// Clone it.
let resp = client
.post(format!(
"{}/api/agents/{}/clone",
server.base_url, template_id
))
.json(&serde_json::json!({
"new_name": "cloned-user-1",
"overrides": {
"description": "Cloned for user 1",
"tags": ["clone", "user-1"]
}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "clone should succeed");
let body: serde_json::Value = resp.json().await.unwrap();
let new_id = body["agent_id"].as_str().unwrap();
assert_ne!(new_id, template_id, "clone must have a fresh agent ID");
assert_eq!(body["name"], "cloned-user-1");
// The full manifest should be returned and reflect the new name + overrides.
let manifest = &body["manifest"];
assert!(manifest.is_object(), "manifest must be returned");
assert_eq!(manifest["name"], "cloned-user-1");
assert_eq!(manifest["description"], "Cloned for user 1");
assert_eq!(
manifest["tags"].as_array().unwrap(),
&vec![
serde_json::json!("clone"),
serde_json::json!("user-1"),
]
);
// Inherited from template — the system_prompt should match.
assert_eq!(
manifest["model"]["system_prompt"],
"You are a test agent. Reply concisely."
);
// The agent list should now contain both template and clone.
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
let names: Vec<&str> = agents
.iter()
.map(|a| a["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"test-agent"));
assert!(names.contains(&"cloned-user-1"));
}
/// Cloning into a name that's already taken must fail with 409 Conflict.
#[tokio::test]
async fn test_clone_agent_name_collision() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Spawn a template agent named "test-agent".
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = resp.json().await.unwrap();
let template_id = body["agent_id"].as_str().unwrap().to_string();
// First clone — succeeds.
let resp = client
.post(format!(
"{}/api/agents/{}/clone",
server.base_url, template_id
))
.json(&serde_json::json!({"new_name": "duplicate-name"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
// Second clone with the same name — must be rejected.
let resp = client
.post(format!(
"{}/api/agents/{}/clone",
server.base_url, template_id
))
.json(&serde_json::json!({"new_name": "duplicate-name"}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
409,
"duplicate name must return 409 Conflict"
);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["error"]
.as_str()
.unwrap()
.contains("already exists"));
// Cloning into the template's own name must also be rejected.
let resp = client
.post(format!(
"{}/api/agents/{}/clone",
server.base_url, template_id
))
.json(&serde_json::json!({"new_name": "test-agent"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 409);
}
/// Cloning a non-existent template must return 404.
#[tokio::test]
async fn test_clone_agent_template_not_found() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Random valid UUID that does not match any agent.
let bogus_id = "00000000-0000-0000-0000-000000000000";
let resp = client
.post(format!("{}/api/agents/{}/clone", server.base_url, bogus_id))
.json(&serde_json::json!({"new_name": "ghost-clone"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["error"]
.as_str()
.unwrap()
.contains("Template agent not found"));
// Malformed agent id → 400.
let resp = client
.post(format!("{}/api/agents/not-a-uuid/clone", server.base_url))
.json(&serde_json::json!({"new_name": "ghost-clone"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
}
/// Empty new_name must be rejected with 400.
#[tokio::test]
async fn test_clone_agent_empty_name_rejected() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Spawn a template agent.
let resp = client
.post(format!("{}/api/agents", server.base_url))
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let template_id = body["agent_id"].as_str().unwrap().to_string();
let resp = client
.post(format!(
"{}/api/agents/{}/clone",
server.base_url, template_id
))
.json(&serde_json::json!({"new_name": " "}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
}
@@ -98,6 +98,7 @@ async fn test_full_daemon_lifecycle() {
model: "test".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
@@ -225,6 +226,7 @@ async fn test_server_immediate_responsiveness() {
model: "test".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
+1
View File
@@ -42,6 +42,7 @@ async fn start_test_server() -> TestServer {
model: "test-model".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
@@ -0,0 +1,385 @@
//! Integration tests for the `/api/skills/{id}/config` surface.
//!
//! These boot a real kernel, start a real axum server on a random port, plant
//! a synthetic skill on disk whose SKILL.md declares a `config:` section, and
//! exercise GET / PUT / DELETE end to end. Bundled skills currently declare
//! no runtime config, so the synthetic skill fixture is what lets us prove
//! the wire contract.
//!
//! Run: cargo test -p openfang-api --test skill_config_api_test -- --nocapture
use axum::Router;
use openfang_api::middleware;
use openfang_api::routes::{self, AppState};
use openfang_kernel::OpenFangKernel;
use openfang_types::config::{DefaultModelConfig, KernelConfig};
use std::sync::Arc;
use std::time::Instant;
use tower_http::cors::CorsLayer;
// ---------------------------------------------------------------------------
// Test server harness
// ---------------------------------------------------------------------------
struct TestServer {
base_url: String,
home_dir: std::path::PathBuf,
#[allow(dead_code)]
state: Arc<AppState>,
_tmp: tempfile::TempDir,
}
impl Drop for TestServer {
fn drop(&mut self) {
self.state.kernel.shutdown();
}
}
/// Write a skill fixture under `<home>/skills/<name>/SKILL.md` that declares
/// a `config:` section. This matches the on-disk format that OpenClaw skills
/// use, so the loader's real `parse_skillmd_str` path is exercised.
fn plant_skill_with_config(home: &std::path::Path, skill_name: &str) {
let skill_dir = home.join("skills").join(skill_name);
std::fs::create_dir_all(&skill_dir).unwrap();
// Leading four spaces inside YAML lists matter — keep them.
let skillmd = format!(
"---
name: {skill_name}
description: Synthetic skill for config endpoint tests
config:
github_token:
description: GitHub personal access token
env: OPENFANG_TEST_SKILLCFG_GH_TOKEN
required: true
default_branch:
description: Default branch name
default: main
required: false
---
# Test Skill
Placeholder body so the parser accepts this as a valid prompt-only skill.
"
);
std::fs::write(skill_dir.join("SKILL.md"), skillmd).unwrap();
}
async fn start_test_server() -> TestServer {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().to_path_buf();
let config = KernelConfig {
home_dir: home.clone(),
data_dir: home.join("data"),
default_model: DefaultModelConfig {
provider: "ollama".to_string(),
model: "test-model".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
// Plant synthetic skill BEFORE booting so the initial skill load picks it up.
plant_skill_with_config(&home, "test-config-skill");
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boot");
let kernel = Arc::new(kernel);
kernel.set_self_handle();
let state = Arc::new(AppState {
kernel,
started_at: Instant::now(),
peer_registry: None,
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
.route("/api/skills", axum::routing::get(routes::list_skills))
.route(
"/api/skills/{id}/config",
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
)
.route(
"/api/skills/{id}/config/{var_name}",
axum::routing::delete(routes::delete_skill_config_var),
)
.layer(axum::middleware::from_fn(middleware::request_logging))
.layer(CorsLayer::permissive())
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test port");
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestServer {
base_url: format!("http://{}", addr),
home_dir: home,
state,
_tmp: tmp,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn get_config_returns_declared_and_resolved() {
// Make sure no host leak from prior tests interferes.
// SAFETY: single-threaded test, env var is unique to this test suite.
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["skill"], "test-config-skill");
// Both vars declared
assert!(body["declared"]["github_token"].is_object());
assert!(body["declared"]["default_branch"].is_object());
assert_eq!(body["declared"]["github_token"]["required"], true);
assert_eq!(body["declared"]["default_branch"]["required"], false);
// github_token has no user override, no env, no default -> unresolved.
assert_eq!(
body["resolved"]["github_token"]["source"], "unresolved",
"github_token should be unresolved without env"
);
assert!(body["resolved"]["github_token"]["is_secret"]
.as_bool()
.unwrap());
// default_branch falls back to default "main".
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
}
#[tokio::test]
async fn get_config_redacts_secret_values_after_put() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Write a real-looking token via PUT.
let payload = serde_json::json!({
"values": {
"github_token": "ghp_realsecretvalue_DO_NOT_LEAK",
"default_branch": "develop"
}
});
let resp = client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&payload)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "PUT should succeed");
// GET back and confirm the secret is redacted and non-secret is visible.
let resp = client
.get(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let returned_token = body["resolved"]["github_token"]["value"]
.as_str()
.unwrap()
.to_string();
assert!(
!returned_token.contains("realsecretvalue"),
"secret leaked on the wire: {returned_token}"
);
assert!(
returned_token.contains("redacted"),
"expected redaction marker, got: {returned_token}"
);
assert_eq!(body["resolved"]["github_token"]["source"], "user");
// Non-secret var kept as-is.
assert_eq!(body["resolved"]["default_branch"]["value"], "develop");
assert_eq!(body["resolved"]["default_branch"]["source"], "user");
// config.toml persisted the change, including the full secret value
// (redaction is only on the wire — disk is the source of truth).
let cfg = std::fs::read_to_string(server.home_dir.join("config.toml")).unwrap();
assert!(
cfg.contains("[skills.test-config-skill]"),
"skills section missing: {cfg}"
);
assert!(cfg.contains("realsecretvalue"));
assert!(cfg.contains("develop"));
}
#[tokio::test]
async fn put_rejects_unknown_variable() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let payload = serde_json::json!({
"values": { "nonexistent_var": "value" }
});
let resp = client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&payload)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["error"].as_str().unwrap().contains("nonexistent_var"));
}
#[tokio::test]
async fn delete_override_reverts_to_default() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Set override first.
client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&serde_json::json!({
"values": { "default_branch": "develop" }
}))
.send()
.await
.unwrap();
// Remove it.
let resp = client
.delete(format!(
"{}/api/skills/test-config-skill/config/default_branch",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// Now source should be "default" again with value "main".
let body: serde_json::Value = client
.get(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
}
#[tokio::test]
async fn delete_refuses_to_strand_required_var() {
// github_token is required, has no default, and no env — so removing an
// override would leave it unresolvable. The endpoint must refuse.
// SAFETY: single-threaded test.
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
let server = start_test_server().await;
let client = reqwest::Client::new();
// Set an override.
client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&serde_json::json!({
"values": { "github_token": "ghp_value" }
}))
.send()
.await
.unwrap();
// Try to delete — should 409.
let resp = client
.delete(format!(
"{}/api/skills/test-config-skill/config/github_token",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 409);
}
#[tokio::test]
async fn get_unknown_skill_returns_404() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!(
"{}/api/skills/this-skill-does-not-exist/config",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn put_reloads_registry_so_agents_see_change() {
let server = start_test_server().await;
let client = reqwest::Client::new();
client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&serde_json::json!({
"values": {
"github_token": "ghp_new",
"default_branch": "release"
}
}))
.send()
.await
.unwrap();
// The kernel's live override map must now hold the new values.
let guard = server.state.kernel.skill_config_overrides.read().unwrap();
let overrides = guard.as_ref().expect("override map set after PUT");
let skill_cfg = overrides.get("test-config-skill").expect("skill present");
assert_eq!(skill_cfg.get("github_token").unwrap(), "ghp_new");
assert_eq!(skill_cfg.get("default_branch").unwrap(), "release");
}
+434 -32
View File
@@ -14,7 +14,8 @@ use dashmap::DashMap;
use futures::StreamExt;
use openfang_types::agent::AgentId;
use openfang_types::approval::ApprovalRequest;
use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat};
use openfang_types::commands::{self as slash_commands, Surfaces};
use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat, PrefixStyle};
use openfang_types::message::ContentBlock;
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -243,6 +244,22 @@ pub trait ChannelBridgeHandle: Send + Sync {
// Default: no tracking
}
/// Send a plain text message to a specific recipient via a registered
/// channel adapter.
///
/// Used by the cron multi-destination delivery engine to fan out job
/// output across channels. `channel_type` is the adapter key (e.g.
/// `"telegram"`, `"slack"`). Default implementation returns an error so
/// test doubles don't accidentally claim success.
async fn send_channel_message(
&self,
_channel_type: &str,
_recipient: &str,
_message: &str,
) -> Result<(), String> {
Err("send_channel_message not implemented on this bridge".to_string())
}
/// Check if auto-reply is enabled and the message should trigger one.
/// Returns Some(reply_text) if auto-reply fires, None otherwise.
async fn check_auto_reply(&self, _agent_id: AgentId, _message: &str) -> Option<String> {
@@ -404,6 +421,26 @@ impl BridgeManager {
adapter: Arc<dyn ChannelAdapter>,
) -> Result<(), Box<dyn std::error::Error>> {
let stream = adapter.start().await?;
// Migration note for Discord/Slack: prior versions keyed `/agent <name>`
// selections on the channel ID rather than the user. `user_defaults` is
// in-memory only, so the daemon restart that loads this binary already
// wipes any stale entries — but log a one-line nudge so users know to
// re-run `/agent <name>` if their previous selection appears to have
// gone away. See `set_user_default` call sites in `dispatch_message`
// and `handle_command` for the keying fix.
match adapter.name() {
"discord" | "slack" => {
info!(
adapter = adapter.name(),
"Channel adapter starting: per-user `/agent <name>` defaults are \
in-memory and reset on daemon restart. If a previous selection \
no longer takes effect, re-run `/agent <name>` once."
);
}
_ => {}
}
let handle = self.handle.clone();
let router = self.router.clone();
let rate_limiter = self.rate_limiter.clone();
@@ -493,6 +530,69 @@ fn channel_type_str(channel: &crate::types::ChannelType) -> &str {
}
}
/// Wrap an outbound message with the responding agent's name according to
/// `style`.
///
/// Applied once at the top of the final response text (never per streaming
/// chunk). If the text already starts with the exact bracketed agent label
/// (e.g. the agent echoed its own name, or an inner agent already prefixed a
/// delegated reply), the wrap is skipped to keep things idempotent.
///
/// Per-platform native identity features (Slack `username` override, Discord
/// embed `author`, Telegram `From:` in rich messages) are intentionally not
/// handled here — that is a follow-up.
pub(crate) fn apply_agent_prefix(style: PrefixStyle, agent_name: &str, text: &str) -> String {
if matches!(style, PrefixStyle::Off) || agent_name.is_empty() {
return text.to_string();
}
let bracket = format!("[{agent_name}]");
let bold = format!("**[{agent_name}]**");
if text.starts_with(&bracket) || text.starts_with(&bold) {
return text.to_string();
}
match style {
PrefixStyle::Off => text.to_string(),
PrefixStyle::Bracket => format!("{bracket} {text}"),
PrefixStyle::BoldBracket => format!("{bold} {text}"),
}
}
/// Look up an agent's display name by id.
///
/// Returns `None` if the kernel can't list agents or the id is not currently
/// known. Only called when `prefix_agent_name` is enabled, so the extra
/// `list_agents()` round-trip is pay-per-use.
async fn resolve_agent_name(handle: &Arc<dyn ChannelBridgeHandle>, id: AgentId) -> Option<String> {
handle
.list_agents()
.await
.ok()?
.into_iter()
.find_map(|(aid, name)| (aid == id).then_some(name))
}
/// Apply `prefix_agent_name` to an outbound agent response if configured.
///
/// Safe to call on every success path: resolves the agent name lazily and
/// returns the original text unchanged when the style is `Off`.
async fn maybe_prefix_response(
handle: &Arc<dyn ChannelBridgeHandle>,
overrides: Option<&ChannelOverrides>,
agent_id: AgentId,
text: String,
) -> String {
let style = overrides
.map(|o| o.prefix_agent_name)
.unwrap_or(PrefixStyle::Off);
if matches!(style, PrefixStyle::Off) {
return text;
}
match resolve_agent_name(handle, agent_id).await {
Some(name) => apply_agent_prefix(style, &name, &text),
None => text,
}
}
/// Send a response, applying output formatting and optional threading.
async fn send_response(
adapter: &dyn ChannelAdapter,
@@ -579,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(
@@ -717,7 +845,15 @@ async fn dispatch_message(
// Handle commands first (early return)
if let ChannelContent::Command { ref name, ref args } = message.content {
let result = handle_command(name, args, handle, router, &message.sender).await;
let result = handle_command(
name,
args,
handle,
router,
&message.sender,
sender_user_id(message),
)
.await;
send_response(adapter, &message.sender, result, thread_id, output_format).await;
return;
}
@@ -733,6 +869,10 @@ async fn dispatch_message(
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. }))
{
let prefix_style = overrides
.as_ref()
.map(|o| o.prefix_agent_name)
.unwrap_or(PrefixStyle::Off);
// We have actual image data — send as structured blocks for vision
dispatch_with_blocks(
blocks,
@@ -745,6 +885,7 @@ async fn dispatch_message(
thread_id,
output_format,
lifecycle_reactions,
prefix_style,
)
.await;
return;
@@ -796,7 +937,15 @@ async fn dispatch_message(
};
if is_channel_command(cmd) {
let result = handle_command(cmd, &args, handle, router, &message.sender).await;
let result = handle_command(
cmd,
&args,
handle,
router,
&message.sender,
sender_user_id(message),
)
.await;
send_response(adapter, &message.sender, result, thread_id, output_format).await;
return;
}
@@ -804,8 +953,12 @@ async fn dispatch_message(
}
// Check broadcast routing first
if router.has_broadcast(&message.sender.platform_id) {
let targets = router.resolve_broadcast(&message.sender.platform_id);
// Broadcast lookup is keyed on the user, matching the read path's
// sender_user_id() resolution. On Discord/Slack `sender.platform_id` is the
// channel ID, so keying on it would collide with channel routing — see the
// companion fix on `set_user_default` writes below.
if router.has_broadcast(sender_user_id(message)) {
let targets = router.resolve_broadcast(sender_user_id(message));
if !targets.is_empty() {
// RBAC check applies to broadcast too
if let Err(denied) = handle
@@ -873,11 +1026,16 @@ async fn dispatch_message(
}
}
// Route to agent (standard path)
let agent_id = router.resolve(
// 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.
// 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,
&message.sender.platform_id,
sender_user_id(message),
message.sender.openfang_user.as_deref(),
sender_channel_id(message),
);
let agent_id = match agent_id {
@@ -895,8 +1053,10 @@ async fn dispatch_message(
};
match fallback {
Some(id) => {
// Auto-set this as the user's default so future messages route directly
router.set_user_default(message.sender.platform_id.clone(), id);
// Auto-set this as the user's default so future messages route directly.
// Key on sender_user_id() (not platform_id) so Discord/Slack — where
// platform_id is the channel — store per-user, matching the read path.
router.set_user_default(sender_user_id(message).to_string(), id);
id
}
None => {
@@ -935,6 +1095,7 @@ async fn dispatch_message(
// Auto-reply check — if enabled, the engine decides whether to process this message.
// If auto-reply is enabled but suppressed for this message, skip agent call entirely.
if let Some(reply) = handle.check_auto_reply(agent_id, &text).await {
let reply = maybe_prefix_response(handle, overrides.as_ref(), agent_id, reply).await;
send_response(adapter, &message.sender, reply, thread_id, output_format).await;
handle
.record_delivery(
@@ -965,15 +1126,24 @@ async fn dispatch_message(
// Prepend sender context so the agent knows who is speaking.
// In group spaces this is essential for multi-user conversations.
//
// For Telegram we also inject the numeric `tg_id` because display names are
// not unique and can change — agents that key per-user state (RBAC, per-user
// workspaces) need a stable identifier. See issue #915.
let sender_name = &message.sender.display_name;
let sender_email = message
.metadata
.get("sender_email")
.and_then(|v| v.as_str());
let telegram_user_id = message
.metadata
.get("telegram_user_id")
.and_then(|v| v.as_str());
let prefixed_text = if !sender_name.is_empty() {
match sender_email {
Some(email) => format!("[From: {sender_name} <{email}>] {text}"),
None => format!("[From: {sender_name}] {text}"),
match (sender_email, telegram_user_id) {
(Some(email), _) => format!("[From: {sender_name} <{email}>] {text}"),
(None, Some(tg_id)) => format!("[From: {sender_name} (tg_id:{tg_id})] {text}"),
(None, None) => format!("[From: {sender_name}] {text}"),
}
} else {
text.clone()
@@ -990,6 +1160,8 @@ async fn dispatch_message(
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
}
let response =
maybe_prefix_response(handle, overrides.as_ref(), agent_id, response).await;
send_response(adapter, &message.sender, response, thread_id, output_format).await;
handle
.record_delivery(
@@ -1019,6 +1191,9 @@ async fn dispatch_message(
)
.await;
}
let response =
maybe_prefix_response(handle, overrides.as_ref(), new_id, response)
.await;
send_response(adapter, &message.sender, response, thread_id, output_format)
.await;
handle
@@ -1306,12 +1481,16 @@ async fn dispatch_with_blocks(
thread_id: Option<&str>,
output_format: OutputFormat,
lifecycle_reactions: bool,
prefix_style: PrefixStyle,
) {
// Route to agent (same logic as text path)
let agent_id = router.resolve(
// Route to agent (same logic as text path).
// 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,
&message.sender.platform_id,
sender_user_id(message),
message.sender.openfang_user.as_deref(),
sender_channel_id(message),
);
let agent_id = match agent_id {
@@ -1328,7 +1507,9 @@ async fn dispatch_with_blocks(
};
match fallback {
Some(id) => {
router.set_user_default(message.sender.platform_id.clone(), id);
// Key on sender_user_id() (not platform_id) so Discord/Slack — where
// platform_id is the channel — store per-user, matching the read path.
router.set_user_default(sender_user_id(message).to_string(), id);
id
}
None => {
@@ -1382,11 +1563,23 @@ async fn dispatch_with_blocks(
typing_task.abort();
// Resolve agent name once (only if the prefix feature is on) and reuse for
// both the first response and any re-resolved retry.
let prefix_name = if matches!(prefix_style, PrefixStyle::Off) {
None
} else {
resolve_agent_name(handle, agent_id).await
};
match result {
Ok(response) => {
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
}
let response = match &prefix_name {
Some(name) => apply_agent_prefix(prefix_style, name, &response),
None => response,
};
send_response(adapter, &message.sender, response, thread_id, output_format).await;
handle
.record_delivery(
@@ -1416,6 +1609,15 @@ async fn dispatch_with_blocks(
)
.await;
}
let retry_name = if matches!(prefix_style, PrefixStyle::Off) {
None
} else {
resolve_agent_name(handle, new_id).await
};
let response = match &retry_name {
Some(name) => apply_agent_prefix(prefix_style, name, &response),
None => response,
};
send_response(adapter, &message.sender, response, thread_id, output_format)
.await;
handle
@@ -1496,14 +1698,29 @@ async fn dispatch_with_blocks(
}
/// Handle a bot command (returns the response text).
///
/// `user_id` is the platform user ID (e.g. Discord author ID, Slack user ID).
/// For adapters that set `sender.platform_id` to the channel/conversation ID
/// (Discord, Slack), callers must pass `sender_user_id(message)` here so that
/// per-user agent routing works correctly. For adapters where platform_id is
/// already the user (CLI, Telegram DM), the two are equivalent.
async fn handle_command(
name: &str,
args: &[String],
handle: &Arc<dyn ChannelBridgeHandle>,
router: &Arc<AgentRouter>,
sender: &ChannelUser,
user_id: &str,
) -> String {
match name {
// Canonicalise through the unified command registry: aliases resolve to
// their canonical name and matching is case-insensitive. If the command
// is not registered on CHANNEL the original string is passed through so
// any legacy / channel-specific names continue to work unchanged.
let canonical: &str = slash_commands::resolve(name)
.filter(|def| def.surfaces.contains(Surfaces::CHANNEL))
.map(|def| def.name)
.unwrap_or(name);
match canonical {
"start" => {
let agents = handle.list_agents().await.unwrap_or_default();
let mut msg = "Welcome to OpenFang! I connect you to AI agents.\n\nAvailable agents:\n"
@@ -1547,14 +1764,17 @@ async fn handle_command(
let agent_name = &args[0];
match handle.find_agent_by_name(agent_name).await {
Ok(Some(agent_id)) => {
router.set_user_default(sender.platform_id.clone(), agent_id);
// Key on user_id (the param wired in by the Discord/Slack call sites
// via sender_user_id(message)) — not sender.platform_id, which is the
// channel ID on those adapters. Matches the read-path resolution.
router.set_user_default(user_id.to_string(), agent_id);
format!("Now talking to agent: {agent_name}")
}
Ok(None) => {
// Try to spawn it
match handle.spawn_agent_by_name(agent_name).await {
Ok(agent_id) => {
router.set_user_default(sender.platform_id.clone(), agent_id);
router.set_user_default(user_id.to_string(), agent_id);
format!("Spawned and connected to agent: {agent_name}")
}
Err(e) => {
@@ -1569,7 +1789,7 @@ async fn handle_command(
// Need to resolve the user's current agent
let agent_id = router.resolve(
&crate::types::ChannelType::CLI,
&sender.platform_id,
user_id,
sender.openfang_user.as_deref(),
);
match agent_id {
@@ -1583,7 +1803,7 @@ async fn handle_command(
"compact" => {
let agent_id = router.resolve(
&crate::types::ChannelType::CLI,
&sender.platform_id,
user_id,
sender.openfang_user.as_deref(),
);
match agent_id {
@@ -1597,7 +1817,7 @@ async fn handle_command(
"model" => {
let agent_id = router.resolve(
&crate::types::ChannelType::CLI,
&sender.platform_id,
user_id,
sender.openfang_user.as_deref(),
);
match agent_id {
@@ -1621,7 +1841,7 @@ async fn handle_command(
"stop" => {
let agent_id = router.resolve(
&crate::types::ChannelType::CLI,
&sender.platform_id,
user_id,
sender.openfang_user.as_deref(),
);
match agent_id {
@@ -1635,7 +1855,7 @@ async fn handle_command(
"usage" => {
let agent_id = router.resolve(
&crate::types::ChannelType::CLI,
&sender.platform_id,
user_id,
sender.openfang_user.as_deref(),
);
match agent_id {
@@ -1649,7 +1869,7 @@ async fn handle_command(
"think" => {
let agent_id = router.resolve(
&crate::types::ChannelType::CLI,
&sender.platform_id,
user_id,
sender.openfang_user.as_deref(),
);
match agent_id {
@@ -1733,7 +1953,10 @@ async fn handle_command(
"peers" => handle.peers_text().await,
"a2a" => handle.a2a_agents_text().await,
_ => format!("Unknown command: /{name}"),
_ => format!(
"Unknown command: /{name}\n\n{}",
slash_commands::render_help(Surfaces::CHANNEL)
),
}
}
@@ -1815,10 +2038,10 @@ mod tests {
openfang_user: None,
};
let result = handle_command("agents", &[], &handle, &router, &sender).await;
let result = handle_command("agents", &[], &handle, &router, &sender, "user1").await;
assert!(result.contains("coder"));
let result = handle_command("help", &[], &handle, &router, &sender).await;
let result = handle_command("help", &[], &handle, &router, &sender, "user1").await;
assert!(result.contains("/agents"));
}
@@ -1836,8 +2059,15 @@ mod tests {
};
// Select existing agent
let result =
handle_command("agent", &["coder".to_string()], &handle, &router, &sender).await;
let result = handle_command(
"agent",
&["coder".to_string()],
&handle,
&router,
&sender,
"user1",
)
.await;
assert!(result.contains("Now talking to agent: coder"));
// Verify router was updated
@@ -1845,6 +2075,48 @@ mod tests {
assert_eq!(resolved, Some(agent_id));
}
/// Discord/Slack-shaped: sender.platform_id is the *channel* id, user_id is
/// the actual user. After /agent <name>, the default must be stored under
/// user_id and resolvable by user_id — NOT by the channel id. This is the
/// "split-keying" fix the read path has and the write path now matches.
#[tokio::test]
async fn test_handle_command_agent_select_keys_on_user_id_not_platform_id() {
let agent_id = AgentId::new();
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
agents: Mutex::new(vec![(agent_id, "coder".to_string())]),
});
let router = Arc::new(AgentRouter::new());
// Discord-shape: platform_id is the channel, the real user is in user_id.
let sender = ChannelUser {
platform_id: "channel-123".to_string(),
display_name: "Test".to_string(),
openfang_user: None,
};
let user_id = "user-789";
let result = handle_command(
"agent",
&["coder".to_string()],
&handle,
&router,
&sender,
user_id,
)
.await;
assert!(result.contains("Now talking to agent: coder"));
// Resolves under the user's id (correct).
let by_user = router.resolve(&ChannelType::Discord, user_id, None);
assert_eq!(by_user, Some(agent_id), "should resolve by user_id");
// Does NOT resolve under the channel id (the bug we just fixed).
let by_channel = router.resolve(&ChannelType::Discord, "channel-123", None);
assert_eq!(
by_channel, None,
"must NOT resolve by sender.platform_id (channel id)"
);
}
#[tokio::test]
async fn test_handle_command_agent_without_args_lists_agents() {
let agent_id = AgentId::new();
@@ -1858,7 +2130,7 @@ mod tests {
openfang_user: None,
};
let result = handle_command("agent", &[], &handle, &router, &sender).await;
let result = handle_command("agent", &[], &handle, &router, &sender, "user1").await;
assert!(result.contains("Usage: /agent <name>"));
assert!(result.contains("coder"));
}
@@ -2041,6 +2313,136 @@ mod tests {
assert_eq!(detect_image_magic(&[]), None);
}
#[test]
fn test_apply_agent_prefix_off_is_identity() {
let text = "hello world";
let out = apply_agent_prefix(PrefixStyle::Off, "coder", text);
assert_eq!(out, text);
// Ensure no reallocation surprise: the output must equal the input byte-for-byte.
assert_eq!(out.as_bytes(), text.as_bytes());
}
#[test]
fn test_apply_agent_prefix_bracket() {
let out = apply_agent_prefix(
PrefixStyle::Bracket,
"platform-architect",
"Here's my take.",
);
assert_eq!(out, "[platform-architect] Here's my take.");
}
#[test]
fn test_apply_agent_prefix_bold_bracket() {
let out = apply_agent_prefix(PrefixStyle::BoldBracket, "coder", "All green.");
assert_eq!(out, "**[coder]** All green.");
}
#[test]
fn test_apply_agent_prefix_idempotent_bracket() {
// If the response already carries our bracket label, don't double-wrap.
let already = "[coder] already prefixed";
let out = apply_agent_prefix(PrefixStyle::Bracket, "coder", already);
assert_eq!(out, already);
}
#[test]
fn test_apply_agent_prefix_idempotent_bold_bracket() {
let already = "**[coder]** already bold";
let out = apply_agent_prefix(PrefixStyle::BoldBracket, "coder", already);
assert_eq!(out, already);
// Bracket style also detects the bolded form and leaves it alone.
let out2 = apply_agent_prefix(PrefixStyle::Bracket, "coder", already);
assert_eq!(out2, already);
}
#[test]
fn test_apply_agent_prefix_empty_name_is_noop() {
let text = "no author";
let out = apply_agent_prefix(PrefixStyle::Bracket, "", text);
assert_eq!(out, text);
}
#[tokio::test]
async fn test_maybe_prefix_response_off_is_byte_identical() {
let agent_id = AgentId::new();
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
agents: Mutex::new(vec![(agent_id, "coder".to_string())]),
});
let overrides = ChannelOverrides::default();
let input = "Hello from the agent.".to_string();
let original_bytes = input.clone();
let out = maybe_prefix_response(&handle, Some(&overrides), agent_id, input).await;
assert_eq!(out.as_bytes(), original_bytes.as_bytes());
}
#[tokio::test]
async fn test_maybe_prefix_response_bracket_wraps() {
let agent_id = AgentId::new();
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
agents: Mutex::new(vec![(agent_id, "coder".to_string())]),
});
let overrides = ChannelOverrides {
prefix_agent_name: PrefixStyle::Bracket,
..Default::default()
};
let out =
maybe_prefix_response(&handle, Some(&overrides), agent_id, "Hi".to_string()).await;
assert_eq!(out, "[coder] Hi");
}
#[tokio::test]
async fn test_maybe_prefix_response_bold_bracket_wraps() {
let agent_id = AgentId::new();
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
agents: Mutex::new(vec![(agent_id, "coder".to_string())]),
});
let overrides = ChannelOverrides {
prefix_agent_name: PrefixStyle::BoldBracket,
..Default::default()
};
let out =
maybe_prefix_response(&handle, Some(&overrides), agent_id, "Hi".to_string()).await;
assert_eq!(out, "**[coder]** Hi");
}
#[tokio::test]
async fn test_maybe_prefix_response_unknown_agent_falls_back() {
// When the agent id isn't in list_agents, we leave the text alone
// rather than fabricating a label.
let known = AgentId::new();
let unknown = AgentId::new();
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
agents: Mutex::new(vec![(known, "coder".to_string())]),
});
let overrides = ChannelOverrides {
prefix_agent_name: PrefixStyle::Bracket,
..Default::default()
};
let out = maybe_prefix_response(&handle, Some(&overrides), unknown, "Hi".to_string()).await;
assert_eq!(out, "Hi");
}
#[test]
fn test_prefix_style_default_is_off_and_serde_snake_case() {
assert_eq!(PrefixStyle::default(), PrefixStyle::Off);
// Round-trip: the serialized representation is snake_case and
// an unspecified config field deserializes to Off so existing TOML
// keeps working.
let v: PrefixStyle = serde_json::from_str("\"bracket\"").unwrap();
assert_eq!(v, PrefixStyle::Bracket);
let v: PrefixStyle = serde_json::from_str("\"bold_bracket\"").unwrap();
assert_eq!(v, PrefixStyle::BoldBracket);
let v: PrefixStyle = serde_json::from_str("\"off\"").unwrap();
assert_eq!(v, PrefixStyle::Off);
}
#[test]
fn test_channel_overrides_default_prefix_off() {
let o = ChannelOverrides::default();
assert_eq!(o.prefix_agent_name, PrefixStyle::Off);
}
#[test]
fn test_media_type_from_url() {
assert_eq!(
+3
View File
@@ -636,6 +636,9 @@ async fn parse_discord_message(
if was_mentioned {
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
}
// Stash the Discord author ID so the router can key bindings on user, not channel.
// (`sender.platform_id` below is the channel ID, used for the send path.)
metadata.insert("sender_user_id".to_string(), serde_json::json!(author_id));
Some(ChannelMessage {
channel: ChannelType::Discord,
+16 -4
View File
@@ -42,8 +42,8 @@ const MAX_MESSAGE_LEN: usize = 4000;
/// Token refresh buffer — refresh 5 minutes before actual expiry.
const TOKEN_REFRESH_BUFFER_SECS: u64 = 300;
/// Feishu websocket endpoint discovery API.
const FEISHU_WS_ENDPOINT_URL: &str = "https://open.feishu.cn/callback/ws/endpoint";
/// WebSocket endpoint path (appended to the region domain).
const FEISHU_WS_ENDPOINT_PATH: &str = "/callback/ws/endpoint";
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
const MAX_BACKOFF: Duration = Duration::from_secs(60);
@@ -269,13 +269,24 @@ impl FeishuAdapter {
///
/// WebSocket mode does not require a public IP or webhook configuration.
pub fn new_websocket(app_id: String, app_secret: String) -> Self {
Self::new_websocket_with_region(app_id, app_secret, FeishuRegion::Cn)
}
/// Create a new Feishu adapter in WebSocket mode with an explicit region.
///
/// Use this when the app is registered on Lark international (`open.larksuite.com`).
pub fn new_websocket_with_region(
app_id: String,
app_secret: String,
region: FeishuRegion,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
app_id,
app_secret: Zeroizing::new(app_secret),
connection_mode: FeishuConnectionMode::WebSocket,
webhook_port: 0,
region: FeishuRegion::Cn,
region,
webhook_path: String::new(),
verification_token: None,
encrypt_key: None,
@@ -918,9 +929,10 @@ struct FeishuAdapterClone {
impl FeishuAdapterClone {
/// Get WebSocket endpoint from Feishu API.
async fn get_websocket_endpoint(&self) -> Result<FeishuWsEndpoint, Box<dyn std::error::Error>> {
let url = format!("{}{}", self.region.domain(), FEISHU_WS_ENDPOINT_PATH);
let resp = self
.client
.post(FEISHU_WS_ENDPOINT_URL)
.post(&url)
.json(&serde_json::json!({
"AppID": self.app_id,
"AppSecret": self.app_secret.as_str(),
+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"));
}
}
+104
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(
@@ -501,6 +548,9 @@ async fn parse_slack_event(
// Check if the bot was @-mentioned (for group_policy = "mention_only")
let mut metadata = HashMap::new();
// Stash the Slack user ID so the router can key bindings on user, not channel.
// (`sender.platform_id` below is the channel ID, used for the send path.)
metadata.insert("sender_user_id".to_string(), serde_json::json!(user_id));
if event_type == "app_mention" {
metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true));
}
@@ -742,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);
}
}
+534 -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.
///
@@ -909,6 +999,16 @@ async fn parse_telegram_update(
// Detect @mention of the bot in entities / caption_entities for MentionOnly group policy.
let mut metadata = HashMap::new();
// Always expose the Telegram numeric user_id in metadata. Display names are not
// unique and can change, so agents that need stable per-user keys (RBAC, per-user
// workspaces, deterministic routing) must rely on this id. The id originates from
// `message.from.id` for normal users or `message.sender_chat.id` for messages sent
// on behalf of a channel/group. See issue #915.
metadata.insert(
"telegram_user_id".to_string(),
serde_json::json!(user_id_str),
);
// Store reply_to_message_id in metadata for downstream consumers.
if let Some(reply_msg) = message.get("reply_to_message") {
if let Some(reply_id) = reply_msg["message_id"].as_i64() {
@@ -1089,6 +1189,83 @@ mod tests {
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello, agent!"));
}
#[tokio::test]
async fn test_parse_injects_telegram_user_id_metadata() {
// Issue #915 — agents need a stable per-user identifier. The numeric
// Telegram user_id from `message.from.id` must land in metadata as a
// string so downstream consumers (bridge prompt builder, tools, etc.)
// can key per-user state on it.
let update = serde_json::json!({
"update_id": 555,
"message": {
"message_id": 1,
"from": {
"id": 554772934_i64,
"first_name": "Alena"
},
"chat": {
"id": -1009876543210_i64,
"type": "group"
},
"date": 1700000000,
"text": "Hello"
}
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
// The chat_id (used for replies) stays on sender.platform_id.
assert_eq!(msg.sender.platform_id, "-1009876543210");
assert_eq!(msg.sender.display_name, "Alena");
// The numeric Telegram user_id is exposed in metadata as a string.
let tg_id = msg
.metadata
.get("telegram_user_id")
.and_then(|v| v.as_str())
.expect("telegram_user_id should be present in metadata");
assert_eq!(tg_id, "554772934");
}
#[tokio::test]
async fn test_parse_sender_chat_user_id_metadata() {
// When a message arrives via `sender_chat` (channel/group posting on
// its own behalf), the chat id is what we have — surface it under
// `telegram_user_id` so the metadata key is always present.
let update = serde_json::json!({
"update_id": 556,
"message": {
"message_id": 2,
"sender_chat": {
"id": -1001234567890_i64,
"type": "channel",
"title": "My Channel"
},
"chat": {
"id": -1001234567890_i64,
"type": "channel"
},
"date": 1700000001,
"text": "Broadcast"
}
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None)
.await
.unwrap();
let tg_id = msg
.metadata
.get("telegram_user_id")
.and_then(|v| v.as_str())
.expect("telegram_user_id should be present in metadata");
assert_eq!(tg_id, "-1001234567890");
}
#[tokio::test]
async fn test_parse_telegram_command() {
let update = serde_json::json!({
@@ -1909,4 +2086,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");
}
}
+435 -20
View File
@@ -440,6 +440,27 @@ enum HandCommands {
/// Instance ID (from `hand active`).
id: String,
},
/// Get, set, or list settings for an active hand instance.
///
/// With no flags, prints the current settings. Use `--set KEY=VAL`
/// (repeatable) to update values, `--unset KEY` to remove a value,
/// or `--get KEY` to print a single value.
Config {
/// Hand ID (e.g. "browser", "clip").
id: String,
/// Print a single setting value.
#[arg(long, value_name = "KEY", conflicts_with_all = ["set", "unset", "list"])]
get: Option<String>,
/// Set a setting value. Format: `KEY=VALUE`. May be repeated.
#[arg(long, value_name = "KEY=VALUE")]
set: Vec<String>,
/// Unset a setting key. May be repeated.
#[arg(long, value_name = "KEY")]
unset: Vec<String>,
/// List the current settings (default when no other flag is given).
#[arg(long)]
list: bool,
},
}
#[derive(Subcommand)]
@@ -1010,6 +1031,13 @@ fn main() {
HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id),
HandCommands::Pause { id } => cmd_hand_pause(&id),
HandCommands::Resume { id } => cmd_hand_resume(&id),
HandCommands::Config {
id,
get,
set,
unset,
list,
} => cmd_hand_config(&id, get.as_deref(), &set, &unset, list),
},
Some(Commands::Config(sub)) => match sub {
ConfigCommands::Show => cmd_config_show(),
@@ -1448,9 +1476,30 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
"openrouter/google/gemini-2.5-flash",
"OpenRouter",
),
("minimax", "MINIMAX_API_KEY", "MiniMax-M2.7", "MiniMax"),
]
}
#[cfg(test)]
mod provider_list_tests {
use super::provider_list;
#[test]
fn provider_list_includes_minimax() {
let minimax = provider_list()
.into_iter()
.find(|(provider, _, _, _)| *provider == "minimax");
assert!(
minimax.is_some(),
"MiniMax should be exposed by provider_list()"
);
let (_, env_var, model, display) = minimax.unwrap();
assert_eq!(env_var, "MINIMAX_API_KEY");
assert_eq!(model, "MiniMax-M2.7");
assert_eq!(display, "MiniMax");
}
}
/// Quick probe to check if Ollama is running on localhost.
fn check_ollama_available() -> bool {
std::net::TcpStream::connect_timeout(
@@ -2466,7 +2515,9 @@ decay_rate = 0.05
if !json {
ui::check_ok("GitHub Copilot (authenticated via device flow)");
}
checks.push(serde_json::json!({"check": "provider", "name": "GitHub Copilot", "status": "ok"}));
checks.push(
serde_json::json!({"check": "provider", "name": "GitHub Copilot", "status": "ok"}),
);
}
}
@@ -4563,6 +4614,167 @@ fn cmd_hand_resume(id: &str) {
}
}
/// Parse a `KEY=VALUE` pair passed to `--set`.
///
/// Empty keys are rejected so `--set =foo` or `--set =bar` surface a clear
/// error rather than silently writing a blank setting name.
fn parse_hand_config_pair(pair: &str) -> Result<(String, String), String> {
let (key, value) = pair
.split_once('=')
.ok_or_else(|| format!("Invalid --set '{pair}': expected KEY=VALUE"))?;
let key = key.trim();
if key.is_empty() {
return Err(format!("Invalid --set '{pair}': empty key"));
}
Ok((key.to_string(), value.to_string()))
}
fn cmd_hand_config(
id: &str,
get: Option<&str>,
set_pairs: &[String],
unset_keys: &[String],
list: bool,
) {
let base = require_daemon("hand config");
let client = daemon_client();
// Always fetch current state first so we can merge updates and print
// a useful view even when the target hand has no active instance.
let url = format!("{base}/api/hands/{id}/settings");
let body = daemon_json(client.get(&url).send());
if let Some(err) = body.get("error").and_then(|v| v.as_str()) {
ui::error(&format!("Hand '{id}': {err}"));
std::process::exit(1);
}
let mut current: std::collections::BTreeMap<String, serde_json::Value> = body
.get("current_values")
.and_then(|v| v.as_object())
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
let schema_defaults: std::collections::BTreeMap<String, String> = body
.get("settings")
.and_then(|v| v.get("settings"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|s| {
let key = s.get("key").and_then(|v| v.as_str())?.to_string();
let default = s
.get("default")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some((key, default))
})
.collect()
})
.unwrap_or_default();
// Pure read paths — no mutation, no daemon round-trip beyond the GET.
if let Some(key) = get {
match current
.get(key)
.map(value_to_display)
.or_else(|| schema_defaults.get(key).cloned())
{
Some(val) => println!("{val}"),
None => {
ui::error(&format!("No setting '{key}' on hand '{id}'"));
std::process::exit(1);
}
}
return;
}
let is_mutation = !set_pairs.is_empty() || !unset_keys.is_empty();
if !is_mutation {
print_hand_config(id, &current, &schema_defaults, list);
return;
}
for pair in set_pairs {
match parse_hand_config_pair(pair) {
Ok((k, v)) => {
current.insert(k, serde_json::Value::String(v));
}
Err(e) => {
ui::error(&e);
std::process::exit(1);
}
}
}
for key in unset_keys {
let key = key.trim();
if key.is_empty() {
ui::error("Invalid --unset: empty key");
std::process::exit(1);
}
current.remove(key);
}
let payload: serde_json::Map<String, serde_json::Value> = current.clone().into_iter().collect();
let resp = daemon_json(
client
.put(&url)
.json(&serde_json::Value::Object(payload))
.send(),
);
if let Some(err) = resp.get("error").and_then(|v| v.as_str()) {
ui::error(&format!("Failed to update hand '{id}' settings: {err}"));
if err.contains("No active instance") {
ui::hint(&format!(
"Activate the hand first: openfang hand activate {id}"
));
}
std::process::exit(1);
}
ui::success(&format!("Updated settings for hand '{id}'."));
print_hand_config(id, &current, &schema_defaults, true);
}
/// Human-readable display for a JSON setting value.
fn value_to_display(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
}
}
fn print_hand_config(
id: &str,
current: &std::collections::BTreeMap<String, serde_json::Value>,
schema_defaults: &std::collections::BTreeMap<String, String>,
_list: bool,
) {
if current.is_empty() && schema_defaults.is_empty() {
println!("No settings configured for hand '{id}'.");
return;
}
println!("Settings for hand '{id}':");
let mut keys: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for k in current.keys() {
keys.insert(k.as_str());
}
for k in schema_defaults.keys() {
keys.insert(k.as_str());
}
for key in keys {
match current.get(key) {
Some(v) => println!(" {key} = {}", value_to_display(v)),
None => {
let default = schema_defaults.get(key).map(|s| s.as_str()).unwrap_or("");
println!(" {key} = {default} (default)");
}
}
}
}
// ---------------------------------------------------------------------------
// Provider / API key helpers
// ---------------------------------------------------------------------------
@@ -4749,6 +4961,38 @@ fn cmd_config_edit() {
}
}
/// Outcome of looking up a dotted key path in a parsed TOML config.
#[derive(Debug, PartialEq)]
enum ConfigGetOutcome {
/// Scalar value formatted for display (may be the empty string).
Value(String),
/// Key exists but resolves to a non-scalar (table or array).
NonScalar,
/// Key path does not exist.
NotFound,
}
/// Look up a dotted key path inside a parsed TOML document and format the
/// resulting scalar for display. Pure function so the behaviour can be tested
/// without touching the filesystem.
fn lookup_config_value(table: &toml::Value, key: &str) -> ConfigGetOutcome {
let mut current = table;
for part in key.split('.') {
match current.get(part) {
Some(v) => current = v,
None => return ConfigGetOutcome::NotFound,
}
}
match current {
toml::Value::String(s) => ConfigGetOutcome::Value(s.clone()),
toml::Value::Integer(i) => ConfigGetOutcome::Value(i.to_string()),
toml::Value::Float(f) => ConfigGetOutcome::Value(f.to_string()),
toml::Value::Boolean(b) => ConfigGetOutcome::Value(b.to_string()),
toml::Value::Datetime(d) => ConfigGetOutcome::Value(d.to_string()),
toml::Value::Array(_) | toml::Value::Table(_) => ConfigGetOutcome::NonScalar,
}
}
fn cmd_config_get(key: &str) {
let home = openfang_home();
let config_path = home.join("config.toml");
@@ -4771,25 +5015,19 @@ fn cmd_config_get(key: &str) {
std::process::exit(1);
});
// Navigate dotted path
let mut current = &table;
for part in key.split('.') {
match current.get(part) {
Some(v) => current = v,
None => {
ui::error(&format!("Key not found: {key}"));
std::process::exit(1);
}
match lookup_config_value(&table, key) {
ConfigGetOutcome::Value(s) => println!("{s}"),
ConfigGetOutcome::NonScalar => {
ui::error_with_fix(
&format!("'{key}' is a section, not a scalar value"),
"Use a deeper dotted key (e.g. `section.field`)",
);
std::process::exit(1);
}
ConfigGetOutcome::NotFound => {
ui::error(&format!("Key not found: {key}"));
std::process::exit(1);
}
}
// Print value
match current {
toml::Value::String(s) => println!("{s}"),
toml::Value::Integer(i) => println!("{i}"),
toml::Value::Float(f) => println!("{f}"),
toml::Value::Boolean(b) => println!("{b}"),
other => println!("{other}"),
}
}
@@ -4992,7 +5230,9 @@ fn cmd_config_set_key(provider: &str) {
ui::error(&format!("Failed to create async runtime: {e}"));
std::process::exit(1);
});
match rt.block_on(openfang_runtime::drivers::copilot::run_interactive_setup(&openfang_dir)) {
match rt.block_on(openfang_runtime::drivers::copilot::run_interactive_setup(
&openfang_dir,
)) {
Ok(_) => {
ui::success("GitHub Copilot configured successfully");
ui::hint("Restart the daemon: openfang stop && openfang start");
@@ -7021,8 +7261,183 @@ args = ["-y", "@modelcontextprotocol/server-github"]
assert_eq!(events.len(), 4);
}
// --- Config get command unit tests ---
fn sample_config_with_base_url() -> &'static str {
r#"api_listen = "127.0.0.1:4200"
[default_model]
provider = "openai"
model = "qwen3-coder-30b/qwen3-coder-30b"
api_key_env = "OPENAI_API_KEY"
base_url = "http://localhost:8991/v1"
api_key = "sk-bf-test"
[memory]
decay_rate = 0.05
"#
}
fn lookup(toml_str: &str, key: &str) -> super::ConfigGetOutcome {
let table: toml::Value = toml::from_str(toml_str).expect("valid toml");
super::lookup_config_value(&table, key)
}
// Regression test for issue #905: `config get default_model.base_url`
// must return the configured base_url string, not an empty string.
#[test]
fn config_get_returns_default_model_base_url() {
let out = lookup(sample_config_with_base_url(), "default_model.base_url");
assert_eq!(
out,
super::ConfigGetOutcome::Value("http://localhost:8991/v1".to_string())
);
}
#[test]
fn config_get_returns_each_default_model_scalar() {
let cfg = sample_config_with_base_url();
assert_eq!(
lookup(cfg, "default_model.provider"),
super::ConfigGetOutcome::Value("openai".to_string())
);
assert_eq!(
lookup(cfg, "default_model.model"),
super::ConfigGetOutcome::Value("qwen3-coder-30b/qwen3-coder-30b".to_string())
);
assert_eq!(
lookup(cfg, "default_model.api_key_env"),
super::ConfigGetOutcome::Value("OPENAI_API_KEY".to_string())
);
assert_eq!(
lookup(cfg, "default_model.api_key"),
super::ConfigGetOutcome::Value("sk-bf-test".to_string())
);
}
#[test]
fn config_get_top_level_scalar() {
assert_eq!(
lookup(sample_config_with_base_url(), "api_listen"),
super::ConfigGetOutcome::Value("127.0.0.1:4200".to_string())
);
}
#[test]
fn config_get_unset_base_url_is_not_found() {
let cfg = r#"
[default_model]
provider = "openai"
model = "gpt-4o"
api_key_env = "OPENAI_API_KEY"
"#;
assert_eq!(
lookup(cfg, "default_model.base_url"),
super::ConfigGetOutcome::NotFound
);
}
#[test]
fn config_get_explicit_empty_string_round_trips_as_empty() {
let cfg = r#"
[default_model]
provider = "openai"
base_url = ""
"#;
assert_eq!(
lookup(cfg, "default_model.base_url"),
super::ConfigGetOutcome::Value(String::new())
);
}
#[test]
fn config_get_missing_key_returns_not_found() {
assert_eq!(
lookup(sample_config_with_base_url(), "default_model.nope"),
super::ConfigGetOutcome::NotFound
);
}
#[test]
fn config_get_section_reports_non_scalar() {
assert_eq!(
lookup(sample_config_with_base_url(), "default_model"),
super::ConfigGetOutcome::NonScalar
);
}
#[test]
fn config_get_numeric_and_boolean_scalars() {
let cfg = r#"
retries = 3
ratio = 0.25
enabled = true
"#;
assert_eq!(
lookup(cfg, "retries"),
super::ConfigGetOutcome::Value("3".to_string())
);
assert_eq!(
lookup(cfg, "ratio"),
super::ConfigGetOutcome::Value("0.25".to_string())
);
assert_eq!(
lookup(cfg, "enabled"),
super::ConfigGetOutcome::Value("true".to_string())
);
}
// --- Uninstall command unit tests ---
// --- hand config command unit tests ---
#[test]
fn test_hand_config_parse_pair_ok() {
let (k, v) = super::parse_hand_config_pair("headless=true").unwrap();
assert_eq!(k, "headless");
assert_eq!(v, "true");
}
#[test]
fn test_hand_config_parse_pair_value_may_contain_equals() {
let (k, v) = super::parse_hand_config_pair("url=https://example.com?a=b").unwrap();
assert_eq!(k, "url");
assert_eq!(v, "https://example.com?a=b");
}
#[test]
fn test_hand_config_parse_pair_value_may_be_empty() {
// Empty values are valid (useful to explicitly blank a setting before
// PUT). Empty keys are the failure case.
let (k, v) = super::parse_hand_config_pair("foo=").unwrap();
assert_eq!(k, "foo");
assert_eq!(v, "");
}
#[test]
fn test_hand_config_parse_pair_rejects_empty_key() {
assert!(super::parse_hand_config_pair("=bar").is_err());
assert!(super::parse_hand_config_pair(" =bar").is_err());
}
#[test]
fn test_hand_config_parse_pair_requires_equals() {
assert!(super::parse_hand_config_pair("headless").is_err());
}
#[test]
fn test_hand_config_parse_multiple_pairs_round_trip() {
let inputs = ["a=1", "b=two", "c=http://x.y"];
let mut map = std::collections::BTreeMap::new();
for pair in inputs {
let (k, v) = super::parse_hand_config_pair(pair).unwrap();
map.insert(k, v);
}
assert_eq!(map.get("a"), Some(&"1".to_string()));
assert_eq!(map.get("b"), Some(&"two".to_string()));
assert_eq!(map.get("c"), Some(&"http://x.y".to_string()));
}
#[test]
fn test_uninstall_path_line_filter() {
use super::is_openfang_path_line;
+96
View File
@@ -121,6 +121,11 @@ pub enum AppEvent {
SkillUninstalled(String),
/// MCP servers loaded.
McpServersLoaded(Vec<McpServerInfo>),
/// Skill config details loaded (installed skill `c` key).
SkillConfigLoaded {
skill: String,
rows: Vec<crate::tui::screens::skills::SkillConfigVarDetail>,
},
/// Templates providers loaded (auth status).
TemplateProvidersLoaded(Vec<ProviderAuth>),
/// Security features loaded.
@@ -1439,6 +1444,14 @@ pub fn spawn_fetch_skills(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
.as_str()
.unwrap_or("")
.to_string(),
config_declared: s["config_declared_count"]
.as_u64()
.unwrap_or(0)
as usize,
config_resolved: s["config_resolved_count"]
.as_u64()
.unwrap_or(0)
as usize,
})
.collect()
})
@@ -1604,6 +1617,89 @@ pub fn spawn_fetch_mcp_servers(backend: BackendRef, tx: mpsc::Sender<AppEvent>)
});
}
/// Fetch declared + resolved config for a specific installed skill.
///
/// Pulls `GET /api/skills/{id}/config` and flattens the response into the
/// `SkillConfigVarDetail` rows that the TUI details pane renders. Secret
/// values are already redacted by the daemon so nothing sensitive crosses
/// the wire here.
pub fn spawn_fetch_skill_config(
backend: BackendRef,
skill_name: String,
tx: mpsc::Sender<AppEvent>,
) {
use crate::tui::screens::skills::SkillConfigVarDetail;
std::thread::spawn(move || match backend {
BackendRef::Daemon(base_url) => {
let client = daemon_client();
let encoded: String = skill_name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' {
c.to_string()
} else {
format!("%{:02X}", c as u32)
}
})
.collect();
let url = format!("{base_url}/api/skills/{encoded}/config");
if let Ok(resp) = client.get(&url).send() {
if let Ok(body) = resp.json::<serde_json::Value>() {
let declared = body.get("declared").cloned().unwrap_or_default();
let resolved = body.get("resolved").cloned().unwrap_or_default();
let mut rows: Vec<SkillConfigVarDetail> = Vec::new();
if let Some(obj) = declared.as_object() {
// Sort keys for deterministic output.
let mut keys: Vec<&String> = obj.keys().collect();
keys.sort();
for k in keys {
let d = &obj[k];
let r = resolved.get(k).cloned().unwrap_or_default();
let value_hint = r
.get("value")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
rows.push(SkillConfigVarDetail {
name: k.clone(),
description: d
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
required: d
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(false),
source: r
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("unresolved")
.to_string(),
value_hint,
is_secret: r
.get("is_secret")
.and_then(|v| v.as_bool())
.unwrap_or(false),
});
}
}
let _ = tx.send(AppEvent::SkillConfigLoaded {
skill: skill_name,
rows,
});
}
}
}
BackendRef::InProcess(_) => {
let _ = tx.send(AppEvent::SkillConfigLoaded {
skill: skill_name,
rows: Vec::new(),
});
}
});
}
/// Fetch provider auth status for templates screen.
pub fn spawn_fetch_template_providers(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
std::thread::spawn(move || match backend {
+23 -17
View File
@@ -11,6 +11,7 @@ use event::{AppEvent, BackendRef};
use openfang_kernel::OpenFangKernel;
use openfang_runtime::llm_driver::StreamEvent;
use openfang_types::agent::AgentId;
use openfang_types::commands::{self, Surfaces};
use screens::{
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
@@ -434,6 +435,10 @@ impl App {
}
self.skills.loading = false;
}
AppEvent::SkillConfigLoaded { skill, rows } => {
self.skills.selected_config_details = Some((skill.clone(), rows));
self.skills.status_msg = format!("Loaded config for '{skill}'");
}
AppEvent::TemplateProvidersLoaded(providers) => {
self.templates.providers = providers;
}
@@ -1562,6 +1567,11 @@ impl App {
event::spawn_fetch_mcp_servers(backend, self.event_tx.clone());
}
}
skills::SkillsAction::LoadSkillConfig(name) => {
if let Some(backend) = self.backend.to_ref() {
event::spawn_fetch_skill_config(backend, name, self.event_tx.clone());
}
}
}
}
@@ -2003,23 +2013,18 @@ impl App {
fn handle_slash_command(&mut self, cmd: &str) {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
match parts[0] {
"/exit" | "/quit" => self.handle_chat_action(chat::ChatAction::Back),
// Canonicalise through the unified command registry: `/quit` -> `exit`,
// `/NEW` -> `new`, etc. Unregistered commands fall through unchanged so
// this refactor does not break any existing surface-specific behaviour.
let canonical_head: String = commands::resolve(parts[0])
.filter(|def| def.surfaces.contains(Surfaces::CLI))
.map(|def| format!("/{}", def.name))
.unwrap_or_else(|| parts[0].to_string());
match canonical_head.as_str() {
"/exit" => self.handle_chat_action(chat::ChatAction::Back),
"/help" => {
self.chat.push_message(
chat::Role::System,
[
"/help \u{2014} show this help",
"/model \u{2014} open model picker (Ctrl+M)",
"/model <name> \u{2014} switch to model directly",
"/status \u{2014} connection & agent info",
"/agents \u{2014} list running agents",
"/clear \u{2014} clear chat history",
"/kill \u{2014} kill the current agent",
"/exit \u{2014} end chat session",
]
.join("\n"),
);
self.chat
.push_message(chat::Role::System, commands::render_help(Surfaces::CLI));
}
"/status" => {
let mut s = Vec::new();
@@ -2185,9 +2190,10 @@ impl App {
}
},
_ => {
let help = commands::render_help(Surfaces::CLI);
self.chat.push_message(
chat::Role::System,
format!("Unknown command: {}. Type /help", parts[0]),
format!("Unknown command: {}\n\n{}", parts[0], help),
);
}
}
+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() {
@@ -148,6 +148,14 @@ const PROVIDERS: &[ProviderInfo] = &[
needs_key: true,
hint: "",
},
ProviderInfo {
name: "minimax",
display: "MiniMax",
env_var: "MINIMAX_API_KEY",
default_model: "MiniMax-M2.7",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "huggingface",
display: "Hugging Face",
@@ -258,6 +266,23 @@ pub enum InitResult {
Cancelled,
}
#[cfg(test)]
mod tests {
use super::PROVIDERS;
#[test]
fn init_wizard_lists_minimax_provider() {
let minimax = PROVIDERS.iter().find(|provider| provider.name == "minimax");
assert!(
minimax.is_some(),
"MiniMax should be selectable in openfang init"
);
let minimax = minimax.unwrap();
assert_eq!(minimax.env_var, "MINIMAX_API_KEY");
assert_eq!(minimax.default_model, "MiniMax-M2.7");
}
}
// ── Internal state ─────────────────────────────────────────────────────────
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -312,7 +337,10 @@ enum CopilotAuthStatus {
}
enum CopilotAuthEvent {
DeviceCode { user_code: String, verification_uri: String },
DeviceCode {
user_code: String,
verification_uri: String,
},
Authenticated,
Models(Vec<String>),
}
@@ -648,8 +676,7 @@ pub fn run() -> InitResult {
let (test_tx, test_rx) = std::sync::mpsc::channel::<bool>();
let (migrate_tx, migrate_rx) =
std::sync::mpsc::channel::<Result<openfang_migrate::report::MigrationReport, String>>();
let (copilot_tx, copilot_rx) =
std::sync::mpsc::channel::<Result<CopilotAuthEvent, String>>();
let (copilot_tx, copilot_rx) = std::sync::mpsc::channel::<Result<CopilotAuthEvent, String>>();
let result = loop {
terminal
@@ -660,7 +687,10 @@ pub fn run() -> InitResult {
if state.step == Step::CopilotAuth {
while let Ok(event) = copilot_rx.try_recv() {
match event {
Ok(CopilotAuthEvent::DeviceCode { user_code, verification_uri }) => {
Ok(CopilotAuthEvent::DeviceCode {
user_code,
verification_uri,
}) => {
state.copilot_user_code = user_code;
state.copilot_verification_uri = verification_uri;
state.copilot_auth_status = CopilotAuthStatus::WaitingForUser;
@@ -839,7 +869,8 @@ pub fn run() -> InitResult {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
let _ = copilot_tx.send(Err(format!("Runtime error: {e}")));
let _ = copilot_tx
.send(Err(format!("Runtime error: {e}")));
return;
}
};
@@ -851,21 +882,31 @@ pub fn run() -> InitResult {
.map_err(|e| format!("HTTP error: {e}"));
let http = match http {
Ok(h) => h,
Err(e) => { let _ = copilot_tx.send(Err(e)); return; }
Err(e) => {
let _ = copilot_tx.send(Err(e));
return;
}
};
// Step 1: request device code
use openfang_runtime::drivers::copilot;
let device = match copilot::request_device_code(&http).await {
Ok(d) => d,
Err(e) => { let _ = copilot_tx.send(Err(e)); return; }
};
let device =
match copilot::request_device_code(&http).await {
Ok(d) => d,
Err(e) => {
let _ = copilot_tx.send(Err(e));
return;
}
};
// Send device code to TUI for display
let _ = copilot_tx.send(Ok(CopilotAuthEvent::DeviceCode {
user_code: device.user_code.clone(),
verification_uri: device.verification_uri.clone(),
}));
let _ =
copilot_tx.send(Ok(CopilotAuthEvent::DeviceCode {
user_code: device.user_code.clone(),
verification_uri: device
.verification_uri
.clone(),
}));
// Browser will be opened by user pressing Enter in TUI
@@ -874,9 +915,14 @@ pub fn run() -> InitResult {
&http,
&device.device_code,
device.interval,
).await {
)
.await
{
Ok(t) => t,
Err(e) => { let _ = copilot_tx.send(Err(e)); return; }
Err(e) => {
let _ = copilot_tx.send(Err(e));
return;
}
};
// Save tokens
@@ -885,16 +931,38 @@ pub fn run() -> InitResult {
return;
}
let _ = copilot_tx.send(Ok(CopilotAuthEvent::Authenticated));
let _ = copilot_tx
.send(Ok(CopilotAuthEvent::Authenticated));
// Step 3: fetch models
let ct = match copilot::exchange_copilot_token(&http, &tokens.access_token).await {
let ct = match copilot::exchange_copilot_token(
&http,
&tokens.access_token,
)
.await
{
Ok(ct) => ct,
Err(e) => { let _ = copilot_tx.send(Err(format!("Token exchange: {e}"))); return; }
Err(e) => {
let _ = copilot_tx
.send(Err(format!("Token exchange: {e}")));
return;
}
};
match copilot::fetch_models(&http, &ct.base_url, &ct.token).await {
Ok(models) => { let _ = copilot_tx.send(Ok(CopilotAuthEvent::Models(models))); }
Err(e) => { let _ = copilot_tx.send(Err(format!("Model fetch: {e}"))); }
match copilot::fetch_models(
&http,
&ct.base_url,
&ct.token,
)
.await
{
Ok(models) => {
let _ = copilot_tx
.send(Ok(CopilotAuthEvent::Models(models)));
}
Err(e) => {
let _ = copilot_tx
.send(Err(format!("Model fetch: {e}")));
}
}
});
});
@@ -923,14 +991,15 @@ pub fn run() -> InitResult {
state.step = Step::Provider;
}
}
KeyCode::Enter => {
if matches!(state.copilot_auth_status, CopilotAuthStatus::WaitingForUser) {
if !state.copilot_verification_uri.is_empty() {
let _ = openfang_runtime::drivers::copilot::open_verification_url(
&state.copilot_verification_uri,
);
}
}
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,
);
}
_ => {}
},
@@ -945,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();
}
_ => {}
}
@@ -1956,14 +2020,15 @@ fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
Constraint::Length(1), // code value
Constraint::Length(1), // blank
Constraint::Length(1), // url
Constraint::Min(0), // spacer
Constraint::Min(0), // spacer
Constraint::Length(1), // hint
])
.split(area);
let title = Paragraph::new(Line::from(vec![
Span::styled(" GitHub Copilot Authentication", Style::default().fg(theme::ACCENT)),
]));
let title = Paragraph::new(Line::from(vec![Span::styled(
" GitHub Copilot Authentication",
Style::default().fg(theme::ACCENT),
)]));
f.render_widget(title, chunks[0]);
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
@@ -1985,9 +2050,7 @@ fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
]));
f.render_widget(line1, chunks[2]);
let code_label = Paragraph::new(Line::from(vec![
Span::raw(" Enter this code:"),
]));
let code_label = Paragraph::new(Line::from(vec![Span::raw(" Enter this code:")]));
f.render_widget(code_label, chunks[5]);
let code_value = Paragraph::new(Line::from(vec![
@@ -2007,9 +2070,10 @@ fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
]));
f.render_widget(url, chunks[8]);
let hint = Paragraph::new(Line::from(vec![
Span::styled(" [Enter] Open browser", theme::dim_style()),
]));
let hint = Paragraph::new(Line::from(vec![Span::styled(
" [Enter] Open browser",
theme::dim_style(),
)]));
f.render_widget(hint, chunks[10]);
}
CopilotAuthStatus::FetchingModels => {
@@ -2040,9 +2104,10 @@ fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
]));
f.render_widget(line, chunks[2]);
let hint = Paragraph::new(Line::from(vec![
Span::styled(" Esc to go back", theme::dim_style()),
]));
let hint = Paragraph::new(Line::from(vec![Span::styled(
" Esc to go back",
theme::dim_style(),
)]));
f.render_widget(hint, chunks[10]);
}
}
+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,
_ => {}
+181 -55
View File
@@ -16,6 +16,23 @@ pub struct SkillInfo {
pub runtime: String,
pub source: String,
pub description: String,
/// Number of config vars the skill's SKILL.md declares. Zero = no badge.
pub config_declared: usize,
/// Number of declared vars that are currently resolved (user override,
/// env, or default). When `< config_declared` the badge shows a warning.
pub config_resolved: usize,
}
/// Declared + resolved config for a single variable, shown on the Skills
/// detail pane when a skill with a non-empty `config:` section is selected.
#[derive(Clone, Default)]
pub struct SkillConfigVarDetail {
pub name: String,
pub description: String,
pub required: bool,
pub source: String, // "user" | "env" | "default" | "unresolved"
pub value_hint: String,
pub is_secret: bool,
}
#[derive(Clone, Default)]
@@ -82,6 +99,9 @@ pub struct SkillsState {
pub tick: usize,
pub confirm_uninstall: bool,
pub status_msg: String,
/// Config variable details for the currently selected installed skill.
/// Keyed by skill name so refreshing the list doesn't strand stale data.
pub selected_config_details: Option<(String, Vec<SkillConfigVarDetail>)>,
}
pub enum SkillsAction {
@@ -92,6 +112,10 @@ pub enum SkillsAction {
InstallSkill(String),
UninstallSkill(String),
RefreshMcp,
/// Fetch declared + resolved config for the named skill and display it
/// in the details pane. Consumed by the caller which drives the API
/// request.
LoadSkillConfig(String),
}
impl SkillsState {
@@ -111,6 +135,7 @@ impl SkillsState {
tick: 0,
confirm_uninstall: false,
status_msg: String::new(),
selected_config_details: None,
}
}
@@ -167,23 +192,30 @@ 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() {
if sel < self.installed.len() {
let name = self.installed[sel].name.clone();
// Only skills that declare config are worth fetching.
if self.installed[sel].config_declared > 0 {
return SkillsAction::LoadSkillConfig(name);
} else {
self.status_msg = format!("'{}' declares no runtime config.", name);
}
}
}
}
KeyCode::Char('r') => return SkillsAction::RefreshInstalled,
@@ -217,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() {
@@ -257,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,
_ => {}
@@ -336,9 +360,11 @@ fn draw_sub_tabs(f: &mut Frame, area: Rect, active: SkillsSub) {
}
fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
let chunks = Layout::vertical([
// Two panes: list on the left, config details on the right, with a
// single-line hint bar underneath.
let outer = Layout::vertical([
Constraint::Length(1), // header
Constraint::Min(3), // list
Constraint::Min(3), // list + details
Constraint::Length(1), // hints
])
.split(area);
@@ -346,14 +372,26 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
format!(
" {:<20} {:<8} {:<12} {}",
"Name", "Runtime", "Source", "Description"
" {:<18} {:<7} {:<10} {:<11} {}",
"Name", "Runtime", "Source", "Config", "Description"
),
theme::table_header(),
)])),
chunks[0],
outer[0],
);
// Only show the details pane when there's room and a selection with
// declared config exists. Below ~80 cols we collapse to list-only.
let has_details = state.selected_config_details.is_some();
let body_chunks: Vec<Rect> = if has_details && outer[1].width >= 70 {
Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(outer[1])
.to_vec()
} else {
vec![outer[1]]
};
// Skills list (left pane or full width)
if state.loading {
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
f.render_widget(
@@ -361,7 +399,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
Span::styled("Loading skills\u{2026}", theme::dim_style()),
])),
chunks[1],
body_chunks[0],
);
} else if state.installed.is_empty() {
f.render_widget(
@@ -369,7 +407,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
" No skills installed. Press [2] to browse ClawHub.",
theme::dim_style(),
)),
chunks[1],
body_chunks[0],
);
} else {
let items: Vec<ListItem> = state
@@ -394,15 +432,29 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
"builtin" | "built-in" => Style::default().fg(theme::GREEN),
_ => theme::dim_style(),
};
let (config_text, config_style) = if s.config_declared == 0 {
(String::from("-"), theme::dim_style())
} else if s.config_resolved >= s.config_declared {
(
format!("[{}/{}]", s.config_resolved, s.config_declared),
Style::default().fg(theme::GREEN),
)
} else {
(
format!("[{}/{} \u{26A0}]", s.config_resolved, s.config_declared),
Style::default().fg(theme::YELLOW),
)
};
ListItem::new(Line::from(vec![
Span::styled(
format!(" {:<20}", truncate(&s.name, 19)),
format!(" {:<18}", truncate(&s.name, 17)),
Style::default().fg(theme::CYAN),
),
Span::styled(format!(" {:<8}", runtime_badge), runtime_style),
Span::styled(format!(" {:<12}", &s.source), source_style),
Span::styled(format!(" {:<7}", runtime_badge), runtime_style),
Span::styled(format!(" {:<10}", &s.source), source_style),
Span::styled(format!(" {:<11}", config_text), config_style),
Span::styled(
format!(" {}", truncate(&s.description, 30)),
format!(" {}", truncate(&s.description, 24)),
theme::dim_style(),
),
]))
@@ -412,7 +464,12 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
let list = List::new(items)
.highlight_style(theme::selected_style())
.highlight_symbol("> ");
f.render_stateful_widget(list, chunks[1], &mut state.installed_list);
f.render_stateful_widget(list, body_chunks[0], &mut state.installed_list);
}
// Config details pane (right side)
if has_details && body_chunks.len() > 1 {
draw_skill_config_details(f, body_chunks[1], state);
}
if state.confirm_uninstall {
@@ -421,7 +478,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
" Uninstall this skill? [y] Yes [any] Cancel",
Style::default().fg(theme::YELLOW),
)])),
chunks[2],
outer[2],
);
} else if !state.status_msg.is_empty() {
f.render_widget(
@@ -429,19 +486,88 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
format!(" {}", state.status_msg),
Style::default().fg(theme::GREEN),
)])),
chunks[2],
outer[2],
);
} else {
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" [\u{2191}\u{2193}] Navigate [u] Uninstall [r] Refresh",
" [\u{2191}\u{2193}] Navigate [c] View config [u] Uninstall [r] Refresh",
theme::hint_style(),
)])),
chunks[2],
outer[2],
);
}
}
fn draw_skill_config_details(f: &mut Frame, area: Rect, state: &SkillsState) {
let Some((name, rows)) = state.selected_config_details.as_ref() else {
return;
};
let block = Block::default()
.title(Line::from(vec![Span::styled(
format!(" Config: {name} "),
Style::default().fg(theme::ACCENT),
)]))
.borders(Borders::LEFT)
.border_style(theme::dim_style())
.padding(Padding::horizontal(1));
let inner = block.inner(area);
f.render_widget(block, area);
if rows.is_empty() {
f.render_widget(
Paragraph::new(Span::styled("No config declared.", theme::dim_style())),
inner,
);
return;
}
let mut lines: Vec<Line> = Vec::new();
for row in rows {
let src_style = match row.source.as_str() {
"user" => Style::default().fg(theme::GREEN),
"env" => Style::default().fg(theme::CYAN),
"default" => theme::dim_style(),
_ => Style::default().fg(theme::RED),
};
let required_marker = if row.required { "*" } else { " " };
let mut header = vec![
Span::styled(
format!("{required_marker} {}", truncate(&row.name, 22)),
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(format!("[{}]", row.source), src_style),
];
if row.is_secret {
header.push(Span::raw(" "));
header.push(Span::styled(
"(secret)",
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::ITALIC),
));
}
lines.push(Line::from(header));
if !row.value_hint.is_empty() {
lines.push(Line::from(vec![Span::styled(
format!(" = {}", truncate(&row.value_hint, 32)),
theme::dim_style(),
)]));
}
if !row.description.is_empty() {
lines.push(Line::from(vec![Span::styled(
format!(" {}", truncate(&row.description, 36)),
theme::dim_style(),
)]));
}
lines.push(Line::from(vec![Span::raw("")]));
}
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_clawhub(f: &mut Frame, area: Rect, state: &mut SkillsState) {
let chunks = Layout::vertical([
Constraint::Length(1), // search / sort
@@ -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,
_ => {}
+27 -6
View File
@@ -85,6 +85,12 @@ const PROVIDERS: &[ProviderInfo] = &[
default_model: "qwen-plus",
needs_key: true,
},
ProviderInfo {
name: "minimax",
env_var: "MINIMAX_API_KEY",
default_model: "MiniMax-M2.7",
needs_key: true,
},
ProviderInfo {
name: "perplexity",
env_var: "PERPLEXITY_API_KEY",
@@ -322,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);
@@ -689,3 +693,20 @@ fn draw_done(f: &mut Frame, area: Rect, state: &WizardState) {
f.render_widget(cont, chunks[1]);
}
}
#[cfg(test)]
mod tests {
use super::PROVIDERS;
#[test]
fn wizard_lists_minimax_provider() {
let minimax = PROVIDERS.iter().find(|provider| provider.name == "minimax");
assert!(
minimax.is_some(),
"MiniMax should be selectable in wizard provider list"
);
let minimax = minimax.unwrap();
assert_eq!(minimax.env_var, "MINIMAX_API_KEY");
assert_eq!(minimax.default_model, "MiniMax-M2.7");
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenFang",
"version": "0.5.9",
"version": "0.6.5",
"identifier": "ai.openfang.desktop",
"build": {},
"app": {
+56
View File
@@ -1144,4 +1144,60 @@ metrics = []
let err = reg.activate("test-hand", HashMap::new(), None).unwrap_err();
assert!(matches!(err, HandError::AlreadyActive(_)));
}
/// Integration test for issue #809: `hand config` round-trip.
///
/// Simulates what `openfang hand config <id> --set KEY=VAL` does against
/// the registry: read current config, merge updates, write back, read
/// again. Persists to a tempdir so the restart path also sees the change.
#[test]
fn test_hand_config_round_trip_via_registry() {
let reg = test_registry_with_dummy_hand("browser");
let inst = reg.activate("browser", HashMap::new(), None).unwrap();
// Read-modify-write cycle mirroring the CLI's --set behavior.
let mut cfg = reg.get_instance(inst.instance_id).unwrap().config;
cfg.insert(
"headless".to_string(),
serde_json::Value::String("true".into()),
);
cfg.insert(
"user_agent".to_string(),
serde_json::Value::String("openfang/1".into()),
);
reg.update_config(inst.instance_id, cfg.clone()).unwrap();
let after = reg.get_instance(inst.instance_id).unwrap();
assert_eq!(
after.config.get("headless"),
Some(&serde_json::Value::String("true".into()))
);
assert_eq!(
after.config.get("user_agent"),
Some(&serde_json::Value::String("openfang/1".into()))
);
// --unset path: drop a key and confirm it's gone.
cfg.remove("user_agent");
reg.update_config(inst.instance_id, cfg).unwrap();
let after_unset = reg.get_instance(inst.instance_id).unwrap();
assert!(!after_unset.config.contains_key("user_agent"));
assert_eq!(
after_unset.config.get("headless"),
Some(&serde_json::Value::String("true".into()))
);
// State survives a persist+load round-trip through a tempdir sidecar.
let tmp = tempfile::tempdir().unwrap();
let state_file = tmp.path().join("hands.json");
reg.persist_state(&state_file).unwrap();
let reloaded = HandRegistry::load_state(&state_file);
assert_eq!(reloaded.len(), 1);
let (hand_id, config, _agent_id) = &reloaded[0];
assert_eq!(hand_id, "browser");
assert_eq!(
config.get("headless"),
Some(&serde_json::Value::String("true".into()))
);
}
}
+315
View File
@@ -69,12 +69,26 @@ pub fn load_config(path: Option<&Path>) -> KernelConfig {
}
}
// GAP-012 (Tier 1): pre-validate the [[bindings]] array so a
// single malformed entry doesn't poison the whole config and
// force a fall-back to defaults (which would silently unbind
// every agent). Bad entries are logged at ERROR and dropped;
// survivors are passed through to typed deserialization.
lenient_extract_bindings(&mut root_value);
match root_value.try_into::<KernelConfig>() {
Ok(config) => {
info!(path = %config_path.display(), "Loaded configuration");
return config;
}
Err(e) => {
// TODO(GAP-012-Tier-2): this fallback still silently
// swaps the user's intent for `KernelConfig::default()`
// on any non-binding deserialization failure. Tier 1
// closes the binding-shape footgun; Tier 2 should
// surface remaining failures via a health endpoint
// and/or stderr banner so the silent-default path
// can't hide a broken config.
tracing::warn!(
error = %e,
path = %config_path.display(),
@@ -242,6 +256,89 @@ pub fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
}
}
/// Lenient pre-pass over the `[[bindings]]` array (GAP-012 Tier 1).
///
/// Strict whole-config deserialization is fragile: any one malformed binding
/// (e.g. a typo'd field that trips `deny_unknown_fields`) causes
/// `try_into::<KernelConfig>()` to fail, which the caller then handles by
/// falling back to `KernelConfig::default()` — silently unbinding *every*
/// agent. That's the worst possible failure mode for a routing config: the
/// user's intent is silently discarded, with only a single line in the logs.
///
/// This pass runs *before* typed deserialization. It walks the bindings
/// array entry-by-entry, attempts to deserialize each into `AgentBinding`,
/// logs malformed entries at ERROR with index + agent name + serde error,
/// and replaces the array with the survivors. The downstream
/// `try_into::<KernelConfig>()` then sees a clean array and succeeds.
///
/// `deny_unknown_fields` on `AgentBinding`/`BindingMatchRule` still applies
/// per-entry — typos in surviving bindings would still produce errors here
/// and be dropped. The strict-field guarantee is preserved at the entry
/// level; only the all-or-nothing behavior is relaxed.
///
/// No-op if `root_value` is not a table or has no `bindings` array.
fn lenient_extract_bindings(root_value: &mut toml::Value) {
use openfang_types::config::AgentBinding;
let tbl = match root_value {
toml::Value::Table(t) => t,
_ => return,
};
// Replace the array in place if (and only if) `bindings` is present
// and is an array. Anything else (missing, wrong type) we leave alone
// so the typed deserializer can produce its own targeted error.
let original = match tbl.get("bindings") {
Some(toml::Value::Array(arr)) => arr.clone(),
_ => return,
};
let mut survivors: Vec<toml::Value> = Vec::with_capacity(original.len());
let mut dropped = 0usize;
for (idx, entry) in original.into_iter().enumerate() {
match entry.clone().try_into::<AgentBinding>() {
Ok(_) => survivors.push(entry),
Err(e) => {
dropped += 1;
// Lazy: only allocate the agent-name fallback string when we
// actually need it for an error log. The happy path skips this.
let agent_name = entry
.get("agent")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>")
.to_string();
tracing::error!(
binding_index = idx,
agent = %agent_name,
error = %e,
"Skipping malformed binding #{} (agent='{}'): {}. \
Other bindings will continue to load. \
Fix the entry and reload to restore routing.",
idx,
agent_name,
e
);
}
}
}
if dropped > 0 {
// Per-entry ERRORs above carry the root cause; this summary is a
// grep-friendly one-liner, so WARN keeps ERROR == per-binding cause.
tracing::warn!(
dropped,
survivors = survivors.len(),
"Dropped {} malformed binding(s); {} binding(s) will load. \
See preceding ERROR lines for per-binding details.",
dropped,
survivors.len()
);
}
tbl.insert("bindings".to_string(), toml::Value::Array(survivors));
}
/// Get the default config file path.
///
/// Respects `OPENFANG_HOME` env var (e.g. `OPENFANG_HOME=/opt/openfang`).
@@ -442,6 +539,224 @@ mod tests {
assert_eq!(config.log_level, "info"); // defaults
}
// ─── GAP-012 Tier 1: lenient bindings extraction ───────────────────
#[test]
fn test_lenient_bindings_drops_typo_keeps_rest() {
// Two bindings; the first has a typo'd field (`channnel_id`) that
// `BindingMatchRule`'s `deny_unknown_fields` would reject. The second
// is well-formed. Pre-fix behavior: whole config falls back to
// defaults (zero bindings). Post-fix: bad one dropped, good one loads.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
log_level = "info"
[[bindings]]
agent = "researcher-broken"
match_rule = {{ channel = "discord", channnel_id = "123" }}
[[bindings]]
agent = "researcher-good"
match_rule = {{ channel = "discord", channel_id = "456" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert_eq!(
config.bindings.len(),
1,
"expected exactly the well-formed binding to survive"
);
assert_eq!(config.bindings[0].agent, "researcher-good");
assert_eq!(
config.bindings[0].match_rule.channel_id.as_deref(),
Some("456")
);
}
#[test]
fn test_lenient_bindings_all_valid_unchanged() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
log_level = "info"
[[bindings]]
agent = "a"
match_rule = {{ channel = "discord", channel_id = "1" }}
[[bindings]]
agent = "b"
match_rule = {{ channel = "telegram", channel_id = "2" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert_eq!(config.bindings.len(), 2);
assert_eq!(config.bindings[0].agent, "a");
assert_eq!(config.bindings[1].agent, "b");
}
#[test]
fn test_lenient_bindings_all_malformed_yields_empty_but_keeps_rest_of_config() {
// Every binding is broken, but the rest of the config (log_level,
// api_listen) must still load. Pre-fix: total fallback to defaults.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
log_level = "trace"
api_listen = "127.0.0.1:9999"
[[bindings]]
agent = "broken-1"
match_rule = {{ channnel_id = "1" }}
[[bindings]]
agent = "broken-2"
match_rule = {{ peer_idd = "u" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert!(config.bindings.is_empty(), "all bindings should be dropped");
assert_eq!(
config.log_level, "trace",
"non-binding config must still load"
);
assert_eq!(config.api_listen, "127.0.0.1:9999");
}
#[test]
fn test_lenient_bindings_no_bindings_section_is_noop() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "log_level = \"info\"").unwrap();
drop(f);
let config = load_config(Some(&path));
assert!(config.bindings.is_empty());
assert_eq!(config.log_level, "info");
}
#[test]
fn test_lenient_bindings_missing_agent_field_dropped() {
// A binding missing the required `agent` field can't deserialize at
// all; it should be dropped (logged as agent='<unknown>') and the
// good one should still load.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
[[bindings]]
match_rule = {{ channel = "discord" }}
[[bindings]]
agent = "good"
match_rule = {{ channel = "discord", channel_id = "1" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert_eq!(config.bindings.len(), 1);
assert_eq!(config.bindings[0].agent, "good");
}
#[test]
fn test_lenient_bindings_preserves_survivor_order() {
// Three bindings with the *middle* one malformed. Survivors must
// retain their original relative order (1st, 3rd) — match-rule
// routing can be order-sensitive (first-match-wins), so silently
// reshuffling on a drop would be a subtle regression.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
[[bindings]]
agent = "first"
match_rule = {{ channel = "discord", channel_id = "1" }}
[[bindings]]
agent = "middle-broken"
match_rule = {{ channnel_id = "2" }}
[[bindings]]
agent = "third"
match_rule = {{ channel = "telegram", channel_id = "3" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert_eq!(config.bindings.len(), 2, "middle binding should be dropped");
assert_eq!(
config.bindings[0].agent, "first",
"first survivor must remain first"
);
assert_eq!(
config.bindings[1].agent, "third",
"third must remain after first (order preserved)"
);
}
#[test]
fn test_lenient_bindings_top_level_field_typo_dropped() {
// Operator typos `agnt` instead of `agent` on the binding itself
// (not inside `match_rule`). `AgentBinding`'s `deny_unknown_fields`
// should reject the entry, the lenient pass should drop it, and
// the well-formed sibling should still load. This is the more
// common operator mistake than missing-field-entirely, so we lock
// the behavior in explicitly.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
[[bindings]]
agnt = "typo-at-top-level"
match_rule = {{ channel = "discord", channel_id = "1" }}
[[bindings]]
agent = "good"
match_rule = {{ channel = "discord", channel_id = "2" }}
"#
)
.unwrap();
drop(f);
let config = load_config(Some(&path));
assert_eq!(
config.bindings.len(),
1,
"binding with top-level field typo should be dropped"
);
assert_eq!(config.bindings[0].agent, "good");
}
#[test]
fn test_no_includes_works() {
let dir = tempfile::tempdir().unwrap();
@@ -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
// -----------------------------------------------------------------------
+20
View File
@@ -198,6 +198,25 @@ impl CronScheduler {
}
}
/// Replace the multi-destination delivery targets on an existing job.
///
/// The schedule, action, and primary `delivery` field are left untouched;
/// only the `delivery_targets` fan-out list is swapped in. Call
/// [`persist`] afterwards to write the change to disk.
pub fn set_delivery_targets(
&self,
id: CronJobId,
targets: Vec<openfang_types::scheduler::CronDeliveryTarget>,
) -> OpenFangResult<()> {
match self.jobs.get_mut(&id) {
Some(mut meta) => {
meta.job.delivery_targets = targets;
Ok(())
}
None => Err(OpenFangError::Internal(format!("Cron job {id} not found"))),
}
}
// -- Queries ------------------------------------------------------------
/// Get a single job by ID.
@@ -504,6 +523,7 @@ mod tests {
text: "ping".into(),
},
delivery: CronDelivery::None,
delivery_targets: Vec::new(),
created_at: Utc::now(),
last_run: None,
next_run: None,
+739
View File
@@ -0,0 +1,739 @@
//! Multi-destination cron output delivery.
//!
//! A single [`CronJob`] may declare zero or more [`CronDeliveryTarget`]s on
//! its `delivery_targets` field. After the job fires and produces output,
//! the [`CronDeliveryEngine`] fans out the same payload to every target
//! concurrently. Failures in one target do not abort delivery to the
//! others — every target's outcome is returned in a [`DeliveryResult`].
//!
//! This is the OpenFang port of the Hermes Agent multi-destination cron
//! pattern: one job → N destinations (channels / webhooks / files / email).
use futures::future::join_all;
use openfang_channels::bridge::ChannelBridgeHandle;
use openfang_types::scheduler::CronDeliveryTarget;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, warn};
/// Webhook HTTP timeout. Matches the legacy single-target cron webhook.
const WEBHOOK_TIMEOUT_SECS: u64 = 30;
/// Per-target delivery outcome returned by [`CronDeliveryEngine::deliver`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeliveryResult {
/// Human-readable target description (`"channel:telegram -> chat_123"`,
/// `"webhook:https://..."`, `"file:/tmp/out.log"`, `"email:alice@x"`).
pub target: String,
/// Whether delivery succeeded.
pub success: bool,
/// Error message if `success` is `false`.
pub error: Option<String>,
}
impl DeliveryResult {
fn ok(target: String) -> Self {
Self {
target,
success: true,
error: None,
}
}
fn err(target: String, msg: String) -> Self {
Self {
target,
success: false,
error: Some(msg),
}
}
}
/// Fan-out delivery engine for cron job output.
///
/// Holds a reference to the channel bridge (for adapter-based delivery) and
/// a shared HTTP client (for webhook delivery). Constructed once per kernel
/// and reused across every cron firing.
pub struct CronDeliveryEngine {
/// Bridge used to invoke `send_channel_message` on registered adapters.
channel_bridge: Arc<dyn ChannelBridgeHandle>,
/// Shared HTTP client for webhook delivery.
http: reqwest::Client,
}
impl CronDeliveryEngine {
/// Build a new engine using the given channel bridge and a fresh
/// `reqwest::Client`. Falls back to the default client if the builder
/// fails (which effectively never happens on supported platforms).
pub fn new(channel_bridge: Arc<dyn ChannelBridgeHandle>) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(WEBHOOK_TIMEOUT_SECS))
.build()
.unwrap_or_default();
Self {
channel_bridge,
http,
}
}
/// Build a new engine with an explicit HTTP client — used by tests.
pub fn with_http_client(
channel_bridge: Arc<dyn ChannelBridgeHandle>,
http: reqwest::Client,
) -> Self {
Self {
channel_bridge,
http,
}
}
/// Deliver `output` to every target concurrently.
///
/// Returns a `Vec<DeliveryResult>` with one entry per target in the same
/// order as the input slice. One target failing does not short-circuit
/// the others — the job already succeeded, delivery is best-effort.
pub async fn deliver(
&self,
targets: &[CronDeliveryTarget],
job_name: &str,
output: &str,
) -> Vec<DeliveryResult> {
if targets.is_empty() {
return Vec::new();
}
let futures = targets
.iter()
.map(|t| self.deliver_one(t, job_name, output));
join_all(futures).await
}
/// Deliver to a single target. Never panics.
async fn deliver_one(
&self,
target: &CronDeliveryTarget,
job_name: &str,
output: &str,
) -> DeliveryResult {
match target {
CronDeliveryTarget::Channel {
channel_type,
recipient,
} => {
let desc = format!("channel:{channel_type} -> {recipient}");
match self
.channel_bridge
.send_channel_message(channel_type, recipient, output)
.await
{
Ok(()) => {
debug!(target = %desc, "Cron fan-out: channel delivery ok");
DeliveryResult::ok(desc)
}
Err(e) => {
warn!(target = %desc, error = %e, "Cron fan-out: channel delivery failed");
DeliveryResult::err(desc, e)
}
}
}
CronDeliveryTarget::Webhook { url, auth_header } => {
let desc = format!("webhook:{url}");
match deliver_webhook(&self.http, url, auth_header.as_deref(), job_name, output)
.await
{
Ok(()) => {
debug!(target = %desc, "Cron fan-out: webhook delivery ok");
DeliveryResult::ok(desc)
}
Err(e) => {
warn!(target = %desc, error = %e, "Cron fan-out: webhook delivery failed");
DeliveryResult::err(desc, e)
}
}
}
CronDeliveryTarget::LocalFile { path, append } => {
let desc = format!("file:{path}");
match deliver_local_file(Path::new(path), *append, output).await {
Ok(()) => {
debug!(target = %desc, "Cron fan-out: file write ok");
DeliveryResult::ok(desc)
}
Err(e) => {
warn!(target = %desc, error = %e, "Cron fan-out: file write failed");
DeliveryResult::err(desc, e)
}
}
}
CronDeliveryTarget::Email {
to,
subject_template,
} => {
let desc = format!("email:{to}");
let subject = render_subject(subject_template.as_deref(), job_name);
// The existing email channel adapter sends via SMTP and does
// not expose a subject/to pair on the trait, so we route a
// formatted message through it. Most adapters treat the
// recipient as a destination identifier; the email adapter
// uses it as the RCPT TO address.
let body = format!("{subject}\n\n{output}");
match self
.channel_bridge
.send_channel_message("email", to, &body)
.await
{
Ok(()) => {
debug!(target = %desc, "Cron fan-out: email delivery ok");
DeliveryResult::ok(desc)
}
Err(e) => {
warn!(target = %desc, error = %e, "Cron fan-out: email delivery failed");
DeliveryResult::err(desc, e)
}
}
}
}
}
}
/// Render an email subject from an optional template. `{job}` is the only
/// supported placeholder; everything else passes through unchanged.
fn render_subject(template: Option<&str>, job_name: &str) -> String {
match template {
Some(t) if !t.is_empty() => t.replace("{job}", job_name),
_ => format!("Cron: {job_name}"),
}
}
/// POST a JSON payload `{ job, output, timestamp }` to `url` and optionally
/// attach an `Authorization` header. Returns `Err(msg)` on non-2xx or
/// network failure.
async fn deliver_webhook(
http: &reqwest::Client,
url: &str,
auth_header: Option<&str>,
job_name: &str,
output: &str,
) -> Result<(), String> {
let payload = serde_json::json!({
"job": job_name,
"output": output,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let mut req = http.post(url).json(&payload);
if let Some(auth) = auth_header {
req = req.header("Authorization", auth);
}
let resp = req
.send()
.await
.map_err(|e| format!("webhook send failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
return Err(format!("webhook returned HTTP {status}"));
}
Ok(())
}
/// Append or overwrite `output` at `path`. Creates parent directories when
/// missing. Returns `Err(msg)` on any I/O failure.
async fn deliver_local_file(path: &Path, append: bool, output: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("create parent dir failed: {e}"))?;
}
}
if append {
use tokio::io::AsyncWriteExt;
let mut f = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await
.map_err(|e| format!("open failed: {e}"))?;
f.write_all(output.as_bytes())
.await
.map_err(|e| format!("write failed: {e}"))?;
// Newline separator between runs makes tailing nicer.
f.write_all(b"\n")
.await
.map_err(|e| format!("write newline failed: {e}"))?;
} else {
tokio::fs::write(path, output.as_bytes())
.await
.map_err(|e| format!("write failed: {e}"))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use openfang_channels::bridge::ChannelBridgeHandle;
use openfang_types::agent::AgentId;
use std::sync::Mutex;
/// Mock bridge that records every channel send. Optionally fails for
/// specific channel names.
struct MockBridge {
calls: Mutex<Vec<(String, String, String)>>,
fail_on_channel: Option<String>,
}
impl MockBridge {
fn new() -> Arc<Self> {
Arc::new(Self {
calls: Mutex::new(Vec::new()),
fail_on_channel: None,
})
}
fn failing_on(channel: &str) -> Arc<Self> {
Arc::new(Self {
calls: Mutex::new(Vec::new()),
fail_on_channel: Some(channel.to_string()),
})
}
fn calls(&self) -> Vec<(String, String, String)> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait]
impl ChannelBridgeHandle for MockBridge {
async fn send_message(&self, _: AgentId, _: &str) -> Result<String, String> {
Ok(String::new())
}
async fn find_agent_by_name(&self, _: &str) -> Result<Option<AgentId>, String> {
Ok(None)
}
async fn list_agents(&self) -> Result<Vec<(AgentId, String)>, String> {
Ok(Vec::new())
}
async fn spawn_agent_by_name(&self, _: &str) -> Result<AgentId, String> {
Err("not implemented".into())
}
async fn send_channel_message(
&self,
channel_type: &str,
recipient: &str,
message: &str,
) -> Result<(), String> {
self.calls.lock().unwrap().push((
channel_type.to_string(),
recipient.to_string(),
message.to_string(),
));
if let Some(ref failing) = self.fail_on_channel {
if failing == channel_type {
return Err(format!("mock: forced failure on '{channel_type}'"));
}
}
Ok(())
}
}
fn test_engine(bridge: Arc<MockBridge>) -> CronDeliveryEngine {
CronDeliveryEngine::new(bridge)
}
// -- LocalFile: overwrite ------------------------------------------------
#[tokio::test]
async fn localfile_overwrite_creates_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("out.txt");
let target = CronDeliveryTarget::LocalFile {
path: path.to_string_lossy().to_string(),
append: false,
};
let engine = test_engine(MockBridge::new());
let results = engine.deliver(&[target], "job-x", "hello world").await;
assert_eq!(results.len(), 1);
assert!(results[0].success, "error: {:?}", results[0].error);
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "hello world");
}
#[tokio::test]
async fn localfile_overwrite_replaces_existing() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("replace.txt");
std::fs::write(&path, "OLD CONTENT").unwrap();
let target = CronDeliveryTarget::LocalFile {
path: path.to_string_lossy().to_string(),
append: false,
};
let engine = test_engine(MockBridge::new());
let results = engine.deliver(&[target], "job-x", "NEW").await;
assert!(results[0].success);
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "NEW");
}
// -- LocalFile: append ---------------------------------------------------
#[tokio::test]
async fn localfile_append_adds_lines() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("log.txt");
let target = CronDeliveryTarget::LocalFile {
path: path.to_string_lossy().to_string(),
append: true,
};
let engine = test_engine(MockBridge::new());
// Two sequential deliveries should accumulate.
engine
.deliver(std::slice::from_ref(&target), "job", "first")
.await;
engine.deliver(&[target], "job", "second").await;
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("first") && content.contains("second"),
"expected both lines in appended file, got: {content:?}"
);
}
#[tokio::test]
async fn localfile_append_creates_missing_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested/deep/out.log");
let target = CronDeliveryTarget::LocalFile {
path: path.to_string_lossy().to_string(),
append: true,
};
let engine = test_engine(MockBridge::new());
let results = engine.deliver(&[target], "job", "payload").await;
assert!(results[0].success, "error: {:?}", results[0].error);
assert!(path.exists(), "nested file should have been created");
}
// -- Webhook: success ----------------------------------------------------
#[tokio::test]
async fn webhook_sends_payload() {
let (port, rx) = spawn_mock_http_server(200, "OK").await;
let url = format!("http://127.0.0.1:{port}/hook");
let target = CronDeliveryTarget::Webhook {
url: url.clone(),
auth_header: Some("Bearer test-token".to_string()),
};
let engine = test_engine(MockBridge::new());
let results = engine
.deliver(&[target], "daily-report", "result body")
.await;
assert!(results[0].success, "error: {:?}", results[0].error);
let captured = rx.await.expect("mock server never received a request");
assert!(
captured.body.contains("\"job\":\"daily-report\""),
"payload missing job name, got: {}",
captured.body
);
assert!(
captured.body.contains("\"output\":\"result body\""),
"payload missing output, got: {}",
captured.body
);
assert!(
captured.body.contains("\"timestamp\""),
"payload missing timestamp, got: {}",
captured.body
);
assert!(
captured
.headers
.iter()
.any(|h| h.eq_ignore_ascii_case("authorization: Bearer test-token")),
"missing auth header, got: {:?}",
captured.headers
);
}
#[tokio::test]
async fn webhook_reports_non_2xx() {
let (port, _rx) = spawn_mock_http_server(500, "Internal Server Error").await;
let url = format!("http://127.0.0.1:{port}/hook");
let target = CronDeliveryTarget::Webhook {
url,
auth_header: None,
};
let engine = test_engine(MockBridge::new());
let results = engine.deliver(&[target], "job", "output").await;
assert!(!results[0].success);
let err = results[0].error.as_deref().unwrap_or("");
assert!(err.contains("500"), "expected 500 in error, got: {err}");
}
// -- Channel target ------------------------------------------------------
#[tokio::test]
async fn channel_target_invokes_bridge() {
let bridge = MockBridge::new();
let engine = test_engine(bridge.clone());
let target = CronDeliveryTarget::Channel {
channel_type: "slack".to_string(),
recipient: "C12345".to_string(),
};
let results = engine.deliver(&[target], "alerts", "fire").await;
assert!(results[0].success, "error: {:?}", results[0].error);
let calls = bridge.calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "slack");
assert_eq!(calls[0].1, "C12345");
assert_eq!(calls[0].2, "fire");
}
// -- Mixed success/failure ----------------------------------------------
#[tokio::test]
async fn mixed_targets_one_success_one_failure() {
let tmp = tempfile::tempdir().unwrap();
let ok_path = tmp.path().join("ok.txt");
let targets = vec![
// Will succeed (file write).
CronDeliveryTarget::LocalFile {
path: ok_path.to_string_lossy().to_string(),
append: false,
},
// Will fail (mock bridge rejects 'slack').
CronDeliveryTarget::Channel {
channel_type: "slack".to_string(),
recipient: "C1".to_string(),
},
];
let bridge = MockBridge::failing_on("slack");
let engine = test_engine(bridge);
let results = engine.deliver(&targets, "job", "payload").await;
assert_eq!(results.len(), 2);
assert!(
results[0].success,
"file delivery should succeed: {:?}",
results[0].error
);
assert!(
!results[1].success,
"channel delivery should fail, but got success"
);
assert!(results[1]
.error
.as_deref()
.unwrap_or("")
.contains("forced failure"));
// File was still written even though the other target failed.
assert_eq!(std::fs::read_to_string(&ok_path).unwrap(), "payload");
}
#[tokio::test]
async fn empty_targets_returns_empty_vec() {
let engine = test_engine(MockBridge::new());
let results = engine.deliver(&[], "job", "x").await;
assert!(results.is_empty());
}
// -- Serde round-trip ---------------------------------------------------
#[test]
fn serde_roundtrip_channel() {
let t = CronDeliveryTarget::Channel {
channel_type: "telegram".into(),
recipient: "12345".into(),
};
let s = serde_json::to_string(&t).unwrap();
assert!(s.contains("\"type\":\"channel\""), "tag missing: {s}");
assert!(s.contains("telegram"));
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
assert_eq!(t, back);
}
#[test]
fn serde_roundtrip_webhook() {
let t = CronDeliveryTarget::Webhook {
url: "https://example.com/hook".into(),
auth_header: Some("Bearer x".into()),
};
let s = serde_json::to_string(&t).unwrap();
assert!(s.contains("\"type\":\"webhook\""), "tag missing: {s}");
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
assert_eq!(t, back);
}
#[test]
fn serde_roundtrip_webhook_without_auth() {
// auth_header should default to None when omitted.
let json = r#"{"type":"webhook","url":"https://x.test/h"}"#;
let back: CronDeliveryTarget = serde_json::from_str(json).unwrap();
assert_eq!(
back,
CronDeliveryTarget::Webhook {
url: "https://x.test/h".into(),
auth_header: None,
}
);
}
#[test]
fn serde_roundtrip_localfile() {
let t = CronDeliveryTarget::LocalFile {
path: "/var/log/cron-out.log".into(),
append: true,
};
let s = serde_json::to_string(&t).unwrap();
assert!(s.contains("\"type\":\"local_file\""), "tag missing: {s}");
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
assert_eq!(t, back);
}
#[test]
fn serde_roundtrip_localfile_default_append() {
// append should default to false when omitted.
let json = r#"{"type":"local_file","path":"/tmp/out.log"}"#;
let back: CronDeliveryTarget = serde_json::from_str(json).unwrap();
assert_eq!(
back,
CronDeliveryTarget::LocalFile {
path: "/tmp/out.log".into(),
append: false,
}
);
}
#[test]
fn serde_roundtrip_email() {
let t = CronDeliveryTarget::Email {
to: "alice@example.com".into(),
subject_template: Some("Report: {job}".into()),
};
let s = serde_json::to_string(&t).unwrap();
assert!(s.contains("\"type\":\"email\""), "tag missing: {s}");
let back: CronDeliveryTarget = serde_json::from_str(&s).unwrap();
assert_eq!(t, back);
}
#[test]
fn render_subject_substitutes_placeholder() {
assert_eq!(render_subject(Some("Cron: {job}"), "daily"), "Cron: daily");
assert_eq!(
render_subject(Some("no placeholder"), "x"),
"no placeholder"
);
assert_eq!(render_subject(None, "daily"), "Cron: daily");
assert_eq!(render_subject(Some(""), "daily"), "Cron: daily");
}
// -- Minimal HTTP mock ---------------------------------------------------
struct CapturedRequest {
headers: Vec<String>,
body: String,
}
/// Spawn a tiny TCP server that serves exactly one request, parses the
/// HTTP/1.1 request line + headers + body, then responds with the given
/// status code and reason phrase. Returns `(port, oneshot_rx)` where the
/// oneshot resolves once the request has been received.
async fn spawn_mock_http_server(
status: u16,
reason: &'static str,
) -> (u16, tokio::sync::oneshot::Receiver<CapturedRequest>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let (mut stream, _) = match listener.accept().await {
Ok(s) => s,
Err(_) => return,
};
// Read until we have full headers and the declared body.
let mut buf = Vec::with_capacity(4096);
let mut tmp = [0u8; 1024];
let mut headers_end = None;
let mut content_length: Option<usize> = None;
loop {
let n = match stream.read(&mut tmp).await {
Ok(0) => break,
Ok(n) => n,
Err(_) => return,
};
buf.extend_from_slice(&tmp[..n]);
if headers_end.is_none() {
if let Some(pos) = find_subsequence(&buf, b"\r\n\r\n") {
headers_end = Some(pos + 4);
// Parse Content-Length.
let head_str = String::from_utf8_lossy(&buf[..pos]);
for line in head_str.lines() {
if let Some(v) = line.strip_prefix("Content-Length: ") {
content_length = v.trim().parse::<usize>().ok();
} else if let Some(v) = line.strip_prefix("content-length: ") {
content_length = v.trim().parse::<usize>().ok();
}
}
}
}
if let (Some(end), Some(cl)) = (headers_end, content_length) {
if buf.len() >= end + cl {
break;
}
}
if headers_end.is_some() && content_length.is_none() {
break;
}
}
// Split into headers + body.
let head_end = headers_end.unwrap_or(buf.len());
let head_str = String::from_utf8_lossy(&buf[..head_end.saturating_sub(4)]).to_string();
let body_bytes = if head_end < buf.len() {
&buf[head_end..]
} else {
&[][..]
};
let body = String::from_utf8_lossy(body_bytes).to_string();
let headers: Vec<String> = head_str.lines().skip(1).map(|l| l.to_string()).collect();
// Send response.
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
let _ = tx.send(CapturedRequest { headers, body });
});
(port, rx)
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
}
+40 -1
View File
@@ -12,7 +12,7 @@
use crate::registry::AgentRegistry;
use chrono::Utc;
use dashmap::DashMap;
use openfang_types::agent::{AgentId, AgentState};
use openfang_types::agent::{AgentEntry, AgentId, AgentState, ScheduleMode};
use tracing::{debug, warn};
/// Default heartbeat check interval (seconds).
@@ -132,6 +132,14 @@ impl Default for RecoveryTracker {
/// and the initial `set_state(Running)` call.
const IDLE_GRACE_SECS: i64 = 10;
/// Reactive agents are healthy while idle between user messages.
///
/// They should only participate in heartbeat failure detection while a turn is
/// actively running. Otherwise silence is the expected steady state.
pub(crate) fn should_exempt_idle_reactive_agent(entry: &AgentEntry, is_running_task: bool) -> bool {
matches!(entry.manifest.schedule, ScheduleMode::Reactive) && !is_running_task
}
/// Check all running and crashed agents and return their heartbeat status.
///
/// This is a pure function — it doesn't start a background task.
@@ -335,6 +343,8 @@ mod tests {
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
max_history_messages: None,
},
state,
mode: AgentMode::default(),
@@ -375,6 +385,35 @@ mod tests {
);
}
#[test]
fn test_idle_reactive_agent_is_exempt_when_not_processing() {
let mut agent = make_entry(
"reactive-idle",
AgentState::Running,
Utc::now() - Duration::seconds(600),
Utc::now() - Duration::seconds(300),
);
agent.manifest.schedule = ScheduleMode::Reactive;
assert!(should_exempt_idle_reactive_agent(&agent, false));
assert!(!should_exempt_idle_reactive_agent(&agent, true));
}
#[test]
fn test_periodic_agent_is_not_exempt_when_idle() {
let mut agent = make_entry(
"periodic-idle",
AgentState::Running,
Utc::now() - Duration::seconds(600),
Utc::now() - Duration::seconds(300),
);
agent.manifest.schedule = ScheduleMode::Periodic {
cron: "0 * * * *".to_string(),
};
assert!(!should_exempt_idle_reactive_agent(&agent, false));
}
#[test]
fn test_active_agent_detected_unresponsive() {
// An agent that WAS active (last_active >> created_at) but has gone
File diff suppressed because it is too large Load Diff
+1
View File
@@ -11,6 +11,7 @@ pub mod capabilities;
pub mod config;
pub mod config_reload;
pub mod cron;
pub mod cron_delivery;
pub mod error;
pub mod event_bus;
pub mod heartbeat;
+2
View File
@@ -395,6 +395,8 @@ mod tests {
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
max_history_messages: None,
},
state: AgentState::Created,
mode: AgentMode::default(),
+8
View File
@@ -449,6 +449,14 @@ fn describe_event(event: &Event) -> String {
"Health check failed: agent {agent_id}, unresponsive for {unresponsive_secs}s"
)
}
SystemEvent::CronJobExecuted {
agent_id,
job_id,
job_name,
..
} => {
format!("Cron job executed: {job_name} ({job_id}) for agent {agent_id}")
}
},
EventPayload::Custom(data) => {
format!("Custom event ({} bytes)", data.len())
+2
View File
@@ -182,6 +182,8 @@ impl SetupWizard {
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
max_history_messages: None,
};
let skills_to_install: Vec<String> = intent
@@ -19,6 +19,7 @@ fn test_config() -> KernelConfig {
model: "llama-3.3-70b-versatile".to_string(),
api_key_env: "GROQ_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
}
@@ -19,6 +19,7 @@ fn test_config() -> KernelConfig {
model: "llama-3.3-70b-versatile".to_string(),
api_key_env: "GROQ_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
}
@@ -115,6 +115,7 @@ fn test_config(tmp: &tempfile::TempDir) -> KernelConfig {
model: "test".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
}
@@ -24,6 +24,7 @@ fn test_config(provider: &str, model: &str, api_key_env: &str) -> KernelConfig {
model: model.to_string(),
api_key_env: api_key_env.to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
}
+4 -1
View File
@@ -586,12 +586,15 @@ 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)
));
}
ContentBlock::RedactedThinking { .. } => {
text_parts.push("[redacted_thinking]".to_string());
}
ContentBlock::Unknown => {}
}
}
+2 -4
View File
@@ -896,10 +896,8 @@ fn derive_capabilities(tools: &[String]) -> AgentCapabilities {
"shell_exec" => {
caps.shell = vec!["*".to_string()];
}
"web_fetch" | "web_search" | "browser_navigate" => {
if caps.network.is_empty() {
caps.network = vec!["*".to_string()];
}
"web_fetch" | "web_search" | "browser_navigate" if caps.network.is_empty() => {
caps.network = vec!["*".to_string()];
}
"agent_send" | "agent_list" => {
if caps.agent_message.is_empty() {
@@ -0,0 +1,225 @@
//! Per-turn agent context loader for external `context.md` files.
//!
//! Some agents depend on a `context.md` file updated by external tools (e.g. a
//! cron job that writes live market data, or a script that refreshes project
//! state). Before issue #843 this file was read once when the session started
//! and then cached for the lifetime of the conversation, so external updates
//! never reached the LLM.
//!
//! The default behaviour is now a small disk read per turn when the prompt is
//! assembled. Agents that depend on the old behaviour can opt back in via the
//! `cache_context` flag on their manifest.
//!
//! This module intentionally does not participate in per-token streaming — it
//! is called once per agent turn, right before the system prompt is built.
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::{collections::HashMap, fs};
use tracing::{debug, warn};
/// Maximum size of `context.md` to inject into the prompt (32 KB).
///
/// Matches the cap used by [`crate::workspace_context`] and the kernel's
/// identity-file reader so a runaway file cannot blow up the prompt.
const MAX_CONTEXT_BYTES: u64 = 32_768;
/// Filename that agents use for per-turn refreshable context.
pub const CONTEXT_FILENAME: &str = "context.md";
/// In-memory cache of the last successful read for each workspace.
///
/// Used for two purposes:
/// 1. When `cache_context = true`, the first successful read is returned on
/// every subsequent call.
/// 2. When `cache_context = false` and a re-read fails on disk (e.g. the file
/// was temporarily replaced by an external writer), we fall back to the
/// previous content instead of dropping context mid-conversation.
fn cache() -> &'static Mutex<HashMap<PathBuf, String>> {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, String>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Load the agent's `context.md` for this turn.
///
/// Returns the current on-disk content, or — if the read fails after a
/// previous success — the cached content with a warning. Returns `None` when
/// no context.md has ever been seen for this workspace.
///
/// When `cache_context` is true the first successful read is stored and
/// returned verbatim on every future call. Callers pass the flag straight from
/// `AgentManifest::cache_context`.
pub fn load_context_md(workspace: &Path, cache_context: bool) -> Option<String> {
let path = workspace.join(CONTEXT_FILENAME);
if cache_context {
if let Some(cached) = get_cached(&path) {
return Some(cached);
}
}
match read_capped(&path) {
Ok(Some(content)) => {
store_cached(&path, &content);
Some(content)
}
Ok(None) => {
// File is absent or empty — do not serve a stale cache for a
// deleted file unless the caller explicitly opted into caching.
if cache_context {
get_cached(&path)
} else {
None
}
}
Err(e) => {
if let Some(prev) = get_cached(&path) {
warn!(
path = %path.display(),
error = %e,
"Failed to re-read context.md; falling back to cached content"
);
Some(prev)
} else {
debug!(path = %path.display(), error = %e, "context.md unreadable and no cache");
None
}
}
}
}
fn get_cached(path: &Path) -> Option<String> {
cache()
.lock()
.ok()
.and_then(|guard| guard.get(path).cloned())
}
fn store_cached(path: &Path, content: &str) {
if let Ok(mut guard) = cache().lock() {
guard.insert(path.to_path_buf(), content.to_string());
}
}
/// Read the file, returning Ok(None) if it is missing or empty, and
/// Ok(Some(...)) if it has usable content. Oversized files are truncated to
/// [`MAX_CONTEXT_BYTES`] so prompt size remains bounded.
fn read_capped(path: &Path) -> std::io::Result<Option<String>> {
let meta = match fs::metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
if !meta.is_file() {
return Ok(None);
}
let content = fs::read_to_string(path)?;
if content.trim().is_empty() {
return Ok(None);
}
if meta.len() > MAX_CONTEXT_BYTES {
let truncated = crate::str_utils::safe_truncate_str(&content, MAX_CONTEXT_BYTES as usize);
return Ok(Some(truncated.to_string()));
}
Ok(Some(content))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn fresh_workspace(tag: &str) -> PathBuf {
// Unique temp dir per test to avoid cross-test cache pollution.
let dir = std::env::temp_dir().join(format!(
"openfang_ctx_{}_{}",
tag,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reread_picks_up_external_update() {
let ws = fresh_workspace("reread");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, "initial content A").unwrap();
let first = load_context_md(&ws, false).unwrap();
assert!(first.contains("initial content A"));
// External writer updates the file (simulates the cron case from #843).
{
let mut f = fs::File::create(&path).unwrap();
f.write_all(b"updated content B").unwrap();
}
let second = load_context_md(&ws, false).unwrap();
assert!(second.contains("updated content B"));
assert!(!second.contains("initial content A"));
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn cache_context_true_freezes_first_read() {
let ws = fresh_workspace("cache");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, "frozen A").unwrap();
let first = load_context_md(&ws, true).unwrap();
assert!(first.contains("frozen A"));
fs::write(&path, "never seen B").unwrap();
let second = load_context_md(&ws, true).unwrap();
assert_eq!(first, second);
assert!(!second.contains("never seen B"));
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn missing_file_returns_none() {
let ws = fresh_workspace("missing");
assert!(load_context_md(&ws, false).is_none());
assert!(load_context_md(&ws, true).is_none());
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn read_failure_falls_back_to_cache() {
let ws = fresh_workspace("fallback");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, "cached payload").unwrap();
let first = load_context_md(&ws, false).unwrap();
assert!(first.contains("cached payload"));
// Write bytes that are not valid UTF-8 so read_to_string returns an
// IO error. This simulates a transient read failure while the cron
// job is mid-rewrite.
{
let mut f = fs::File::create(&path).unwrap();
f.write_all(&[0xff, 0xfe, 0xfd, 0x80, 0x81]).unwrap();
}
let second = load_context_md(&ws, false);
assert_eq!(second.as_deref(), Some("cached payload"));
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn empty_file_treated_as_absent() {
let ws = fresh_workspace("empty");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, " \n\n ").unwrap();
assert!(load_context_md(&ws, false).is_none());
let _ = fs::remove_dir_all(&ws);
}
}
+618 -119
View File
@@ -40,21 +40,44 @@ const MAX_RETRIES: u32 = 3;
/// Base delay for exponential backoff (milliseconds).
const BASE_RETRY_DELAY_MS: u64 = 1000;
/// Timeout for individual tool executions (seconds).
/// Default timeout for individual tool executions (seconds).
/// Raised from 60s to 120s for browser automation and long-running builds.
/// Overridable via `OPENFANG_TOOL_TIMEOUT_SECS` env var. Set to `0` to disable
/// the timeout entirely (useful for slow local inference like vLLM on old GPUs).
const TOOL_TIMEOUT_SECS: u64 = 120;
/// Timeout for inter-agent tool calls (seconds).
/// Default timeout for inter-agent tool calls (seconds).
/// Agent delegation (agent_send, agent_spawn) can involve a full agent loop on the
/// target, so these need a significantly longer timeout than regular tools.
/// Overridable via `OPENFANG_AGENT_TOOL_TIMEOUT_SECS` env var. Set to `0` to
/// disable (issue #1125: slow vLLM rigs running Hands need unbounded waits).
const AGENT_TOOL_TIMEOUT_SECS: u64 = 600;
/// Parse a u64 env var, returning `None` when unset or unparseable so the
/// caller falls back to the compiled-in default.
fn env_timeout_secs(var: &str) -> Option<u64> {
std::env::var(var).ok().and_then(|s| s.trim().parse().ok())
}
/// Returns the appropriate timeout duration for a given tool name.
/// Inter-agent calls get a longer timeout since they may trigger full agent loops.
fn tool_timeout_for(tool_name: &str) -> Duration {
match tool_name {
"agent_send" | "agent_spawn" => Duration::from_secs(AGENT_TOOL_TIMEOUT_SECS),
_ => Duration::from_secs(TOOL_TIMEOUT_SECS),
///
/// Returns `None` when the operator opted out by setting the relevant env var
/// to `0`. In that case the tool runs with no upper bound, which is what users
/// on slow local inference (vLLM on old GPUs) want for Hands and inter-agent
/// delegation (issue #1125).
fn tool_timeout_for(tool_name: &str) -> Option<Duration> {
let secs = match tool_name {
"agent_send" | "agent_spawn" => {
env_timeout_secs("OPENFANG_AGENT_TOOL_TIMEOUT_SECS")
.unwrap_or(AGENT_TOOL_TIMEOUT_SECS)
}
_ => env_timeout_secs("OPENFANG_TOOL_TIMEOUT_SECS").unwrap_or(TOOL_TIMEOUT_SECS),
};
if secs == 0 {
None
} else {
Some(Duration::from_secs(secs))
}
}
@@ -62,8 +85,10 @@ fn tool_timeout_for(tool_name: &str) -> Duration {
/// Raised from 3 to 5 to allow longer-form generation.
const MAX_CONTINUATIONS: u32 = 5;
/// Maximum message history size before auto-trimming to prevent context overflow.
const MAX_HISTORY_MESSAGES: usize = 20;
/// Default maximum message history size before auto-trimming to prevent context overflow.
/// Per-agent overrides come from `AgentManifest::max_history_messages` (issue #871).
#[allow(dead_code)]
const MAX_HISTORY_MESSAGES: usize = openfang_types::agent::DEFAULT_MAX_HISTORY_MESSAGES;
/// Detect when the LLM claims to have performed an action (sent, posted, emailed)
/// without actually calling any tools. Prevents hallucinated completions.
@@ -109,6 +134,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`)
@@ -165,6 +254,30 @@ pub struct AgentLoopResult {
pub directives: openfang_types::message::ReplyDirectives,
}
/// Build the user-turn message, combining text with any image content blocks.
///
/// When the turn has both text and image blocks the text is emitted as the
/// first block followed by the images so the LLM sees the full multimodal
/// turn. When only one is present the single-mode representation is used.
fn build_user_turn_message(user_message: &str, blocks: Option<Vec<ContentBlock>>) -> Message {
match blocks {
Some(blocks) if !blocks.is_empty() => {
if user_message.trim().is_empty() {
Message::user_with_blocks(blocks)
} else {
let mut combined = Vec::with_capacity(blocks.len() + 1);
combined.push(ContentBlock::Text {
text: user_message.to_string(),
provider_metadata: None,
});
combined.extend(blocks);
Message::user_with_blocks(combined)
}
}
_ => Message::user(user_message),
}
}
/// Run the agent execution loop for a single user message.
///
/// This is the core of OpenFang: it loads session context, recalls memories,
@@ -278,12 +391,10 @@ pub async fn run_agent_loop(
// Add the user message to session history.
// When content blocks are provided (e.g. text + image from a channel),
// use multimodal message format so the LLM receives the image for vision.
if let Some(blocks) = user_content_blocks {
session.messages.push(Message::user_with_blocks(blocks));
} else {
session.messages.push(Message::user(user_message));
}
// combine them with the user text so the LLM sees the full multimodal turn.
session
.messages
.push(build_user_turn_message(user_message, user_content_blocks));
// Build the messages for the LLM, filtering system messages
// System prompt goes into the separate `system` field.
@@ -340,12 +451,15 @@ pub async fn run_agent_loop(
// Safety valve: trim excessively long message histories to prevent context overflow.
// The full compaction system handles sophisticated summarization, but this prevents
// the catastrophic case where 200+ messages cause instant context overflow.
if messages.len() > MAX_HISTORY_MESSAGES {
let trim_count = messages.len() - MAX_HISTORY_MESSAGES;
// Per-agent cap: manifest override -> runtime default (issue #871).
let max_history = manifest.effective_max_history_messages();
if messages.len() > max_history {
let trim_count = messages.len() - max_history;
warn!(
agent = %manifest.name,
total_messages = messages.len(),
trimming = trim_count,
max_history = max_history,
"Trimming old messages to prevent context overflow"
);
messages.drain(..trim_count);
@@ -583,7 +697,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);
@@ -697,10 +820,12 @@ pub async fn run_agent_loop(
session.messages.push(Message {
role: Role::Assistant,
content: MessageContent::Blocks(assistant_blocks.clone()),
..Default::default()
});
messages.push(Message {
role: Role::Assistant,
content: MessageContent::Blocks(assistant_blocks),
..Default::default()
});
// Build allowed tool names list for capability enforcement
@@ -791,49 +916,51 @@ pub async fn run_agent_loop(
// Resolve effective exec policy (per-agent override or global)
let effective_exec_policy = manifest.exec_policy.as_ref();
// Timeout-wrapped execution
let timeout = tool_timeout_for(&tool_call.name);
let timeout_secs = timeout.as_secs();
let result = match tokio::time::timeout(
timeout,
tool_runner::execute_tool(
&tool_call.id,
&tool_call.name,
&tool_call.input,
kernel.as_ref(),
Some(&allowed_tool_names),
Some(&caller_id_str),
skill_registry,
mcp_connections,
web_ctx,
browser_ctx,
if hand_allowed_env.is_empty() {
None
} else {
Some(&hand_allowed_env)
},
workspace_root,
media_engine,
effective_exec_policy,
tts_engine,
docker_config,
process_manager,
),
)
.await
{
Ok(result) => result,
Err(_) => {
warn!(tool = %tool_call.name, "Tool execution timed out after {}s", timeout_secs);
openfang_types::tool::ToolResult {
tool_use_id: tool_call.id.clone(),
content: format!(
"Tool '{}' timed out after {}s.",
tool_call.name, timeout_secs
),
is_error: true,
// Timeout-wrapped execution. `tool_timeout_for` returns None
// when the operator disabled the timeout (issue #1125).
let timeout_opt = tool_timeout_for(&tool_call.name);
let exec_fut = tool_runner::execute_tool(
&tool_call.id,
&tool_call.name,
&tool_call.input,
kernel.as_ref(),
Some(&allowed_tool_names),
Some(&caller_id_str),
skill_registry,
mcp_connections,
web_ctx,
browser_ctx,
if hand_allowed_env.is_empty() {
None
} else {
Some(&hand_allowed_env)
},
workspace_root,
media_engine,
effective_exec_policy,
tts_engine,
docker_config,
process_manager,
);
let result = match timeout_opt {
Some(timeout) => {
let timeout_secs = timeout.as_secs();
match tokio::time::timeout(timeout, exec_fut).await {
Ok(result) => result,
Err(_) => {
warn!(tool = %tool_call.name, "Tool execution timed out after {}s", timeout_secs);
openfang_types::tool::ToolResult {
tool_use_id: tool_call.id.clone(),
content: format!(
"Tool '{}' timed out after {}s.",
tool_call.name, timeout_secs
),
is_error: true,
}
}
}
}
None => exec_fut.await,
};
// Fire AfterToolCall hook
@@ -917,6 +1044,7 @@ pub async fn run_agent_loop(
let tool_results_msg = Message {
role: Role::User,
content: MessageContent::Blocks(tool_result_blocks.clone()),
..Default::default()
};
session.messages.push(tool_results_msg.clone());
messages.push(tool_results_msg);
@@ -936,7 +1064,12 @@ pub async fn run_agent_loop(
} else {
text
};
session.messages.push(Message::assistant(&text));
// Issue #1148: preserve Thinking / RedactedThinking blocks
// present in the response so reasoning state survives
// MaxTokens truncation — same as the EndTurn branch.
let assistant_msg =
build_assistant_message_preserving_thinking(&response.content, &text);
session.messages.push(assistant_msg);
if let Err(e) = memory.save_session_async(session).await {
warn!("Failed to save session on max continuations: {e}");
}
@@ -967,10 +1100,15 @@ pub async fn run_agent_loop(
directives: Default::default(),
});
}
// Model hit token limit — add partial response and continue
// Model hit token limit — add partial response and continue.
// Issue #1148: preserve full response content (Thinking,
// RedactedThinking, etc.) so reasoning state is not dropped
// when continuing across the token-limit boundary.
let text = response.text();
session.messages.push(Message::assistant(&text));
messages.push(Message::assistant(&text));
let assistant_msg =
build_assistant_message_preserving_thinking(&response.content, &text);
session.messages.push(assistant_msg.clone());
messages.push(assistant_msg);
session.messages.push(Message::user("Please continue."));
messages.push(Message::user("Please continue."));
warn!(iteration, "Max tokens hit, continuing");
@@ -1121,6 +1259,7 @@ async fn call_with_retry(
api_key,
base_url: fb.base_url.clone(),
skip_permissions: true,
subprocess_timeout_secs: None,
};
let fb_driver = match crate::drivers::create_driver(&fb_config) {
Ok(d) => d,
@@ -1304,6 +1443,7 @@ async fn stream_with_retry(
api_key,
base_url: fb.base_url.clone(),
skip_permissions: true,
subprocess_timeout_secs: None,
};
let fb_driver = match crate::drivers::create_driver(&fb_config) {
Ok(d) => d,
@@ -1479,12 +1619,10 @@ pub async fn run_agent_loop_streaming(
// Add the user message to session history.
// When content blocks are provided (e.g. text + image from a channel),
// use multimodal message format so the LLM receives the image for vision.
if let Some(blocks) = user_content_blocks {
session.messages.push(Message::user_with_blocks(blocks));
} else {
session.messages.push(Message::user(user_message));
}
// combine them with the user text so the LLM sees the full multimodal turn.
session
.messages
.push(build_user_turn_message(user_message, user_content_blocks));
let llm_messages: Vec<Message> = session
.messages
@@ -1532,12 +1670,15 @@ pub async fn run_agent_loop_streaming(
let mut accumulated_text = String::new();
// Safety valve: trim excessively long message histories to prevent context overflow.
if messages.len() > MAX_HISTORY_MESSAGES {
let trim_count = messages.len() - MAX_HISTORY_MESSAGES;
// Per-agent cap: manifest override -> runtime default (issue #871).
let max_history = manifest.effective_max_history_messages();
if messages.len() > max_history {
let trim_count = messages.len() - max_history;
warn!(
agent = %manifest.name,
total_messages = messages.len(),
trimming = trim_count,
max_history = max_history,
"Trimming old messages to prevent context overflow (streaming)"
);
messages.drain(..trim_count);
@@ -1637,6 +1778,12 @@ pub async fn run_agent_loop_streaming(
}
}
// Stamp last_active before the (potentially long) LLM call so the
// heartbeat monitor doesn't flag us as unresponsive mid-iteration.
if let Some(k) = &kernel {
k.touch_agent(&agent_id_str);
}
// Stream LLM call with retry, error classification, and circuit breaker
let provider_name = manifest.model.provider.as_str();
let mut response = stream_with_retry(
@@ -1770,7 +1917,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);
@@ -1878,10 +2031,12 @@ pub async fn run_agent_loop_streaming(
session.messages.push(Message {
role: Role::Assistant,
content: MessageContent::Blocks(assistant_blocks.clone()),
..Default::default()
});
messages.push(Message {
role: Role::Assistant,
content: MessageContent::Blocks(assistant_blocks),
..Default::default()
});
let allowed_tool_names: Vec<String> =
@@ -1970,49 +2125,51 @@ pub async fn run_agent_loop_streaming(
// Resolve effective exec policy (per-agent override or global)
let effective_exec_policy = manifest.exec_policy.as_ref();
// Timeout-wrapped execution
let timeout = tool_timeout_for(&tool_call.name);
let timeout_secs = timeout.as_secs();
let result = match tokio::time::timeout(
timeout,
tool_runner::execute_tool(
&tool_call.id,
&tool_call.name,
&tool_call.input,
kernel.as_ref(),
Some(&allowed_tool_names),
Some(&caller_id_str),
skill_registry,
mcp_connections,
web_ctx,
browser_ctx,
if hand_allowed_env.is_empty() {
None
} else {
Some(&hand_allowed_env)
},
workspace_root,
media_engine,
effective_exec_policy,
tts_engine,
docker_config,
process_manager,
),
)
.await
{
Ok(result) => result,
Err(_) => {
warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", timeout_secs);
openfang_types::tool::ToolResult {
tool_use_id: tool_call.id.clone(),
content: format!(
"Tool '{}' timed out after {}s.",
tool_call.name, timeout_secs
),
is_error: true,
// Timeout-wrapped execution. `tool_timeout_for` returns None
// when the operator disabled the timeout (issue #1125).
let timeout_opt = tool_timeout_for(&tool_call.name);
let exec_fut = tool_runner::execute_tool(
&tool_call.id,
&tool_call.name,
&tool_call.input,
kernel.as_ref(),
Some(&allowed_tool_names),
Some(&caller_id_str),
skill_registry,
mcp_connections,
web_ctx,
browser_ctx,
if hand_allowed_env.is_empty() {
None
} else {
Some(&hand_allowed_env)
},
workspace_root,
media_engine,
effective_exec_policy,
tts_engine,
docker_config,
process_manager,
);
let result = match timeout_opt {
Some(timeout) => {
let timeout_secs = timeout.as_secs();
match tokio::time::timeout(timeout, exec_fut).await {
Ok(result) => result,
Err(_) => {
warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", timeout_secs);
openfang_types::tool::ToolResult {
tool_use_id: tool_call.id.clone(),
content: format!(
"Tool '{}' timed out after {}s.",
tool_call.name, timeout_secs
),
is_error: true,
}
}
}
}
None => exec_fut.await,
};
// Fire AfterToolCall hook
@@ -2110,6 +2267,7 @@ pub async fn run_agent_loop_streaming(
let tool_results_msg = Message {
role: Role::User,
content: MessageContent::Blocks(tool_result_blocks.clone()),
..Default::default()
};
session.messages.push(tool_results_msg.clone());
messages.push(tool_results_msg);
@@ -2127,7 +2285,12 @@ pub async fn run_agent_loop_streaming(
} else {
text
};
session.messages.push(Message::assistant(&text));
// Issue #1148: preserve Thinking / RedactedThinking blocks
// present in the response so reasoning state survives
// MaxTokens truncation — same as the EndTurn branch.
let assistant_msg =
build_assistant_message_preserving_thinking(&response.content, &text);
session.messages.push(assistant_msg);
if let Err(e) = memory.save_session_async(session).await {
warn!("Failed to save session on max continuations: {e}");
}
@@ -2158,9 +2321,14 @@ pub async fn run_agent_loop_streaming(
directives: Default::default(),
});
}
// Issue #1148: preserve full response content (Thinking,
// RedactedThinking, etc.) so reasoning state is not dropped
// when continuing across the token-limit boundary.
let text = response.text();
session.messages.push(Message::assistant(&text));
messages.push(Message::assistant(&text));
let assistant_msg =
build_assistant_message_preserving_thinking(&response.content, &text);
session.messages.push(assistant_msg.clone());
messages.push(assistant_msg);
session.messages.push(Message::user("Please continue."));
messages.push(Message::user("Please continue."));
warn!(iteration, "Max tokens hit (streaming), continuing");
@@ -3064,6 +3232,154 @@ 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));
}
/// Issue #1148 — when the LLM hits MaxTokens, the persisted assistant
/// turn must keep `Thinking` and `RedactedThinking` blocks so reasoning
/// state survives across the token-limit boundary. The helper used by
/// the MaxTokens branches is the same `build_assistant_message_preserving_thinking`
/// that EndTurn uses; this test pins that contract for both block types
/// so the four MaxTokens persistence sites stay correct.
#[test]
fn test_build_assistant_message_preserves_redacted_thinking_for_max_tokens() {
let response_blocks = vec![
ContentBlock::Thinking {
thinking: "Mid-stream reasoning".to_string(),
signature: Some("sig_xyz".to_string()),
provider_metadata: Some(serde_json::json!({
"format": "anthropic_extended_thinking"
})),
},
ContentBlock::RedactedThinking {
data: "encrypted_blob_abc".to_string(),
},
ContentBlock::Text {
text: "Partial answer before token limit".to_string(),
provider_metadata: None,
},
];
let final_text = "Partial answer before token limit";
let msg = build_assistant_message_preserving_thinking(&response_blocks, final_text);
let blocks = match &msg.content {
MessageContent::Blocks(b) => b,
other => panic!("expected Blocks content for MaxTokens persistence, got {other:?}"),
};
// All reasoning blocks must survive the persistence step so the
// follow-up "Please continue." turn carries them back to the model.
let has_thinking = blocks
.iter()
.any(|b| matches!(b, ContentBlock::Thinking { .. }));
let has_redacted = blocks
.iter()
.any(|b| matches!(b, ContentBlock::RedactedThinking { .. }));
assert!(has_thinking, "Thinking block must be preserved on MaxTokens");
assert!(
has_redacted,
"RedactedThinking block must be preserved on MaxTokens"
);
// Verify the opaque blob is byte-identical (Anthropic rejects altered data).
for b in blocks {
if let ContentBlock::RedactedThinking { data } = b {
assert_eq!(data, "encrypted_blob_abc");
}
}
// Final text reflects what the user will see.
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);
@@ -3115,17 +3431,200 @@ mod tests {
assert_eq!(AGENT_TOOL_TIMEOUT_SECS, 600);
}
/// All `tool_timeout_for` cases live in one test (defaults plus env
/// overrides) to avoid env-var races between parallel test threads.
/// Issue #1125: operators on slow local inference (vLLM on old GPUs) need
/// to disable or extend the inter-agent timeout via env var.
#[test]
fn test_tool_timeout_for_agent_tools() {
assert_eq!(tool_timeout_for("agent_send"), Duration::from_secs(600));
assert_eq!(tool_timeout_for("agent_spawn"), Duration::from_secs(600));
assert_eq!(tool_timeout_for("file_read"), Duration::from_secs(120));
assert_eq!(tool_timeout_for("shell_exec"), Duration::from_secs(120));
// Baseline: no env overrides → compiled-in defaults.
std::env::remove_var("OPENFANG_AGENT_TOOL_TIMEOUT_SECS");
std::env::remove_var("OPENFANG_TOOL_TIMEOUT_SECS");
assert_eq!(
tool_timeout_for("agent_send"),
Some(Duration::from_secs(600))
);
assert_eq!(
tool_timeout_for("agent_spawn"),
Some(Duration::from_secs(600))
);
assert_eq!(
tool_timeout_for("file_read"),
Some(Duration::from_secs(120))
);
assert_eq!(
tool_timeout_for("shell_exec"),
Some(Duration::from_secs(120))
);
// Override: set to 0 → timeout disabled.
std::env::set_var("OPENFANG_AGENT_TOOL_TIMEOUT_SECS", "0");
std::env::set_var("OPENFANG_TOOL_TIMEOUT_SECS", "0");
assert_eq!(tool_timeout_for("agent_send"), None);
assert_eq!(tool_timeout_for("agent_spawn"), None);
assert_eq!(tool_timeout_for("file_read"), None);
// Override: custom positive values are honored verbatim.
std::env::set_var("OPENFANG_AGENT_TOOL_TIMEOUT_SECS", "1800");
std::env::set_var("OPENFANG_TOOL_TIMEOUT_SECS", "300");
assert_eq!(
tool_timeout_for("agent_send"),
Some(Duration::from_secs(1800))
);
assert_eq!(
tool_timeout_for("file_read"),
Some(Duration::from_secs(300))
);
// Override: unparseable values fall back to compiled-in defaults.
std::env::set_var("OPENFANG_AGENT_TOOL_TIMEOUT_SECS", "not-a-number");
std::env::set_var("OPENFANG_TOOL_TIMEOUT_SECS", "");
assert_eq!(
tool_timeout_for("agent_send"),
Some(Duration::from_secs(600))
);
assert_eq!(
tool_timeout_for("file_read"),
Some(Duration::from_secs(120))
);
std::env::remove_var("OPENFANG_AGENT_TOOL_TIMEOUT_SECS");
std::env::remove_var("OPENFANG_TOOL_TIMEOUT_SECS");
}
#[test]
fn test_max_history_messages() {
assert_eq!(MAX_HISTORY_MESSAGES, 20);
assert_eq!(
openfang_types::agent::DEFAULT_MAX_HISTORY_MESSAGES,
MAX_HISTORY_MESSAGES
);
}
/// Issue #871: an agent with a manifest override uses that value.
#[test]
fn test_effective_max_history_uses_manifest_override() {
let mut manifest = openfang_types::agent::AgentManifest {
max_history_messages: Some(40),
..Default::default()
};
assert_eq!(manifest.effective_max_history_messages(), 40);
manifest.max_history_messages = Some(6);
assert_eq!(manifest.effective_max_history_messages(), 6);
}
/// Issue #871: an agent without an override falls back to the runtime
/// default. `Some(0)` is also treated as the default to avoid an agent
/// accidentally disabling history entirely.
#[test]
fn test_effective_max_history_falls_back_to_default() {
let mut manifest = openfang_types::agent::AgentManifest {
max_history_messages: None,
..Default::default()
};
assert_eq!(
manifest.effective_max_history_messages(),
MAX_HISTORY_MESSAGES
);
manifest.max_history_messages = Some(0);
assert_eq!(
manifest.effective_max_history_messages(),
MAX_HISTORY_MESSAGES
);
}
/// Issue #871: `max_history_messages` round-trips through serde with
/// `#[serde(default)]`, so manifests without the field still deserialize.
#[test]
fn test_manifest_max_history_round_trip_json() {
let json_no_override = r#"{"name":"worker","module":"builtin:chat"}"#;
let manifest: openfang_types::agent::AgentManifest =
serde_json::from_str(json_no_override).unwrap();
assert_eq!(manifest.max_history_messages, None);
assert_eq!(
manifest.effective_max_history_messages(),
MAX_HISTORY_MESSAGES
);
let json_with_override =
r#"{"name":"orchestrator","module":"builtin:chat","max_history_messages":40}"#;
let manifest: openfang_types::agent::AgentManifest =
serde_json::from_str(json_with_override).unwrap();
assert_eq!(manifest.max_history_messages, Some(40));
assert_eq!(manifest.effective_max_history_messages(), 40);
}
fn sample_image_block() -> ContentBlock {
ContentBlock::Image {
media_type: "image/png".to_string(),
data: "aGVsbG8=".to_string(),
}
}
#[test]
fn test_build_user_turn_text_only() {
let msg = build_user_turn_message("hello", None);
assert_eq!(msg.role, Role::User);
match msg.content {
MessageContent::Text(text) => assert_eq!(text, "hello"),
MessageContent::Blocks(_) => panic!("expected Text content for text-only turn"),
}
}
#[test]
fn test_build_user_turn_images_only() {
let msg = build_user_turn_message("", Some(vec![sample_image_block()]));
assert_eq!(msg.role, Role::User);
match msg.content {
MessageContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 1);
assert!(matches!(blocks[0], ContentBlock::Image { .. }));
}
MessageContent::Text(_) => panic!("expected Blocks content for images-only turn"),
}
}
#[test]
fn test_build_user_turn_text_and_images_combined() {
let msg =
build_user_turn_message("what is in this image?", Some(vec![sample_image_block()]));
assert_eq!(msg.role, Role::User);
match msg.content {
MessageContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 2, "text must be combined with images");
match &blocks[0] {
ContentBlock::Text { text, .. } => {
assert_eq!(text, "what is in this image?");
}
_ => panic!("expected first block to be user text"),
}
assert!(matches!(blocks[1], ContentBlock::Image { .. }));
}
MessageContent::Text(_) => panic!("expected Blocks content for multimodal turn"),
}
}
#[test]
fn test_build_user_turn_whitespace_text_treated_as_empty() {
let msg = build_user_turn_message(" \n", Some(vec![sample_image_block()]));
match msg.content {
MessageContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 1);
assert!(matches!(blocks[0], ContentBlock::Image { .. }));
}
MessageContent::Text(_) => panic!("expected Blocks content"),
}
}
#[test]
fn test_build_user_turn_empty_blocks_falls_back_to_text() {
let msg = build_user_turn_message("hi", Some(Vec::new()));
match msg.content {
MessageContent::Text(text) => assert_eq!(text, "hi"),
MessageContent::Blocks(_) => panic!("expected Text content when blocks are empty"),
}
}
// --- Integration tests for empty response guards ---
+17 -4
View File
@@ -404,6 +404,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
conversation_text.push_str(&format!("[Image: {media_type}]\n\n"));
}
ContentBlock::Thinking { .. } => {}
ContentBlock::RedactedThinking { .. } => {}
ContentBlock::Unknown => {}
}
}
@@ -435,10 +436,10 @@ async fn summarize_messages(
let safe_start = if conversation_text.is_char_boundary(start) {
start
} else {
// Find the nearest valid character boundary moving upward
(start..conversation_text.len())
.find(|&i| conversation_text.is_char_boundary(i))
.unwrap_or(conversation_text.len())
// Find the nearest valid character boundary moving upward
(start..conversation_text.len())
.find(|&i| conversation_text.is_char_boundary(i))
.unwrap_or(conversation_text.len())
};
conversation_text = conversation_text[safe_start..].to_string();
}
@@ -457,6 +458,7 @@ async fn summarize_messages(
text: summarize_prompt,
provider_metadata: None,
}]),
..Default::default()
}],
tools: vec![],
max_tokens: config.max_summary_tokens,
@@ -575,6 +577,7 @@ async fn summarize_in_chunks(
text: merge_prompt,
provider_metadata: None,
}]),
..Default::default()
}],
tools: vec![],
max_tokens: config.max_summary_tokens,
@@ -912,6 +915,7 @@ mod tests {
input: serde_json::json!({"query": "test"}),
provider_metadata: None,
}]),
..Default::default()
};
messages[2] = Message {
role: Role::User,
@@ -921,6 +925,7 @@ mod tests {
content: "Search results here".to_string(),
is_error: false,
}]),
..Default::default()
};
let session = Session {
@@ -1251,6 +1256,7 @@ mod tests {
provider_metadata: None,
},
]),
..Default::default()
},
Message {
role: Role::User,
@@ -1260,6 +1266,7 @@ mod tests {
content: "Results found".to_string(),
is_error: false,
}]),
..Default::default()
},
Message {
role: Role::User,
@@ -1267,6 +1274,7 @@ mod tests {
media_type: "image/png".to_string(),
data: "base64data".to_string(),
}]),
..Default::default()
},
];
@@ -1401,6 +1409,7 @@ mod tests {
content: tool_content,
is_error: false,
}]),
..Default::default()
}];
let text = build_conversation_text(&messages, &config);
// The base64 blob should be stripped/replaced by session_repair
@@ -1421,6 +1430,7 @@ mod tests {
content: large_result,
is_error: false,
}]),
..Default::default()
}];
let text = build_conversation_text(&messages, &config);
// Should be capped at ~2000 chars (plus the "..." suffix)
@@ -1445,6 +1455,7 @@ mod tests {
content: short_result.to_string(),
is_error: false,
}]),
..Default::default()
}];
let text = build_conversation_text(&messages, &config);
assert!(text.contains(short_result));
@@ -1464,6 +1475,7 @@ mod tests {
input: serde_json::json!({}),
provider_metadata: None,
}]),
..Default::default()
},
Message {
role: Role::User,
@@ -1473,6 +1485,7 @@ mod tests {
content: "file contents".to_string(),
is_error: false,
}]),
..Default::default()
},
Message::assistant("Done reading."),
];
@@ -290,6 +290,7 @@ mod tests {
content: big_result.clone(),
is_error: false,
}]),
..Default::default()
},
Message {
role: openfang_types::message::Role::User,
@@ -299,6 +300,7 @@ mod tests {
content: big_result,
is_error: false,
}]),
..Default::default()
},
];
@@ -350,6 +352,7 @@ mod tests {
content: big_chinese,
is_error: false,
}]),
..Default::default()
}];
// Must not panic on multi-byte content
let compacted = apply_context_guard(&mut messages, &budget, &[]);
@@ -237,6 +237,7 @@ mod tests {
Role::Assistant
},
content: MessageContent::Text(text),
..Default::default()
}
})
.collect()
@@ -295,6 +296,7 @@ mod tests {
content: big_result.clone(),
is_error: false,
}]),
..Default::default()
},
Message {
role: Role::User,
@@ -304,6 +306,7 @@ mod tests {
content: big_result,
is_error: false,
}]),
..Default::default()
},
];
// Tiny context window to force all stages
@@ -342,6 +345,7 @@ mod tests {
content: chinese_result,
is_error: false,
}]),
..Default::default()
},
];
// Tiny context window to force stage 3 tool truncation
@@ -365,6 +369,7 @@ mod tests {
input: serde_json::json!({}),
provider_metadata: None,
}]),
..Default::default()
},
Message {
role: Role::User,
@@ -374,6 +379,7 @@ mod tests {
content: "file contents".to_string(),
is_error: false,
}]),
..Default::default()
},
Message::user("thanks"),
];
+428 -12
View File
@@ -84,6 +84,22 @@ 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 },
/// Redacted (encrypted) thinking block echoed back to the API.
///
/// Anthropic returns these when the model decides to hide reasoning;
/// the `data` blob is opaque and MUST be echoed verbatim on the next
/// turn or the API rejects the resubmitted history.
#[serde(rename = "redacted_thinking")]
RedactedThinking { data: String },
}
#[derive(Debug, Serialize)]
@@ -120,8 +136,21 @@ 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>,
},
/// Redacted (encrypted) thinking block. The `data` blob is opaque to
/// us and must be persisted as-is so we can echo it back on the next
/// request — Anthropic rejects history that strips these blocks.
#[serde(rename = "redacted_thinking")]
RedactedThinking { data: String },
}
#[derive(Debug, Deserialize)]
@@ -144,12 +173,23 @@ 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,
input_json: String,
},
/// Redacted (encrypted) thinking block streamed from Anthropic.
/// The opaque `data` blob arrives on `content_block_start` and must be
/// persisted so the next turn can echo it back verbatim.
RedactedThinking { data: String },
}
#[async_trait]
@@ -412,7 +452,29 @@ 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,
});
}
"redacted_thinking" => {
// Anthropic delivers redacted_thinking
// as a single block_start with the opaque
// `data` blob (no delta events). Store it
// verbatim so we can echo it back on the
// next request — API rejects history
// that strips redacted_thinking blocks.
let data = block["data"]
.as_str()
.unwrap_or("")
.to_string();
blocks.push(ContentBlockAccum::RedactedThinking { data });
}
_ => {}
}
@@ -452,11 +514,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 +596,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,
@@ -530,6 +632,11 @@ impl LlmDriver for AnthropicDriver {
});
tool_calls.push(ToolCall { id, name, input });
}
ContentBlockAccum::RedactedThinking { data } => {
if !data.is_empty() {
content.push(ContentBlock::RedactedThinking { data });
}
}
}
}
@@ -615,7 +722,40 @@ 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::RedactedThinking { data } => {
// Echo the encrypted blob verbatim. Anthropic
// rejects history that drops redacted_thinking
// blocks, so always include them on resubmission.
if data.is_empty() {
None
} else {
Some(ApiContentBlock::RedactedThinking {
data: data.clone(),
})
}
}
ContentBlock::Unknown => None,
})
.collect();
@@ -651,8 +791,20 @@ 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"
})),
});
}
ResponseContentBlock::RedactedThinking { data } => {
content.push(ContentBlock::RedactedThinking { data });
}
}
}
@@ -758,6 +910,7 @@ mod tests {
input: serde_json::Value::String(r#"{"query": "test"}"#.to_string()),
provider_metadata: None,
}]),
..Default::default()
};
let api_msg = convert_message(&msg);
if let ApiContent::Blocks(blocks) = api_msg.content {
@@ -772,4 +925,267 @@ 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()),
..Default::default()
};
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,
},
]),
..Default::default()
};
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"),
}
}
/// Issue #1148 — Anthropic `redacted_thinking` blocks must survive the
/// full driver round-trip. The opaque `data` blob is required verbatim
/// on resubmission; dropping or mutating it causes the API to reject
/// the assistant turn on the next request.
#[test]
fn test_redacted_thinking_round_trip() {
// Step 1: API delivers a response with a redacted_thinking block.
let api_response = ApiResponse {
content: vec![
ResponseContentBlock::RedactedThinking {
data: "EncRyPt3D_BLO8".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: The opaque blob must reach the ContentBlock layer.
match &response.content[0] {
ContentBlock::RedactedThinking { data } => {
assert_eq!(data, "EncRyPt3D_BLO8");
}
other => panic!("expected RedactedThinking content block, got {other:?}"),
}
// Step 3: Resubmit the assistant turn as conversation history.
let assistant_msg = Message {
role: Role::Assistant,
content: MessageContent::Blocks(response.content.clone()),
..Default::default()
};
let api_msg = convert_message(&assistant_msg);
let blocks = match api_msg.content {
ApiContent::Blocks(b) => b,
_ => panic!("expected Blocks content"),
};
// The redacted_thinking block must appear in the outbound payload.
let mut found_redacted = false;
for block in &blocks {
if let ApiContentBlock::RedactedThinking { data } = block {
assert_eq!(data, "EncRyPt3D_BLO8");
found_redacted = true;
}
}
assert!(
found_redacted,
"outbound API request must include the redacted_thinking block"
);
// Step 4: On-the-wire JSON shape (`type=redacted_thinking`, `data` present).
let outbound_json = serde_json::to_value(&blocks).unwrap();
let arr = outbound_json.as_array().unwrap();
let redacted_json = arr
.iter()
.find(|v| v["type"] == "redacted_thinking")
.expect("redacted_thinking block in JSON");
assert_eq!(redacted_json["data"], "EncRyPt3D_BLO8");
}
/// API response wire format for `redacted_thinking` is parsed correctly.
#[test]
fn test_redacted_thinking_serde() {
let json = serde_json::json!({
"type": "redacted_thinking",
"data": "opaque_blob_xyz"
});
let block: ResponseContentBlock = serde_json::from_value(json).unwrap();
match block {
ResponseContentBlock::RedactedThinking { data } => {
assert_eq!(data, "opaque_blob_xyz");
}
_ => panic!("expected RedactedThinking response block"),
}
}
/// Empty redacted_thinking blocks (e.g. interrupted stream) must be
/// dropped on outbound to avoid sending malformed history.
#[test]
fn test_redacted_thinking_empty_dropped_outbound() {
let assistant_msg = Message {
role: Role::Assistant,
content: MessageContent::Blocks(vec![
ContentBlock::RedactedThinking {
data: String::new(),
},
ContentBlock::Text {
text: "Hello.".to_string(),
provider_metadata: None,
},
]),
..Default::default()
};
let api_msg = convert_message(&assistant_msg);
let blocks = match api_msg.content {
ApiContent::Blocks(b) => b,
_ => panic!("expected Blocks content"),
};
for block in &blocks {
assert!(
!matches!(block, ApiContentBlock::RedactedThinking { .. }),
"empty redacted_thinking block must be dropped"
);
}
assert!(blocks
.iter()
.any(|b| matches!(b, ApiContentBlock::Text { .. })));
}
}
@@ -299,8 +299,11 @@ fn convert_content_block(block: &ContentBlock) -> Option<BedrockContentBlock> {
},
},
}),
// Image, Thinking, and Unknown are not supported — silently drop
ContentBlock::Image { .. } | ContentBlock::Thinking { .. } | ContentBlock::Unknown => None,
// Image, Thinking, RedactedThinking, and Unknown are not supported — silently drop
ContentBlock::Image { .. }
| ContentBlock::Thinking { .. }
| ContentBlock::RedactedThinking { .. }
| ContentBlock::Unknown => None,
}
}
@@ -782,6 +785,7 @@ mod tests {
let messages = vec![Message {
role: Role::User,
content: MessageContent::Text("Hello".to_string()),
..Default::default()
}];
let (bedrock_msgs, system) = convert_messages(&messages, &None);
assert_eq!(bedrock_msgs.len(), 1);
@@ -794,6 +798,7 @@ mod tests {
let messages = vec![Message {
role: Role::System,
content: MessageContent::Text("Be helpful".to_string()),
..Default::default()
}];
let (bedrock_msgs, system) = convert_messages(&messages, &None);
assert!(bedrock_msgs.is_empty());
@@ -806,6 +811,7 @@ mod tests {
let messages = vec![Message {
role: Role::User,
content: MessageContent::Text("Hi".to_string()),
..Default::default()
}];
let (_, system) = convert_messages(&messages, &Some("You are an AI".to_string()));
assert!(system.is_some());
@@ -711,6 +711,7 @@ mod tests {
messages: vec![Message {
role: Role::User,
content: MessageContent::text("Hello"),
..Default::default()
}],
tools: vec![],
max_tokens: 1024,
+46 -52
View File
@@ -10,7 +10,7 @@
//! `config.toml`. The driver handles the rest — device flow, token persistence,
//! refresh, and Copilot API token exchange — automatically.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -55,6 +55,7 @@ const OAUTH_SCOPES: &str = "copilot";
const TOKEN_FILE_NAME: &str = ".copilot-tokens.json";
/// Device flow polling interval (seconds) — GitHub default is 5.
#[allow(dead_code)]
const DEVICE_FLOW_POLL_INTERVAL: Duration = Duration::from_secs(5);
/// Maximum time to wait for user to authorize the device flow.
@@ -83,14 +84,14 @@ impl PersistedTokens {
}
/// Load from the OpenFang data directory.
pub fn load(openfang_dir: &PathBuf) -> Option<Self> {
pub fn load(openfang_dir: &Path) -> Option<Self> {
let path = openfang_dir.join(TOKEN_FILE_NAME);
let data = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&data).ok()
}
/// Persist to the OpenFang data directory with restricted permissions.
pub fn save(&self, openfang_dir: &PathBuf) -> Result<(), String> {
pub fn save(&self, openfang_dir: &Path) -> Result<(), String> {
let path = openfang_dir.join(TOKEN_FILE_NAME);
let json = serde_json::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize tokens: {e}"))?;
@@ -138,6 +139,7 @@ impl CachedCopilotToken {
#[derive(Clone)]
struct CachedModels {
models: Vec<String>,
#[allow(dead_code)]
fetched_at: Instant,
}
@@ -169,6 +171,7 @@ struct OAuthTokenResponse {
#[serde(default)]
expires_in: Option<i64>,
#[serde(default)]
#[allow(dead_code)]
refresh_token_expires_in: Option<i64>,
#[serde(default)]
error: Option<String>,
@@ -177,9 +180,7 @@ struct OAuthTokenResponse {
}
/// Request a device code from GitHub using the Copilot client ID.
pub async fn request_device_code(
client: &reqwest::Client,
) -> Result<DeviceCodeResponse, String> {
pub async fn request_device_code(client: &reqwest::Client) -> Result<DeviceCodeResponse, String> {
let resp = client
.post(GITHUB_DEVICE_CODE_URL)
.header("Accept", "application/json")
@@ -259,9 +260,7 @@ pub async fn poll_for_token(
let access_token = token_resp
.access_token
.ok_or("Missing access_token in response")?;
let refresh_token = token_resp
.refresh_token
.unwrap_or_default(); // Empty if token expiration is disabled on the OAuth App
let refresh_token = token_resp.refresh_token.unwrap_or_default(); // Empty if token expiration is disabled on the OAuth App
let expires_in = token_resp.expires_in.unwrap_or(0); // 0 = non-expiring
return Ok(PersistedTokens {
@@ -361,7 +360,7 @@ pub async fn exchange_copilot_token(
.ok_or("Missing 'token' field in Copilot response")?;
let expires_at_unix = body.get("expires_at").and_then(|v| v.as_i64()).unwrap_or(0);
let ttl_secs = (expires_at_unix - unix_now() as i64).max(60) as u64;
let ttl_secs = (expires_at_unix - unix_now()).max(60) as u64;
// Extract base URL from endpoints.api or proxy-ep in the token.
let base_url = body
@@ -444,7 +443,11 @@ pub async fn fetch_models(
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.get("id").and_then(|id| id.as_str()).map(|s| s.to_string()))
.filter_map(|m| {
m.get("id")
.and_then(|id| id.as_str())
.map(|s| s.to_string())
})
.collect()
})
.unwrap_or_default();
@@ -514,12 +517,7 @@ impl CopilotDriver {
};
if let Some(ref rt) = refresh_token {
match refresh_access_token(
&self.http_client,
rt,
)
.await
{
match refresh_access_token(&self.http_client, rt).await {
Ok(new_tokens) => {
info!("Copilot access token refreshed successfully");
if let Err(e) = new_tokens.save(&self.openfang_dir) {
@@ -547,8 +545,9 @@ impl CopilotDriver {
}
/// Ensure we have a valid Copilot API token (tid=…).
async fn ensure_copilot_token(&self) -> Result<CachedCopilotToken, crate::llm_driver::LlmError>
{
async fn ensure_copilot_token(
&self,
) -> Result<CachedCopilotToken, crate::llm_driver::LlmError> {
// Check cache.
{
let lock = self.copilot_token.lock().unwrap_or_else(|e| e.into_inner());
@@ -595,13 +594,16 @@ impl CopilotDriver {
&self,
copilot_token: &CachedCopilotToken,
) -> Result<Vec<String>, crate::llm_driver::LlmError> {
let models =
fetch_models(&self.http_client, &copilot_token.base_url, &copilot_token.token)
.await
.map_err(|e| crate::llm_driver::LlmError::Api {
status: 500,
message: format!("Failed to fetch model list: {e}"),
})?;
let models = fetch_models(
&self.http_client,
&copilot_token.base_url,
&copilot_token.token,
)
.await
.map_err(|e| crate::llm_driver::LlmError::Api {
status: 500,
message: format!("Failed to fetch model list: {e}"),
})?;
let mut lock = self.models.lock().unwrap_or_else(|e| e.into_inner());
*lock = Some(CachedModels {
@@ -638,10 +640,7 @@ impl CopilotDriver {
execute: F,
) -> Result<crate::llm_driver::CompletionResponse, crate::llm_driver::LlmError>
where
F: Fn(
super::openai::OpenAIDriver,
crate::llm_driver::CompletionRequest,
) -> Fut,
F: Fn(super::openai::OpenAIDriver, crate::llm_driver::CompletionRequest) -> Fut,
Fut: std::future::Future<
Output = Result<crate::llm_driver::CompletionResponse, crate::llm_driver::LlmError>,
>,
@@ -652,9 +651,10 @@ impl CopilotDriver {
match execute(driver, request.clone()).await {
Ok(resp) => Ok(resp),
Err(crate::llm_driver::LlmError::Api { status, ref message })
if status == 400 && message.contains("model_not_supported") =>
{
Err(crate::llm_driver::LlmError::Api {
status,
ref message,
}) if status == 400 && message.contains("model_not_supported") => {
// Refresh model list so subsequent calls have updated info.
warn!(
model = %request.model,
@@ -683,9 +683,10 @@ impl crate::llm_driver::LlmDriver for CopilotDriver {
&self,
request: crate::llm_driver::CompletionRequest,
) -> Result<crate::llm_driver::CompletionResponse, crate::llm_driver::LlmError> {
self.execute_with_model_retry(request, |driver, req| async move {
driver.complete(req).await
})
self.execute_with_model_retry(
request,
|driver, req| async move { driver.complete(req).await },
)
.await
}
@@ -700,9 +701,10 @@ impl crate::llm_driver::LlmDriver for CopilotDriver {
match driver.stream(request.clone(), tx.clone()).await {
Ok(resp) => Ok(resp),
Err(crate::llm_driver::LlmError::Api { status, ref message })
if status == 400 && message.contains("model_not_supported") =>
{
Err(crate::llm_driver::LlmError::Api {
status,
ref message,
}) if status == 400 && message.contains("model_not_supported") => {
warn!(
model = %request.model,
"Model not supported — refreshing model catalog"
@@ -732,9 +734,7 @@ impl crate::llm_driver::LlmDriver for CopilotDriver {
///
/// Called from `openfang config set-key github-copilot`, `openfang init`,
/// `openfang onboard`, and `openfang configure`.
pub async fn run_interactive_setup(
openfang_dir: &PathBuf,
) -> Result<PersistedTokens, String> {
pub async fn run_interactive_setup(openfang_dir: &Path) -> Result<PersistedTokens, String> {
run_device_flow(openfang_dir).await
}
@@ -742,9 +742,7 @@ pub async fn run_interactive_setup(
///
/// Prints the user code and verification URL, attempts to open the browser,
/// then polls until the user authorizes.
pub async fn run_device_flow(
openfang_dir: &PathBuf,
) -> Result<PersistedTokens, String> {
pub async fn run_device_flow(openfang_dir: &Path) -> Result<PersistedTokens, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
@@ -767,12 +765,7 @@ pub async fn run_device_flow(
println!(" Waiting for authorization...");
// Step 3: Poll for authorization.
let tokens = poll_for_token(
&client,
&device.device_code,
device.interval,
)
.await?;
let tokens = poll_for_token(&client, &device.device_code, device.interval).await?;
// Step 4: Persist.
tokens.save(openfang_dir)?;
@@ -782,6 +775,7 @@ pub async fn run_device_flow(
}
/// Read a line from stdin with a prompt. Used during interactive setup.
#[allow(dead_code)]
fn prompt_line(prompt: &str) -> Result<String, String> {
use std::io::{self, BufRead, Write};
print!("{prompt}");
@@ -825,7 +819,7 @@ pub fn open_verification_url(url: &str) -> bool {
}
/// Check if Copilot OAuth tokens exist on disk.
pub fn copilot_auth_available(openfang_dir: &PathBuf) -> bool {
pub fn copilot_auth_available(openfang_dir: &Path) -> bool {
openfang_dir.join(TOKEN_FILE_NAME).exists()
}
+107 -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 }));
@@ -1362,6 +1453,7 @@ mod tests {
Message {
role: Role::System,
content: MessageContent::Text("System prompt here.".to_string()),
..Default::default()
},
Message::user("Hi"),
];
@@ -1495,6 +1587,7 @@ mod tests {
"thought_signature": "sig_xyz789"
})),
}]),
..Default::default()
},
Message {
role: Role::User,
@@ -1504,6 +1597,7 @@ mod tests {
content: "Results about Rust programming".to_string(),
is_error: false,
}]),
..Default::default()
},
];
@@ -1541,6 +1635,7 @@ mod tests {
"thought_signature": "text_sig_abc"
})),
}]),
..Default::default()
},
];
@@ -1612,6 +1707,7 @@ mod tests {
input: serde_json::json!({"path": "/tmp/test"}),
provider_metadata: None,
}]),
..Default::default()
},
Message {
role: Role::User,
@@ -1621,6 +1717,7 @@ mod tests {
content: "file contents".to_string(),
is_error: false,
}]),
..Default::default()
},
];
@@ -1864,6 +1961,7 @@ mod tests {
Message {
role: Role::Assistant,
content: MessageContent::Blocks(completion.content),
..Default::default()
},
Message {
role: Role::User,
@@ -1873,6 +1971,7 @@ mod tests {
content: "search results".to_string(),
is_error: false,
}]),
..Default::default()
},
];
let (contents, _) = convert_messages(&messages, &None);
@@ -1994,7 +2093,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]),
+152 -11
View File
@@ -19,7 +19,7 @@ use openfang_types::model_catalog::{
AI21_BASE_URL, ANTHROPIC_BASE_URL, AZURE_OPENAI_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL,
COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL,
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, NOVITA_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NOVITA_BASE_URL, NVIDIA_NIM_BASE_URL,
OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL,
QWEN_BASE_URL, REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL,
VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
@@ -325,10 +325,28 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
// Claude Code CLI — subprocess-based, no API key needed
if provider == "claude-code" {
let cli_path = config.base_url.clone();
return Ok(Arc::new(claude_code::ClaudeCodeDriver::new(
cli_path,
config.skip_permissions,
)));
// Timeout precedence (highest wins):
// 1. OPENFANG_SUBPROCESS_TIMEOUT_SECS env var (no-rebuild override for emergencies)
// 2. DriverConfig.subprocess_timeout_secs, populated upstream from
// config.toml — `default_model.subprocess_timeout_secs` for the
// primary driver, `[[fallback_providers]].subprocess_timeout_secs`
// for global fallbacks. See kernel.rs::resolve_driver and
// kernel.rs::create_drivers for the wiring.
// 3. Driver default (currently 300s, set inside ClaudeCodeDriver::new)
// NOTE: The field and env var are scope-named to apply to any subprocess
// driver, but today only `provider = "claude-code"` reads them. Other
// drivers accept the field silently (forward-compat); future subprocess
// drivers (qwen-code, etc.) will opt in here individually.
let timeout = std::env::var("OPENFANG_SUBPROCESS_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or(config.subprocess_timeout_secs);
return Ok(Arc::new(match timeout {
Some(secs) => {
claude_code::ClaudeCodeDriver::with_timeout(cli_path, config.skip_permissions, secs)
}
None => claude_code::ClaudeCodeDriver::new(cli_path, config.skip_permissions),
}));
}
// Qwen Code CLI — subprocess-based, uses Qwen OAuth (free tier)
@@ -614,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() {
@@ -648,6 +699,7 @@ mod tests {
api_key: Some("test".to_string()),
base_url: Some("http://localhost:9999/v1".to_string()),
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(driver.is_ok());
@@ -660,6 +712,7 @@ mod tests {
api_key: None,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(driver.is_err());
@@ -772,29 +825,33 @@ 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,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(
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,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(driver.is_err());
@@ -803,30 +860,34 @@ 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
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(
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,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(driver.is_err());
@@ -835,13 +896,15 @@ 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,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let result = create_driver(&config);
assert!(result.is_err());
@@ -851,7 +914,6 @@ mod tests {
"Error should mention base_url: {}",
err
);
std::env::remove_var("MYCUSTOM_API_KEY");
}
#[test]
@@ -870,6 +932,7 @@ mod tests {
api_key: Some("explicit-key".to_string()),
base_url: Some("https://api.example.com/v1".to_string()),
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(driver.is_ok());
@@ -897,6 +960,7 @@ mod tests {
api_key: Some("test-azure-key".to_string()),
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(driver.is_ok(), "Azure driver with key + URL should succeed");
@@ -909,6 +973,7 @@ mod tests {
api_key: None,
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
skip_permissions: true,
subprocess_timeout_secs: None,
};
let result = create_driver(&config);
assert!(result.is_err(), "Azure driver without key should error");
@@ -927,6 +992,7 @@ mod tests {
api_key: Some("test-azure-key".to_string()),
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let result = create_driver(&config);
assert!(result.is_err(), "Azure driver without URL should error");
@@ -945,6 +1011,7 @@ mod tests {
api_key: Some("test-azure-key".to_string()),
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(
@@ -969,6 +1036,7 @@ mod tests {
api_key: Some("test-bedrock-api-key".to_string()),
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
// Should succeed because api_key is provided
let driver = create_driver(&config);
@@ -977,4 +1045,77 @@ mod tests {
"Bedrock with explicit api_key should construct successfully"
);
}
#[test]
fn test_claude_code_driver_constructs_with_default_timeout() {
// No timeout in config and no env override → driver uses its built-in default.
std::env::remove_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS");
let config = DriverConfig {
provider: "claude-code".to_string(),
api_key: None,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: None,
};
let driver = create_driver(&config);
assert!(driver.is_ok(), "claude-code driver should construct");
}
#[test]
fn test_claude_code_driver_constructs_with_config_timeout() {
// Timeout set via config field → with_timeout path is exercised.
std::env::remove_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS");
let config = DriverConfig {
provider: "claude-code".to_string(),
api_key: None,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: Some(480),
};
let driver = create_driver(&config);
assert!(
driver.is_ok(),
"claude-code driver should construct with custom timeout"
);
}
#[test]
fn test_claude_code_driver_constructs_with_env_timeout_override() {
// Env var present → wins over config field. We can't read the timeout off the
// trait object here, but at minimum the construction path must not panic
// when both are set and the env var parses cleanly.
std::env::set_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS", "600");
let config = DriverConfig {
provider: "claude-code".to_string(),
api_key: None,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: Some(120),
};
let driver = create_driver(&config);
std::env::remove_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS");
assert!(
driver.is_ok(),
"claude-code driver should construct when env override is set"
);
}
#[test]
fn test_claude_code_driver_ignores_unparseable_env_timeout() {
// Garbage env var → falls through to config field, doesn't error.
std::env::set_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS", "not-a-number");
let config = DriverConfig {
provider: "claude-code".to_string(),
api_key: None,
base_url: None,
skip_permissions: true,
subprocess_timeout_secs: Some(420),
};
let driver = create_driver(&config);
std::env::remove_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS");
assert!(
driver.is_ok(),
"unparseable env override should fall through to config field"
);
}
}
+311 -129
View File
@@ -276,7 +276,7 @@ struct OaiUsage {
/// Strip trailing empty assistant messages without tool calls.
/// Some API proxies reject empty assistant messages as "prefill".
fn strip_trailing_empty_assistant(messages: &mut Vec<OaiMessage>) {
while messages.last().map_or(false, |m| {
while messages.last().is_some_and(|m| {
m.role == "assistant"
&& m.tool_calls.is_none()
&& match &m.content {
@@ -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"
);
}
}
@@ -457,6 +457,7 @@ mod tests {
messages: vec![Message {
role: Role::User,
content: MessageContent::text("Hello"),
..Default::default()
}],
tools: vec![],
max_tokens: 1024,
+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"
);
}
}
@@ -43,6 +43,15 @@ pub trait KernelHandle: Send + Sync {
/// Kill an agent by ID.
fn kill_agent(&self, agent_id: &str) -> Result<(), String>;
/// Activate (wake up) an inactive agent by ID, flipping its state to Running.
/// Used by orchestrator agents to dispatch work to currently inactive agents
/// (Suspended, Crashed, or never-started). Terminated agents cannot be revived.
/// Returns the agent's name on success.
fn activate_agent(&self, agent_id: &str) -> Result<String, String> {
let _ = agent_id;
Err("Agent activation not available".to_string())
}
/// Store a value in shared memory (cross-agent accessible).
fn memory_store(&self, key: &str, value: serde_json::Value) -> Result<(), String>;
+1
View File
@@ -8,6 +8,7 @@
pub const USER_AGENT: &str = "openfang/0.3.48";
pub mod a2a;
pub mod agent_context;
pub mod agent_loop;
pub mod apply_patch;
pub mod audit;
+23
View File
@@ -100,6 +100,7 @@ impl CompletionResponse {
self.content.iter().any(|block| match block {
ContentBlock::Text { text, .. } => !text.is_empty(),
ContentBlock::Thinking { thinking, .. } => !thinking.is_empty(),
ContentBlock::RedactedThinking { data } => !data.is_empty(),
ContentBlock::ToolUse { .. } | ContentBlock::Image { .. } => true,
_ => false,
})
@@ -188,6 +189,27 @@ pub struct DriverConfig {
/// restricts what agents can do, making this safe.
#[serde(default = "default_skip_permissions")]
pub skip_permissions: bool,
/// Per-message subprocess turn timeout in seconds.
///
/// Caps how long the runtime will wait for a single CLI subprocess turn
/// (one message round-trip) before killing the process and reporting a
/// timeout failure. When unset, the driver's own default is used
/// (currently 300s). Long-context Opus calls with heavy tool surfaces
/// routinely take >4 minutes, so users running large prompts may want
/// to bump this to 480600s.
///
/// Can also be overridden at runtime via the
/// `OPENFANG_SUBPROCESS_TIMEOUT_SECS` env var, which wins over both
/// this field and the driver default.
///
/// **Scope:** Currently only honored by `provider = "claude-code"`.
/// Other providers (`default`, `qwen-code`, `openai`, `bedrock`, etc.)
/// accept the field for forward-compatibility but silently ignore it
/// today. As additional subprocess-based drivers are added, they will
/// opt in to this field individually.
#[serde(default)]
pub subprocess_timeout_secs: Option<u64>,
}
fn default_skip_permissions() -> bool {
@@ -202,6 +224,7 @@ impl std::fmt::Debug for DriverConfig {
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("base_url", &self.base_url)
.field("skip_permissions", &self.skip_permissions)
.field("subprocess_timeout_secs", &self.subprocess_timeout_secs)
.finish()
}
}
+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",
] {
@@ -114,16 +114,44 @@ impl MediaEngine {
let model = default_audio_model(provider);
// Build API request
// Build API request.
//
// `audio_base_url` (config.media.audio_base_url) overrides the hardcoded
// provider URL when set, 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 the cloud provider.
// The Authorization header is still built from the provider's standard
// env var (`*_API_KEY`); local services typically accept any non-empty
// bearer token. Closes #1051.
let (api_url, api_key) = match provider {
"groq" => (
"https://api.groq.com/openai/v1/audio/transcriptions",
std::env::var("GROQ_API_KEY").map_err(|_| "GROQ_API_KEY not set")?,
),
"openai" => (
"https://api.openai.com/v1/audio/transcriptions",
std::env::var("OPENAI_API_KEY").map_err(|_| "OPENAI_API_KEY not set")?,
),
"groq" => {
let url = self
.config
.audio_base_url
.as_deref()
.map(|base| format!("{}/v1/audio/transcriptions", base.trim_end_matches('/')))
.unwrap_or_else(|| {
"https://api.groq.com/openai/v1/audio/transcriptions".to_string()
});
(
url,
std::env::var("GROQ_API_KEY").map_err(|_| "GROQ_API_KEY not set")?,
)
}
"openai" => {
let url = self
.config
.audio_base_url
.as_deref()
.map(|base| format!("{}/v1/audio/transcriptions", base.trim_end_matches('/')))
.unwrap_or_else(|| {
"https://api.openai.com/v1/audio/transcriptions".to_string()
});
(
url,
std::env::var("OPENAI_API_KEY").map_err(|_| "OPENAI_API_KEY not set")?,
)
}
other => return Err(format!("Unsupported audio provider: {}", other)),
};
@@ -141,7 +169,7 @@ impl MediaEngine {
let client = reqwest::Client::new();
let resp = client
.post(api_url)
.post(&api_url)
.bearer_auth(&api_key)
.multipart(form)
.timeout(std::time::Duration::from_secs(60))
@@ -412,6 +440,62 @@ mod tests {
assert!(engine.semaphore.available_permits() <= 8);
}
/// Closes #1051: when `audio_base_url` is set, the URL building logic
/// must use the override (with `/v1/audio/transcriptions` appended) and
/// strip any trailing slash from the user-supplied base. When unset, the
/// hardcoded provider URL is used.
#[test]
fn test_audio_base_url_override_logic() {
// Helper closure mirroring the URL construction in `transcribe_audio`
// for both providers, kept in sync intentionally.
fn build(provider: &str, base: Option<&str>) -> String {
match provider {
"groq" => base
.map(|b| format!("{}/v1/audio/transcriptions", b.trim_end_matches('/')))
.unwrap_or_else(|| {
"https://api.groq.com/openai/v1/audio/transcriptions".to_string()
}),
"openai" => base
.map(|b| format!("{}/v1/audio/transcriptions", b.trim_end_matches('/')))
.unwrap_or_else(|| {
"https://api.openai.com/v1/audio/transcriptions".to_string()
}),
_ => unreachable!(),
}
}
// Default: hardcoded provider URLs preserved (backward compatibility).
assert_eq!(
build("openai", None),
"https://api.openai.com/v1/audio/transcriptions"
);
assert_eq!(
build("groq", None),
"https://api.groq.com/openai/v1/audio/transcriptions"
);
// Override applied for both providers.
assert_eq!(
build("openai", Some("http://127.0.0.1:8000")),
"http://127.0.0.1:8000/v1/audio/transcriptions"
);
assert_eq!(
build("groq", Some("http://localhost:9000")),
"http://localhost:9000/v1/audio/transcriptions"
);
// Trailing slash on the user-supplied base is stripped to avoid
// double slashes in the final URL.
assert_eq!(
build("openai", Some("http://127.0.0.1:8000/")),
"http://127.0.0.1:8000/v1/audio/transcriptions"
);
assert_eq!(
build("openai", Some("https://whisper.example.com/")),
"https://whisper.example.com/v1/audio/transcriptions"
);
}
#[tokio::test]
async fn test_describe_image_wrong_type() {
let engine = MediaEngine::new(MediaConfig::default());
+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"
);
}
}
+42 -1
View File
@@ -61,6 +61,11 @@ pub struct PromptContext {
pub sender_id: Option<String>,
/// Sender display name.
pub sender_name: Option<String>,
/// Current on-disk `context.md` content for the agent (see `agent_context`).
///
/// Read per-turn by the kernel so external writers (cron jobs, integrations)
/// are reflected in the next LLM call. See issue #843.
pub context_md: Option<String>,
}
/// Build the complete system prompt from a `PromptContext`.
@@ -204,6 +209,19 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String {
}
}
// Section 15 — Live agent context (`context.md`). Re-read per turn so
// external writers (e.g. cron jobs refreshing live data) show up on the
// very next message. See issue #843.
if let Some(ref live) = ctx.context_md {
let trimmed = live.trim();
if !trimmed.is_empty() {
sections.push(format!(
"## Live Context\nThe following context is refreshed from `context.md` each turn and may change between messages.\n\n{}",
cap_str(trimmed, 8000)
));
}
}
sections.join("\n\n")
}
@@ -501,7 +519,7 @@ pub fn tool_category(name: &str) -> &'static str {
"memory_store" | "memory_recall" | "memory_delete" | "memory_list" => "Memory",
"agent_send" | "agent_spawn" | "agent_list" | "agent_kill" => "Agents",
"agent_send" | "agent_spawn" | "agent_list" | "agent_kill" | "agent_activate" => "Agents",
"image_describe" | "image_generate" | "audio_transcribe" | "tts_speak" => "Media",
@@ -563,6 +581,7 @@ pub fn tool_hint(name: &str) -> &'static str {
"agent_spawn" => "create a new agent",
"agent_list" => "list running agents",
"agent_kill" => "terminate an agent",
"agent_activate" => "wake up an inactive agent so it can receive work",
// Media
"image_describe" => "describe an image",
@@ -929,6 +948,28 @@ mod tests {
assert!(prompt.contains("A helpful agent"));
}
#[test]
fn test_context_md_section_included() {
let mut ctx = basic_ctx();
ctx.context_md = Some("BTCUSD: 67000\nETHUSD: 3400".to_string());
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("## Live Context"));
assert!(prompt.contains("BTCUSD: 67000"));
assert!(prompt.contains("ETHUSD: 3400"));
}
#[test]
fn test_context_md_section_omitted_when_empty_or_none() {
let mut ctx = basic_ctx();
ctx.context_md = None;
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("## Live Context"));
ctx.context_md = Some(" \n\n ".to_string());
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("## Live Context"));
}
#[test]
fn test_workspace_in_persona() {
let mut ctx = basic_ctx();
+6
View File
@@ -198,6 +198,7 @@ mod tests {
vec![Message {
role: Role::User,
content: MessageContent::text("Hello!"),
..Default::default()
}],
vec![],
);
@@ -216,6 +217,7 @@ mod tests {
"Write a function that implements async file reading with struct and impl blocks:\n\
```rust\nfn main() { }\n```"
),
..Default::default()
}],
vec![],
);
@@ -238,6 +240,7 @@ mod tests {
vec![Message {
role: Role::User,
content: MessageContent::text("Use the available tools to solve this problem."),
..Default::default()
}],
tools,
);
@@ -257,6 +260,7 @@ mod tests {
"This is message {} with enough content to add some token weight to the conversation.",
i
)),
..Default::default()
})
.collect();
let request = make_request(messages, vec![]);
@@ -353,6 +357,7 @@ mod tests {
vec![Message {
role: Role::User,
content: MessageContent::text("Hi"),
..Default::default()
}],
vec![],
);
@@ -363,6 +368,7 @@ mod tests {
vec![Message {
role: Role::User,
content: MessageContent::text("Hi"),
..Default::default()
}],
vec![],
);
+16 -9
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(),
},
);
@@ -286,18 +293,18 @@ impl WasmSandbox {
|mut caller: Caller<'_, GuestState>,
request_ptr: i32,
request_len: i32|
-> Result<i64, anyhow::Error> {
-> Result<i64, Error> {
// Read request from guest memory
let memory = caller
.get_export("memory")
.and_then(|e| e.into_memory())
.ok_or_else(|| anyhow::anyhow!("no memory export"))?;
.ok_or_else(|| format_err!("no memory export"))?;
let data = memory.data(&caller);
let start = request_ptr as usize;
let end = start + request_len as usize;
if end > data.len() {
anyhow::bail!("host_call: request out of bounds");
bail!("host_call: request out of bounds");
}
let request_bytes = data[start..end].to_vec();
@@ -324,7 +331,7 @@ impl WasmSandbox {
let alloc_fn = caller
.get_export("alloc")
.and_then(|e| e.into_func())
.ok_or_else(|| anyhow::anyhow!("no alloc export"))?;
.ok_or_else(|| format_err!("no alloc export"))?;
let alloc_typed = alloc_fn.typed::<i32, i32>(&caller)?;
let ptr = alloc_typed.call(&mut caller, len)?;
@@ -332,12 +339,12 @@ impl WasmSandbox {
let memory = caller
.get_export("memory")
.and_then(|e| e.into_memory())
.ok_or_else(|| anyhow::anyhow!("no memory export"))?;
.ok_or_else(|| format_err!("no memory export"))?;
let mem_data = memory.data_mut(&mut caller);
let dest_start = ptr as usize;
let dest_end = dest_start + response_bytes.len();
if dest_end > mem_data.len() {
anyhow::bail!("host_call: response exceeds memory bounds");
bail!("host_call: response exceeds memory bounds");
}
mem_data[dest_start..dest_end].copy_from_slice(&response_bytes);
@@ -356,17 +363,17 @@ impl WasmSandbox {
level: i32,
msg_ptr: i32,
msg_len: i32|
-> Result<(), anyhow::Error> {
-> Result<(), Error> {
let memory = caller
.get_export("memory")
.and_then(|e| e.into_memory())
.ok_or_else(|| anyhow::anyhow!("no memory export"))?;
.ok_or_else(|| format_err!("no memory export"))?;
let data = memory.data(&caller);
let start = msg_ptr as usize;
let end = start + msg_len as usize;
if end > data.len() {
anyhow::bail!("host_log: pointer out of bounds");
bail!("host_log: pointer out of bounds");
}
let msg = std::str::from_utf8(&data[start..end]).unwrap_or("<invalid utf8>");
let agent_id = &caller.data().agent_id;
+26 -2
View File
@@ -118,6 +118,7 @@ pub fn validate_and_repair_with_stats(messages: &[Message]) -> (Vec<Message>, Re
cleaned.push(Message {
role: msg.role,
content: new_content,
..Default::default()
});
}
@@ -331,7 +332,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) {
@@ -356,6 +357,7 @@ fn reorder_tool_results(messages: &mut Vec<Message>) -> usize {
Message {
role: Role::User,
content: MessageContent::Blocks(blocks),
..Default::default()
},
);
}
@@ -433,7 +435,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;
@@ -456,6 +458,7 @@ fn insert_synthetic_results(messages: &mut Vec<Message>) -> usize {
Message {
role: Role::User,
content: MessageContent::Blocks(blocks),
..Default::default()
},
);
}
@@ -770,6 +773,7 @@ mod tests {
content: "some result".to_string(),
is_error: false,
}]),
..Default::default()
},
Message::assistant("Done"),
];
@@ -804,6 +808,7 @@ mod tests {
Message {
role: Role::User,
content: MessageContent::Text(String::new()),
..Default::default()
},
Message::assistant("Hi"),
];
@@ -823,6 +828,7 @@ mod tests {
input: serde_json::json!({"query": "rust"}),
provider_metadata: None,
}]),
..Default::default()
},
Message {
role: Role::User,
@@ -832,6 +838,7 @@ mod tests {
content: "Results found".to_string(),
is_error: false,
}]),
..Default::default()
},
Message::assistant("Here are the results"),
];
@@ -855,6 +862,7 @@ mod tests {
input: serde_json::json!({"query": "rust"}),
provider_metadata: None,
}]),
..Default::default()
},
Message::user("While you search, I have another question"),
Message {
@@ -865,6 +873,7 @@ mod tests {
content: "Search results".to_string(),
is_error: false,
}]),
..Default::default()
},
Message::assistant("Here are results"),
];
@@ -909,6 +918,7 @@ mod tests {
input: serde_json::json!({"path": "/etc/hosts"}),
provider_metadata: None,
}]),
..Default::default()
},
Message::assistant("I tried to read the file"),
];
@@ -947,6 +957,7 @@ mod tests {
input: serde_json::json!({}),
provider_metadata: None,
}]),
..Default::default()
},
Message {
role: Role::User,
@@ -956,6 +967,7 @@ mod tests {
content: "First result".to_string(),
is_error: false,
}]),
..Default::default()
},
Message {
role: Role::User,
@@ -965,6 +977,7 @@ mod tests {
content: "Duplicate result".to_string(),
is_error: false,
}]),
..Default::default()
},
Message::assistant("Done"),
];
@@ -1014,6 +1027,7 @@ mod tests {
input: serde_json::json!({"key": "fact1", "value": "hello"}),
provider_metadata: None,
}]),
..Default::default()
},
// Matching ToolResult for the first call.
Message {
@@ -1024,6 +1038,7 @@ mod tests {
content: "stored".to_string(),
is_error: false,
}]),
..Default::default()
},
// Second turn: assistant calls memory_store again with the SAME id
// because Moonshot reuses the `function_name:index` format.
@@ -1035,6 +1050,7 @@ mod tests {
input: serde_json::json!({"key": "fact2", "value": "world"}),
provider_metadata: None,
}]),
..Default::default()
},
// No matching ToolResult for the second call (e.g. lost during
// compaction or interrupted mid-execution).
@@ -1201,11 +1217,13 @@ mod tests {
content: "lost".to_string(),
is_error: false,
}]),
..Default::default()
},
Message::user("World"),
Message {
role: Role::User,
content: MessageContent::Text(String::new()),
..Default::default()
},
Message::assistant("Hi"),
];
@@ -1228,6 +1246,7 @@ mod tests {
text: String::new(),
provider_metadata: None,
}]),
..Default::default()
},
Message::user("Never mind"),
Message::assistant("OK"),
@@ -1274,6 +1293,7 @@ mod tests {
provider_metadata: None,
},
]),
..Default::default()
},
// Only tu-a has a result, tu-b is missing
Message {
@@ -1284,6 +1304,7 @@ mod tests {
content: "search result".to_string(),
is_error: false,
}]),
..Default::default()
},
// Orphaned result from a non-existent tool use
Message {
@@ -1294,11 +1315,13 @@ mod tests {
content: "ghost result".to_string(),
is_error: false,
}]),
..Default::default()
},
// Empty message
Message {
role: Role::User,
content: MessageContent::Text(String::new()),
..Default::default()
},
Message::assistant("Done"),
];
@@ -1350,6 +1373,7 @@ mod tests {
is_error: false,
},
]),
..Default::default()
},
Message::assistant("Hi"),
];
@@ -192,13 +192,13 @@ fn extract_shell_wrapper_commands(command: &str) -> Vec<String> {
let base_lower = base.to_lowercase();
// Also strip .exe suffix for Windows
let base_normalized = base_lower.strip_suffix(".exe").unwrap_or(&base_lower);
if !SHELL_WRAPPERS.iter().any(|w| *w == base_normalized) {
if !SHELL_WRAPPERS.contains(&base_normalized) {
return Vec::new();
}
// Find the inline flag and extract everything after it
for (wrappers, flag) in SHELL_INLINE_FLAGS {
if !wrappers.iter().any(|w| *w == base_normalized) {
if !wrappers.contains(&base_normalized) {
continue;
}
// Search for the flag in the command args (case-insensitive for PowerShell)
@@ -1089,10 +1089,7 @@ mod tests {
allowed_commands: vec!["powershell".to_string(), "Get-Process".to_string()],
..ExecPolicy::default()
};
let result = validate_command_allowlist(
r#"powershell -Command "Get-Process""#,
&policy,
);
let result = validate_command_allowlist(r#"powershell -Command "Get-Process""#, &policy);
assert!(
result.is_ok(),
"Get-Process should be allowed when in allowed_commands"
@@ -1106,10 +1103,8 @@ mod tests {
allowed_commands: vec!["cmd".to_string()],
..ExecPolicy::default()
};
let result = validate_command_allowlist(
r#"cmd /C "del /F /Q C:\temp\secret.txt""#,
&policy,
);
let result =
validate_command_allowlist(r#"cmd /C "del /F /Q C:\temp\secret.txt""#, &policy);
assert!(
result.is_err(),
"del inside cmd /C must be blocked when not in allowlist"
@@ -1123,10 +1118,7 @@ mod tests {
allowed_commands: vec!["bash".to_string()],
..ExecPolicy::default()
};
let result = validate_command_allowlist(
r#"bash -c "curl https://evil.com""#,
&policy,
);
let result = validate_command_allowlist(r#"bash -c "curl https://evil.com""#, &policy);
assert!(
result.is_err(),
"curl inside bash -c must be blocked when not in allowlist"
@@ -1141,10 +1133,7 @@ mod tests {
..ExecPolicy::default()
};
// "echo" is in safe_bins by default
let result = validate_command_allowlist(
r#"bash -c "echo hello""#,
&policy,
);
let result = validate_command_allowlist(r#"bash -c "echo hello""#, &policy);
assert!(
result.is_ok(),
"echo inside bash -c should be allowed (echo is in safe_bins)"
+413 -52
View File
@@ -299,6 +299,7 @@ pub async fn execute_tool(
"agent_spawn" => tool_agent_spawn(input, kernel, caller_agent_id).await,
"agent_list" => tool_agent_list(kernel),
"agent_kill" => tool_agent_kill(input, kernel),
"agent_activate" => tool_agent_activate(input, kernel),
// Shared memory tools
"memory_store" => tool_memory_store(input, kernel),
@@ -313,8 +314,8 @@ pub async fn execute_tool(
"event_publish" => tool_event_publish(input, kernel).await,
// Scheduling tools
"schedule_create" => tool_schedule_create(input, kernel).await,
"schedule_list" => tool_schedule_list(kernel).await,
"schedule_create" => tool_schedule_create(input, kernel, caller_agent_id).await,
"schedule_list" => tool_schedule_list(kernel, caller_agent_id).await,
"schedule_delete" => tool_schedule_delete(input, kernel).await,
// Knowledge graph tools
@@ -694,6 +695,24 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"required": ["agent_id"]
}),
},
ToolDefinition {
name: "agent_activate".to_string(),
description: "Activate (wake up) an inactive agent so it can receive messages \
and process events. Use this when agent_list shows an agent in a \
Suspended, Crashed, or Created state and you want to delegate work \
to it via agent_send. Terminated agents cannot be revived."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "The target agent's UUID or human-readable name"
}
},
"required": ["agent_id"]
}),
},
// --- Shared memory tools ---
ToolDefinition {
name: "memory_store".to_string(),
@@ -1696,6 +1715,20 @@ fn tool_agent_kill(
Ok(format!("Agent {agent_id} killed successfully."))
}
fn tool_agent_activate(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let agent_id = input["agent_id"]
.as_str()
.ok_or("Missing 'agent_id' parameter")?;
let name = kh.activate_agent(agent_id)?;
Ok(format!(
"Agent '{name}' activated. It is now Running and ready to receive messages."
))
}
// ---------------------------------------------------------------------------
// Shared memory tools
// ---------------------------------------------------------------------------
@@ -2091,11 +2124,65 @@ fn parse_time_to_hour(s: &str) -> Result<u32, String> {
Ok(hour)
}
const SCHEDULES_KEY: &str = "__openfang_schedules";
/// Sanitize a description into a valid `CronJob.name` (alphanumeric +
/// space/hyphen/underscore, 1..=128 chars).
fn sanitize_schedule_name(description: &str) -> String {
let filtered: String = description
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
let trimmed = filtered.trim();
if trimmed.is_empty() {
return "scheduled-task".to_string();
}
trimmed.chars().take(128).collect()
}
/// Resolve the `agent` field of `schedule_create` into an agent UUID string
/// suitable for `KernelHandle::cron_create`.
///
/// - Empty / "self" → caller's agent ID.
/// - Valid UUID → passed through.
/// - Non-empty name → looked up via `find_agents`; an exact name match wins,
/// a single fuzzy match is accepted, ambiguity is an error.
fn resolve_schedule_target(
kh: &Arc<dyn KernelHandle>,
agent: &str,
caller_agent_id: Option<&str>,
) -> Result<String, String> {
let a = agent.trim();
if a.is_empty() || a.eq_ignore_ascii_case("self") {
return caller_agent_id.map(|s| s.to_string()).ok_or_else(|| {
"No caller agent available; specify 'agent' to target an agent by name or UUID"
.to_string()
});
}
if uuid::Uuid::parse_str(a).is_ok() {
return Ok(a.to_string());
}
let matches = kh.find_agents(a);
if let Some(m) = matches.iter().find(|m| m.name == a) {
return Ok(m.id.clone());
}
match matches.len() {
0 => Err(format!("Agent '{a}' not found")),
1 => Ok(matches[0].id.clone()),
n => Err(format!(
"Agent name '{a}' is ambiguous ({n} matches). Pass the agent UUID."
)),
}
}
async fn tool_schedule_create(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
caller_agent_id: Option<&str>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let description = input["description"]
@@ -2104,58 +2191,77 @@ async fn tool_schedule_create(
let schedule_str = input["schedule"]
.as_str()
.ok_or("Missing 'schedule' parameter")?;
let agent = input["agent"].as_str().unwrap_or("");
let agent_input = input["agent"].as_str().unwrap_or("");
let cron_expr = parse_schedule_to_cron(schedule_str)?;
let schedule_id = uuid::Uuid::new_v4().to_string();
let target_agent_id = resolve_schedule_target(kh, agent_input, caller_agent_id)?;
let name = sanitize_schedule_name(description);
let entry = serde_json::json!({
"id": schedule_id,
"description": description,
"schedule_input": schedule_str,
"cron": cron_expr,
"agent": agent,
"created_at": chrono::Utc::now().to_rfc3339(),
"enabled": true,
let job_json = serde_json::json!({
"name": name,
"schedule": { "kind": "cron", "expr": cron_expr, "tz": null },
"action": {
"kind": "agent_turn",
"message": description,
"model_override": null,
"timeout_secs": null,
},
"delivery": { "kind": "none" },
"one_shot": false,
});
// Load existing schedules from shared memory
let mut schedules: Vec<serde_json::Value> = match kh.memory_recall(SCHEDULES_KEY)? {
Some(serde_json::Value::Array(arr)) => arr,
_ => Vec::new(),
let resp = kh.cron_create(&target_agent_id, job_json).await?;
// Kernel returns JSON `{ "job_id": "...", "status": "created" }`.
let job_id = serde_json::from_str::<serde_json::Value>(&resp)
.ok()
.and_then(|v| v["job_id"].as_str().map(str::to_string))
.unwrap_or_else(|| resp.clone());
let agent_display = if agent_input.trim().is_empty() {
"(self)".to_string()
} else {
agent_input.to_string()
};
schedules.push(entry);
kh.memory_store(SCHEDULES_KEY, serde_json::Value::Array(schedules))?;
Ok(format!(
"Schedule created:\n ID: {schedule_id}\n Description: {description}\n Cron: {cron_expr}\n Original: {schedule_str}"
"Schedule created:\n ID: {job_id}\n Description: {description}\n Cron: {cron_expr}\n Original: {schedule_str}\n Agent: {agent_display}"
))
}
async fn tool_schedule_list(kernel: Option<&Arc<dyn KernelHandle>>) -> Result<String, String> {
async fn tool_schedule_list(
kernel: Option<&Arc<dyn KernelHandle>>,
caller_agent_id: Option<&str>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let agent_id =
caller_agent_id.ok_or("Agent ID required for schedule_list (no caller context)")?;
let schedules: Vec<serde_json::Value> = match kh.memory_recall(SCHEDULES_KEY)? {
Some(serde_json::Value::Array(arr)) => arr,
_ => Vec::new(),
};
if schedules.is_empty() {
let jobs = kh.cron_list(agent_id).await?;
if jobs.is_empty() {
return Ok("No scheduled tasks.".to_string());
}
let mut output = format!("Scheduled tasks ({}):\n\n", schedules.len());
for s in &schedules {
let enabled = s["enabled"].as_bool().unwrap_or(true);
let mut output = format!("Scheduled tasks ({}):\n\n", jobs.len());
for job in &jobs {
let enabled = job["enabled"].as_bool().unwrap_or(true);
let status = if enabled { "active" } else { "paused" };
let id = job["id"].as_str().unwrap_or("?");
let schedule_display = match job["schedule"]["kind"].as_str() {
Some("cron") => job["schedule"]["expr"].as_str().unwrap_or("?").to_string(),
Some("every") => format!(
"every {}s",
job["schedule"]["every_secs"].as_u64().unwrap_or(0)
),
Some("at") => job["schedule"]["at"].as_str().unwrap_or("?").to_string(),
_ => "?".to_string(),
};
let description = job["action"]["message"]
.as_str()
.or_else(|| job["action"]["text"].as_str())
.unwrap_or_else(|| job["name"].as_str().unwrap_or("?"));
let created = job["created_at"].as_str().unwrap_or("?");
let agent = job["agent_id"].as_str().unwrap_or("(self)");
output.push_str(&format!(
" [{status}] {} — {}\n Cron: {} | Agent: {}\n Created: {}\n\n",
s["id"].as_str().unwrap_or("?"),
s["description"].as_str().unwrap_or("?"),
s["cron"].as_str().unwrap_or("?"),
s["agent"].as_str().unwrap_or("(self)"),
s["created_at"].as_str().unwrap_or("?"),
" [{status}] {id} — {description}\n Cron: {schedule_display} | Agent: {agent}\n Created: {created}\n\n"
));
}
@@ -2168,20 +2274,9 @@ async fn tool_schedule_delete(
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let id = input["id"].as_str().ok_or("Missing 'id' parameter")?;
let mut schedules: Vec<serde_json::Value> = match kh.memory_recall(SCHEDULES_KEY)? {
Some(serde_json::Value::Array(arr)) => arr,
_ => Vec::new(),
};
let before = schedules.len();
schedules.retain(|s| s["id"].as_str() != Some(id));
if schedules.len() == before {
return Err(format!("Schedule '{id}' not found."));
}
kh.memory_store(SCHEDULES_KEY, serde_json::Value::Array(schedules))?;
kh.cron_cancel(id)
.await
.map_err(|e| format!("Schedule '{id}' not found: {e}"))?;
Ok(format!("Schedule '{id}' deleted."))
}
@@ -3358,6 +3453,8 @@ mod tests {
assert!(names.contains(&"agent_spawn"));
assert!(names.contains(&"agent_list"));
assert!(names.contains(&"agent_kill"));
// Issue #890 — wake up inactive agents
assert!(names.contains(&"agent_activate"));
assert!(names.contains(&"memory_store"));
assert!(names.contains(&"memory_recall"));
// 6 collaboration tools
@@ -4191,4 +4288,268 @@ mod tests {
assert!(!is_shell_tool("file_read"));
assert!(!is_shell_tool("web_fetch"));
}
// ----------------------------------------------------------------------
// Issue #1069: schedule_* tools route through the kernel cron scheduler
// ----------------------------------------------------------------------
#[test]
fn test_sanitize_schedule_name_strips_punctuation() {
// Colons, commas, dots, and other punctuation are replaced with '-'.
let out = sanitize_schedule_name("Remind me: file report, please.");
assert!(!out.contains(':'));
assert!(!out.contains(','));
assert!(!out.contains('.'));
// Spaces, hyphens, and underscores survive.
assert!(out
.chars()
.all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '_'));
assert!(!out.is_empty());
}
#[test]
fn test_sanitize_schedule_name_empty_fallback() {
assert_eq!(sanitize_schedule_name(""), "scheduled-task");
assert_eq!(sanitize_schedule_name(" "), "scheduled-task");
}
#[test]
fn test_sanitize_schedule_name_caps_length() {
let long = "a".repeat(500);
let out = sanitize_schedule_name(&long);
assert!(out.chars().count() <= 128);
}
// Minimal in-memory KernelHandle used to verify schedule_* tool wiring.
// Records every cron_* call so tests can assert what the tool pushed into
// the kernel, without booting a real OpenFangKernel.
struct FakeKernelHandle {
created: std::sync::Mutex<Vec<(String, serde_json::Value)>>,
cancelled: std::sync::Mutex<Vec<String>>,
jobs: std::sync::Mutex<Vec<serde_json::Value>>,
}
impl FakeKernelHandle {
fn new() -> Self {
Self {
created: std::sync::Mutex::new(Vec::new()),
cancelled: std::sync::Mutex::new(Vec::new()),
jobs: std::sync::Mutex::new(Vec::new()),
}
}
fn with_job(self, job: serde_json::Value) -> Self {
self.jobs.lock().unwrap().push(job);
self
}
}
#[async_trait::async_trait]
impl crate::kernel_handle::KernelHandle for FakeKernelHandle {
async fn spawn_agent(
&self,
_manifest_toml: &str,
_parent_id: Option<&str>,
) -> Result<(String, String), String> {
Err("not used".into())
}
async fn send_to_agent(&self, _agent_id: &str, _message: &str) -> Result<String, String> {
Err("not used".into())
}
fn list_agents(&self) -> Vec<crate::kernel_handle::AgentInfo> {
vec![]
}
fn kill_agent(&self, _agent_id: &str) -> Result<(), String> {
Ok(())
}
fn memory_store(&self, _key: &str, _value: serde_json::Value) -> Result<(), String> {
Ok(())
}
fn memory_recall(&self, _key: &str) -> Result<Option<serde_json::Value>, String> {
Ok(None)
}
fn find_agents(&self, _query: &str) -> Vec<crate::kernel_handle::AgentInfo> {
vec![]
}
async fn task_post(
&self,
_title: &str,
_description: &str,
_assigned_to: Option<&str>,
_created_by: Option<&str>,
) -> Result<String, String> {
Err("not used".into())
}
async fn task_claim(&self, _agent_id: &str) -> Result<Option<serde_json::Value>, String> {
Ok(None)
}
async fn task_complete(&self, _task_id: &str, _result: &str) -> Result<(), String> {
Ok(())
}
async fn task_list(&self, _status: Option<&str>) -> Result<Vec<serde_json::Value>, String> {
Ok(vec![])
}
async fn publish_event(
&self,
_event_type: &str,
_payload: serde_json::Value,
) -> Result<(), String> {
Ok(())
}
async fn knowledge_add_entity(
&self,
_entity: openfang_types::memory::Entity,
) -> Result<String, String> {
Err("not used".into())
}
async fn knowledge_add_relation(
&self,
_relation: openfang_types::memory::Relation,
) -> Result<String, String> {
Err("not used".into())
}
async fn knowledge_query(
&self,
_pattern: openfang_types::memory::GraphPattern,
) -> Result<Vec<openfang_types::memory::GraphMatch>, String> {
Ok(vec![])
}
async fn cron_create(
&self,
agent_id: &str,
job_json: serde_json::Value,
) -> Result<String, String> {
let id = format!("job-{}", self.created.lock().unwrap().len());
self.created
.lock()
.unwrap()
.push((agent_id.to_string(), job_json.clone()));
// Mirror what the real kernel returns (see cron_create in
// openfang-kernel): `{ "job_id": "...", "status": "created" }`.
let resp = serde_json::json!({ "job_id": id, "status": "created" });
Ok(resp.to_string())
}
async fn cron_list(&self, _agent_id: &str) -> Result<Vec<serde_json::Value>, String> {
Ok(self.jobs.lock().unwrap().clone())
}
async fn cron_cancel(&self, job_id: &str) -> Result<(), String> {
self.cancelled.lock().unwrap().push(job_id.to_string());
Ok(())
}
}
#[tokio::test]
async fn test_schedule_create_routes_to_cron_scheduler() {
let fake = Arc::new(FakeKernelHandle::new());
let handle: Arc<dyn crate::kernel_handle::KernelHandle> = fake.clone();
let caller = "11111111-1111-1111-1111-111111111111";
let input = serde_json::json!({
"description": "Daily report",
"schedule": "daily at 9am",
"agent": "self",
});
let out = tool_schedule_create(&input, Some(&handle), Some(caller))
.await
.expect("tool_schedule_create should succeed with a valid schedule");
// User-facing response shape is preserved.
assert!(out.starts_with("Schedule created:"));
assert!(out.contains("Daily report"));
assert!(out.contains("Cron: "));
// The fake kernel received a cron_create for the caller agent with a
// well-formed job_json. This is the whole point of #1069: the tool
// must call into the cron scheduler, not just write to shared memory.
let created = fake.created.lock().unwrap();
assert_eq!(created.len(), 1, "cron_create must be called exactly once");
assert_eq!(created[0].0, caller, "target agent must be the caller");
let job = &created[0].1;
assert_eq!(job["schedule"]["kind"], "cron");
assert_eq!(job["action"]["kind"], "agent_turn");
assert_eq!(job["action"]["message"], "Daily report");
assert!(job["schedule"]["expr"].is_string());
assert_eq!(job["one_shot"], false);
}
#[tokio::test]
async fn test_schedule_create_rejects_missing_description() {
let fake = Arc::new(FakeKernelHandle::new());
let handle: Arc<dyn crate::kernel_handle::KernelHandle> = fake.clone();
let input = serde_json::json!({ "schedule": "every hour" });
let err = tool_schedule_create(&input, Some(&handle), Some("aaa"))
.await
.unwrap_err();
assert!(err.contains("description"));
}
#[tokio::test]
async fn test_schedule_list_reads_from_cron_scheduler() {
let job = serde_json::json!({
"id": "cron-1",
"name": "demo",
"enabled": true,
"schedule": { "kind": "cron", "expr": "0 9 * * *" },
"action": { "kind": "agent_turn", "message": "hello" },
"created_at": "2026-01-01T00:00:00Z",
"agent_id": "aaa",
});
let fake = Arc::new(FakeKernelHandle::new().with_job(job));
let handle: Arc<dyn crate::kernel_handle::KernelHandle> = fake.clone();
let out = tool_schedule_list(Some(&handle), Some("aaa"))
.await
.expect("schedule_list should succeed");
assert!(out.contains("Scheduled tasks (1)"));
assert!(out.contains("0 9 * * *"));
assert!(out.contains("hello"));
}
#[tokio::test]
async fn test_schedule_list_empty() {
let fake = Arc::new(FakeKernelHandle::new());
let handle: Arc<dyn crate::kernel_handle::KernelHandle> = fake.clone();
let out = tool_schedule_list(Some(&handle), Some("aaa"))
.await
.unwrap();
assert_eq!(out, "No scheduled tasks.");
}
#[tokio::test]
async fn test_schedule_delete_routes_to_cron_cancel() {
let fake = Arc::new(FakeKernelHandle::new());
let handle: Arc<dyn crate::kernel_handle::KernelHandle> = fake.clone();
let input = serde_json::json!({ "id": "abc-123" });
let out = tool_schedule_delete(&input, Some(&handle)).await.unwrap();
assert!(out.contains("abc-123"));
let cancelled = fake.cancelled.lock().unwrap();
assert_eq!(cancelled.len(), 1);
assert_eq!(cancelled[0], "abc-123");
}
#[tokio::test]
async fn test_schedule_tools_require_kernel() {
// Without a kernel handle, the new tools must fail loudly rather than
// silently writing to the old shared-memory key.
let err = tool_schedule_create(
&serde_json::json!({"description": "x", "schedule": "every hour"}),
None,
Some("aaa"),
)
.await
.unwrap_err();
assert!(err.to_lowercase().contains("kernel"));
let err = tool_schedule_list(None, Some("aaa")).await.unwrap_err();
assert!(err.to_lowercase().contains("kernel"));
let err = tool_schedule_delete(&serde_json::json!({"id": "x"}), None)
.await
.unwrap_err();
assert!(err.to_lowercase().contains("kernel"));
}
}
+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
+9
View File
@@ -189,6 +189,15 @@ pub fn parse_bundled(name: &str, content: &str) -> Result<SkillManifest, crate::
Ok(converted.manifest)
}
/// Parse a bundled SKILL.md into its full converted form (manifest + declared
/// config vars). Used by the registry loader so it can resolve/inject config.
pub fn parse_bundled_full(
name: &str,
content: &str,
) -> Result<crate::openclaw_compat::ConvertedSkillMd, crate::SkillError> {
convert_skillmd_str(name, content)
}
#[cfg(test)]
mod tests {
use super::*;

Some files were not shown because too many files have changed in this diff Show More