Compare commits

...
72 Commits
Author SHA1 Message Date
jaberjaber23 ea287093c4 bug fixes 2026-03-18 05:58:22 +03:00
jaberjaber23 3688d86ef8 bug fixes 2026-03-18 05:49:42 +03:00
jaberjaber23 9f9903797e bump v0.4.5 2026-03-18 05:42:11 +03:00
jaberjaber23 3cd8847a95 bug fixes 2026-03-18 05:37:32 +03:00
jaberjaber23 88bb55c8f2 bug fixes 2026-03-18 05:08:12 +03:00
jaberjaber23 f1ca52714d feature batch 2026-03-15 20:23:30 +03:00
Evan HuandClaude Opus 4.6 77ed954d18 wecom channel adapter
* feat: Add WeCom (WeChat Work) channel adapter

- Add wecom.rs channel adapter implementation
- Add WeComConfig in config.rs
- Register WeCom adapter in channel_bridge.rs

WeCom channel supports:
- Inbound messages via callback webhook
- Outbound messages via WeCom API
- Access token caching and auto-refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle WeCom callbacks and preserve hand extension tools

* fix: render WeCom replies as plain text

* fix: resolve clippy warnings in wecom adapter

- Remove unused WECOM_API_HOST constant
- Fix needless borrow in send_text call
- Replace assert_eq!(bool, true) with assert!()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt for wecom-related files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt --all

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: upgrade quinn-proto and add cargo audit ignore list

- Upgrade quinn-proto 0.11.13 → 0.11.14 (RUSTSEC-2026-0037 DoS fix)
- Add .cargo/audit.toml to ignore unmaintainable transitive deps
  (tauri GTK3 bindings, time pinned by mac-notification-sys, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:50:43 +03:00
jaberjaber23 4fa2f9474b bug fixes 2026-03-15 19:39:39 +03:00
jaberjaber23 75c9c80679 bug fixes 2026-03-15 18:26:53 +03:00
jaberjaber23 3eaa9e02c9 community fixes 2026-03-15 17:48:09 +03:00
Tilman Baumann f165263296 test merge
* Feat: Add Nix support

Adding Nix support. Nixos modules may follow...

Run directly with `nix run github:RightNow-AI/openfang`

There are a bunch of flake outputs (based on cargo workspace)
Focus on these:
* openfang-cli (default)
* openfang-desktop

* nix: cmake depdencency was introduced via llama

* Follow upstream style
2026-03-15 17:40:22 +03:00
NextDoorLaoHuang-HFandroot c122e1ddd7 test merge
* Improve OpenClaw provider alias migration compatibility

* Fix local provider env mapping regression in migration

* test(migrate): cover json5 default_model provider/env mapping

* test(migrate): add JSON5 agent provider mapping integration tests

* test(migrate): add legacy YAML provider alias integration coverage

* fix(migrate): harden JSON5 provider catalog resolution

* chore(migrate): scope split_model_ref helper to tests

---------

Co-authored-by: root <root@LAPTOP-NGAQG9OH.localdomain>
2026-03-15 17:40:09 +03:00
pluginmdandClaude Opus 4.6 d2ea030f03 test merge
Merge lark.rs features (dedup, encryption, group filtering, rich text parsing)
into feishu.rs with FeishuRegion toggle (cn/intl). Single [channels.feishu]
config handles both domestic Feishu and international Lark via region field.

- Expand FeishuConfig: region, webhook_path, verification_token, encrypt_key_env, bot_names
- Add FeishuRegion enum with domain switching (open.feishu.cn / open.larksuite.com)
- Add AES-256-CBC event decryption, message/event dedup, group chat filtering
- Update channel_bridge.rs wiring for full config
- Update routes.rs ChannelMeta with new UI fields (region basic, rest advanced)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 17:39:56 +03:00
tuzkierandWang Hanbin eb87e3fd42 test merge
Adds a WebSocket-based DingTalk Stream channel adapter as an alternative
to the existing webhook-based DingTalk adapter.

DingTalk Stream Mode uses a long-lived WebSocket connection to the
DingTalk Gateway, eliminating the need for a public webhook endpoint.

Changes:
- `openfang-types`: add `DingTalkStreamConfig` struct and wire into
  `ChannelsConfig` alongside the existing `DingTalkConfig`
- `openfang-channels`: implement `DingTalkStreamAdapter` (WebSocket
  connection management, ping/pong, token refresh, send via batchSend API)
- `openfang-api`: register `dingtalk_stream` in the channel registry,
  `is_channel_configured`, and `channel_config_values`
- `openfang-api`: wire adapter startup in `channel_bridge.rs`
- `openfang-cli`: add `dingtalk_stream` entry to the TUI channels list

Configuration:
```toml
[channels.dingtalk_stream]
app_key_env = "DINGTALK_APP_KEY"      # Enterprise Internal App Key
app_secret_env = "DINGTALK_APP_SECRET" # Enterprise Internal App Secret
robot_code_env = "DINGTALK_ROBOT_CODE" # optional, defaults to app_key
```

Requires an Enterprise Internal App in the DingTalk Open Platform with
Stream Mode enabled. No public endpoint needed.

Made-with: Cursor

Co-authored-by: Wang Hanbin <wanghb@best-inc.com>
2026-03-15 17:39:52 +03:00
6d742e9081 test merge
* feat: heartbeat auto-recovery for crashed agents

Extend the heartbeat monitor to detect and automatically recover crashed
agents, reducing operator intervention for 24/7 autonomous deployments:

- Add RecoveryTracker: per-agent failure count with configurable cooldown
- Heartbeat now monitors both Running and Crashed agents
- Crashed agents auto-recover up to max_recovery_attempts (default 3)
- After exhausting attempts, agents are marked Terminated
- Unresponsive Running agents marked Crashed for next-cycle recovery
- Increase default timeout from 60s to 180s (browser/LLM tasks need time)
- Add HeartbeatStatus.state field for downstream consumers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: claude code driver — PID tracking and message timeout

Add subprocess lifecycle management to prevent hung CLI processes from
blocking agents indefinitely:

- Track active subprocess PIDs in a concurrent DashMap for external monitoring
- Enforce configurable message timeout (default 300s) with automatic process kill
- Return proper LlmError::Api on non-zero exit in streaming mode (was silently ignored)
- Add with_timeout(), active_pids(), pid_map() public methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: /restart endpoint — manual per-agent recovery without daemon bounce

POST /api/agents/{id}/restart and /api/agents/{id}/start both:
- Cancel any active task via stop_agent_run()
- Reset agent state to Running (updates last_active)
- Return JSON with previous state and whether a task was cancelled

Enables operators to recover individual crashed/stuck agents through the
API without restarting the entire daemon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: ZiLLA Dev <dev@zilla.wtf>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 17:39:26 +03:00
Victor Duarte 317b947608 docker runtimes 2026-03-15 17:27:13 +03:00
jaberjaber23 1cb8b989d1 community fixes 2026-03-15 16:59:21 +03:00
psumotek bde1f5414c channel agent reresolution
When an agent is restarted, its UUID changes but the channel bridge still
holds the old UUID from startup. This causes "Agent not found" errors.

This fix stores the agent *name* alongside the cached UUID at bridge
startup and, on "Agent not found" errors, re-resolves the name to a
fresh UUID via find_agent_by_name(), updates the cache, and retries the
message — all transparently to the end user.

Changes:
- router.rs: add channel_default_names DashMap, set_channel_default_with_name(),
  channel_default_name(), update_channel_default()
- channel_bridge.rs: use set_channel_default_with_name() at startup
- bridge.rs: add try_reresolution() helper, integrate retry logic into
  dispatch_message() and dispatch_with_blocks() error paths with proper
  lifecycle_reactions guards and sanitize_agent_error() usage
2026-03-15 16:55:08 +03:00
TJUEZandTJUEZ d15207fa51 shell skill runtime
Add Shell runtime type to SkillRuntime enum and implement
execute_shell function for running Bash scripts as skills.

This allows skills to use Bash script files, which many
existing skills rely on.

Ref: RightNow-AI/openfang/issues/620

Co-authored-by: TJUEZ <tjuez@email.com>
2026-03-15 16:54:57 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e808ca0d08 bump mailparse
Bumps [mailparse](https://github.com/staktrace/mailparse) from 0.15.0 to 0.16.1.
- [Commits](https://github.com/staktrace/mailparse/compare/v0.15.0...v0.16.1)

---
updated-dependencies:
- dependency-name: mailparse
  dependency-version: 0.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:54:06 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c554adae0d bump ci action
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:33 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 590121d5f3 bump ci action
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:31 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 58a7a07942 bump ci action
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:28 +03:00
Nahuel Gonzalez 72b87dfaa9 chromium no-sandbox root
Chromium refuses to launch without --no-sandbox when the process is
running as UID 0. This causes the browser hand to fail immediately with
'Chromium exited before printing DevTools URL' on any server-based
OpenFang installation that runs as root (the default install).

Added is_running_as_root() which reads /proc/self/status on Linux to
detect UID 0 without requiring a libc dependency, with a fallback to
the HOME env var for other Unix systems. When root is detected,
--no-sandbox is appended to the Chromium launch args automatically.
2026-03-15 15:30:29 +03:00
Frankandtsubasakong b8fb6987e0 tool error guidance
Co-authored-by: tsubasakong <185121705+tsubasakong@users.noreply.github.com>
2026-03-15 15:08:19 +03:00
Vincent LeraitreandClaude Opus 4.6 dec081a326 slack unfurl links
* Add unfurl_links config for Slack channel

Add unfurl_links: bool (default true) to SlackConfig to control
Slack's automatic URL preview expansion. When set to false, links
in agent messages are not unfurled, keeping output compact.

Applied to SlackAdapter's chat.postMessage payload via unfurl_links
and unfurl_media parameters, affecting both real-time and cron
delivery paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Rename test per review: clarify it tests explicit true, not default

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:44:53 +03:00
Mark BandClaude Sonnet 4.6 52647b2996 agent rename fix
* feat: add release-fast Cargo profile for faster dev builds

Introduces a `release-fast` profile that inherits from `release` but
uses thin LTO and 8 codegen units instead of full LTO + 1, cutting
link time significantly while remaining fast enough for integration
testing. Documents usage in CONTRIBUTING.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: allow renaming an agent to its current name

AgentRegistry::update_name was calling name_index.contains_key()
without excluding the agent being renamed. Renaming to the same name
always returned AgentAlreadyExists instead of succeeding silently.

Fix: only error when a *different* agent owns the target name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:44:22 +03:00
Frank 905cfd2c21 docs link fix 2026-03-15 14:43:55 +03:00
jaberjaber23 7ad7489860 community fixes 2026-03-15 14:43:10 +03:00
Jones Fernandes 77c4c9add8 whatsapp setup docs 2026-03-15 14:34:31 +03:00
jaberjaber23 07019f764e community fixes 2026-03-15 06:28:23 +03:00
jaberjaber23 80eed53305 community fixes 2026-03-15 06:23:04 +03:00
Evan Hu 14c4c1d1f5 stable hand agent IDs
* fix: use fixed agent ID for hand agents based on hand_id

This ensures triggers and cron jobs continue to work after daemon restart,
as hand agents now have stable IDs instead of generating a new UUID each time.

Changes:
- Add AgentId::from_string() method for deterministic ID generation
- Modify spawn_agent_with_parent() to accept optional fixed_id
- Use hand_id-based fixed ID in activate_hand()

See: #519

* remove: remove serena local config from commit

* chore: ignore .serena directory
2026-03-15 06:18:48 +03:00
jaberjaber23 fa7dd277e6 community fixes 2026-03-15 06:16:06 +03:00
jaberjaber23 0e589e3f9a community fixes 2026-03-15 06:12:33 +03:00
Daniel.Chung 59703d50d6 codex id_token
Signed-off-by: zhong <zdianjiang@gmail.com>
2026-03-15 06:05:52 +03:00
Sky Moore 5413269943 async session save
* fix: use async save_session to avoid blocking tokio runtime

save_session() was synchronous, holding a Mutex<Connection> on the
tokio worker thread during SQLite writes. On pods with 1 CPU core
(1 tokio worker thread), this starved the entire runtime — including
health check endpoints — causing K8s to mark the pod not-ready and
return 504 on all subsequent requests.

Add save_session_async() that wraps the SQLite write in
spawn_blocking, matching the pattern already used by other memory
operations (recall, remember, etc.). Update all 12 call sites in
the agent loop.

* fix: move health check DB query to spawn_blocking and add SSE keep_alive

The health endpoint called structured_get() synchronously on the tokio
async runtime, acquiring the shared std::sync::Mutex<Connection> on a
worker thread. When the agent loop held this mutex during session saves,
the health check blocked the tokio thread, starving the SSE stream and
causing Kubernetes probe timeouts.

- Health and health_detail now run the DB check via spawn_blocking
- SSE message/stream endpoint now includes keep_alive to flush periodic
  heartbeats even during contention

* feat: add hands upsert API for idempotent hand definition updates

Add upsert_from_content() to HandRegistry that overwrites existing
definitions instead of rejecting duplicates. Exposed as POST
/api/hands/upsert for use by the shard manager to keep hand definitions
up to date across pod restarts.

* fix: websocket streaming delays

* fix: get response immediately
2026-03-15 06:05:19 +03:00
mdrissel c5582ceb1e docker build args
Adds LTO and CODEGEN_UNITS arguments that default to optimized prod settings but can be overridden (e.g., LTO=false, CODEGEN_UNITS=16) by developers for faster iteration.
2026-03-15 06:04:37 +03:00
Mark BandClaude Sonnet 4.6 36dc62745c mastodon polling fix
The polling loop was updating last_notification_id on every iteration,
leaving it set to the oldest (smallest) ID in the batch after the loop
completed. On the next poll, since_id was set to that oldest ID, causing
Mastodon to return all previously seen notifications again.

Re-delivered notifications caused the bot to respond to the same user
mention repeatedly. Combined with api_post_status chaining each response
chunk as a reply to the previous chunk, this produced long self-reply
threads that appeared to be the bot conversing with itself.

Fix: capture the first (newest) notification ID before processing the
batch, so since_id always advances correctly on each poll cycle.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 06:04:16 +03:00
JingyiQiu cb6e6909d4 telegram formatting 2026-03-15 06:01:45 +03:00
jaberjaber23 b3be3f4940 community fixes 2026-03-15 05:58:00 +03:00
Mark BandClaude Sonnet 4.6 7505007d8c release-fast profile
Introduces a `release-fast` profile that inherits from `release` but
uses thin LTO and 8 codegen units instead of full LTO + 1, cutting
link time significantly while remaining fast enough for integration
testing. Documents usage in CONTRIBUTING.md.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 05:57:33 +03:00
jaberjaber23 fdd6c1a1f7 vault wiring 2026-03-15 05:48:09 +03:00
jaberjaber23 135c37fbf7 community fixes 2026-03-15 01:25:15 +03:00
jaberjaber23 a7a96a7b0f community fixes 2026-03-15 00:18:34 +03:00
jaberjaber23 52bacf0946 community fixes 2026-03-14 22:49:43 +03:00
jaberjaber23 d55e1b8545 community batch v0.4.0 2026-03-12 23:33:19 +03:00
jaberjaber23 0c059d1dc1 bump v0.3.49 2026-03-12 18:34:16 +03:00
jaberjaber23 b6b8b4ebe1 fix community issues 2026-03-12 16:42:39 +03:00
jaberjaber23 14f4845170 trader dashboard 2026-03-12 06:20:46 +03:00
jaberjaber23 be8a589986 bump v0.3.47 2026-03-12 01:23:35 +03:00
jaberjaber23 951e8d0feb fix 11 issues 2026-03-12 01:22:45 +03:00
jaberjaber23 98f8d1ca79 fix community PRs (inspired by #438 @pandego, #433 @ozekimasaki, #417 @f-liva, #392 @cryptonahue, #410 @hobostay, #413 @castorinop, #275 @woodcoal, #464 @citadelgrad, #419 @shipdocs, #480 @skeltavik, #439 @modship) 2026-03-11 03:25:16 +03:00
jaberjaber23 24f5717ae9 fix streaming-think, cron-orphans 2026-03-10 18:24:33 +03:00
jaberjaber23 f10eefdc0e fix 6 issues 2026-03-10 17:28:06 +03:00
jaberjaber23 edd0fed518 fix 7 issues 2026-03-10 16:53:04 +03:00
jaberjaber23 86b50070e8 fix gemini-schema 2026-03-10 04:09:26 +03:00
jaberjaber23 c6aab08faa fix temperature, free-models 2026-03-10 04:06:24 +03:00
jaberjaber23 62ec09d0ae bump version 2026-03-10 02:16:05 +03:00
jaberjaber23 f6f9cf7e9f fix claude-code 2026-03-10 02:14:49 +03:00
jaberjaber23 56aeb499c9 fix tool-schema 2026-03-10 01:41:33 +03:00
jaberjaber23 b4e6a693f5 version bump 2026-03-10 01:27:39 +03:00
jaberjaber23 48d5418c91 claude code fix
Fix Claude Code provider setup flow: wizard now shows Detect button instead of API key input for keyless providers, TUI wizards include claude-code in provider list, credentials detection checks both .credentials.json paths, subprocess env_clear prevents API key leaks. Fixes #376 #303.
2026-03-10 01:19:02 +03:00
jaberjaber23 cc93ef4571 community fixes
Fix tool name mapping so LLM-hallucinated aliases (fs-write, fsRead, writeFile, etc.) normalize to canonical names before capability check and dispatch (#349). Fix provider keys not loading after dashboard save by creating fresh drivers that read current env vars instead of stale boot-time cache (#465, #458, #355). Fix Moonshot/kimi model IDs and provider inference (#428). Add Telegram message reactions for agent lifecycle feedback (#435). Add configurable api_url for Telegram proxy support (#477). Add Discord ignore_bots config option (#403). Fix openfang init EPERM crash with 7-browser fallback on Linux (#389). Add text-based tool call parsing for models without native function calling — [TOOL_CALL], <tool_call>, bare JSON patterns (#354, #332). Fix pre-existing Windows test failures with cross-platform paths. 1948 tests pass, 0 clippy warnings.
2026-03-10 00:40:45 +03:00
jaberjaber23 3e069798f9 community fixes
Fix 8 issues: empty LLM response after ~4 rounds by re-validating message pairs after history trim (#460), MCP tools permission denied by bypassing ToolInvoke capability filter for extension tools (#352), Telegram photos silently dropped now downloaded and passed as multimodal ContentBlock::Image (#362), workflow visual builder double-click editing and live property updates (#357), Claude Code provider card reflects actual install/auth status (#376), Python 3 detection runs actual command instead of path lookup (#405), hand agent_id persisted for cron job reassignment on restart (#402), CLI sends auth headers on all commands not just stop (#478). 1921 tests pass, 0 clippy warnings.
2026-03-09 23:07:08 +03:00
jaberjaber23 ad10aa5e80 community fixes
Fix 12 GitHub issues: SSE streaming token counts (#stream_options), UTF-8 boundary panics (#472), cron timezone scheduling (#473), TOML multiline system_prompt (#463), dashboard 401 auth interceptor (#468), custom provider env var convention (#471), cron stale agent_id reassignment (#461), concurrent provider probing with cache (#474), model switch provider sync (#466/#387), OpenRouter real models (#385), embedding URL normalization (#395), ZHIPU content format (#384), Fish shell PATH detection (#372). 1915 tests pass, 0 clippy warnings.
2026-03-09 21:19:50 +03:00
jaberjaber23 385aee8e56 fix streaming
- Add stream_options (include_usage) for accurate token counts in streaming mode
- Add fallback for providers that don't support stream_options
- Add SSE stream diagnostic logging
2026-03-09 04:57:04 +03:00
jaberjaber23 a00327abe9 fix auth 2026-03-09 03:19:14 +03:00
jaberjaber23 487555a5e5 bump version 2026-03-09 02:18:51 +03:00
jaberjaber23 9d51426cb4 fix bugs 2026-03-09 02:16:36 +03:00
jaberjaber23 6fab720843 bump version 2026-03-08 22:53:48 +03:00
jaberjaber23 4667f497ef fix csp 2026-03-08 22:51:21 +03:00
167 changed files with 22852 additions and 2601 deletions
+34
View File
@@ -0,0 +1,34 @@
# Ignored advisories — all are transitive dependencies we cannot upgrade directly.
#
# time 0.3.45: pinned by mac-notification-sys (tauri dependency), awaiting upstream fix
# GTK3/glib/pango/etc: tauri uses gtk-rs GTK3 bindings which are unmaintained
# paste, proc-macro-error, fxhash: unmaintained transitive deps
# lexical-core: unmaintained, pulled by tauri dep chain
# serde_cbor: unmaintained, pulled by tao (tauri)
# cocoa/cocoa-foundation: unmaintained, pulled by tauri/tao
[advisories]
ignore = [
"RUSTSEC-2026-0009", # time DoS — pinned by mac-notification-sys
"RUSTSEC-2024-0370", # proc-macro-error unmaintained
"RUSTSEC-2024-0411", # gtk-rs GTK3 unmaintained (gdk-pixbuf)
"RUSTSEC-2024-0412", # gtk-rs GTK3 unmaintained (gdk)
"RUSTSEC-2024-0413", # gtk-rs GTK3 unmaintained (atk)
"RUSTSEC-2024-0414", # gtk-rs GTK3 unmaintained (pango)
"RUSTSEC-2024-0415", # gtk-rs GTK3 unmaintained (gio)
"RUSTSEC-2024-0416", # gtk-rs GTK3 unmaintained (atk-sys)
"RUSTSEC-2024-0417", # gtk-rs GTK3 unmaintained (gdk-pixbuf-sys)
"RUSTSEC-2024-0418", # gtk-rs GTK3 unmaintained (gdk-sys)
"RUSTSEC-2024-0419", # gtk-rs GTK3 unmaintained (gtk3-macros)
"RUSTSEC-2024-0420", # gtk-rs GTK3 unmaintained (pango-sys)
"RUSTSEC-2024-0429", # gtk-rs GTK3 unmaintained (gtk-sys)
"RUSTSEC-2024-0436", # paste unmaintained
"RUSTSEC-2025-0057", # fxhash unmaintained
"RUSTSEC-2025-0075", # glib unmaintained
"RUSTSEC-2025-0080", # cocoa unmaintained
"RUSTSEC-2025-0081", # cocoa-foundation unmaintained
"RUSTSEC-2025-0098", # lexical-core unmaintained
"RUSTSEC-2025-0100", # gio-sys unmaintained
"RUSTSEC-2026-0002", # serde_cbor unmaintained
"RUSTSEC-2023-0086", # lexopt unmaintained (if present)
]
+6 -6
View File
@@ -20,7 +20,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
@@ -45,7 +45,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
@@ -67,7 +67,7 @@ jobs:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
@@ -87,7 +87,7 @@ jobs:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
@@ -97,7 +97,7 @@ jobs:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-audit
@@ -109,7 +109,7 @@ jobs:
name: Secrets Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install trufflehog
+5 -5
View File
@@ -49,7 +49,7 @@ jobs:
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install system deps (Linux)
if: runner.os == 'Linux'
@@ -162,7 +162,7 @@ jobs:
archive: zip
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -212,15 +212,15 @@ jobs:
name: Docker Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU (for arm64 emulation)
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version
+2
View File
@@ -34,6 +34,7 @@ BUILD_LOG.md
# OS
.DS_Store
._*
Thumbs.db
# IDE & tools
@@ -43,3 +44,4 @@ Thumbs.db
*.swp
*.swo
*~
.serena/
+10
View File
@@ -56,6 +56,16 @@ Tests that require a real LLM key will skip gracefully if the env var is absent.
cargo build --workspace
```
### Fast Release Build (for development)
The default `--release` profile uses full LTO and single-codegen-unit, which produces the smallest/fastest binary but is slow to compile. For iterating locally, use the `release-fast` profile instead:
```bash
cargo build --profile release-fast -p openfang-cli
```
This cuts link time significantly (thin LTO, 8 codegen units, `opt-level=2`) while still producing a binary fast enough to run integration tests against. Use `--release` only for final binaries or CI.
### Run All Tests
```bash
Generated
+322 -329
View File
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.3.31"
version = "0.4.5"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -49,9 +49,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Time
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
# IDs
uuid = { version = "1", features = ["v4", "serde"] }
uuid = { version = "1", features = ["v4", "v5", "serde"] }
# Database
rusqlite = { version = "0.31", features = ["bundled", "serde_json"] }
@@ -61,7 +62,7 @@ clap = { version = "4", features = ["derive"] }
clap_complete = "4"
# HTTP client (for LLM drivers)
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
# Async trait
async-trait = "0.1"
@@ -101,6 +102,9 @@ walkdir = "2"
# Security
sha2 = "0.10"
sha1 = "0.10"
aes = "0.8"
cbc = "0.1"
hmac = "0.12"
hex = "0.4"
subtle = "2"
@@ -135,7 +139,10 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
imap = "2"
native-tls = "0.2"
mailparse = "0.15"
mailparse = "0.16"
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
openssl = { version = "0.10", features = ["vendored"] }
# Testing
tokio-test = "0.4"
@@ -146,3 +153,10 @@ lto = true
codegen-units = 1
strip = true
opt-level = 3
[profile.release-fast]
inherits = "release"
lto = "thin"
codegen-units = 8
opt-level = 2
strip = false
+16 -2
View File
@@ -7,10 +7,24 @@ COPY crates ./crates
COPY xtask ./xtask
COPY agents ./agents
COPY packages ./packages
# Optional build args for dev environments to speed up compilation
# Example: docker build --build-arg LTO=false --build-arg CODEGEN_UNITS=16 .
ARG LTO=true
ARG CODEGEN_UNITS=1
ENV CARGO_PROFILE_RELEASE_LTO=${LTO} \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=${CODEGEN_UNITS}
RUN cargo build --release --bin openfang
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
FROM rust:1-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3 \
python3-pip \
python3-venv \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/openfang /usr/local/bin/
COPY --from=builder /build/agents /opt/openfang/agents
EXPOSE 4200
+91
View File
@@ -264,6 +264,97 @@ Connect your agents to every platform your users are on.
Each adapter supports per-channel model overrides, DM/group policies, rate limiting, and output formatting.
---
## WhatsApp Web Gateway (QR Code)
Connect your personal WhatsApp account to OpenFang via QR code — just like WhatsApp Web. No Meta Business account required.
### Prerequisites
- **Node.js >= 18** installed ([download](https://nodejs.org/))
- OpenFang installed and initialized
### Setup
**1. Install the gateway dependencies:**
```bash
cd packages/whatsapp-gateway
npm install
```
**2. Configure `config.toml`:**
```toml
[channels.whatsapp]
mode = "web"
default_agent = "assistant"
```
**3. Set the gateway URL (choose one):**
Add to your shell profile for persistence:
```bash
# macOS / Linux
echo 'export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"' >> ~/.zshrc
source ~/.zshrc
```
Or set it inline when starting the gateway:
```bash
export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"
```
**4. Start the gateway:**
```bash
node packages/whatsapp-gateway/index.js
```
The gateway listens on port `3009` by default. Override with `WHATSAPP_GATEWAY_PORT`.
**5. Start OpenFang:**
```bash
openfang start
# Dashboard at http://localhost:4200
```
**6. Scan the QR code:**
Open the dashboard → **Channels****WhatsApp**. A QR code will appear. Scan it with your phone:
> **WhatsApp** → **Settings** → **Linked Devices** → **Link a Device**
Once scanned, the status changes to `connected` and incoming messages are routed to your configured agent.
### Gateway Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `WHATSAPP_WEB_GATEWAY_URL` | Gateway URL for OpenFang to connect to | _(empty = disabled)_ |
| `WHATSAPP_GATEWAY_PORT` | Port the gateway listens on | `3009` |
| `OPENFANG_URL` | OpenFang API URL the gateway reports to | `http://127.0.0.1:4200` |
| `OPENFANG_DEFAULT_AGENT` | Agent that handles incoming messages | `assistant` |
### Gateway API Endpoints
| Method | Route | Description |
|--------|-------|-------------|
| `POST` | `/login/start` | Generate QR code (returns base64 PNG) |
| `GET` | `/login/status` | Connection status (`disconnected`, `qr_ready`, `connected`) |
| `POST` | `/message/send` | Send a message (`{ "to": "5511999999999", "text": "Hello" }`) |
| `GET` | `/health` | Health check |
### Alternative: WhatsApp Cloud API
For production workloads, use the [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api) with a Meta Business account. See the [Cloud API configuration docs](https://openfang.sh/docs/channels/whatsapp).
---
## 27 LLM Providers — 123+ Models
+3
View File
@@ -33,6 +33,9 @@ governor = { workspace = true }
tokio-stream = { workspace = true }
subtle = { workspace = true }
base64 = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
hex = { workspace = true }
socket2 = { workspace = true }
reqwest = { workspace = true }
+173 -23
View File
@@ -43,6 +43,7 @@ use openfang_channels::webex::WebexAdapter;
// Wave 5
use async_trait::async_trait;
use openfang_channels::dingtalk::DingTalkAdapter;
use openfang_channels::dingtalk_stream::DingTalkStreamAdapter;
use openfang_channels::discourse::DiscourseAdapter;
use openfang_channels::gitter::GitterAdapter;
use openfang_channels::gotify::GotifyAdapter;
@@ -50,12 +51,15 @@ use openfang_channels::linkedin::LinkedInAdapter;
use openfang_channels::mumble::MumbleAdapter;
use openfang_channels::ntfy::NtfyAdapter;
use openfang_channels::webhook::WebhookAdapter;
use openfang_channels::wecom::WeComAdapter;
use openfang_kernel::OpenFangKernel;
use openfang_types::agent::AgentId;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{error, info, warn};
use openfang_runtime::str_utils::safe_truncate_str;
/// Wraps `OpenFangKernel` to implement `ChannelBridgeHandle`.
pub struct KernelBridgeAdapter {
kernel: Arc<OpenFangKernel>,
@@ -70,6 +74,37 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.send_message(agent_id, message)
.await
.map_err(|e| format!("{e}"))?;
// Silent/NO_REPLY responses should not be forwarded to channels
if result.silent {
return Ok(String::new());
}
Ok(result.response)
}
async fn send_message_with_blocks(
&self,
agent_id: AgentId,
blocks: Vec<openfang_types::message::ContentBlock>,
) -> Result<String, String> {
// Extract text for the message parameter (used for memory recall / logging)
let text: String = blocks
.iter()
.filter_map(|b| match b {
openfang_types::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let text = if text.is_empty() {
"[Image]".to_string()
} else {
text
};
let result = self
.kernel
.send_message_with_blocks(agent_id, &text, blocks)
.await
.map_err(|e| format!("{e}"))?;
Ok(result.response)
}
@@ -351,7 +386,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| t.agent_id.to_string());
let status = if t.enabled { "on" } else { "off" };
let id_short = &t.id.0.to_string()[..8];
let id_str = t.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
msg.push_str(&format!(
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
id_short,
@@ -390,7 +426,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.kernel
.triggers
.register(agent.id, pattern, prompt.to_string(), 0);
let id_short = &trigger_id.0.to_string()[..8];
let id_str = trigger_id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
}
@@ -405,7 +442,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
1 => {
let t = matched[0];
if self.kernel.triggers.remove(t.id) {
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
let id_str = t.id.0.to_string();
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
} else {
"Failed to remove trigger.".to_string()
}
@@ -428,7 +466,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| job.agent_id.to_string());
let status = if job.enabled { "on" } else { "off" };
let id_short = &job.id.0.to_string()[..8];
let id_str = job.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let sched = match &job.schedule {
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
@@ -450,6 +489,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
msg
}
#[allow(dead_code)]
async fn manage_schedule_text(&self, action: &str, args: &[String]) -> String {
match action {
"add" => {
@@ -488,7 +528,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
match self.kernel.cron_scheduler.add_job(job, false) {
Ok(id) => {
let id_short = &id.0.to_string()[..8];
let id_str = id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
}
Err(e) => format!("Failed to create job: {e}"),
@@ -510,7 +551,12 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
let j = matched[0];
match self.kernel.cron_scheduler.remove_job(j.id) {
Ok(_) => {
format!("Job [{}] '{}' removed.", &j.id.0.to_string()[..8], j.name)
let id_str = j.id.0.to_string();
format!(
"Job [{}] '{}' removed.",
safe_truncate_str(&id_str, 8),
j.name
)
}
Err(e) => format!("Failed to remove job: {e}"),
}
@@ -539,10 +585,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
openfang_types::scheduler::CronAction::SystemEvent { text } => {
text.clone()
}
openfang_types::scheduler::CronAction::WorkflowRun {
workflow_id,
input,
..
} => {
format!(
"Run workflow {workflow_id}{}",
input
.as_deref()
.map(|i| format!(" with input: {i}"))
.unwrap_or_default()
)
}
};
match self.kernel.send_message(j.agent_id, &message).await {
Ok(result) => {
let id_short = &j.id.0.to_string()[..8];
let id_str = j.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] ran:\n{}", result.response)
}
Err(e) => format!("Failed to run job: {e}"),
@@ -562,7 +622,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
let mut msg = format!("Pending approvals ({}):\n", pending.len());
for req in &pending {
let id_short = &req.id.to_string()[..8];
let id_str = req.id.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
let age = if age_secs >= 60 {
format!("{}m", age_secs / 60)
@@ -603,10 +664,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
) {
Ok(_) => {
let verb = if approve { "Approved" } else { "Rejected" };
let id_str = req.id.to_string();
format!(
"{} [{}] {}{}",
verb,
&req.id.to_string()[..8],
safe_truncate_str(&id_str, 8),
req.tool_name,
req.agent_id
)
@@ -646,9 +708,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
));
}
self.kernel
.set_agent_model(agent_id, model)
.set_agent_model(agent_id, model, None)
.map_err(|e| format!("{e}"))?;
Ok(format!("Model switched to: {model}"))
// Read back resolved model+provider from registry
let entry = self
.kernel
.registry
.get(agent_id)
.ok_or_else(|| "Agent not found after model switch".to_string())?;
Ok(format!(
"Model switched to: {} (provider: {})",
entry.manifest.model.model, entry.manifest.model.provider
))
}
async fn stop_run(&self, agent_id: AgentId) -> Result<String, String> {
@@ -727,12 +798,17 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
// Wave 5
"mumble" => channels.mumble.as_ref().map(|c| c.overrides.clone()),
"dingtalk" => channels.dingtalk.as_ref().map(|c| c.overrides.clone()),
"dingtalk_stream" => channels
.dingtalk_stream
.as_ref()
.map(|c| c.overrides.clone()),
"discourse" => channels.discourse.as_ref().map(|c| c.overrides.clone()),
"gitter" => channels.gitter.as_ref().map(|c| c.overrides.clone()),
"ntfy" => channels.ntfy.as_ref().map(|c| c.overrides.clone()),
"gotify" => channels.gotify.as_ref().map(|c| c.overrides.clone()),
"webhook" => channels.webhook.as_ref().map(|c| c.overrides.clone()),
"linkedin" => channels.linkedin.as_ref().map(|c| c.overrides.clone()),
"wecom" => channels.wecom.as_ref().map(|c| c.overrides.clone()),
_ => None,
}
}
@@ -774,6 +850,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
recipient: &str,
success: bool,
error: Option<&str>,
thread_id: Option<&str>,
) {
let receipt = if success {
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
@@ -786,9 +863,13 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
};
self.kernel.delivery_tracker.record(agent_id, receipt);
// Persist last channel for cron CronDelivery::LastChannel
// Persist last channel for cron CronDelivery::LastChannel.
// Include thread_id when present so forum-topic context survives restarts.
if success {
let kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
let mut kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
if let Some(tid) = thread_id {
kv_val["thread_id"] = serde_json::json!(tid);
}
let _ = self
.kernel
.memory
@@ -859,7 +940,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
return "OFP peer network is disabled. Set network_enabled = true in config.toml."
.to_string();
}
match &self.kernel.peer_registry {
match self.kernel.peer_registry.get() {
Some(registry) => {
let peers = registry.all_peers();
if peers.is_empty() {
@@ -934,16 +1015,38 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
}
}
/// Read a token from an env var, returning None with a warning if missing/empty.
fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
match std::env::var(env_var) {
/// Resolve a token: if the value looks like an actual secret (contains `:`,
/// starts with `xoxb-`, `xapp-`, `sk-`, etc.), use it directly.
/// Otherwise treat it as an env var name and look it up.
fn read_token(env_var_or_token: &str, adapter_name: &str) -> Option<String> {
// Heuristic: actual tokens contain `:` (Telegram, Discord) or start with
// known prefixes. Env var names are uppercase ASCII identifiers.
let looks_like_token = env_var_or_token.contains(':')
|| env_var_or_token.starts_with("xoxb-")
|| env_var_or_token.starts_with("xapp-")
|| env_var_or_token.starts_with("sk-")
|| env_var_or_token.starts_with("Bearer ");
if looks_like_token {
warn!(
"{adapter_name}: config field contains what looks like an actual token \
rather than an env var name — using it directly. \
Tip: store the token in an env var and use the var name instead for security."
);
return Some(env_var_or_token.to_string());
}
match std::env::var(env_var_or_token) {
Ok(t) if !t.is_empty() => Some(t),
Ok(_) => {
warn!("{adapter_name} bot token env var '{env_var}' is empty, skipping");
warn!("{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping");
None
}
Err(_) => {
warn!("{adapter_name} bot token env var '{env_var}' not set, skipping");
warn!(
"{adapter_name} token env var '{env_var_or_token}' not set, skipping. \
Set it with: export {env_var_or_token}=<your-token>"
);
None
}
}
@@ -1003,6 +1106,7 @@ pub async fn start_channel_bridge_with_config(
// Wave 5
|| config.mumble.is_some()
|| config.dingtalk.is_some()
|| config.dingtalk_stream.is_some()
|| config.discourse.is_some()
|| config.gitter.is_some()
|| config.ntfy.is_some()
@@ -1030,6 +1134,7 @@ pub async fn start_channel_bridge_with_config(
token,
tg_config.allowed_users.clone(),
poll_interval,
tg_config.api_url.clone(),
));
adapters.push((adapter, tg_config.default_agent.clone()));
}
@@ -1057,6 +1162,9 @@ pub async fn start_channel_bridge_with_config(
app_token,
bot_token,
sl_config.allowed_channels.clone(),
sl_config.auto_thread_reply,
sl_config.thread_ttl_hours,
sl_config.unfurl_links,
));
adapters.push((adapter, sl_config.default_agent.clone()));
}
@@ -1066,7 +1174,9 @@ pub async fn start_channel_bridge_with_config(
// WhatsApp — supports Cloud API mode (access token) or Web/QR mode (gateway URL)
if let Some(ref wa_config) = config.whatsapp {
let cloud_token = read_token(&wa_config.access_token_env, "WhatsApp");
let gateway_url = std::env::var(&wa_config.gateway_url_env).ok().filter(|u| !u.is_empty());
let gateway_url = std::env::var(&wa_config.gateway_url_env)
.ok()
.filter(|u| !u.is_empty());
if cloud_token.is_some() || gateway_url.is_some() {
let token = cloud_token.unwrap_or_default();
@@ -1314,10 +1424,20 @@ pub async fn start_channel_bridge_with_config(
// Feishu/Lark
if let Some(ref fs_config) = config.feishu {
if let Some(secret) = read_token(&fs_config.app_secret_env, "Feishu") {
let adapter = Arc::new(FeishuAdapter::new(
let region = openfang_channels::feishu::FeishuRegion::parse_region(&fs_config.region);
let encrypt_key = fs_config
.encrypt_key_env
.as_ref()
.and_then(|env| read_token(env, "Feishu encrypt_key"));
let adapter = Arc::new(FeishuAdapter::with_config(
fs_config.app_id.clone(),
secret,
fs_config.webhook_port,
region,
Some(fs_config.webhook_path.clone()),
fs_config.verification_token.clone(),
encrypt_key,
fs_config.bot_names.clone(),
));
adapters.push((adapter, fs_config.default_agent.clone()));
}
@@ -1331,6 +1451,21 @@ pub async fn start_channel_bridge_with_config(
}
}
// WeCom/WeChat Work
if let Some(ref wc_config) = config.wecom {
if let Some(secret) = read_token(&wc_config.secret_env, "WeCom") {
let adapter = Arc::new(WeComAdapter::with_verification(
wc_config.corp_id.clone(),
wc_config.agent_id.clone(),
secret,
wc_config.webhook_port,
wc_config.encoding_aes_key.clone(),
wc_config.token.clone(),
));
adapters.push((adapter, wc_config.default_agent.clone()));
}
}
// ── Wave 4 ──────────────────────────────────────────────────
// Nextcloud Talk
@@ -1437,7 +1572,7 @@ pub async fn start_channel_bridge_with_config(
}
}
// DingTalk
// DingTalk (webhook mode)
if let Some(ref dt_config) = config.dingtalk {
if let Some(token) = read_token(&dt_config.access_token_env, "DingTalk") {
let secret = read_token(&dt_config.secret_env, "DingTalk (secret)").unwrap_or_default();
@@ -1446,6 +1581,21 @@ pub async fn start_channel_bridge_with_config(
}
}
// DingTalk (stream mode)
if let Some(ref ds_config) = config.dingtalk_stream {
if let Some(app_key) = read_token(&ds_config.app_key_env, "DingTalk Stream (app_key)") {
if let Some(app_secret) =
read_token(&ds_config.app_secret_env, "DingTalk Stream (app_secret)")
{
let robot_code =
read_token(&ds_config.robot_code_env, "DingTalk Stream (robot_code)")
.unwrap_or_else(|| app_key.clone());
let adapter = Arc::new(DingTalkStreamAdapter::new(app_key, app_secret, robot_code));
adapters.push((adapter, ds_config.default_agent.clone()));
}
}
}
// Discourse
if let Some(ref dc_config) = config.discourse {
if let Some(api_key) = read_token(&dc_config.api_key_env, "Discourse") {
@@ -1550,7 +1700,7 @@ pub async fn start_channel_bridge_with_config(
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
adapter.name()
);
router.set_channel_default(channel_key, agent_id);
router.set_channel_default_with_name(channel_key, agent_id, name.clone());
// First configured default also becomes system-wide fallback
if !system_default_set {
router.set_default(agent_id);
+1
View File
@@ -9,6 +9,7 @@ pub mod openai_compat;
pub mod rate_limiter;
pub mod routes;
pub mod server;
pub mod session_auth;
pub mod stream_chunker;
pub mod stream_dedup;
pub mod types;
+53 -17
View File
@@ -43,13 +43,24 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
response
}
/// Authentication state passed to the auth middleware.
#[derive(Clone)]
pub struct AuthState {
pub api_key: String,
pub auth_enabled: bool,
pub session_secret: String,
}
/// Bearer token authentication middleware.
///
/// When `api_key` is non-empty, requests to non-public endpoints must include
/// `Authorization: Bearer <api_key>`. If the key is empty, only whitelisted
/// public endpoints are accessible — all others return 401.
/// 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 dashboard auth is enabled, session cookies are also accepted.
pub async fn auth(
axum::extract::State(api_key): axum::extract::State<String>,
axum::extract::State(auth_state): axum::extract::State<AuthState>,
request: Request<Body>,
next: Next,
) -> Response<Body> {
@@ -113,24 +124,23 @@ pub async fn auth(
|| (path == "/api/workflows" && is_get)
|| path == "/api/logs/stream" // SSE stream, read-only
|| (path.starts_with("/api/cron/") && is_get)
|| path.starts_with("/api/providers/github-copilot/oauth/");
|| path.starts_with("/api/providers/github-copilot/oauth/")
|| path == "/api/auth/login"
|| path == "/api/auth/logout"
|| (path == "/api/auth/check" && is_get);
if is_public {
return next.run(request).await;
}
// SECURITY: If no API key configured, non-public endpoints still require auth.
// Fall through to the token check which will fail (no valid token matches empty key),
// returning 401 for any non-whitelisted route.
if api_key.is_empty() {
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("www-authenticate", "Bearer")
.body(Body::from(
serde_json::json!({"error": "No API key configured. Set api_key in config.toml or pass --api-key on startup."}).to_string(),
))
.unwrap_or_default();
// 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.
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;
}
let api_key = api_key_trimmed.as_str();
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
let bearer_token = request
@@ -176,6 +186,17 @@ pub async fn auth(
return next.run(request).await;
}
// Check session cookie (dashboard login sessions)
if auth_state.auth_enabled {
if let Some(token) = extract_session_cookie(&request) {
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
.is_some()
{
return next.run(request).await;
}
}
}
// Determine error message: was a credential provided but wrong, or missing entirely?
let credential_provided = header_auth.is_some() || query_auth.is_some();
let error_msg = if credential_provided {
@@ -193,6 +214,21 @@ 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;
@@ -203,7 +239,7 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
// All JS/CSS is bundled inline — only external resource is Google Fonts.
headers.insert(
"content-security-policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
.parse()
.unwrap(),
);
+6 -5
View File
@@ -202,9 +202,10 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
let blocks: Vec<ContentBlock> = parts
.iter()
.filter_map(|part| match part {
OaiContentPart::Text { text } => {
Some(ContentBlock::Text { text: text.clone() })
}
OaiContentPart::Text { text } => Some(ContentBlock::Text {
text: text.clone(),
provider_metadata: None,
}),
OaiContentPart::ImageUrl { image_url } => {
// Parse data URI: data:{media_type};base64,{data}
if let Some(rest) = image_url.url.strip_prefix("data:") {
@@ -322,7 +323,7 @@ pub async fn chat_completions(
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle))
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle), None, None)
.await
{
Ok(result) => {
@@ -378,7 +379,7 @@ async fn stream_response(
let (mut rx, _handle) = state
.kernel
.send_message_streaming(agent_id, message, Some(kernel_handle))
.send_message_streaming(agent_id, message, Some(kernel_handle), None, None)
.map_err(|e| format!("Streaming setup failed: {e}"))?;
let (tx, stream_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(64);
File diff suppressed because it is too large Load Diff
+54 -24
View File
@@ -45,16 +45,17 @@ pub async fn build_router(
let state = Arc::new(AppState {
kernel: kernel.clone(),
started_at: Instant::now(),
peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())),
peer_registry: kernel.peer_registry.get().map(|r| Arc::new(r.clone())),
bridge_manager: tokio::sync::Mutex::new(bridge),
channels_config: tokio::sync::RwLock::new(channels_config),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
// 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.is_empty() {
let cors = if state.kernel.config.api_key.trim().is_empty() {
// No auth → restrict CORS to localhost origins (include both 127.0.0.1 and localhost)
let port = listen_addr.port();
let mut origins: Vec<axum::http::HeaderValue> = vec![
@@ -102,13 +103,27 @@ pub async fn build_router(
.allow_headers(tower_http::cors::Any)
};
let api_key = state.kernel.config.api_key.clone();
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
let api_key = state.kernel.config.api_key.trim().to_string();
let auth_state = crate::middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
session_secret: if !api_key.is_empty() {
api_key.clone()
} else if state.kernel.config.auth.enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
},
};
let gcra_limiter = rate_limiter::create_rate_limiter();
let app = Router::new()
.route("/", axum::routing::get(webchat::webchat_page))
.route("/logo.png", axum::routing::get(webchat::logo_png))
.route("/favicon.ico", axum::routing::get(webchat::favicon_ico))
.route("/manifest.json", axum::routing::get(webchat::manifest_json))
.route("/sw.js", axum::routing::get(webchat::sw_js))
.route(
"/api/metrics",
axum::routing::get(routes::prometheus_metrics),
@@ -126,13 +141,23 @@ pub async fn build_router(
)
.route(
"/api/agents/{id}",
axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
axum::routing::get(routes::get_agent)
.delete(routes::kill_agent)
.patch(routes::patch_agent),
)
.route(
"/api/agents/{id}/mode",
axum::routing::put(routes::set_agent_mode),
)
.route("/api/profiles", axum::routing::get(routes::list_profiles))
.route(
"/api/agents/{id}/restart",
axum::routing::post(routes::restart_agent),
)
.route(
"/api/agents/{id}/start",
axum::routing::post(routes::restart_agent),
)
.route(
"/api/agents/{id}/message",
axum::routing::post(routes::send_message),
@@ -286,6 +311,12 @@ pub async fn build_router(
"/api/workflows",
axum::routing::get(routes::list_workflows).post(routes::create_workflow),
)
.route(
"/api/workflows/{id}",
axum::routing::get(routes::get_workflow)
.put(routes::update_workflow)
.delete(routes::delete_workflow),
)
.route(
"/api/workflows/{id}/run",
axum::routing::post(routes::run_workflow),
@@ -335,6 +366,10 @@ pub async fn build_router(
"/api/hands/install",
axum::routing::post(routes::install_hand),
)
.route(
"/api/hands/upsert",
axum::routing::post(routes::upsert_hand),
)
.route(
"/api/hands/active",
axum::routing::get(routes::list_active_hands),
@@ -354,8 +389,7 @@ pub async fn build_router(
)
.route(
"/api/hands/{hand_id}/settings",
axum::routing::get(routes::get_hand_settings)
.put(routes::update_hand_settings),
axum::routing::get(routes::get_hand_settings).put(routes::update_hand_settings),
)
.route(
"/api/hands/instances/{id}/pause",
@@ -412,14 +446,11 @@ pub async fn build_router(
"/api/comms/events/stream",
axum::routing::get(routes::comms_events_stream),
)
.route(
"/api/comms/send",
axum::routing::post(routes::comms_send),
)
.route(
"/api/comms/task",
axum::routing::post(routes::comms_task),
)
.route("/api/comms/send", axum::routing::post(routes::comms_send))
.route("/api/comms/task", axum::routing::post(routes::comms_task));
// Split into a second router chunk to stay within axum's type nesting limit.
let app = app
// Tools endpoint
.route("/api/tools", axum::routing::get(routes::list_tools))
// Config endpoints
@@ -464,8 +495,7 @@ pub async fn build_router(
)
.route(
"/api/budget/agents/{id}",
axum::routing::get(routes::agent_budget_status)
.put(routes::update_agent_budget),
axum::routing::get(routes::agent_budget_status).put(routes::update_agent_budget),
)
// Session endpoints
.route("/api/sessions", axum::routing::get(routes::list_sessions))
@@ -669,8 +699,12 @@ pub async fn build_router(
"/v1/models",
axum::routing::get(crate::openai_compat::list_models),
)
// Dashboard authentication endpoints
.route("/api/auth/login", axum::routing::post(routes::auth_login))
.route("/api/auth/logout", axum::routing::post(routes::auth_logout))
.route("/api/auth/check", axum::routing::get(routes::auth_check))
.layer(axum::middleware::from_fn_with_state(
api_key,
auth_state,
middleware::auth,
))
.layer(axum::middleware::from_fn_with_state(
@@ -787,8 +821,7 @@ pub async fn run_daemon(
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(1024)?;
let listener =
tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
let listener = tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
// Run server with graceful shutdown.
// SECURITY: `into_make_service_with_connect_info` injects the peer
@@ -919,11 +952,8 @@ fn is_daemon_responding(addr: &str) -> bool {
.or_else(|| addr.strip_prefix("https://"))
.unwrap_or(addr);
if let Ok(sock_addr) = addr_only.parse::<std::net::SocketAddr>() {
std::net::TcpStream::connect_timeout(
&sock_addr,
std::time::Duration::from_millis(500),
)
.is_ok()
std::net::TcpStream::connect_timeout(&sock_addr, std::time::Duration::from_millis(500))
.is_ok()
} else {
// Fallback: try connecting to hostname
std::net::TcpStream::connect(addr_only)
+109
View File
@@ -0,0 +1,109 @@
//! Stateless session token authentication for the dashboard.
//! Tokens are HMAC-SHA256 signed and contain username + expiry.
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Create a session token: base64(username:expiry_unix:hmac_hex)
pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> String {
use base64::Engine;
let expiry = chrono::Utc::now().timestamp() + (ttl_hours as i64 * 3600);
let payload = format!("{username}:{expiry}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key");
mac.update(payload.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
}
/// 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;
let decoded = base64::engine::general_purpose::STANDARD
.decode(token)
.ok()?;
let decoded_str = String::from_utf8(decoded).ok()?;
let parts: Vec<&str> = decoded_str.splitn(3, ':').collect();
if parts.len() != 3 {
return None;
}
let (username, expiry_str, provided_sig) = (parts[0], parts[1], parts[2]);
let expiry: i64 = expiry_str.parse().ok()?;
if chrono::Utc::now().timestamp() > expiry {
return None;
}
let payload = format!("{username}:{expiry_str}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(payload.as_bytes());
let expected_sig = hex::encode(mac.finalize().into_bytes());
use subtle::ConstantTimeEq;
if provided_sig.len() != expected_sig.len() {
return None;
}
if provided_sig
.as_bytes()
.ct_eq(expected_sig.as_bytes())
.into()
{
Some(username.to_string())
} else {
None
}
}
/// Hash a password with SHA256 for config storage.
pub fn hash_password(password: &str) -> String {
use sha2::Digest;
hex::encode(Sha256::digest(password.as_bytes()))
}
/// Verify a password against a stored SHA256 hash (constant-time).
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
let computed = hash_password(password);
use subtle::ConstantTimeEq;
if computed.len() != stored_hash.len() {
return false;
}
computed.as_bytes().ct_eq(stored_hash.as_bytes()).into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_password() {
let hash = hash_password("secret123");
assert!(verify_password("secret123", &hash));
assert!(!verify_password("wrong", &hash));
}
#[test]
fn test_create_and_verify_token() {
let token = create_session_token("admin", "my-secret", 1);
let user = verify_session_token(&token, "my-secret");
assert_eq!(user, Some("admin".to_string()));
}
#[test]
fn test_token_wrong_secret() {
let token = create_session_token("admin", "my-secret", 1);
let user = verify_session_token(&token, "wrong-secret");
assert_eq!(user, None);
}
#[test]
fn test_token_invalid_base64() {
let user = verify_session_token("not-valid-base64!!!", "secret");
assert_eq!(user, None);
}
#[test]
fn test_password_hash_length_mismatch() {
assert!(!verify_password("x", "short"));
}
}
+16 -2
View File
@@ -140,9 +140,23 @@ impl StreamChunker {
}
/// Find the last occurrence of a pattern within a byte range.
///
/// Both `range.start` and `range.end` are clamped to the nearest valid UTF-8
/// char boundary so that slicing never panics on multi-byte content.
fn find_last_in_range(text: &str, pattern: &str, range: &std::ops::Range<usize>) -> Option<usize> {
let search_text = &text[range.start..range.end.min(text.len())];
search_text.rfind(pattern).map(|pos| range.start + pos)
let len = text.len();
// Clamp end to text length and walk back to a char boundary
let mut end = range.end.min(len);
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
// Walk start forward to the nearest char boundary (never past end)
let mut start = range.start.min(end);
while start < end && !text.is_char_boundary(start) {
start += 1;
}
let search_text = &text[start..end];
search_text.rfind(pattern).map(|pos| start + pos)
}
#[cfg(test)]
+6
View File
@@ -42,6 +42,12 @@ pub struct MessageRequest {
/// Optional file attachments (uploaded via /upload endpoint).
#[serde(default)]
pub attachments: Vec<AttachmentRef>,
/// Sender identity (e.g. WhatsApp phone number, Telegram user ID).
#[serde(default)]
pub sender_id: Option<String>,
/// Sender display name.
#[serde(default)]
pub sender_name: Option<String>,
}
/// Response from sending a message.
+32 -1
View File
@@ -46,6 +46,34 @@ pub async fn favicon_ico() -> impl IntoResponse {
)
}
/// Embedded PWA manifest for installable web app support.
const MANIFEST_JSON: &str = include_str!("../static/manifest.json");
/// Embedded service worker for PWA support.
const SW_JS: &str = include_str!("../static/sw.js");
/// GET /manifest.json — Serve the PWA web app manifest.
pub async fn manifest_json() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/manifest+json"),
(header::CACHE_CONTROL, "public, max-age=86400, immutable"),
],
MANIFEST_JSON,
)
}
/// GET /sw.js — Serve the PWA service worker.
pub async fn sw_js() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
SW_JS,
)
}
/// GET / — Serve the OpenFang Dashboard single-page application.
///
/// Returns the full SPA with ETag header based on package version for caching.
@@ -81,13 +109,16 @@ const WEBCHAT_HTML: &str = concat!(
include_str!("../static/vendor/github-dark.min.css"),
"\n</style>\n",
include_str!("../static/index_body.html"),
// Vendor libs: marked + highlight first (used by app.js)
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
"<script>\n",
include_str!("../static/vendor/marked.min.js"),
"\n</script>\n",
"<script>\n",
include_str!("../static/vendor/highlight.min.js"),
"\n</script>\n",
"<script>\n",
include_str!("../static/vendor/chart.umd.min.js"),
"\n</script>\n",
// App code
"<script>\n",
include_str!("../static/js/api.js"),
+154 -72
View File
@@ -146,7 +146,9 @@ pub async fn agent_ws(
uri: axum::http::Uri,
) -> impl IntoResponse {
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
let api_key = &state.kernel.config.api_key;
// Trim whitespace so empty/whitespace-only api_key disables auth.
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 {
@@ -500,16 +502,29 @@ async fn handle_text_message(
// Send message to agent with streaming
let kernel_handle: Arc<dyn KernelHandle> =
state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_streaming(agent_id, &content, Some(kernel_handle))
{
match state.kernel.send_message_streaming(
agent_id,
&content,
Some(kernel_handle),
None,
None,
) {
Ok((mut rx, handle)) => {
// Forward stream events to WebSocket with debouncing
// Forward stream events to WebSocket with debouncing.
//
// The stream_task also accumulates the full response text and
// captures ContentComplete usage data. This lets us send the
// `response` event immediately when the stream channel closes
// (after `drop(phase_cb)` in the kernel), WITHOUT waiting for
// post-processing (canonical session writes, JSONL, compaction)
// that happens in the kernel task after the loop.
let sender_stream = Arc::clone(sender);
let verbose_clone = Arc::clone(verbose);
let stream_task = tokio::spawn(async move {
let mut text_buffer = String::new();
let mut accumulated_text = String::new();
let mut stream_usage: Option<openfang_types::message::TokenUsage> = None;
let mut is_silent = false;
let far_future = tokio::time::Instant::now() + Duration::from_secs(86400);
let mut flush_deadline = far_future;
@@ -533,7 +548,15 @@ async fn handle_text_message(
break;
}
Some(ev) => {
// Capture ContentComplete for immediate response
if let StreamEvent::ContentComplete { usage, .. } = &ev {
stream_usage = Some(*usage);
// Don't forward — handled below
continue;
}
if let StreamEvent::TextDelta { ref text } = ev {
accumulated_text.push_str(text);
text_buffer.push_str(text);
if text_buffer.len() >= DEBOUNCE_CHARS {
let _ = flush_text_buffer(
@@ -598,14 +621,62 @@ async fn handle_text_message(
}
}
}
// Check if the agent signalled NO_REPLY via the stream
// (PhaseChange with a "silent" marker — currently the
// kernel sets result.silent after the loop, so we detect
// it from empty accumulated text when ContentComplete
// had no text deltas at all).
if accumulated_text.is_empty() && stream_usage.is_some() {
is_silent = true;
}
(accumulated_text, stream_usage, is_silent)
});
// Wait for the agent loop to complete
match handle.await {
Ok(Ok(result)) => {
// Cancel the stream forwarder (should be done by now)
stream_task.abort();
// Wait for the stream to finish (fast — closes as soon as
// drop(phase_cb) runs after the agent loop). This does NOT
// wait for post-processing.
let stream_result = stream_task.await;
// Spawn the kernel task in the background for cleanup
// (canonical session writes, JSONL mirror, compaction).
// We don't need its result for the response event.
let sender_bg = Arc::clone(sender);
tokio::spawn(async move {
match handle.await {
Ok(Err(e)) => {
warn!("Agent post-processing failed: {e}");
let user_msg = classify_streaming_error(&e);
let _ = send_json(
&sender_bg,
&serde_json::json!({
"type": "error",
"content": user_msg,
}),
)
.await;
}
Err(e) => {
warn!("Agent task panicked: {e}");
let _ = send_json(
&sender_bg,
&serde_json::json!({
"type": "error",
"content": "Internal error occurred",
}),
)
.await;
}
Ok(Ok(_)) => {
// Post-processing completed successfully — nothing to send
}
}
});
// Send the response immediately from stream data
match stream_result {
Ok((accumulated_text, stream_usage, is_silent)) => {
// Send typing lifecycle: stop
let _ = send_json(
sender,
@@ -616,43 +687,36 @@ async fn handle_text_message(
)
.await;
// NO_REPLY: agent intentionally chose not to reply
if result.silent {
let usage = stream_usage.unwrap_or_default();
if is_silent {
let _ = send_json(
sender,
&serde_json::json!({
"type": "silent_complete",
"input_tokens": result.total_usage.input_tokens,
"output_tokens": result.total_usage.output_tokens,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}),
)
.await;
return;
}
// Strip <think>...</think> blocks from model output
// (e.g. MiniMax, DeepSeek reasoning tokens)
let cleaned_response = strip_think_tags(&result.response);
// Strip <think>...</think> blocks
let cleaned = strip_think_tags(&accumulated_text);
// Guard: ensure we never send an empty response
let content = if cleaned_response.trim().is_empty() {
let content = if cleaned.trim().is_empty() {
format!(
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
result.total_usage.input_tokens,
result.total_usage.output_tokens,
result.iterations,
"[The agent completed processing but returned no text response. ({} in / {} out)]",
usage.input_tokens, usage.output_tokens,
)
} else {
cleaned_response
cleaned
};
// Estimate context pressure from last call
let per_call = if result.iterations > 0 {
result.total_usage.input_tokens / result.iterations as u64
} else {
result.total_usage.input_tokens
};
let ctx_pct = (per_call as f64 / 200_000.0 * 100.0).min(100.0);
// Estimate context pressure
let ctx_pct =
(usage.input_tokens as f64 / 200_000.0 * 100.0).min(100.0);
let pressure = if ctx_pct > 85.0 {
"critical"
} else if ctx_pct > 70.0 {
@@ -668,38 +732,17 @@ async fn handle_text_message(
&serde_json::json!({
"type": "response",
"content": content,
"input_tokens": result.total_usage.input_tokens,
"output_tokens": result.total_usage.output_tokens,
"iterations": result.iterations,
"cost_usd": result.cost_usd,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"iterations": 0, // Not available from stream; handle updates later if needed
"cost_usd": null,
"context_pressure": pressure,
}),
)
.await;
}
Ok(Err(e)) => {
stream_task.abort();
warn!("Agent message failed: {e}");
let _ = send_json(
sender,
&serde_json::json!({
"type": "typing", "state": "stop",
}),
)
.await;
let user_msg = classify_streaming_error(&e);
let _ = send_json(
sender,
&serde_json::json!({
"type": "error",
"content": user_msg,
}),
)
.await;
}
Err(e) => {
stream_task.abort();
warn!("Agent task panicked: {e}");
warn!("Stream task panicked: {e}");
let _ = send_json(
sender,
&serde_json::json!({
@@ -807,14 +850,21 @@ async fn handle_command(
serde_json::json!({"type": "error", "content": "Agent not found"})
}
} else {
match state.kernel.set_agent_model(agent_id, args) {
match state.kernel.set_agent_model(agent_id, args, None) {
Ok(()) => {
let msg = if let Some(entry) = state.kernel.registry.get(agent_id) {
format!("Model switched to: {} (provider: {})", entry.manifest.model.model, entry.manifest.model.provider)
if let Some(entry) = state.kernel.registry.get(agent_id) {
let model = &entry.manifest.model.model;
let provider = &entry.manifest.model.provider;
serde_json::json!({
"type": "command_result",
"command": cmd,
"message": format!("Model switched to: {model} (provider: {provider})"),
"model": model,
"provider": provider
})
} else {
format!("Model switched to: {args}")
};
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
}
}
Err(e) => {
serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")})
@@ -903,7 +953,7 @@ async fn handle_command(
let msg = if !state.kernel.config.network_enabled {
"OFP network disabled.".to_string()
} else {
match &state.kernel.peer_registry {
match state.kernel.peer_registry.get() {
Some(registry) => {
let peers = registry.all_peers();
if peers.is_empty() {
@@ -1110,6 +1160,9 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
let status = extract_status_code(&inner);
let classified = llm_errors::classify_error(&inner, status);
// Build a user-facing message. The classified.sanitized_message now
// includes a redacted excerpt of the raw error (issue #493 fix), so we
// use it as the base and only override for cases that need extra context.
match classified.category {
llm_errors::LlmErrorCategory::ContextOverflow => {
"Context is full. Try /compact or /new.".to_string()
@@ -1117,24 +1170,36 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
llm_errors::LlmErrorCategory::RateLimit => {
if let Some(delay_ms) = classified.suggested_delay_ms {
let secs = (delay_ms / 1000).max(1);
format!("Provider rate limited. Wait ~{secs}s and try again.")
format!("Rate limited. Wait ~{secs}s and try again.")
} else {
"Provider rate limited. Wait a moment and try again.".to_string()
"Rate limited. Wait a moment and try again.".to_string()
}
}
llm_errors::LlmErrorCategory::Billing => {
"Check provider account status (billing issue detected).".to_string()
format!("Billing issue. {}", classified.sanitized_message)
}
llm_errors::LlmErrorCategory::Auth => {
// Show the actual error detail so users can diagnose (issue #493).
// The sanitized_message already redacts secrets.
classified.sanitized_message.clone()
}
llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(),
llm_errors::LlmErrorCategory::ModelNotFound => {
if inner.contains("localhost:11434") || inner.contains("ollama") {
"Model not found on Ollama. Run `ollama pull <model>` to download it, then try again. Use /model to see options.".to_string()
"Model not found on Ollama. Run `ollama pull <model>` first. Use /model to see options.".to_string()
} else {
"Model unavailable. Use /model to see options or check your provider configuration.".to_string()
format!(
"{}. Use /model to see options.",
classified.sanitized_message
)
}
}
llm_errors::LlmErrorCategory::Format => {
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
// Claude Code CLI errors have actionable messages — pass them through
if inner.contains("Claude Code CLI") || inner.contains("claude auth") {
classified.raw_message.clone()
} else {
classified.sanitized_message.clone()
}
}
_ => classified.sanitized_message,
}
@@ -1142,6 +1207,14 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
/// Try to extract an HTTP status code from an error string.
fn extract_status_code(s: &str) -> Option<u16> {
// "API error (NNN):" — the format produced by LlmError::Api Display impl
if let Some(idx) = s.find("API error (") {
let after = &s[idx + 11..];
let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(code) = num.parse::<u16>() {
return Some(code);
}
}
// "status: NNN"
if let Some(idx) = s.find("status: ") {
let after = &s[idx + 8..];
@@ -1260,6 +1333,15 @@ mod tests {
);
assert_eq!(extract_status_code("StatusCode(401)"), Some(401));
assert_eq!(extract_status_code("some random error"), None);
// LlmError::Api Display format (issue #493 fix)
assert_eq!(
extract_status_code("LLM driver error: API error (403): quota exceeded"),
Some(403)
);
assert_eq!(
extract_status_code("API error (401): invalid api key"),
Some(401)
);
}
#[test]
@@ -3238,3 +3238,206 @@ mark.search-highlight {
.comms-event-row:hover { background: var(--bg-hover); }
.comms-event-time { min-width: 50px; text-align: right; }
.comms-event-detail { margin-left: auto; }
/* ═══════════════════════════════════════════════════════════════════════════
Trader Dashboard
═══════════════════════════════════════════════════════════════════════════ */
.trader-dashboard {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
width: 96vw;
max-width: 1200px;
max-height: 92vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.trader-dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-card);
z-index: 10;
border-radius: 12px 12px 0 0;
}
.trader-dashboard-body {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* KPI Cards */
.trader-kpi-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
}
@media (max-width: 900px) {
.trader-kpi-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 540px) {
.trader-kpi-row { grid-template-columns: repeat(2, 1fr); }
}
.trader-kpi-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
text-align: center;
}
.trader-kpi-label {
font-size: 0.7rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.trader-kpi-value {
font-size: 1.15rem;
font-weight: 700;
color: var(--text);
font-family: var(--font-mono);
}
.kpi-positive { color: var(--success) !important; }
.kpi-negative { color: var(--error) !important; }
/* Chart Rows */
.trader-chart-row {
display: flex;
gap: 12px;
}
@media (max-width: 768px) {
.trader-chart-row { flex-direction: column; }
}
.trader-chart-panel {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
min-width: 0;
}
.trader-chart-title {
font-size: 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
font-weight: 600;
}
.trader-chart-wrap {
position: relative;
width: 100%;
min-height: 180px;
}
.trader-chart-wrap canvas {
width: 100% !important;
height: 100% !important;
}
.trader-chart-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 0.85rem;
}
/* Heatmap Table */
.trader-heatmap-wrap {
overflow-x: auto;
}
.trader-heatmap-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.trader-heatmap-table th {
text-align: left;
padding: 6px 10px;
color: var(--text-dim);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.trader-heatmap-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
}
.heatmap-positive { color: var(--success); font-weight: 600; }
.heatmap-negative { color: var(--error); font-weight: 600; }
/* Signal Badges */
.signal-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.3px;
}
.signal-strong_buy, .signal-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.signal-sell, .signal-strong_sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
.signal-hold { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
/* Confidence Bar */
.confidence-bar-wrap {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
}
.confidence-bar {
height: 6px;
border-radius: 3px;
transition: width 0.3s ease;
}
.conf-high { background: var(--success); }
.conf-mid { background: var(--warning); }
.conf-low { background: var(--error); }
.confidence-label {
font-size: 0.7rem;
color: var(--text-dim);
min-width: 32px;
font-family: var(--font-mono);
}
/* Trades Table */
.trader-trades-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.trader-trades-table th {
text-align: left;
padding: 6px 10px;
color: var(--text-dim);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.trader-trades-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-mono);
font-size: 0.78rem;
}
.trade-side-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 0.68rem;
font-weight: 700;
}
.trade-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.trade-sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
+317 -55
View File
@@ -1,13 +1,28 @@
<body x-data="app" :data-theme="theme">
<!-- API Key Auth Prompt -->
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
<!-- Auth Prompt (API Key or Username/Password) -->
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '', loginUser: '', loginPass: '' }">
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
<!-- Session login mode -->
<template x-if="$store.app.authMode === 'session'">
<div>
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">Sign In</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">Enter your dashboard credentials.</p>
<input type="text" x-model="loginUser" placeholder="Username" autocomplete="username" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.5rem">
<input type="password" x-model="loginPass" placeholder="Password" autocomplete="current-password" @keydown.enter="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Sign In</button>
</div>
</template>
<!-- API key mode -->
<template x-if="$store.app.authMode === 'apikey'">
<div>
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
</div>
</template>
</div>
</div>
@@ -171,6 +186,10 @@
</div>
<div class="sidebar-footer">
<div x-show="$store.app.sessionUser" style="padding:4px 16px;display:flex;align-items:center;justify-content:space-between">
<span class="text-xs text-dim" x-text="$store.app.sessionUser" style="letter-spacing:0.5px"></span>
<button @click="$store.app.sessionLogout()" class="btn btn-ghost btn-sm" style="font-size:11px;padding:2px 8px;opacity:0.7" title="Sign out">Logout</button>
</div>
<div class="sidebar-label text-xs text-dim" style="padding:0 16px 4px;letter-spacing:0.5px">Ctrl+K agents | Ctrl+N new</div>
</div>
<div class="sidebar-toggle" @click="toggleSidebar()" x-text="sidebarCollapsed ? '\u276F' : '\u276E'"></div>
@@ -736,7 +755,7 @@
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
</div>
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
@keydown.enter.prevent="if(!$event.isComposing && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.enter.prevent="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.escape="showSlashMenu = false; showModelPicker = false"
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
@@ -769,7 +788,7 @@
<div class="model-switcher-dropdown" x-show="showModelSwitcher" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform translate-y-1" x-transition:enter-end="opacity-100 transform translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<div class="model-switcher-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="!$event.isComposing && $event.keyCode !== 229 && filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
<option value="">All</option>
<template x-for="pn in switcherProviders" :key="pn">
@@ -792,7 +811,7 @@
<div class="model-switcher-item" :class="{'active': currentAgent && m.id === currentAgent.model_name}" @click="switchModel(m)" @mouseenter="modelSwitcherIdx = filteredSwitcherModels.indexOf(m)">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px">
<span class="model-switcher-item-name" x-text="m.display_name || m.id"></span>
<span class="model-switcher-item-name" x-text="m.provider + ':' + (m.display_name || m.id)"></span>
<span class="model-switcher-tier" :class="'tier-' + (m.tier || 'balanced').toLowerCase()" x-text="m.tier || 'Balanced'"></span>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
@@ -903,7 +922,23 @@
</select>
</div>
<div class="detail-row" x-show="detailAgent.profile"><span class="detail-label">Profile</span><span class="detail-value" style="text-transform:capitalize" x-text="detailAgent.profile || '-'"></span></div>
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="detailAgent.model_provider"></span></div>
<div class="detail-row"><span class="detail-label">Provider</span>
<template x-if="!editingProvider">
<span>
<span class="detail-value" x-text="detailAgent.model_provider"></span>
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingProvider = true; newProviderValue = detailAgent.model_provider">Change</button>
</span>
</template>
<template x-if="editingProvider">
<span class="flex gap-1" style="align-items:center">
<input class="form-input" style="width:160px;font-size:12px" x-model="newProviderValue" placeholder="provider" @keydown.enter="changeProvider()" @keydown.escape="editingProvider = false">
<button class="btn btn-primary btn-sm" @click="changeProvider()" :disabled="modelSaving" style="padding:2px 10px">
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
</button>
<button class="btn btn-ghost btn-sm" @click="editingProvider = false" style="padding:2px 8px">Cancel</button>
</span>
</template>
</div>
<div class="detail-row"><span class="detail-label">Model</span>
<template x-if="!editingModel">
<span>
@@ -1091,7 +1126,7 @@
<div x-show="spawnStep === 1">
<div class="form-group">
<label>Agent Name</label>
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="nextStep()">
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) nextStep()">
</div>
<div class="form-group">
<label>Emoji</label>
@@ -1320,7 +1355,9 @@
<td class="text-xs" x-text="new Date(wf.created_at).toLocaleDateString()"></td>
<td>
<button class="btn btn-primary btn-sm" @click="showRunModal(wf)">Run</button>
<button class="btn btn-ghost btn-sm" @click="showEditModal(wf)">Edit</button>
<button class="btn btn-ghost btn-sm" @click="viewRuns(wf)">History</button>
<button class="btn btn-danger btn-sm" @click="deleteWorkflow(wf)">Delete</button>
</td>
</tr>
</template>
@@ -1385,6 +1422,39 @@
</div>
</div>
</template>
<!-- Edit modal -->
<template x-if="editModal">
<div class="modal-overlay" @click.self="editModal = null" @keydown.escape.window="editModal = null">
<div class="modal">
<div class="modal-header"><h3 x-text="'Edit: ' + editModal.name"></h3><button class="modal-close" @click="editModal = null">&times;</button></div>
<div class="form-group"><label>Name</label><input class="form-input" x-model="editWf.name" placeholder="Workflow name"></div>
<div class="form-group"><label>Description</label><input class="form-input" x-model="editWf.description" placeholder="What does this workflow do?"></div>
<div class="mb-4">
<div class="form-group" style="margin:0"><label>Steps</label></div>
<div class="text-xs text-dim mb-2">Each step runs an agent. Use <code style="color:var(--accent)">{{input}}</code> in prompts to pass the previous step's output.</div>
<template x-for="(step, i) in editWf.steps" :key="i">
<div class="card mt-2" style="padding:10px">
<div class="flex gap-2 items-center">
<span class="text-xs text-dim font-bold" x-text="'#' + (i+1)" style="width:24px"></span>
<input class="form-input" style="flex:1" x-model="step.name" placeholder="Step name">
<input class="form-input" style="flex:1" x-model="step.agent_name" placeholder="Agent name">
<select class="form-select" style="width:120px" x-model="step.mode">
<option value="sequential">Sequential</option>
<option value="fan_out">Fan Out</option>
<option value="conditional">Conditional</option>
<option value="loop">Loop</option>
</select>
<button class="btn btn-danger btn-sm" @click="editWf.steps.splice(i,1)">&times;</button>
</div>
<input class="form-input mt-2" x-model="step.prompt" placeholder="Prompt template (use {{input}})">
</div>
</template>
<button class="btn btn-ghost btn-sm mt-2" @click="editWf.steps.push({name:'',agent_name:'',mode:'sequential',prompt:'{{input}}'})">+ Add Step</button>
</div>
<button class="btn btn-primary btn-block" @click="saveWorkflow()">Save Changes</button>
</div>
</div>
</template>
</div>
</div>
</template>
@@ -1468,7 +1538,7 @@
<div>
<div class="form-group">
<label class="text-xs">Label</label>
<input class="form-input" x-model="selectedNode.label" style="font-size:11px">
<input class="form-input" x-model="selectedNode.label" @input="applyNodeEdit()" style="font-size:11px">
</div>
<!-- Agent config -->
@@ -1476,7 +1546,7 @@
<div>
<div class="form-group">
<label class="text-xs">Agent</label>
<select class="form-select" x-model="selectedNode.config.agent_name" style="font-size:11px">
<select class="form-select" x-model="selectedNode.config.agent_name" @change="applyNodeEdit()" style="font-size:11px">
<option value="">Select agent...</option>
<template x-for="a in agents" :key="a.id || a.name">
<option :value="a.name" x-text="a.name"></option>
@@ -1485,11 +1555,11 @@
</div>
<div class="form-group">
<label class="text-xs">Prompt Template</label>
<textarea class="form-textarea" x-model="selectedNode.config.prompt" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
<textarea class="form-textarea" x-model="selectedNode.config.prompt" @input="applyNodeEdit()" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
</div>
<div class="form-group">
<label class="text-xs">Model (optional)</label>
<input class="form-input" x-model="selectedNode.config.model" style="font-size:11px" placeholder="Default model">
<input class="form-input" x-model="selectedNode.config.model" @input="applyNodeEdit()" style="font-size:11px" placeholder="Default model">
</div>
</div>
</template>
@@ -1499,7 +1569,7 @@
<div>
<div class="form-group">
<label class="text-xs">Expression</label>
<input class="form-input" x-model="selectedNode.config.expression" style="font-size:11px" placeholder="output.contains('yes')">
<input class="form-input" x-model="selectedNode.config.expression" @input="applyNodeEdit()" style="font-size:11px" placeholder="output.contains('yes')">
</div>
<div class="text-xs text-dim">Top port = true, bottom port = false</div>
</div>
@@ -1510,11 +1580,11 @@
<div>
<div class="form-group">
<label class="text-xs">Max Iterations</label>
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" style="font-size:11px" min="1" max="100">
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" @input="applyNodeEdit()" style="font-size:11px" min="1" max="100">
</div>
<div class="form-group">
<label class="text-xs">Until (stop condition)</label>
<input class="form-input" x-model="selectedNode.config.until" style="font-size:11px" placeholder="output === 'done'">
<input class="form-input" x-model="selectedNode.config.until" @input="applyNodeEdit()" style="font-size:11px" placeholder="output === 'done'">
</div>
</div>
</template>
@@ -1524,7 +1594,7 @@
<div>
<div class="form-group">
<label class="text-xs">Fan-out Count</label>
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" style="font-size:11px" min="2" max="10">
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" @input="applyNodeEdit()" style="font-size:11px" min="2" max="10">
</div>
</div>
</template>
@@ -1534,7 +1604,7 @@
<div>
<div class="form-group">
<label class="text-xs">Strategy</label>
<select class="form-select" x-model="selectedNode.config.strategy" style="font-size:11px">
<select class="form-select" x-model="selectedNode.config.strategy" @change="applyNodeEdit()" style="font-size:11px">
<option value="all">Wait for all</option>
<option value="first">First to finish</option>
<option value="majority">Majority vote</option>
@@ -2172,7 +2242,7 @@
<!-- Search bar with live search and clear button -->
<div class="search-input mb-4" style="position:relative">
<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 ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<button x-show="clawhubSearch" @click="clearSearch()" class="search-clear-btn" title="Clear search (Esc)">&times;</button>
</div>
@@ -2223,7 +2293,7 @@
<div class="flex gap-3 items-center">
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
</div>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || isSkillInstalled(skill.slug)" x-text="isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
</div>
</div>
</template>
@@ -2246,7 +2316,7 @@
<span class="text-xs text-dim" x-show="skill.stars" x-text="skill.stars + ' stars'"></span>
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
</div>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || isSkillInstalled(skill.slug)" x-text="isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
</div>
</div>
</template>
@@ -2530,6 +2600,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Actions -->
<div class="flex gap-2 mt-3">
<button class="btn btn-ghost btn-sm" @click="loadStats(inst)">Stats</button>
<template x-if="isTraderHand(inst)">
<button class="btn btn-primary btn-sm" @click="openDashboard(inst)">Dashboard</button>
</template>
<template x-if="isBrowserHand(inst)">
<button class="btn btn-ghost btn-sm" @click="openBrowserViewer(inst)">View Browser</button>
</template>
@@ -2642,9 +2715,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- ═══ Step 1: Dependencies ═══ -->
<div class="hand-wizard-body" x-show="setupStep === 1">
<template x-for="req in (setupWizard.requirements || [])" :key="req.key">
<div class="dep-card" :class="req.satisfied ? 'dep-met' : 'dep-missing'">
<div class="dep-card" :class="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'dep-met' : 'dep-missing'">
<div class="dep-card-header">
<div class="dep-status-icon" :class="[req.satisfied ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="req.satisfied ? '\u2713' : '\u2717'"></div>
<div class="dep-status-icon" :class="[(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? '\u2713' : '\u2717'"></div>
<span class="dep-card-title" x-text="req.label"></span>
<template x-if="req.install && req.install.estimated_time">
<span class="dep-time-badge" x-text="req.install.estimated_time"></span>
@@ -2687,24 +2760,32 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</ol>
</template>
<!-- API Key: numbered steps + signup link -->
<!-- API Key: input field + numbered steps + signup link -->
<template x-if="req.type === 'ApiKey' && req.install">
<div>
<template x-if="req.install.steps && req.install.steps.length">
<ol class="api-key-steps">
<template x-for="step in req.install.steps" :key="step">
<li x-text="step"></li>
</template>
</ol>
</template>
<template x-if="req.install.env_example">
<div class="install-block" style="margin-top:8px">
<div class="install-cmd">
<code x-text="req.install.env_example"></code>
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
<div style="margin-bottom:10px">
<label class="text-xs text-dim" style="display:block;margin-bottom:4px" x-text="'Paste your ' + req.label + ':'"></label>
<input type="password" class="form-input" x-model="apiKeyInputs[req.key]" :placeholder="req.label" style="width:100%;font-family:var(--font-mono);font-size:12px">
<div class="text-xs" style="margin-top:4px;color:var(--green)" x-show="apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== ''">&check; Token entered</div>
</div>
<details style="margin-bottom:8px">
<summary class="text-xs text-dim" style="cursor:pointer;user-select:none">Or set as environment variable</summary>
<template x-if="req.install.steps && req.install.steps.length">
<ol class="api-key-steps">
<template x-for="step in req.install.steps" :key="step">
<li x-text="step"></li>
</template>
</ol>
</template>
<template x-if="req.install.env_example">
<div class="install-block" style="margin-top:8px">
<div class="install-cmd">
<code x-text="req.install.env_example"></code>
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
</div>
</div>
</div>
</template>
</template>
</details>
<div class="flex gap-2 mt-2">
<template x-if="req.install.signup_url">
<a :href="req.install.signup_url" target="_blank" rel="noopener" class="btn btn-primary btn-sm">Get API Key &rarr;</a>
@@ -2937,6 +3018,152 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</template>
<!-- Trader Dashboard Modal -->
<template x-if="dashboardOpen">
<div class="modal-overlay" @click.self="closeDashboard()" @keydown.escape.window="closeDashboard()">
<div class="trader-dashboard">
<!-- Header -->
<div class="trader-dashboard-header">
<div class="flex items-center gap-2">
<span style="font-size:1.4rem">&#x1F4C8;</span>
<div>
<div style="font-weight:600;font-size:1.1rem" x-text="dashboardData ? (dashboardData.agent_name || 'Trading Hand') : 'Trading Hand'"></div>
<div class="text-xs text-dim">Live Trading Dashboard</div>
</div>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm" @click="refreshDashboard()">Refresh</button>
<button class="modal-close" @click="closeDashboard()">&times;</button>
</div>
</div>
<!-- Loading -->
<div x-show="dashboardLoading" class="text-center" style="padding:60px 0">
<div class="spinner"></div>
<div class="text-dim mt-2">Loading dashboard data...</div>
</div>
<!-- Dashboard Content -->
<div class="trader-dashboard-body" x-show="!dashboardLoading && dashboardData">
<!-- KPI Row -->
<div class="trader-kpi-row">
<div class="trader-kpi-card">
<div class="trader-kpi-label">Portfolio Value</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.portfolio_value || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Total P&amp;L</div>
<div class="trader-kpi-value" :class="dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('+') ? 'kpi-positive' : (dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('-') ? 'kpi-negative' : '')" x-text="dashboardData ? (dashboardData.total_pnl || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Win Rate</div>
<div class="trader-kpi-value" x-text="dashboardData && dashboardData.win_rate ? (dashboardData.win_rate + '%') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Sharpe Ratio</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.sharpe_ratio || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Max Drawdown</div>
<div class="trader-kpi-value kpi-negative" x-text="dashboardData && dashboardData.max_drawdown ? (dashboardData.max_drawdown + '%') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Trades</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.trades_count || '0') : '0'"></div>
</div>
</div>
<!-- Charts Row 1: Equity Curve + Daily P&L -->
<div class="trader-chart-row">
<div class="trader-chart-panel" style="flex:2">
<div class="trader-chart-title">Equity Curve</div>
<div class="trader-chart-wrap">
<canvas id="traderEquityChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.equity_curve || !dashboardData.equity_curve.length">No equity data yet</div>
</div>
</div>
<div class="trader-chart-panel" style="flex:1">
<div class="trader-chart-title">Daily P&amp;L</div>
<div class="trader-chart-wrap">
<canvas id="traderPnlChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.daily_pnl || !dashboardData.daily_pnl.length">No P&amp;L data yet</div>
</div>
</div>
</div>
<!-- Charts Row 2: Signal Radar + Watchlist Heatmap -->
<div class="trader-chart-row">
<div class="trader-chart-panel" style="flex:1;max-width:320px">
<div class="trader-chart-title">Signal Radar</div>
<div class="trader-chart-wrap" style="max-height:280px">
<canvas id="traderRadarChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.signal_radar">No signal data yet</div>
</div>
</div>
<div class="trader-chart-panel" style="flex:2">
<div class="trader-chart-title">Watchlist Heatmap</div>
<div class="trader-heatmap-wrap" x-show="dashboardData && dashboardData.watchlist_heatmap && dashboardData.watchlist_heatmap.length">
<table class="trader-heatmap-table">
<thead>
<tr><th>Ticker</th><th>Change</th><th>Signal</th><th>Confidence</th></tr>
</thead>
<tbody>
<template x-for="item in (dashboardData ? dashboardData.watchlist_heatmap || [] : [])" :key="item.ticker">
<tr>
<td style="font-weight:600" x-text="item.ticker"></td>
<td :class="item.change_pct >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(item.change_pct >= 0 ? '+' : '') + item.change_pct + '%'"></td>
<td><span class="signal-badge" :class="'signal-' + (item.signal || 'hold').toLowerCase()" x-text="item.signal || 'HOLD'"></span></td>
<td>
<div class="confidence-bar-wrap">
<div class="confidence-bar" :style="'width:' + (item.confidence || 0) + '%'" :class="item.confidence >= 70 ? 'conf-high' : (item.confidence >= 40 ? 'conf-mid' : 'conf-low')"></div>
<span class="confidence-label" x-text="(item.confidence || 0) + '%'"></span>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.watchlist_heatmap || !dashboardData.watchlist_heatmap.length">No watchlist data yet</div>
</div>
</div>
<!-- Recent Trades Table -->
<div class="trader-chart-panel">
<div class="trader-chart-title">Recent Trades</div>
<div x-show="dashboardData && dashboardData.recent_trades && dashboardData.recent_trades.length">
<table class="trader-trades-table">
<thead>
<tr><th>Date</th><th>Ticker</th><th>Side</th><th>Price</th><th>Qty</th><th>P&amp;L</th></tr>
</thead>
<tbody>
<template x-for="trade in (dashboardData ? dashboardData.recent_trades || [] : [])" :key="trade.date + trade.ticker">
<tr>
<td class="text-dim" x-text="trade.date"></td>
<td style="font-weight:600" x-text="trade.ticker"></td>
<td><span class="trade-side-badge" :class="trade.side === 'BUY' ? 'trade-buy' : 'trade-sell'" x-text="trade.side"></span></td>
<td x-text="'$' + Number(trade.price || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
<td x-text="trade.qty"></td>
<td :class="(trade.pnl || 0) >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(trade.pnl >= 0 ? '+$' : '-$') + Math.abs(trade.pnl || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.recent_trades || !dashboardData.recent_trades.length">No trades yet</div>
</div>
</div>
<!-- No data state -->
<div x-show="!dashboardLoading && !dashboardData" class="text-center" style="padding:60px 0">
<div style="font-size:2rem;margin-bottom:8px">&#x1F4C8;</div>
<div class="text-dim">Could not load dashboard data.</div>
<button class="btn btn-ghost btn-sm mt-3" @click="refreshDashboard()">Retry</button>
</div>
</div>
</div>
</template>
<!-- Activation result toast -->
<div x-show="activateResult" x-transition class="info-card" style="position:fixed;bottom:24px;right:24px;z-index:200;max-width:360px" @click="activateResult = null">
<div class="flex items-center gap-2">
@@ -3372,11 +3599,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Network tab -->
<div x-show="tab === 'network'" x-data="{
netStatus: null, a2aAgents: [], a2aDiscoverUrl: '', a2aDiscovering: false,
async loadNetStatus() { try { this.netStatus = await (await fetch('/api/network/status')).json(); } catch(e) {} },
async loadA2aAgents() { try { let r = await (await fetch('/api/a2a/agents')).json(); this.a2aAgents = r.agents || []; } catch(e) {} },
async loadNetStatus() { try { this.netStatus = await OpenFangAPI.get('/api/network/status'); } catch(e) {} },
async loadA2aAgents() { try { let r = await OpenFangAPI.get('/api/a2a/agents'); this.a2aAgents = r.agents || []; } catch(e) {} },
async discoverA2a() {
if (!this.a2aDiscoverUrl) return; this.a2aDiscovering = true;
try { await fetch('/api/a2a/discover', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:this.a2aDiscoverUrl})}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
try { await OpenFangAPI.post('/api/a2a/discover', {url:this.a2aDiscoverUrl}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
this.a2aDiscovering = false;
}
}" x-init="loadNetStatus(); loadA2aAgents()">
@@ -3458,14 +3685,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div x-show="tab === 'budget'" x-data="{
budgetData: null, agentRanking: [], budgetLoading: true,
editMode: false,
editHourly: '', editDaily: '', editMonthly: '', editAlert: '',
editHourly: '', editDaily: '', editMonthly: '', editAlert: '', editTokenLimit: '',
saving: false,
async loadBudget() {
this.budgetLoading = true;
try {
let [b, a] = await Promise.all([
fetch('/api/budget').then(r => r.json()),
fetch('/api/budget/agents').then(r => r.json())
OpenFangAPI.get('/api/budget'),
OpenFangAPI.get('/api/budget/agents')
]);
this.budgetData = b;
this.agentRanking = a.agents || [];
@@ -3477,6 +3704,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
this.editDaily = this.budgetData.daily_limit || 0;
this.editMonthly = this.budgetData.monthly_limit || 0;
this.editAlert = ((this.budgetData.alert_threshold || 0.8) * 100).toFixed(0);
this.editTokenLimit = this.budgetData.default_max_llm_tokens_per_hour || 0;
this.editMode = true;
},
async saveBudget() {
@@ -3488,12 +3716,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
if (+this.editMonthly !== this.budgetData.monthly_limit) body.max_monthly_usd = +this.editMonthly;
let alertVal = (+this.editAlert) / 100;
if (Math.abs(alertVal - this.budgetData.alert_threshold) > 0.001) body.alert_threshold = alertVal;
await fetch('/api/budget', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
if (+this.editTokenLimit !== (this.budgetData.default_max_llm_tokens_per_hour || 0)) body.default_max_llm_tokens_per_hour = +this.editTokenLimit;
await OpenFangAPI.put('/api/budget', body);
this.editMode = false;
await this.loadBudget();
} catch(e) { alert('Failed to save: ' + e); }
} catch(e) { OpenFangToast.error('Failed to save: ' + (e.message || e)); }
this.saving = false;
},
fmtTokens(v) { return v > 0 ? (v >= 1000000 ? (v/1000000).toFixed(1)+'M' : v >= 1000 ? (v/1000).toFixed(0)+'K' : v) : 'per-agent'; },
pctColor(pct) { return pct >= 0.8 ? '#ef4444' : pct >= 0.5 ? '#eab308' : '#22c55e'; },
fmtUsd(v) { return v > 0 ? '$' + v.toFixed(4) : 'unlimited'; }
}" x-init="loadBudget()">
@@ -3533,9 +3763,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</div>
</div>
<div class="text-xs text-dim mb-3" x-show="budgetData.alert_threshold > 0 && !editMode">
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
</div>
<div class="text-xs text-dim mb-3" x-show="!editMode">
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
</div>
<!-- Edit limits form -->
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
@@ -3557,7 +3790,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
</div>
</div>
<div class="text-xs text-dim">Set to 0 for unlimited. Changes apply immediately (in-memory, not persisted to config.toml).</div>
<div style="margin-bottom:8px">
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
</div>
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
</div>
@@ -3565,7 +3802,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
<div class="table-wrap" x-show="agentRanking.length">
<table>
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th></tr></thead>
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
<tbody>
<template x-for="a in agentRanking" :key="a.agent_id">
<tr>
@@ -3574,6 +3811,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
</tr>
</template>
</tbody>
@@ -4453,7 +4691,29 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</div>
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj)">
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider === 'claude-code'">
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
<div class="card-header">Configure Claude Code</div>
<div class="text-xs text-dim mb-2" style="line-height:1.8">
Claude Code uses its own CLI authentication &mdash; no API key needed.
</div>
<div style="background:var(--bg);border-radius:4px;padding:10px 12px;margin-bottom:12px;font-size:12px;line-height:1.8">
<div><span style="color:var(--accent)">1.</span> Install: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
<div><span style="color:var(--accent)">2.</span> Authenticate: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">claude auth</code></div>
<div><span style="color:var(--accent)">3.</span> Click <strong>Detect</strong> below to verify</div>
</div>
<button class="btn btn-primary btn-sm" @click="detectClaudeCode()" :disabled="testingProvider">
<span x-show="!testingProvider">Detect Claude Code</span>
<span x-show="testingProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
</button>
<div x-show="testResult" class="mt-2">
<div x-show="testResult && testResult.status === 'ok'" class="badge badge-success" style="padding:6px 12px">Claude Code detected<span x-show="testResult && testResult.latency_ms" x-text="' (' + (testResult ? testResult.latency_ms : '') + 'ms)'"></span></div>
<div x-show="testResult && testResult.status !== 'ok'" class="badge badge-error" style="padding:6px 12px">Claude Code CLI not detected. Make sure you&rsquo;ve run: <code>npm install -g @anthropic-ai/claude-code &amp;&amp; claude auth</code></div>
</div>
</div>
</template>
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider !== 'claude-code'">
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
<div class="card-header" x-text="'Configure ' + selectedProviderObj.display_name"></div>
<div class="text-xs text-dim mb-2" x-show="selectedProviderObj.api_key_env">
@@ -4543,7 +4803,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div class="card" style="border-left:3px solid var(--accent)">
<div class="form-group" style="margin-bottom:8px">
<label>Agent Name</label>
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="createAgent()">
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) createAgent()">
</div>
<div class="text-xs text-dim" x-text="'Will use ' + templates[selectedTemplate].provider + ' / ' + templates[selectedTemplate].model + ' with ' + profileInfo(templates[selectedTemplate].profile).label + ' profile'"></div>
<div class="mt-2">
@@ -4589,7 +4849,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Input -->
<div style="display:flex;gap:8px;margin-top:12px">
<input class="form-input" type="text" x-model="tryItInput" placeholder="Type a message..."
@keydown.enter="sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
<button class="btn btn-primary btn-sm" @click="sendTryItMessage(tryItInput)" :disabled="tryItSending || !tryItInput.trim()">Send</button>
</div>
</div>
@@ -4775,3 +5035,5 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Toast notification container -->
<div id="toast-container" class="toast-container" aria-live="polite"></div>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
@@ -6,7 +6,12 @@
<title>OpenFang Dashboard</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#6366f1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/contrib/auto-render.min.js"></script>
</head>
+11
View File
@@ -161,6 +161,17 @@ var OpenFangAPI = (function() {
return fetch(BASE + path, opts).then(function(r) {
if (_connectionState !== 'connected') setConnectionState('connected');
if (!r.ok) {
// On 401, auto-show auth prompt so the user can re-enter their key
if (r.status === 401 && typeof Alpine !== 'undefined') {
try {
var store = Alpine.store('app');
if (store && !store.showAuthPrompt) {
_authToken = '';
localStorage.removeItem('openfang-api-key');
store.showAuthPrompt = true;
}
} catch(e2) { /* ignore Alpine errors */ }
}
return r.text().then(function(text) {
var msg = '';
try {
+101 -5
View File
@@ -24,14 +24,68 @@ function escapeHtml(text) {
function renderMarkdown(text) {
if (!text) return '';
if (typeof marked !== 'undefined') {
var html = marked.parse(text);
// Protect LaTeX blocks from marked.js mangling (underscores, backslashes, etc.)
var latexBlocks = [];
var protected_ = text;
// Protect display math $$...$$ first (greedy across lines)
protected_ = protected_.replace(/\$\$([\s\S]+?)\$\$/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect inline math $...$ (single line, not empty, not starting/ending with space)
protected_ = protected_.replace(/\$([^\s$](?:[^$]*[^\s$])?)\$/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect \[...\] display math
protected_ = protected_.replace(/\\\[([\s\S]+?)\\\]/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect \(...\) inline math
protected_ = protected_.replace(/\\\(([\s\S]+?)\\\)/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
var html = marked.parse(protected_);
// Restore LaTeX blocks
for (var i = 0; i < latexBlocks.length; i++) {
html = html.replace('\x00LATEX' + i + '\x00', latexBlocks[i]);
}
// Add copy buttons to code blocks
html = html.replace(/<pre><code/g, '<pre><button class="copy-btn" onclick="copyCode(this)">Copy</button><code');
// Open external links in new tab
html = html.replace(/<a\s+href="(https?:\/\/[^"]*)"(?![^>]*target=)([^>]*)>/gi, '<a href="$1" target="_blank" rel="noopener"$2>');
return html;
}
return escapeHtml(text);
}
// Render LaTeX math in the chat message container using KaTeX auto-render.
// Call this after new messages are inserted into the DOM.
function renderLatex(el) {
if (typeof renderMathInElement !== 'function') return;
var target = el || document.getElementById('messages');
if (!target) return;
try {
renderMathInElement(target, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false }
],
throwOnError: false,
trust: false
});
} catch(e) { /* KaTeX render error — ignore gracefully */ }
}
function copyCode(btn) {
var code = btn.nextElementSibling;
if (code) {
@@ -104,6 +158,8 @@ document.addEventListener('alpine:init', function() {
focusMode: localStorage.getItem('openfang-focus') === 'true',
showOnboarding: false,
showAuthPrompt: false,
authMode: 'apikey',
sessionUser: null,
toggleFocusMode() {
this.focusMode = !this.focusMode;
@@ -155,16 +211,33 @@ document.addEventListener('alpine:init', function() {
async checkAuth() {
try {
// Use a protected endpoint (not in the public allowlist) to detect
// whether the server requires an API key.
// First check if session-based auth is configured
var authInfo = await OpenFangAPI.get('/api/auth/check');
if (authInfo.mode === 'none') {
// No session auth — fall back to API key detection
this.authMode = 'apikey';
this.sessionUser = null;
} else if (authInfo.mode === 'session') {
this.authMode = 'session';
if (authInfo.authenticated) {
this.sessionUser = authInfo.username;
this.showAuthPrompt = false;
return;
}
// Session auth enabled but not authenticated — show login prompt
this.showAuthPrompt = true;
return;
}
} catch(e) { /* ignore — fall through to API key check */ }
// API key mode detection
try {
await OpenFangAPI.get('/api/tools');
this.showAuthPrompt = false;
} catch(e) {
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
// Only show prompt if we don't already have a saved key
var saved = localStorage.getItem('openfang-api-key');
if (saved) {
// Saved key might be stale — clear it and show prompt
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
@@ -181,6 +254,29 @@ document.addEventListener('alpine:init', function() {
this.refreshAgents();
},
async sessionLogin(username, password) {
try {
var result = await OpenFangAPI.post('/api/auth/login', { username: username, password: password });
if (result.status === 'ok') {
this.sessionUser = result.username;
this.showAuthPrompt = false;
this.refreshAgents();
} else {
OpenFangToast.error(result.error || 'Login failed');
}
} catch(e) {
OpenFangToast.error(e.message || 'Login failed');
}
},
async sessionLogout() {
try {
await OpenFangAPI.post('/api/auth/logout');
} catch(e) { /* ignore */ }
this.sessionUser = null;
this.showAuthPrompt = true;
},
clearApiKey() {
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
+56 -8
View File
@@ -1,6 +1,21 @@
// OpenFang Agents Page — Multi-step spawn wizard, detail view with tabs, file editor, personality presets
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n""").
* Backslashes are escaped, and runs of 3+ consecutive double-quotes are
* broken up so the TOML parser never sees an unintended closing delimiter.
*/
function tomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("...").
* Backslashes, double-quotes, and common control chars are escaped.
*/
function tomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function agentsPage() {
return {
tab: 'agents',
@@ -62,6 +77,8 @@ function agentsPage() {
// -- Model switch --
editingModel: false,
newModelValue: '',
editingProvider: false,
newProviderValue: '',
modelSaving: false,
// -- Fallback chain --
editingFallback: false,
@@ -378,7 +395,7 @@ function agentsPage() {
},
// ── Multi-step wizard navigation ──
openSpawnWizard() {
async openSpawnWizard() {
this.showSpawnModal = true;
this.spawnStep = 1;
this.spawnMode = 'wizard';
@@ -386,8 +403,18 @@ function agentsPage() {
this.selectedPreset = '';
this.soulContent = '';
this.spawnForm.name = '';
this.spawnForm.provider = 'groq';
this.spawnForm.model = 'llama-3.3-70b-versatile';
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
this.spawnForm.profile = 'full';
try {
var res = await fetch('/api/status');
if (res.ok) {
var status = await res.json();
if (status.default_provider) this.spawnForm.provider = status.default_provider;
if (status.default_model) this.spawnForm.model = status.default_model;
}
} catch(e) { /* keep hardcoded defaults */ }
},
nextStep() {
@@ -411,7 +438,7 @@ function agentsPage() {
var f = this.spawnForm;
var si = this.spawnIdentity;
var lines = [
'name = "' + f.name + '"',
'name = "' + tomlBasicEscape(f.name) + '"',
'module = "builtin:chat"'
];
if (f.profile && f.profile !== 'custom') {
@@ -420,7 +447,7 @@ function agentsPage() {
lines.push('', '[model]');
lines.push('provider = "' + f.provider + '"');
lines.push('model = "' + f.model + '"');
lines.push('system_prompt = "' + f.systemPrompt.replace(/"/g, '\\"') + '"');
lines.push('system_prompt = """\n' + tomlMultilineEscape(f.systemPrompt) + '\n"""');
if (f.profile === 'custom') {
lines.push('', '[capabilities]');
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
@@ -597,8 +624,9 @@ function agentsPage() {
if (!this.detailAgent || !this.newModelValue.trim()) return;
this.modelSaving = true;
try {
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
OpenFangToast.success('Model changed (memory reset)');
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
var providerInfo = (resp && resp.provider) ? ' (provider: ' + resp.provider + ')' : '';
OpenFangToast.success('Model changed' + providerInfo + ' (memory reset)');
this.editingModel = false;
await Alpine.store('app').refreshAgents();
// Refresh detailAgent
@@ -612,6 +640,26 @@ function agentsPage() {
this.modelSaving = false;
},
// ── Provider switch ──
async changeProvider() {
if (!this.detailAgent || !this.newProviderValue.trim()) return;
this.modelSaving = true;
try {
var combined = this.newProviderValue.trim() + '/' + this.detailAgent.model_name;
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: combined });
OpenFangToast.success('Provider changed to ' + (resp && resp.provider ? resp.provider : this.newProviderValue.trim()));
this.editingProvider = false;
await Alpine.store('app').refreshAgents();
var agents = Alpine.store('app').agents;
for (var i = 0; i < agents.length; i++) {
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
}
} catch(e) {
OpenFangToast.error('Failed to change provider: ' + e.message);
}
this.modelSaving = false;
},
// ── Fallback model chain ──
async addFallback() {
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
@@ -697,12 +745,12 @@ function agentsPage() {
},
async spawnBuiltin(t) {
var toml = 'name = "' + t.name + '"\n';
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
var toml = 'name = "' + tomlBasicEscape(t.name) + '"\n';
toml += 'description = "' + tomlBasicEscape(t.description) + '"\n';
toml += 'module = "builtin:chat"\n';
toml += 'profile = "' + t.profile + '"\n\n';
toml += '[model]\nprovider = "' + t.provider + '"\nmodel = "' + t.model + '"\n';
toml += 'system_prompt = """\n' + t.system_prompt + '\n"""\n';
toml += 'system_prompt = """\n' + tomlMultilineEscape(t.system_prompt) + '\n"""\n';
try {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
+41 -16
View File
@@ -264,9 +264,10 @@ function chatPage() {
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
var self = this;
this.modelSwitching = true;
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function() {
self.currentAgent.model_name = model.id;
self.currentAgent.model_provider = model.provider;
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) {
// Use server-resolved model/provider to stay in sync (fixes #387/#466)
self.currentAgent.model_name = (resp && resp.model) || model.id;
self.currentAgent.model_provider = (resp && resp.provider) || model.provider;
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
self.showModelSwitcher = false;
self.modelSwitching = false;
@@ -421,9 +422,12 @@ function chatPage() {
if (self.currentAgent) {
if (cmdArgs) {
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) {
self.currentAgent.model_name = cmdArgs;
if (resp && resp.provider) { self.currentAgent.model_provider = resp.provider; }
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`' + (resp && resp.provider ? ' (provider: `' + resp.provider + '`)' : ''), meta: '', tools: [] });
// Use server-resolved model/provider (fixes #387/#466)
var resolvedModel = (resp && resp.model) || cmdArgs;
var resolvedProvider = (resp && resp.provider) || '';
self.currentAgent.model_name = resolvedModel;
if (resolvedProvider) { self.currentAgent.model_provider = resolvedProvider; }
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + resolvedModel + '`' + (resolvedProvider ? ' (provider: `' + resolvedProvider + '`)' : ''), meta: '', tools: [] });
self.scrollToBottom();
}).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); });
} else {
@@ -526,7 +530,7 @@ function chatPage() {
id: (t.name || 'tool') + '-hist-' + idx,
name: t.name || 'unknown',
running: false,
expanded: false,
expanded: true,
input: t.input || '',
result: t.result || '',
is_error: !!t.is_error
@@ -646,17 +650,32 @@ function chatPage() {
// Show tool/phase progress so the user sees the agent is working
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) {
var detail = data.detail || data.phase || 'Working...';
// Context warning: show prominently
// Skip phases that have no user-meaningful display text — "streaming"
// and "done" are lifecycle signals, not status to show in the chat bubble.
if (data.phase === 'streaming' || data.phase === 'done') {
break;
}
// Context warning: show prominently as a separate system message
if (data.phase === 'context_warning') {
this.messages.push({ id: ++msgId, role: 'system', text: detail, meta: '', tools: [] });
var cwDetail = data.detail || 'Context limit reached.';
this.messages.push({ id: ++msgId, role: 'system', text: cwDetail, meta: '', tools: [] });
} else if (data.phase === 'thinking' && this.thinkingMode === 'stream') {
// Stream reasoning tokens to a collapsible panel
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
phaseMsg._reasoning += (detail || '') + '\n';
phaseMsg._reasoning += (data.detail || '') + '\n';
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
} else {
phaseMsg.text = detail;
} else if (phaseMsg.thinking) {
// Only update text on messages still in thinking state (not yet
// receiving streamed content) to avoid overwriting accumulated text.
var phaseDetail;
if (data.phase === 'tool_use') {
phaseDetail = 'Using ' + (data.detail || 'tool') + '...';
} else if (data.phase === 'thinking') {
phaseDetail = 'Thinking...';
} else {
phaseDetail = data.detail || 'Working...';
}
phaseMsg.text = phaseDetail;
}
}
this.scrollToBottom();
@@ -684,7 +703,7 @@ function chatPage() {
id: toolMatch[1] + '-txt-' + Date.now(),
name: toolMatch[1],
running: true,
expanded: false,
expanded: true,
input: inputMatch ? inputMatch[1].replace(/<\/function>?\s*$/, '').trim() : '',
result: '',
is_error: false
@@ -702,7 +721,7 @@ function chatPage() {
var lastMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
if (lastMsg && lastMsg.streaming) {
if (!lastMsg.tools) lastMsg.tools = [];
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: false, input: '', result: '', is_error: false });
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: true, input: '', result: '', is_error: false });
}
this.scrollToBottom();
break;
@@ -1027,10 +1046,16 @@ function chatPage() {
});
},
_latexTimer: null,
scrollToBottom() {
var self = this;
var el = document.getElementById('messages');
if (el) self.$nextTick(function() { el.scrollTop = el.scrollHeight; });
if (el) self.$nextTick(function() {
el.scrollTop = el.scrollHeight;
// Debounce LaTeX rendering to avoid running on every streaming token
if (self._latexTimer) clearTimeout(self._latexTimer);
self._latexTimer = setTimeout(function() { renderLatex(el); }, 150);
});
},
addFiles(files) {
+453 -3
View File
@@ -18,6 +18,15 @@ function handsPage() {
browserViewerOpen: false,
_browserPollTimer: null,
// ── Trader Dashboard State ────────────────────────────────────────────
dashboardOpen: false,
dashboardLoading: false,
dashboardData: null,
_dashboardInst: null,
_chartEquity: null,
_chartPnl: null,
_chartRadar: null,
// ── Setup Wizard State ──────────────────────────────────────────────
setupWizard: null,
setupStep: 1,
@@ -27,6 +36,7 @@ function handsPage() {
_clipboardTimer: null,
detectedPlatform: 'linux',
installPlatforms: {},
apiKeyInputs: {},
async loadData() {
this.loading = true;
@@ -101,11 +111,15 @@ function handsPage() {
} else {
this._detectClientPlatform();
}
// Initialize per-requirement platform selections
// Initialize per-requirement platform selections and API key inputs
this.installPlatforms = {};
this.apiKeyInputs = {};
if (data.requirements) {
for (var j = 0; j < data.requirements.length; j++) {
this.installPlatforms[data.requirements[j].key] = this.detectedPlatform;
if (data.requirements[j].type === 'ApiKey') {
this.apiKeyInputs[data.requirements[j].key] = '';
}
}
}
this.setupWizard = data;
@@ -274,7 +288,10 @@ function handsPage() {
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
var count = 0;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
if (this.setupWizard.requirements[i].satisfied) count++;
var req = this.setupWizard.requirements[i];
if (req.satisfied) { count++; continue; }
// Count API key reqs as met if user entered a value
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') count++;
}
return count;
},
@@ -285,7 +302,34 @@ function handsPage() {
},
get setupAllReqsMet() {
return this.setupReqsTotal > 0 && this.setupReqsMet === this.setupReqsTotal;
if (!this.setupWizard || !this.setupWizard.requirements) return false;
if (this.setupReqsTotal === 0) return false;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.satisfied) continue;
// API key reqs are satisfied if the user entered a value in the input
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') continue;
return false;
}
return true;
},
getSettingKeyForReq(req) {
// Find the matching setting key for an API key requirement.
// Convention: setting key is the lowercase version of the requirement key.
if (!this.setupWizard || !this.setupWizard.settings) return null;
var lowerKey = req.key.toLowerCase();
for (var i = 0; i < this.setupWizard.settings.length; i++) {
if (this.setupWizard.settings[i].key === lowerKey) return lowerKey;
}
// Fallback: try matching by check_value lowercased
if (req.check_value) {
var lowerCheck = req.check_value.toLowerCase();
for (var j = 0; j < this.setupWizard.settings.length; j++) {
if (this.setupWizard.settings[j].key === lowerCheck) return lowerCheck;
}
}
return null;
},
get setupHasReqs() {
@@ -297,6 +341,10 @@ function handsPage() {
},
setupNextStep() {
// When leaving step 1, sync API key inputs into settings values
if (this.setupStep === 1) {
this._syncApiKeysToSettings();
}
if (this.setupStep === 1 && this.setupHasSettings) {
this.setupStep = 2;
} else if (this.setupStep === 1) {
@@ -306,6 +354,19 @@ function handsPage() {
}
},
_syncApiKeysToSettings() {
if (!this.setupWizard || !this.setupWizard.requirements) return;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
},
setupPrevStep() {
if (this.setupStep === 3 && this.setupHasSettings) {
this.setupStep = 2;
@@ -323,11 +384,24 @@ function handsPage() {
this.setupChecking = false;
this.clipboardMsg = null;
this.installPlatforms = {};
this.apiKeyInputs = {};
},
async launchHand() {
if (!this.setupWizard) return;
var handId = this.setupWizard.id;
// Sync API key inputs from step 1 into settings values
if (this.setupWizard.requirements) {
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
}
var config = {};
for (var key in this.settingsValues) {
config[key] = this.settingsValues[key];
@@ -499,6 +573,382 @@ function handsPage() {
this.stopBrowserPolling();
this.browserViewerOpen = false;
this.browserViewer = null;
},
// ── Trader Dashboard ──────────────────────────────────────────────────
isTraderHand(inst) {
return inst.hand_id === 'trader';
},
async openDashboard(inst) {
this._dashboardInst = inst;
this.dashboardOpen = true;
this.dashboardLoading = true;
this.dashboardData = null;
await this._fetchDashboardData(inst);
this.dashboardLoading = false;
// Render charts after DOM update
var self = this;
setTimeout(function() { self._renderCharts(); }, 60);
},
async refreshDashboard() {
if (!this._dashboardInst) return;
this.dashboardLoading = true;
await this._fetchDashboardData(this._dashboardInst);
this.dashboardLoading = false;
var self = this;
setTimeout(function() { self._renderCharts(); }, 60);
},
closeDashboard() {
this.dashboardOpen = false;
this._destroyCharts();
this.dashboardData = null;
this._dashboardInst = null;
},
async _fetchDashboardData(inst) {
var data = {
agent_name: inst.agent_name || inst.hand_id,
portfolio_value: null,
total_pnl: null,
win_rate: null,
sharpe_ratio: null,
max_drawdown: null,
trades_count: null,
equity_curve: [],
daily_pnl: [],
watchlist_heatmap: [],
signal_radar: null,
recent_trades: []
};
// Fetch basic stats from the hand stats endpoint
try {
var stats = await OpenFangAPI.get('/api/hands/instances/' + inst.instance_id + '/stats');
var m = stats.metrics || {};
if (m['Portfolio Value']) data.portfolio_value = this._metricVal(m['Portfolio Value']);
if (m['Total P&L']) data.total_pnl = this._metricVal(m['Total P&L']);
if (m['Win Rate']) data.win_rate = this._metricVal(m['Win Rate']);
if (m['Sharpe Ratio']) data.sharpe_ratio = this._metricVal(m['Sharpe Ratio']);
if (m['Max Drawdown']) data.max_drawdown = this._metricVal(m['Max Drawdown']);
if (m['Trades Executed']) data.trades_count = this._metricVal(m['Trades Executed']);
} catch(e) {
// Stats endpoint might fail — continue with KV data
}
// Fetch rich chart data from agent memory KV
var agentId = inst.agent_id || 'shared';
var kvKeys = [
'trader_hand_equity_curve',
'trader_hand_daily_pnl',
'trader_hand_watchlist_heatmap',
'trader_hand_signal_radar',
'trader_hand_recent_trades',
'trader_hand_portfolio_value',
'trader_hand_total_pnl',
'trader_hand_win_rate',
'trader_hand_sharpe_ratio',
'trader_hand_max_drawdown',
'trader_hand_trades_count'
];
for (var i = 0; i < kvKeys.length; i++) {
try {
var resp = await OpenFangAPI.get('/api/memory/agents/' + agentId + '/kv/' + kvKeys[i]);
if (resp && resp.value !== null && resp.value !== undefined) {
var val = resp.value;
this._applyKvToData(data, kvKeys[i], val);
}
} catch(e) {
// Key might not exist yet — that's fine
}
}
this.dashboardData = data;
},
_metricVal(metric) {
if (!metric) return null;
var v = metric.value;
if (v === null || v === undefined) return null;
// Values come as JSON values — could be string, number, etc.
if (typeof v === 'string') return v;
return String(v);
},
_applyKvToData(data, key, val) {
// Values from KV can be strings (JSON-encoded) or already parsed
var parsed = val;
if (typeof val === 'string') {
try { parsed = JSON.parse(val); } catch(e) { parsed = val; }
}
switch(key) {
case 'trader_hand_portfolio_value':
if (!data.portfolio_value) data.portfolio_value = String(parsed);
break;
case 'trader_hand_total_pnl':
if (!data.total_pnl) data.total_pnl = String(parsed);
break;
case 'trader_hand_win_rate':
if (!data.win_rate) data.win_rate = String(parsed);
break;
case 'trader_hand_sharpe_ratio':
if (!data.sharpe_ratio) data.sharpe_ratio = String(parsed);
break;
case 'trader_hand_max_drawdown':
if (!data.max_drawdown) data.max_drawdown = String(parsed);
break;
case 'trader_hand_trades_count':
if (!data.trades_count) data.trades_count = String(parsed);
break;
case 'trader_hand_equity_curve':
if (Array.isArray(parsed)) data.equity_curve = parsed;
break;
case 'trader_hand_daily_pnl':
if (Array.isArray(parsed)) data.daily_pnl = parsed;
break;
case 'trader_hand_watchlist_heatmap':
if (Array.isArray(parsed)) data.watchlist_heatmap = parsed;
break;
case 'trader_hand_signal_radar':
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data.signal_radar = parsed;
break;
case 'trader_hand_recent_trades':
if (Array.isArray(parsed)) data.recent_trades = parsed;
break;
}
},
_destroyCharts() {
if (this._chartEquity) { this._chartEquity.destroy(); this._chartEquity = null; }
if (this._chartPnl) { this._chartPnl.destroy(); this._chartPnl = null; }
if (this._chartRadar) { this._chartRadar.destroy(); this._chartRadar = null; }
},
_renderCharts() {
if (typeof Chart === 'undefined') return;
this._destroyCharts();
if (!this.dashboardData) return;
var d = this.dashboardData;
// Detect theme
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
var gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
var textColor = isDark ? '#8A8380' : '#6B6560';
var accentColor = '#FF5C00';
var successColor = isDark ? '#4ADE80' : '#22C55E';
var errorColor = '#EF4444';
// ── Equity Curve ──
if (d.equity_curve && d.equity_curve.length > 0) {
var eqCanvas = document.getElementById('traderEquityChart');
if (eqCanvas) {
var labels = [];
var values = [];
for (var i = 0; i < d.equity_curve.length; i++) {
labels.push(d.equity_curve[i].date || '');
values.push(parseFloat(d.equity_curve[i].value) || 0);
}
// Determine gradient
var eqCtx = eqCanvas.getContext('2d');
var gradient = eqCtx.createLinearGradient(0, 0, 0, eqCanvas.parentElement.clientHeight || 180);
gradient.addColorStop(0, isDark ? 'rgba(255, 92, 0, 0.25)' : 'rgba(255, 92, 0, 0.15)');
gradient.addColorStop(1, 'rgba(255, 92, 0, 0)');
this._chartEquity = new Chart(eqCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
data: values,
borderColor: accentColor,
backgroundColor: gradient,
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: d.equity_curve.length > 20 ? 0 : 3,
pointHoverRadius: 5,
pointBackgroundColor: accentColor
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) {
return '$' + ctx.parsed.y.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
}
}
},
scales: {
x: {
grid: { color: gridColor },
ticks: { color: textColor, maxTicksLimit: 8, font: { size: 10 } }
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { size: 10 },
callback: function(v) { return '$' + v.toLocaleString(); }
}
}
}
}
});
}
}
// ── Daily P&L Bar Chart ──
if (d.daily_pnl && d.daily_pnl.length > 0) {
var pnlCanvas = document.getElementById('traderPnlChart');
if (pnlCanvas) {
var pnlLabels = [];
var pnlValues = [];
var pnlColors = [];
for (var j = 0; j < d.daily_pnl.length; j++) {
pnlLabels.push(d.daily_pnl[j].date || '');
var pnlVal = parseFloat(d.daily_pnl[j].pnl) || 0;
pnlValues.push(pnlVal);
pnlColors.push(pnlVal >= 0 ? successColor : errorColor);
}
this._chartPnl = new Chart(pnlCanvas.getContext('2d'), {
type: 'bar',
data: {
labels: pnlLabels,
datasets: [{
data: pnlValues,
backgroundColor: pnlColors,
borderRadius: 3,
borderSkipped: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) {
var v = ctx.parsed.y;
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: textColor, maxTicksLimit: 7, font: { size: 10 } }
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { size: 10 },
callback: function(v) {
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString();
}
}
}
}
}
});
}
}
// ── Signal Radar Chart ──
if (d.signal_radar) {
var radarCanvas = document.getElementById('traderRadarChart');
if (radarCanvas) {
var radarLabels = [];
var radarValues = [];
var keys = ['technical', 'fundamental', 'sentiment', 'macro'];
var displayLabels = ['Technical', 'Fundamental', 'Sentiment', 'Macro'];
for (var k = 0; k < keys.length; k++) {
radarLabels.push(displayLabels[k]);
radarValues.push(parseFloat(d.signal_radar[keys[k]]) || 0);
}
this._chartRadar = new Chart(radarCanvas.getContext('2d'), {
type: 'radar',
data: {
labels: radarLabels,
datasets: [{
data: radarValues,
borderColor: accentColor,
backgroundColor: isDark ? 'rgba(255, 92, 0, 0.2)' : 'rgba(255, 92, 0, 0.12)',
borderWidth: 2,
pointBackgroundColor: accentColor,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) { return ctx.parsed.r + '/100'; }
}
}
},
scales: {
r: {
min: 0,
max: 100,
beginAtZero: true,
grid: { color: gridColor },
angleLines: { color: gridColor },
pointLabels: {
color: textColor,
font: { size: 11, weight: '600' }
},
ticks: {
color: textColor,
backdropColor: 'transparent',
stepSize: 25,
font: { size: 9 }
}
}
}
}
});
}
}
}
};
}
@@ -352,7 +352,10 @@ function settingsPage() {
providerAuthText(p) {
if (p.auth_status === 'configured') return 'Configured';
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'Not Set';
if (p.auth_status === 'not_set' || p.auth_status === 'missing') {
if (p.id === 'claude-code') return 'Not Installed';
return 'Not Set';
}
return 'No Key Needed';
},
@@ -398,8 +401,12 @@ function settingsPage() {
var key = this.providerKeyInputs[provider.id];
if (!key || !key.trim()) { OpenFangToast.error('Please enter an API key'); return; }
try {
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
OpenFangToast.success('API key saved for ' + provider.display_name);
var resp = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
if (resp && resp.switched_default) {
OpenFangToast.warning(resp.message || 'Default provider was switched to ' + provider.display_name);
} else {
OpenFangToast.success('API key saved for ' + provider.display_name);
}
this.providerKeyInputs[provider.id] = '';
await this.loadProviders();
await this.loadModels();
+39 -5
View File
@@ -1,6 +1,16 @@
// OpenFang Setup Wizard — First-run guided setup (Provider + Agent + Channel)
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n"""). */
function wizardTomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("..."). */
function wizardTomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function wizardPage() {
return {
step: 1,
@@ -283,11 +293,13 @@ function wizardPage() {
},
get canGoNext() {
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider;
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider || this.claudeCodeDetected;
if (this.step === 3) return this.agentName.trim().length > 0;
return true;
},
claudeCodeDetected: false,
get hasConfiguredProvider() {
var self = this;
return this.providers.some(function(p) {
@@ -409,6 +421,28 @@ function wizardPage() {
this.testingProvider = false;
},
async detectClaudeCode() {
this.testingProvider = true;
this.testResult = null;
try {
var result = await OpenFangAPI.post('/api/providers/claude-code/test', {});
this.testResult = result;
if (result.status === 'ok') {
this.claudeCodeDetected = true;
this.keySaved = true;
this.setupSummary.provider = 'Claude Code';
OpenFangToast.success('Claude Code detected (' + (result.latency_ms || '?') + 'ms)');
} else {
this.testResult = { status: 'error', error: 'Claude Code CLI not detected' };
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
}
} catch(e) {
this.testResult = { status: 'error', error: e.message };
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
}
this.testingProvider = false;
},
// ── Step 3: Agent creation ──
selectTemplate(index) {
@@ -438,12 +472,12 @@ function wizardPage() {
}
var toml = '[agent]\n';
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
toml += 'name = "' + wizardTomlBasicEscape(name) + '"\n';
toml += 'description = "' + wizardTomlBasicEscape(tpl.description) + '"\n';
toml += 'profile = "' + tpl.profile + '"\n\n';
toml += '[model]\nprovider = "' + provider + '"\n';
toml += 'model = "' + model + '"\n';
toml += 'system_prompt = """\n' + tpl.system_prompt + '\n"""\n';
toml += 'system_prompt = """\n' + wizardTomlMultilineEscape(tpl.system_prompt) + '\n"""\n';
this.creatingAgent = true;
try {
@@ -469,7 +503,7 @@ function wizardPage() {
gemini: 'gemini-2.5-flash',
groq: 'llama-3.3-70b-versatile',
deepseek: 'deepseek-chat',
openrouter: 'openrouter/auto',
openrouter: 'openrouter/google/gemini-2.5-flash',
mistral: 'mistral-large-latest',
together: 'meta-llama/Llama-3-70b-chat-hf',
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
@@ -38,6 +38,11 @@ function workflowBuilder() {
],
_renderScheduled: false,
_lastClickNodeId: null,
_lastClickTime: 0,
_didDrag: false,
_didConnect: false,
_didPan: false,
async init() {
var self = this;
@@ -334,8 +339,23 @@ function workflowBuilder() {
onNodeMouseDown: function(node, e) {
e.stopPropagation();
// Detect double-click manually — the native dblclick event never fires
// because scheduleRender() destroys and recreates all SVG elements between
// the first and second click, so the browser loses the DOM target for dblclick.
var now = Date.now();
if (this._lastClickNodeId === node.id && (now - this._lastClickTime) < 350) {
// Double-click detected — open editor instead of starting drag
this._lastClickNodeId = null;
this._lastClickTime = 0;
this.editNode(node);
return;
}
this._lastClickNodeId = node.id;
this._lastClickTime = now;
this.selectedNode = node;
this.selectedConnection = null;
this._didDrag = false;
this.dragging = node.id;
var rect = this._getCanvasRect();
this.dragOffset = {
@@ -350,6 +370,7 @@ function workflowBuilder() {
this.selectedConnection = null;
this.showNodeEditor = false;
// Start canvas pan
this._didPan = false;
this.canvasDragging = true;
this.canvasDragStart = { x: e.clientX - this.canvasOffset.x * this.zoom, y: e.clientY - this.canvasOffset.y * this.zoom };
},
@@ -357,6 +378,7 @@ function workflowBuilder() {
onCanvasMouseMove: function(e) {
var rect = this._getCanvasRect();
if (this.dragging) {
this._didDrag = true;
var node = this.getNode(this.dragging);
if (node) {
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
@@ -364,12 +386,14 @@ function workflowBuilder() {
}
this.scheduleRender();
} else if (this.connecting) {
this._didConnect = true;
this.connectPreview = {
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
};
this.scheduleRender();
} else if (this.canvasDragging) {
this._didPan = true;
this.canvasOffset = {
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
y: (e.clientY - this.canvasDragStart.y) / this.zoom
@@ -378,11 +402,19 @@ function workflowBuilder() {
},
onCanvasMouseUp: function() {
// Only re-render if something actually moved. Rendering on every mouseup
// destroys SVG elements between clicks, which prevents dblclick detection.
var needsRender = this._didDrag || this._didConnect || this._didPan;
this.dragging = null;
this.connecting = null;
this.connectPreview = null;
this.canvasDragging = false;
this.scheduleRender();
this._didDrag = false;
this._didConnect = false;
this._didPan = false;
if (needsRender) {
this.scheduleRender();
}
},
onCanvasWheel: function(e) {
@@ -427,6 +459,12 @@ function workflowBuilder() {
editNode: function(node) {
this.selectedNode = node;
this.showNodeEditor = true;
this.scheduleRender();
},
// Called from editor panel inputs to reflect changes on the canvas SVG
applyNodeEdit: function() {
this.scheduleRender();
},
// ── TOML Generation ──────────────────────────────────
@@ -13,6 +13,8 @@ function workflowsPage() {
loading: true,
loadError: '',
newWf: { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] },
editModal: null,
editWf: { name: '', description: '', steps: [] },
// -- Workflows methods --
async loadWorkflows() {
@@ -74,6 +76,57 @@ function workflowsPage() {
} catch(e) {
OpenFangToast.error('Failed to load run history: ' + e.message);
}
},
async deleteWorkflow(wf) {
if (!confirm('Delete workflow "' + wf.name + '"? This cannot be undone.')) return;
try {
await OpenFangAPI.delete('/api/workflows/' + wf.id);
OpenFangToast.success('Workflow "' + wf.name + '" deleted');
await this.loadWorkflows();
} catch(e) {
OpenFangToast.error('Failed to delete workflow: ' + e.message);
}
},
async showEditModal(wf) {
try {
var full = await OpenFangAPI.get('/api/workflows/' + wf.id);
this.editWf = {
name: full.name || '',
description: full.description || '',
steps: (full.steps || []).map(function(s) {
return {
name: s.name || '',
agent_name: (s.agent && s.agent.name) || '',
mode: s.mode || 'sequential',
prompt: s.prompt_template || '{{input}}'
};
})
};
if (this.editWf.steps.length === 0) {
this.editWf.steps.push({ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' });
}
this.editModal = wf;
} catch(e) {
OpenFangToast.error('Failed to load workflow: ' + e.message);
}
},
async saveWorkflow() {
if (!this.editModal) return;
var steps = this.editWf.steps.map(function(s) {
return { name: s.name || 'step', agent_name: s.agent_name, mode: s.mode, prompt: s.prompt || '{{input}}' };
});
try {
var wfName = this.editWf.name;
await OpenFangAPI.put('/api/workflows/' + this.editModal.id, { name: wfName, description: this.editWf.description, steps: steps });
this.editModal = null;
OpenFangToast.success('Workflow "' + wfName + '" updated');
await this.loadWorkflows();
} catch(e) {
OpenFangToast.error('Failed to update workflow: ' + e.message);
}
}
};
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "OpenFang Agent OS",
"short_name": "OpenFang",
"description": "Open-source Agent Operating System",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#6366f1",
"icons": [
{"src": "/logo.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo.png", "sizes": "512x512", "type": "image/png"}
]
}
+3
View File
@@ -0,0 +1,3 @@
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
File diff suppressed because one or more lines are too long
@@ -77,6 +77,7 @@ async fn start_test_server_with_provider(
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(),
});
let app = Router::new()
@@ -705,9 +706,21 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
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(),
});
let api_key_state = state.kernel.config.api_key.clone();
let api_key = state.kernel.config.api_key.trim().to_string();
let auth_state = middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
session_secret: if !api_key.is_empty() {
api_key.clone()
} else if state.kernel.config.auth.enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
},
};
let app = Router::new()
.route("/api/health", axum::routing::get(routes::health))
@@ -751,7 +764,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
)
.route("/api/shutdown", axum::routing::post(routes::shutdown))
.layer(axum::middleware::from_fn_with_state(
api_key_state,
auth_state,
middleware::auth,
))
.layer(axum::middleware::from_fn(middleware::request_logging))
@@ -114,6 +114,7 @@ async fn test_full_daemon_lifecycle() {
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(),
});
let app = Router::new()
@@ -238,6 +239,7 @@ async fn test_server_immediate_responsiveness() {
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(),
});
let app = Router::new()
+1
View File
@@ -58,6 +58,7 @@ async fn start_test_server() -> TestServer {
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(),
});
let app = Router::new()
+5
View File
@@ -24,9 +24,14 @@ zeroize = { workspace = true }
axum = { workspace = true }
hmac = { workspace = true }
sha2 = { workspace = true }
sha1 = { workspace = true }
aes = "0.8"
cbc = "0.1"
base64 = { workspace = true }
hex = { workspace = true }
html-escape = { workspace = true }
regex-lite = "0.1"
roxmltree = "0.20"
lettre = { workspace = true }
imap = { workspace = true }
+2 -2
View File
@@ -215,7 +215,7 @@ impl BlueskyAdapter {
let chunks = split_message(text, MAX_MESSAGE_LEN);
for chunk in chunks {
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let mut record = serde_json::json!({
"$type": "app.bsky.feed.post",
@@ -496,7 +496,7 @@ impl ChannelAdapter for BlueskyAdapter {
if last_seen_at.is_some() {
let mark_url = format!("{}/xrpc/app.bsky.notification.updateSeen", service_url);
let mark_body = serde_json::json!({
"seenAt": Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
"seenAt": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
});
let _ = client
.post(&mark_url)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,600 @@
//! DingTalk Stream channel adapter.
//!
//! Uses DingTalk Stream Mode (WebSocket long-connection) instead of the
//! legacy webhook approach. The webhook adapter in `dingtalk.rs` is preserved
//! for backwards compatibility.
//!
//! Protocol:
//! 1. POST /v1.0/oauth2/accessToken → get access token
//! 2. POST /v1.0/gateway/connections/open → get WebSocket URL
//! 3. Connect via WebSocket, handle ping/pong and EVENT messages
//! 4. Outbound: POST /v1.0/robot/oToMessages/batchSend
use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use chrono::Utc;
use futures::{SinkExt, Stream, StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{error, info, warn};
const API_BASE: &str = "https://api.dingtalk.com";
const MAX_MESSAGE_LEN: usize = 20000;
// ─── Adapter ─────────────────────────────────────────────────────────────────
pub struct DingTalkStreamAdapter {
app_key: String,
app_secret: String,
robot_code: String,
client: reqwest::Client,
token_cache: Arc<Mutex<TokenCache>>,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
}
impl DingTalkStreamAdapter {
pub fn new(app_key: String, app_secret: String, robot_code: String) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
app_key,
app_secret,
robot_code,
client: reqwest::Client::new(),
token_cache: Arc::new(Mutex::new(TokenCache::default())),
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
}
}
async fn get_token(&self) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
{
let c = self.token_cache.lock().unwrap();
if !c.token.is_empty() && c.expire_at > now + 300 {
return Ok(c.token.clone());
}
}
let resp: serde_json::Value = self
.client
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
.json(&serde_json::json!({
"appKey": self.app_key,
"appSecret": self.app_secret,
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let token = resp["accessToken"]
.as_str()
.ok_or("missing accessToken")?
.to_string();
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
{
let mut c = self.token_cache.lock().unwrap();
c.token = token.clone();
c.expire_at = now + expire_in;
}
Ok(token)
}
async fn send_to_ids(
&self,
user_ids: &[&str],
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let token = self
.get_token()
.await
.map_err(|e| -> Box<dyn std::error::Error> { e })?;
let (msg_key, _msg_param) = match &content {
ChannelContent::Text(t) => (
"sampleText",
serde_json::json!({ "content": t }).to_string(),
),
_ => (
"sampleText",
serde_json::json!({ "content": "(unsupported content type)" }).to_string(),
),
};
let text = match &content {
ChannelContent::Text(t) => t.as_str(),
_ => "(unsupported)",
};
let chunks = split_message(text, MAX_MESSAGE_LEN);
for chunk in &chunks {
let param = serde_json::json!({ "content": chunk }).to_string();
let body = serde_json::json!({
"robotCode": self.robot_code,
"userIds": user_ids,
"msgKey": msg_key,
"msgParam": param,
});
let resp = self
.client
.post(format!("{API_BASE}/v1.0/robot/oToMessages/batchSend"))
.header("x-acs-dingtalk-access-token", &token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err_body = resp.text().await.unwrap_or_default();
return Err(format!("DingTalk batchSend error {status}: {err_body}").into());
}
if chunks.len() > 1 {
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
Ok(())
}
}
#[async_trait]
impl ChannelAdapter for DingTalkStreamAdapter {
fn name(&self) -> &str {
"dingtalk_stream"
}
fn channel_type(&self) -> ChannelType {
ChannelType::Custom("dingtalk_stream".to_string())
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let app_key = self.app_key.clone();
let app_secret = self.app_secret.clone();
let client = self.client.clone();
let token_cache = Arc::clone(&self.token_cache);
let mut shutdown_rx = self.shutdown_rx.clone();
info!("DingTalk Stream adapter starting WebSocket connection");
tokio::spawn(async move {
let mut attempt: u32 = 0;
loop {
if *shutdown_rx.borrow() {
info!("DingTalk Stream: shutdown requested");
break;
}
// 1. Get access token
let token =
match get_access_token(&client, &app_key, &app_secret, &token_cache).await {
Ok(t) => t,
Err(e) => {
warn!("DingTalk Stream: token fetch failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
// 2. Get WebSocket endpoint
let ws_url = match get_ws_endpoint(&client, &app_key, &app_secret, &token).await {
Ok(u) => u,
Err(e) => {
warn!("DingTalk Stream: endpoint fetch failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
info!(
"DingTalk Stream: connecting to {}...",
&ws_url[..ws_url.len().min(60)]
);
// 3. Connect
let ws_stream = match connect_async(&ws_url).await {
Ok((ws, _)) => ws,
Err(e) => {
warn!("DingTalk Stream: WS connect failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
info!("DingTalk Stream: connected");
attempt = 0;
let (mut sink, mut source) = ws_stream.split();
// 4. Message loop
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!("DingTalk Stream: graceful shutdown");
return;
}
}
msg = source.next() => {
match msg {
None => { warn!("DingTalk Stream: connection closed"); break; }
Some(Err(e)) => { warn!("DingTalk Stream: WS error: {e}"); break; }
Some(Ok(Message::Text(text))) => {
handle_frame(&text, &mut sink, &tx).await;
}
Some(Ok(Message::Ping(d))) => { let _ = sink.send(Message::Pong(d)).await; }
Some(Ok(Message::Close(_))) => { info!("DingTalk Stream: close frame"); break; }
_ => {}
}
}
}
}
// Reconnect
attempt += 1;
let delay = backoff(attempt);
info!("DingTalk Stream: reconnecting in {delay:?}");
tokio::time::sleep(delay).await;
}
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn send(
&self,
user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let uid = &user.platform_id;
if uid.is_empty() {
return Err("DingTalk Stream: no platform_id to reply to".into());
}
self.send_to_ids(&[uid.as_str()], content).await
}
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
}
}
// ─── Token helpers ───────────────────────────────────────────────────────────
#[derive(Default)]
struct TokenCache {
token: String,
expire_at: u64,
}
async fn get_access_token(
http: &reqwest::Client,
app_key: &str,
app_secret: &str,
cache: &Arc<Mutex<TokenCache>>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
{
let c = cache.lock().unwrap();
if !c.token.is_empty() && c.expire_at > now + 300 {
return Ok(c.token.clone());
}
}
let resp: serde_json::Value = http
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
.json(&serde_json::json!({ "appKey": app_key, "appSecret": app_secret }))
.send()
.await?
.error_for_status()?
.json()
.await?;
let token = resp["accessToken"]
.as_str()
.ok_or("missing accessToken")?
.to_string();
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
{
let mut c = cache.lock().unwrap();
c.token = token.clone();
c.expire_at = now + expire_in;
}
Ok(token)
}
// ─── Gateway / WebSocket helpers ─────────────────────────────────────────────
#[derive(Serialize)]
struct OpenConnectionRequest<'a> {
#[serde(rename = "clientId")]
client_id: &'a str,
#[serde(rename = "clientSecret")]
client_secret: &'a str,
subscriptions: Vec<SubItem>,
ua: &'a str,
#[serde(rename = "localIp")]
local_ip: &'a str,
}
#[derive(Serialize)]
struct SubItem {
#[serde(rename = "type")]
sub_type: String,
topic: String,
}
#[derive(Deserialize)]
struct OpenConnectionResponse {
endpoint: String,
ticket: String,
}
async fn get_ws_endpoint(
http: &reqwest::Client,
app_key: &str,
app_secret: &str,
token: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let body = OpenConnectionRequest {
client_id: app_key,
client_secret: app_secret,
subscriptions: vec![SubItem {
sub_type: "CALLBACK".to_string(),
topic: "/v1.0/im/bot/messages/get".to_string(),
}],
ua: "openfang/0.3",
local_ip: "",
};
let resp: OpenConnectionResponse = http
.post(format!("{API_BASE}/v1.0/gateway/connections/open"))
.header("x-acs-dingtalk-access-token", token)
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
let sep = if resp.endpoint.contains('?') {
"&"
} else {
"?"
};
Ok(format!("{}{}ticket={}", resp.endpoint, sep, resp.ticket))
}
// ─── Frame handling ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ProtoFrame {
#[serde(rename = "type")]
msg_type: String,
headers: ProtoHeaders,
#[serde(default)]
data: serde_json::Value,
}
#[derive(Deserialize)]
struct ProtoHeaders {
#[serde(rename = "messageId", default)]
message_id: String,
#[serde(default)]
topic: String,
}
#[derive(Serialize)]
struct AckReply {
code: u32,
headers: AckHeaders,
message: String,
data: String,
}
#[derive(Serialize)]
struct AckHeaders {
#[serde(rename = "contentType")]
content_type: String,
#[serde(rename = "messageId")]
message_id: String,
topic: String,
}
fn make_ack(message_id: &str, topic: &str) -> String {
serde_json::to_string(&AckReply {
code: 200,
headers: AckHeaders {
content_type: "application/json".to_string(),
message_id: message_id.to_string(),
topic: topic.to_string(),
},
message: "OK".to_string(),
data: String::new(),
})
.unwrap_or_default()
}
#[derive(Deserialize)]
struct CallbackPayload {
#[serde(rename = "msgtype", default)]
msg_type: String,
#[serde(default)]
text: Option<TextContent>,
#[serde(rename = "senderStaffId", default)]
sender_staff_id: String,
#[serde(rename = "senderId", default)]
sender_id: String,
#[serde(rename = "senderNick", default)]
sender_nick: String,
#[serde(rename = "conversationId", default)]
conversation_id: String,
#[serde(rename = "conversationType", default)]
conversation_type: String,
#[serde(rename = "messageId", default)]
message_id: String,
}
#[derive(Deserialize)]
struct TextContent {
content: String,
}
async fn handle_frame<S>(text: &str, sink: &mut S, tx: &mpsc::Sender<ChannelMessage>)
where
S: SinkExt<Message> + Unpin,
<S as futures::Sink<Message>>::Error: std::fmt::Display,
{
let frame: ProtoFrame = match serde_json::from_str(text) {
Ok(f) => f,
Err(e) => {
warn!("DingTalk Stream: bad frame: {e}");
return;
}
};
let mid = &frame.headers.message_id;
let topic = &frame.headers.topic;
match frame.msg_type.as_str() {
"SYSTEM" if topic == "ping" => {
let _ = sink.send(Message::Text(make_ack(mid, "pong"))).await;
}
"CALLBACK" | "EVENT" => {
let data_str = frame.data.to_string();
// Try direct parse, then try unwrapping double-encoded string
let cb: Option<CallbackPayload> = serde_json::from_str(&data_str).ok().or_else(|| {
serde_json::from_str::<String>(&data_str)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
});
if let Some(cb) = cb {
if cb.msg_type == "text" {
if let Some(ref tc) = cb.text {
let trimmed = tc.content.trim().to_string();
if !trimmed.is_empty() {
let content = if trimmed.starts_with('/') {
let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
let cmd = parts[0].trim_start_matches('/');
let args: Vec<String> = parts
.get(1)
.map(|a| a.split_whitespace().map(String::from).collect())
.unwrap_or_default();
ChannelContent::Command {
name: cmd.to_string(),
args,
}
} else {
ChannelContent::Text(trimmed)
};
let mut meta = HashMap::new();
meta.insert(
"conversation_id".to_string(),
serde_json::Value::String(cb.conversation_id),
);
let uid = if cb.sender_staff_id.is_empty() {
cb.sender_id
} else {
cb.sender_staff_id
};
let msg = ChannelMessage {
channel: ChannelType::Custom("dingtalk_stream".to_string()),
platform_message_id: cb.message_id,
sender: ChannelUser {
platform_id: uid,
display_name: cb.sender_nick,
openfang_user: None,
},
content,
target_agent: None,
timestamp: Utc::now(),
is_group: cb.conversation_type == "2",
thread_id: None,
metadata: meta,
};
if tx.send(msg).await.is_err() {
error!("DingTalk Stream: channel receiver dropped");
}
}
}
}
}
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
}
_ => {
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
}
}
}
fn backoff(attempt: u32) -> Duration {
let ms = (1000u64 * 2u64.saturating_pow(attempt.min(6))).min(60_000);
Duration::from_millis(ms)
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adapter_creation() {
let a = DingTalkStreamAdapter::new("k".into(), "s".into(), "r".into());
assert_eq!(a.name(), "dingtalk_stream");
assert_eq!(
a.channel_type(),
ChannelType::Custom("dingtalk_stream".to_string())
);
}
#[test]
fn backoff_doubles() {
assert_eq!(backoff(0), Duration::from_millis(1000));
assert_eq!(backoff(1), Duration::from_millis(2000));
assert_eq!(backoff(2), Duration::from_millis(4000));
}
#[test]
fn backoff_capped() {
assert_eq!(backoff(10), Duration::from_millis(60_000));
assert_eq!(backoff(20), Duration::from_millis(60_000));
}
#[test]
fn make_ack_valid_json() {
let ack = make_ack("msg1", "topic1");
let v: serde_json::Value = serde_json::from_str(&ack).unwrap();
assert_eq!(v["code"], 200);
assert_eq!(v["headers"]["messageId"], "msg1");
}
}
+97 -16
View File
@@ -318,9 +318,14 @@ impl ChannelAdapter for DiscordAdapter {
}
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
if let Some(msg) =
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users, ignore_bots)
.await
if let Some(msg) = parse_discord_message(
d,
&bot_user_id,
&allowed_guilds,
&allowed_users,
ignore_bots,
)
.await
{
debug!(
"Discord {event_name} from {}: {:?}",
@@ -517,8 +522,8 @@ async fn parse_discord_message(
.map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str())))
.unwrap_or(false);
// Also check content for <@bot_id> or <@!bot_id> patterns
let mentioned_in_content =
content_text.contains(&format!("<@{bid}>")) || content_text.contains(&format!("<@!{bid}>"));
let mentioned_in_content = content_text.contains(&format!("<@{bid}>"))
|| content_text.contains(&format!("<@!{bid}>"));
mentioned_in_array || mentioned_in_content
} else {
false
@@ -566,7 +571,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert_eq!(msg.sender.display_name, "alice");
assert_eq!(msg.sender.platform_id, "ch1");
@@ -612,6 +619,51 @@ mod tests {
assert!(msg.is_none());
}
#[tokio::test]
async fn test_parse_discord_ignore_bots_false_allows_other_bots() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"content": "Bot message",
"author": {
"id": "other_bot",
"username": "somebot",
"discriminator": "0",
"bot": true
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
// With ignore_bots=false, other bots' messages should be allowed
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
assert!(msg.is_some());
let msg = msg.unwrap();
assert_eq!(msg.sender.display_name, "somebot");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Bot message"));
}
#[tokio::test]
async fn test_parse_discord_ignore_bots_false_still_filters_self() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"content": "My own message",
"author": {
"id": "bot123",
"username": "openfang",
"discriminator": "0",
"bot": true
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
// Even with ignore_bots=false, the bot's own messages must still be filtered
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
assert!(msg.is_none());
}
#[tokio::test]
async fn test_parse_discord_message_guild_filter() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
@@ -629,7 +681,8 @@ mod tests {
});
// Not in allowed guilds
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
let msg =
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
assert!(msg.is_none());
// In allowed guilds
@@ -652,7 +705,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -696,7 +751,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.sender.display_name, "alice#1234");
}
@@ -718,7 +775,9 @@ mod tests {
});
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert!(
matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content")
@@ -741,7 +800,14 @@ mod tests {
});
// Not in allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()], true).await;
let msg = parse_discord_message(
&d,
&bot_id,
&[],
&["user111".into(), "user222".into()],
true,
)
.await;
assert!(msg.is_none());
// In allowed users
@@ -772,9 +838,14 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(msg.is_group);
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
assert_eq!(
msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()),
Some(true)
);
// Message without mention in group
let d2 = serde_json::json!({
@@ -790,7 +861,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true).await.unwrap();
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(msg2.is_group);
assert!(!msg2.metadata.contains_key("was_mentioned"));
}
@@ -810,13 +883,21 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(!msg.is_group);
}
#[test]
fn test_discord_adapter_creation() {
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], true, 37376);
let adapter = DiscordAdapter::new(
"test-token".to_string(),
vec!["123".to_string(), "456".to_string()],
vec![],
true,
37376,
);
assert_eq!(adapter.name(), "discord");
assert_eq!(adapter.channel_type(), ChannelType::Discord);
}
+4 -6
View File
@@ -139,8 +139,7 @@ impl EmailAdapter {
async fn build_smtp_transport(
&self,
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error>> {
let creds =
Credentials::new(self.username.clone(), self.password.as_str().to_string());
let creds = Credentials::new(self.username.clone(), self.password.as_str().to_string());
let transport = if self.smtp_port == 465 {
// Implicit TLS (port 465)
@@ -215,8 +214,8 @@ fn fetch_unseen_emails(
.build()
.map_err(|e| format!("TLS connector error: {e}"))?;
let client = imap::connect((host, port), host, &tls)
.map_err(|e| format!("IMAP connect failed: {e}"))?;
let client =
imap::connect((host, port), host, &tls).map_err(|e| format!("IMAP connect failed: {e}"))?;
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
// that reject LOGIN and only support AUTH=PLAIN (SASL).
@@ -390,8 +389,7 @@ impl ChannelAdapter for EmailAdapter {
}
// Extract target agent from subject brackets (stored in metadata for router)
let _target_agent =
EmailAdapter::extract_agent_from_subject(&subject);
let _target_agent = EmailAdapter::extract_agent_from_subject(&subject);
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
// Build the message body: prepend subject context
File diff suppressed because it is too large Load Diff
+466 -48
View File
@@ -17,21 +17,177 @@ pub fn format_for_channel(text: &str, format: OutputFormat) -> String {
}
}
/// Format a message for WeCom, using a stronger plain-text conversion to avoid
/// leaking Markdown syntax into enterprise chat replies.
pub fn format_for_wecom(text: &str, format: OutputFormat) -> String {
match format {
OutputFormat::PlainText => markdown_to_wecom_plain(text),
_ => format_for_channel(text, format),
}
}
/// Convert Markdown to Telegram HTML subset.
///
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`.
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`, `<blockquote>`.
fn markdown_to_telegram_html(text: &str) -> String {
// Escape HTML special characters first so agent names and other text
// don't get interpreted as HTML tags by Telegram's parser.
let mut result = text
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
let mut blocks = Vec::new();
let lines: Vec<&str> = normalized.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if trimmed.is_empty() {
i += 1;
continue;
}
// Fenced code block
if let Some(fence) = fence_delimiter(trimmed) {
i += 1;
let mut code_lines = Vec::new();
while i < lines.len() {
let candidate = lines[i].trim();
if candidate.starts_with(fence) {
i += 1;
break;
}
code_lines.push(lines[i]);
i += 1;
}
let code = escape_html(&code_lines.join("\n"));
blocks.push(format!("<pre><code>{}</code></pre>", code));
continue;
}
// ATX heading (#, ##, ...)
if let Some(content) = heading_text(trimmed) {
blocks.push(format!("<b>{}</b>", render_inline_markdown(content.trim())));
i += 1;
continue;
}
// Blockquote
if trimmed.starts_with('>') {
let mut quote_lines = Vec::new();
while i < lines.len() {
let current = lines[i].trim();
if current.is_empty() || !current.starts_with('>') {
break;
}
let content = current.strip_prefix('>').unwrap_or(current).trim_start();
quote_lines.push(render_inline_markdown(content));
i += 1;
}
blocks.push(format!(
"<blockquote>{}</blockquote>",
quote_lines.join("\n")
));
continue;
}
// Unordered list
if let Some(item) = unordered_list_item(trimmed) {
let mut items = vec![format!("{}", render_inline_markdown(item.trim()))];
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if let Some(next_item) = unordered_list_item(current) {
items.push(format!("{}", render_inline_markdown(next_item.trim())));
i += 1;
} else if current.is_empty() {
i += 1;
break;
} else {
break;
}
}
blocks.push(items.join("\n"));
continue;
}
// Ordered list
if let Some(item) = ordered_list_item(trimmed) {
let mut items = vec![format!("1. {}", render_inline_markdown(item.trim()))];
let mut counter = 2;
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if let Some(next_item) = ordered_list_item(current) {
items.push(format!(
"{}. {}",
counter,
render_inline_markdown(next_item.trim())
));
counter += 1;
i += 1;
} else if current.is_empty() {
i += 1;
break;
} else {
break;
}
}
blocks.push(items.join("\n"));
continue;
}
// Paragraph
let mut paragraph_lines = vec![trimmed];
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if current.is_empty()
|| fence_delimiter(current).is_some()
|| heading_text(current).is_some()
|| current.starts_with('>')
|| unordered_list_item(current).is_some()
|| ordered_list_item(current).is_some()
{
break;
}
paragraph_lines.push(current);
i += 1;
}
let joined = paragraph_lines.join("\n");
blocks.push(render_inline_markdown(&joined));
}
blocks.join("\n\n")
}
fn render_inline_markdown(text: &str) -> String {
let mut result = escape_html(text);
// Links: [text](url) → <a href="url">text</a>
while let Some(bracket_start) = result.find('[') {
if let Some(bracket_end_rel) = result[bracket_start..].find("](") {
let bracket_end = bracket_start + bracket_end_rel;
if let Some(paren_end_rel) = result[bracket_end + 2..].find(')') {
let paren_end = bracket_end + 2 + paren_end_rel;
let link_text = result[bracket_start + 1..bracket_end].to_string();
let url = result[bracket_end + 2..paren_end].to_string();
result = format!(
"{}<a href=\"{}\">{}</a>{}",
&result[..bracket_start],
url,
link_text,
&result[paren_end + 1..]
);
} else {
break;
}
} else {
break;
}
}
// Bold: **text** → <b>text</b>
while let Some(start) = result.find("**") {
if let Some(end) = result[start + 2..].find("**") {
let end = start + 2 + end;
if let Some(end_rel) = result[start + 2..].find("**") {
let end = start + 2 + end_rel;
let inner = result[start + 2..end].to_string();
result = format!("{}<b>{}</b>{}", &result[..start], inner, &result[end + 2..]);
} else {
@@ -39,8 +195,23 @@ fn markdown_to_telegram_html(text: &str) -> String {
}
}
// Italic: *text* → <i>text</i> (but not inside bold tags)
// Simple heuristic: match single * not preceded/followed by *
// Inline code: `text` → <code>text</code>
while let Some(start) = result.find('`') {
if let Some(end_rel) = result[start + 1..].find('`') {
let end = start + 1 + end_rel;
let inner = result[start + 1..end].to_string();
result = format!(
"{}<code>{}</code>{}",
&result[..start],
inner,
&result[end + 1..]
);
} else {
break;
}
}
// Italic: *text* → <i>text</i> (single star only)
let mut out = String::with_capacity(result.len());
let chars: Vec<char> = result.chars().collect();
let mut i = 0;
@@ -61,48 +232,57 @@ fn markdown_to_telegram_html(text: &str) -> String {
}
i += 1;
}
result = out;
// Inline code: `text` → <code>text</code>
while let Some(start) = result.find('`') {
if let Some(end) = result[start + 1..].find('`') {
let end = start + 1 + end;
let inner = result[start + 1..end].to_string();
result = format!(
"{}<code>{}</code>{}",
&result[..start],
inner,
&result[end + 1..]
);
} else {
break;
out
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn fence_delimiter(line: &str) -> Option<&'static str> {
if line.starts_with("```") {
Some("```")
} else if line.starts_with("~~~") {
Some("~~~")
} else {
None
}
}
fn heading_text(line: &str) -> Option<&str> {
let hashes = line.chars().take_while(|c| *c == '#').count();
if (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ') {
Some(&line[hashes + 1..])
} else {
None
}
}
fn unordered_list_item(line: &str) -> Option<&str> {
for prefix in ["- ", "* ", "+ "] {
if let Some(rest) = line.strip_prefix(prefix) {
return Some(rest);
}
}
None
}
// Links: [text](url) → <a href="url">text</a>
while let Some(bracket_start) = result.find('[') {
if let Some(bracket_end) = result[bracket_start..].find("](") {
let bracket_end = bracket_start + bracket_end;
if let Some(paren_end) = result[bracket_end + 2..].find(')') {
let paren_end = bracket_end + 2 + paren_end;
let link_text = &result[bracket_start + 1..bracket_end];
let url = &result[bracket_end + 2..paren_end];
result = format!(
"{}<a href=\"{}\">{}</a>{}",
&result[..bracket_start],
url,
link_text,
&result[paren_end + 1..]
);
} else {
break;
}
} else {
break;
}
fn ordered_list_item(line: &str) -> Option<&str> {
let digit_count = line.chars().take_while(|c| c.is_ascii_digit()).count();
if digit_count == 0 {
return None;
}
let rest = &line[digit_count..];
if let Some(item) = rest.strip_prefix(". ") {
Some(item)
} else if let Some(item) = rest.strip_prefix(") ") {
Some(item)
} else {
None
}
result
}
/// Convert Markdown to Slack mrkdwn format.
@@ -146,6 +326,192 @@ fn markdown_to_slack_mrkdwn(text: &str) -> String {
result
}
fn strip_atx_heading(line: &str) -> String {
let trimmed = line.trim_start();
let heading_level = trimmed.chars().take_while(|c| *c == '#').count();
if !(1..=6).contains(&heading_level) {
return line.to_string();
}
if trimmed.chars().nth(heading_level) != Some(' ') {
return line.to_string();
}
trimmed[heading_level..]
.trim()
.trim_end_matches('#')
.trim_end()
.to_string()
}
fn strip_blockquote_prefix(line: &str) -> String {
let mut trimmed = line.trim_start();
while let Some(rest) = trimmed.strip_prefix('>') {
trimmed = rest.trim_start();
}
trimmed.to_string()
}
fn strip_task_list_prefix(line: &str) -> String {
let trimmed = line.trim_start();
for prefix in [
"- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] ",
] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
return rest.to_string();
}
}
line.to_string()
}
fn is_fenced_code_marker(line: &str) -> bool {
let trimmed = line.trim();
let mut chars = trimmed.chars();
let Some(marker) = chars.next() else {
return false;
};
if marker != '`' && marker != '~' {
return false;
}
chars.all(|c| c == marker || c.is_ascii_alphanumeric())
}
fn is_setext_heading_underline(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.len() < 3 {
return false;
}
trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.contains(['=', '-'])
}
fn is_table_divider(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, '|' | ':' | '-' | ' '))
}
fn strip_inline_markdown(mut text: String) -> String {
while let Some(start) = text.find("![") {
if let Some(mid) = text[start..].find("](") {
let mid = start + mid;
if let Some(end) = text[mid + 2..].find(')') {
let end = mid + 2 + end;
let alt = &text[start + 2..mid];
let url = &text[mid + 2..end];
let replacement = if alt.is_empty() {
url.to_string()
} else {
format!("{alt} ({url})")
};
text = format!("{}{}{}", &text[..start], replacement, &text[end + 1..]);
continue;
}
}
break;
}
while let Some(start) = text.find('[') {
if let Some(mid) = text[start..].find("](") {
let mid = start + mid;
if let Some(end) = text[mid + 2..].find(')') {
let end = mid + 2 + end;
let label = &text[start + 1..mid];
let url = &text[mid + 2..end];
text = format!("{}{} ({}){}", &text[..start], label, url, &text[end + 1..]);
continue;
}
}
break;
}
while let Some(start) = text.find('<') {
if let Some(end) = text[start + 1..].find('>') {
let end = start + 1 + end;
let inner = &text[start + 1..end];
if inner.starts_with("http://")
|| inner.starts_with("https://")
|| inner.starts_with("mailto:")
{
text = format!("{}{}{}", &text[..start], inner, &text[end + 1..]);
continue;
}
}
break;
}
text = text.replace("**", "");
text = text.replace("__", "");
text = text.replace("~~", "");
text = text.replace('`', "");
let mut out = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
for (i, &ch) in chars.iter().enumerate() {
if ch == '*'
&& (i == 0 || chars[i - 1] != '*')
&& (i + 1 >= chars.len() || chars[i + 1] != '*')
{
continue;
}
out.push(ch);
}
out
}
/// Strip common Markdown blocks for WeCom plain-text replies.
fn markdown_to_wecom_plain(text: &str) -> String {
let mut result_lines = Vec::new();
let mut in_fenced_code = false;
for raw_line in text.replace("\r\n", "\n").lines() {
let trimmed = raw_line.trim();
if is_fenced_code_marker(trimmed) {
in_fenced_code = !in_fenced_code;
continue;
}
if in_fenced_code {
result_lines.push(raw_line.trim_end().to_string());
continue;
}
if is_setext_heading_underline(trimmed) || is_table_divider(trimmed) {
continue;
}
let mut line = strip_atx_heading(raw_line);
line = strip_blockquote_prefix(&line);
line = strip_task_list_prefix(&line);
let trimmed_line = line.trim();
if trimmed_line.starts_with('|') && trimmed_line.ends_with('|') && trimmed_line.len() > 2 {
line = trimmed_line
.trim_matches('|')
.split('|')
.map(|cell| cell.trim())
.collect::<Vec<_>>()
.join(" ");
}
line = strip_inline_markdown(line);
result_lines.push(line.trim().to_string());
}
let mut collapsed = Vec::new();
for line in result_lines {
if line.is_empty()
&& collapsed
.last()
.is_some_and(|prev: &String| prev.is_empty())
{
continue;
}
collapsed.push(line);
}
collapsed.join("\n").trim().to_string()
}
/// Strip all Markdown formatting, producing plain text.
fn markdown_to_plain(text: &str) -> String {
let mut result = text.to_string();
@@ -231,6 +597,36 @@ mod tests {
assert_eq!(result, "<a href=\"https://example.com\">click here</a>");
}
#[test]
fn test_telegram_html_heading() {
let result = markdown_to_telegram_html("## Result");
assert_eq!(result, "<b>Result</b>");
}
#[test]
fn test_telegram_html_unordered_list() {
let result = markdown_to_telegram_html("- alpha\n- beta");
assert_eq!(result, "• alpha\n• beta");
}
#[test]
fn test_telegram_html_ordered_list() {
let result = markdown_to_telegram_html("1. alpha\n2. beta");
assert_eq!(result, "1. alpha\n2. beta");
}
#[test]
fn test_telegram_html_fenced_code_block() {
let result = markdown_to_telegram_html("```rust\nfn main() {}\n```");
assert_eq!(result, "<pre><code>fn main() {}</code></pre>");
}
#[test]
fn test_telegram_html_blockquote() {
let result = markdown_to_telegram_html("> note\n> second line");
assert_eq!(result, "<blockquote>note\nsecond line</blockquote>");
}
#[test]
fn test_slack_mrkdwn_bold() {
let result = markdown_to_slack_mrkdwn("Hello **world**!");
@@ -254,4 +650,26 @@ mod tests {
let result = markdown_to_plain("[click](https://example.com)");
assert_eq!(result, "click (https://example.com)");
}
#[test]
fn test_wecom_plain_text_strips_common_markdown_blocks() {
let result = markdown_to_wecom_plain(
"# Title\n\
\n\
> quoted text\n\
\n\
- [x] done item\n\
- [ ] todo item\n\
\n\
```rust\n\
let value = 1;\n\
```\n\
\n\
[docs](https://example.com)\n",
);
assert_eq!(
result,
"Title\n\nquoted text\n\ndone item\ntodo item\n\nlet value = 1;\n\ndocs (https://example.com)"
);
}
}
+2
View File
@@ -43,6 +43,7 @@ pub mod twist;
pub mod webex;
// Wave 5 — Niche & differentiating channels
pub mod dingtalk;
pub mod dingtalk_stream;
pub mod discourse;
pub mod gitter;
pub mod gotify;
@@ -50,3 +51,4 @@ pub mod linkedin;
pub mod mumble;
pub mod ntfy;
pub mod webhook;
pub mod wecom;
+14 -2
View File
@@ -456,10 +456,18 @@ impl ChannelAdapter for MastodonAdapter {
let notifications: Vec<serde_json::Value> =
poll_resp.json().await.unwrap_or_default();
for notif in &notifications {
if let Some(nid) = notif["id"].as_str() {
// Mastodon returns notifications newest-first. Record the first
// (highest) ID before processing so we never re-fetch these on
// the next poll. Updating inside the loop would leave us with
// the oldest ID, causing every previously seen notification to
// be re-delivered and re-processed.
if let Some(newest) = notifications.first() {
if let Some(nid) = newest["id"].as_str() {
last_notification_id = Some(nid.to_string());
}
}
for notif in &notifications {
if let Some(msg) = parse_mastodon_notification(notif, &own_account_id) {
if tx.send(msg).await.is_err() {
return;
@@ -518,6 +526,10 @@ impl ChannelAdapter for MastodonAdapter {
Ok(())
}
fn suppress_error_responses(&self) -> bool {
true
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
+136 -5
View File
@@ -12,7 +12,7 @@ use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{info, warn};
use tracing::{debug, info, warn};
use zeroize::Zeroizing;
const SYNC_TIMEOUT_MS: u64 = 30000;
@@ -35,6 +35,8 @@ pub struct MatrixAdapter {
shutdown_rx: watch::Receiver<bool>,
/// Sync token for resuming /sync.
since_token: Arc<RwLock<Option<String>>>,
/// Whether to auto-accept room invites.
auto_accept_invites: bool,
}
impl MatrixAdapter {
@@ -55,6 +57,7 @@ impl MatrixAdapter {
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
since_token: Arc::new(RwLock::new(None)),
auto_accept_invites: true,
}
}
@@ -116,12 +119,84 @@ impl MatrixAdapter {
Ok(user_id)
}
#[allow(dead_code)]
#[cfg(test)]
fn is_allowed_room(&self, room_id: &str) -> bool {
self.allowed_rooms.is_empty() || self.allowed_rooms.iter().any(|r| r == room_id)
}
}
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
async fn accept_invite(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
room_id: &str,
) {
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/join");
match client
.post(&url)
.bearer_auth(access_token)
.json(&serde_json::json!({}))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
info!("Matrix: auto-accepted invite to {room_id}");
}
Ok(resp) => {
let status = resp.status();
warn!("Matrix: failed to accept invite to {room_id}: {status}");
}
Err(e) => {
warn!("Matrix: error accepting invite to {room_id}: {e}");
}
}
}
/// Get the number of joined members in a room.
async fn get_room_member_count(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
room_id: &str,
) -> Option<usize> {
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members");
let resp = client
.get(&url)
.bearer_auth(access_token)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body["joined"].as_object().map(|m| m.len())
}
/// Do an initial /sync with timeout=0 to get the since token without processing events.
/// This prevents replaying old messages when the adapter first connects.
async fn initial_sync(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
) -> Option<String> {
let url = format!(
"{homeserver}/_matrix/client/v3/sync?timeout=0&filter={{\"room\":{{\"timeline\":{{\"limit\":0}}}}}}"
);
let resp = client
.get(&url)
.bearer_auth(access_token)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body["next_batch"].as_str().map(String::from)
}
#[async_trait]
impl ChannelAdapter for MatrixAdapter {
fn name(&self) -> &str {
@@ -148,6 +223,15 @@ impl ChannelAdapter for MatrixAdapter {
let client = self.client.clone();
let since_token = Arc::clone(&self.since_token);
let mut shutdown_rx = self.shutdown_rx.clone();
let auto_accept = self.auto_accept_invites;
// FIX #4: Do an initial sync to get the since token, skipping old messages.
if since_token.read().await.is_none() {
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
info!("Matrix: initial sync complete, skipping old messages");
*since_token.write().await = Some(token);
}
}
tokio::spawn(async move {
let mut backoff = Duration::from_secs(1);
@@ -168,7 +252,7 @@ impl ChannelAdapter for MatrixAdapter {
info!("Matrix adapter shutting down");
break;
}
result = client.get(&url).bearer_auth(&*access_token).send() => {
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
match result {
Ok(r) => r,
Err(e) => {
@@ -203,6 +287,24 @@ impl ChannelAdapter for MatrixAdapter {
*since_token.write().await = Some(next.to_string());
}
// FIX #1: Auto-accept room invites.
if auto_accept {
if let Some(invites) = body["rooms"]["invite"].as_object() {
for (room_id, _invite_data) in invites {
if !allowed_rooms.is_empty()
&& !allowed_rooms.iter().any(|r| r == room_id)
{
debug!(
"Matrix: ignoring invite to {room_id} (not in allowed_rooms)"
);
continue;
}
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
.await;
}
}
}
// Process room events
if let Some(rooms) = body["rooms"]["join"].as_object() {
for (room_id, room_data) in rooms {
@@ -245,6 +347,35 @@ impl ChannelAdapter for MatrixAdapter {
let event_id = event["event_id"].as_str().unwrap_or("").to_string();
// FIX #2: Detect @mentions in message text.
let mut metadata = HashMap::new();
if content.contains(&user_id) {
metadata.insert(
"was_mentioned".to_string(),
serde_json::json!(true),
);
}
// FIX #3: Determine if room is a DM (2 members) or group.
let is_group = get_room_member_count(
&client,
&homeserver,
access_token.as_str(),
room_id,
)
.await
.map(|count| count > 2)
.unwrap_or(true);
// For DMs, auto-set was_mentioned so dm_policy works.
if !is_group {
metadata.insert(
"was_mentioned".to_string(),
serde_json::json!(true),
);
metadata.insert("is_dm".to_string(), serde_json::json!(true));
}
let channel_msg = ChannelMessage {
channel: ChannelType::Matrix,
platform_message_id: event_id,
@@ -256,9 +387,9 @@ impl ChannelAdapter for MatrixAdapter {
content: msg_content,
target_agent: None,
timestamp: Utc::now(),
is_group: true,
is_group,
thread_id: None,
metadata: HashMap::new(),
metadata,
};
if tx.send(channel_msg).await.is_err() {
+5 -2
View File
@@ -165,7 +165,10 @@ impl ChannelAdapter for NostrAdapter {
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let pubkey = self.derive_pubkey();
info!("Nostr adapter starting (pubkey: {}...)", openfang_types::truncate_str(&pubkey, 16));
info!(
"Nostr adapter starting (pubkey: {}...)",
openfang_types::truncate_str(&pubkey, 16)
);
if self.relays.is_empty() {
return Err("Nostr: no relay URLs configured".into());
@@ -339,7 +342,7 @@ impl ChannelAdapter for NostrAdapter {
platform_id: sender_pubkey.clone(),
display_name: format!(
"{}...",
&sender_pubkey[..8.min(sender_pubkey.len())]
openfang_types::truncate_str(&sender_pubkey, 8)
),
openfang_user: None,
},
+28
View File
@@ -34,6 +34,8 @@ pub struct AgentRouter {
default_agent: Option<AgentId>,
/// Per-channel-type default agent (e.g., Telegram -> agent_a, Discord -> agent_b).
channel_defaults: DashMap<String, AgentId>,
/// Per-channel-type default agent *name* (for re-resolution when UUID becomes stale).
channel_default_names: DashMap<String, String>,
/// Sorted bindings (most specific first). Uses Mutex for runtime updates via Arc.
bindings: Mutex<Vec<(AgentBinding, String)>>,
/// Broadcast configuration. Uses Mutex for runtime updates via Arc.
@@ -50,6 +52,7 @@ impl AgentRouter {
direct_routes: DashMap::new(),
default_agent: None,
channel_defaults: DashMap::new(),
channel_default_names: DashMap::new(),
bindings: Mutex::new(Vec::new()),
broadcast: Mutex::new(BroadcastConfig::default()),
agent_name_cache: DashMap::new(),
@@ -66,6 +69,31 @@ impl AgentRouter {
self.channel_defaults.insert(channel_key, agent_id);
}
/// Set a per-channel-type default agent AND remember the agent name for
/// re-resolution when the cached UUID becomes stale (e.g. after agent restart).
pub fn set_channel_default_with_name(
&self,
channel_key: String,
agent_id: AgentId,
agent_name: String,
) {
self.channel_defaults.insert(channel_key.clone(), agent_id);
self.channel_default_names.insert(channel_key, agent_name);
}
/// Retrieve the stored agent name for a channel default (if any).
pub fn channel_default_name(&self, channel_key: &str) -> Option<String> {
self.channel_default_names
.get(channel_key)
.map(|r| r.clone())
}
/// Update the cached agent ID for a channel default (after re-resolution).
pub fn update_channel_default(&self, channel_key: &str, new_agent_id: AgentId) {
self.channel_defaults
.insert(channel_key.to_string(), new_agent_id);
}
/// Set a user's default agent.
pub fn set_user_default(&self, user_key: String, agent_id: AgentId) {
self.user_defaults.insert(user_key, agent_id);
+189 -19
View File
@@ -7,11 +7,12 @@ use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use dashmap::DashMap;
use futures::{SinkExt, Stream, StreamExt};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
@@ -32,10 +33,25 @@ pub struct SlackAdapter {
shutdown_rx: watch::Receiver<bool>,
/// Bot's own user ID (populated after auth.test).
bot_user_id: Arc<RwLock<Option<String>>>,
/// Threads where the bot was @-mentioned. Maps thread_ts -> last interaction time.
active_threads: Arc<DashMap<String, Instant>>,
/// How long to track a thread after last interaction.
thread_ttl: Duration,
/// Whether auto-thread-reply is enabled.
auto_thread_reply: bool,
/// Whether to unfurl (expand previews for) links in posted messages.
unfurl_links: bool,
}
impl SlackAdapter {
pub fn new(app_token: String, bot_token: String, allowed_channels: Vec<String>) -> Self {
pub fn new(
app_token: String,
bot_token: String,
allowed_channels: Vec<String>,
auto_thread_reply: bool,
thread_ttl_hours: u64,
unfurl_links: bool,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
app_token: Zeroizing::new(app_token),
@@ -45,6 +61,10 @@ impl SlackAdapter {
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
bot_user_id: Arc::new(RwLock::new(None)),
active_threads: Arc::new(DashMap::new()),
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
auto_thread_reply,
unfurl_links,
}
}
@@ -76,14 +96,20 @@ impl SlackAdapter {
&self,
channel_id: &str,
text: &str,
thread_ts: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let chunks = split_message(text, SLACK_MSG_LIMIT);
for chunk in chunks {
let body = serde_json::json!({
let mut body = serde_json::json!({
"channel": channel_id,
"text": chunk,
"unfurl_links": self.unfurl_links,
"unfurl_media": self.unfurl_links,
});
if let Some(ts) = thread_ts {
body["thread_ts"] = serde_json::json!(ts);
}
let resp: serde_json::Value = self
.client
@@ -133,6 +159,30 @@ impl ChannelAdapter for SlackAdapter {
let allowed_channels = self.allowed_channels.clone();
let client = self.client.clone();
let mut shutdown = self.shutdown_rx.clone();
let active_threads = self.active_threads.clone();
let auto_thread_reply = self.auto_thread_reply;
// Spawn periodic cleanup of expired thread entries.
{
let active_threads = self.active_threads.clone();
let thread_ttl = self.thread_ttl;
let mut cleanup_shutdown = self.shutdown_rx.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(300));
loop {
tokio::select! {
_ = interval.tick() => {
active_threads.retain(|_, last| last.elapsed() < thread_ttl);
}
_ = cleanup_shutdown.changed() => {
if *cleanup_shutdown.borrow() {
return;
}
}
}
}
});
}
tokio::spawn(async move {
let mut backoff = INITIAL_BACKOFF;
@@ -240,8 +290,14 @@ impl ChannelAdapter for SlackAdapter {
// Extract the event
let event = &payload["payload"]["event"];
if let Some(msg) =
parse_slack_event(event, &bot_user_id, &allowed_channels).await
if let Some(msg) = parse_slack_event(
event,
&bot_user_id,
&allowed_channels,
&active_threads,
auto_thread_reply,
)
.await
{
debug!(
"Slack message from {}: {:?}",
@@ -289,10 +345,30 @@ impl ChannelAdapter for SlackAdapter {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_message(channel_id, &text).await?;
self.api_send_message(channel_id, &text, None).await?;
}
_ => {
self.api_send_message(channel_id, "(Unsupported content type)")
self.api_send_message(channel_id, "(Unsupported content type)", None)
.await?;
}
}
Ok(())
}
async fn send_in_thread(
&self,
user: &ChannelUser,
content: ChannelContent,
thread_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_message(channel_id, &text, Some(thread_id))
.await?;
}
_ => {
self.api_send_message(channel_id, "(Unsupported content type)", Some(thread_id))
.await?;
}
}
@@ -335,9 +411,11 @@ async fn parse_slack_event(
event: &serde_json::Value,
bot_user_id: &Arc<RwLock<Option<String>>>,
allowed_channels: &[String],
active_threads: &Arc<DashMap<String, Instant>>,
auto_thread_reply: bool,
) -> Option<ChannelMessage> {
let event_type = event["type"].as_str()?;
if event_type != "message" {
if event_type != "message" && event_type != "app_mention" {
return None;
}
@@ -413,6 +491,50 @@ async fn parse_slack_event(
ChannelContent::Text(text.to_string())
};
// Extract thread_id: threaded replies have `thread_ts`, top-level messages
// use their own `ts` so the reply will start a thread under the original.
let thread_id = msg_data["thread_ts"]
.as_str()
.or_else(|| event["thread_ts"].as_str())
.map(|s| s.to_string())
.or_else(|| Some(ts.to_string()));
// Check if the bot was @-mentioned (for group_policy = "mention_only")
let mut metadata = HashMap::new();
if event_type == "app_mention" {
metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true));
}
// Determine the real thread_ts from the event (None for top-level messages).
let real_thread_ts = msg_data["thread_ts"]
.as_str()
.or_else(|| event["thread_ts"].as_str());
let mut explicitly_mentioned = false;
if let Some(ref bid) = *bot_user_id.read().await {
let mention_tag = format!("<@{bid}>");
if text.contains(&mention_tag) {
explicitly_mentioned = true;
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
// Track thread for auto-reply on subsequent messages.
if let Some(tts) = real_thread_ts {
active_threads.insert(tts.to_string(), Instant::now());
}
}
}
// Auto-reply to follow-up messages in tracked threads.
if !explicitly_mentioned && auto_thread_reply {
if let Some(tts) = real_thread_ts {
if let Some(mut entry) = active_threads.get_mut(tts) {
// Refresh TTL and mark as mentioned so dispatch proceeds.
*entry = Instant::now();
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
}
}
}
Some(ChannelMessage {
channel: ChannelType::Slack,
platform_message_id: ts.to_string(),
@@ -425,8 +547,8 @@ async fn parse_slack_event(
target_agent: None,
timestamp,
is_group: true,
thread_id: None,
metadata: HashMap::new(),
thread_id,
metadata,
})
}
@@ -445,7 +567,9 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Slack);
assert_eq!(msg.sender.platform_id, "C789");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello agent!"));
@@ -463,7 +587,7 @@ mod tests {
"bot_id": "B999"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -478,7 +602,7 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -494,12 +618,25 @@ mod tests {
});
// Not in allowed channels
let msg =
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()]).await;
let msg = parse_slack_event(
&event,
&bot_id,
&["C111".to_string(), "C222".to_string()],
&Arc::new(DashMap::new()),
true,
)
.await;
assert!(msg.is_none());
// In allowed channels
let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()]).await;
let msg = parse_slack_event(
&event,
&bot_id,
&["C789".to_string()],
&Arc::new(DashMap::new()),
true,
)
.await;
assert!(msg.is_some());
}
@@ -516,7 +653,7 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -531,7 +668,9 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
.await
.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -556,7 +695,9 @@ mod tests {
"ts": "1700000001.000200"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Slack);
assert_eq!(msg.sender.platform_id, "C789");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message text"));
@@ -568,8 +709,37 @@ mod tests {
"xapp-test".to_string(),
"xoxb-test".to_string(),
vec!["C123".to_string()],
true,
24,
true,
);
assert_eq!(adapter.name(), "slack");
assert_eq!(adapter.channel_type(), ChannelType::Slack);
}
#[test]
fn test_slack_adapter_unfurl_links_enabled() {
let adapter = SlackAdapter::new(
"xapp-test".to_string(),
"xoxb-test".to_string(),
vec![],
true,
24,
true,
);
assert!(adapter.unfurl_links);
}
#[test]
fn test_slack_adapter_unfurl_links_disabled() {
let adapter = SlackAdapter::new(
"xapp-test".to_string(),
"xoxb-test".to_string(),
vec![],
true,
24,
false,
);
assert!(!adapter.unfurl_links);
}
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -49,6 +49,13 @@ pub enum ChannelContent {
url: String,
filename: String,
},
/// Local file data (bytes read from disk). Used by the proactive `channel_send`
/// tool when `file_path` is provided instead of `file_url`.
FileData {
data: Vec<u8>,
filename: String,
mime_type: String,
},
Voice {
url: String,
duration_seconds: u32,
@@ -261,6 +268,15 @@ pub trait ChannelAdapter: Send + Sync {
) -> Result<(), Box<dyn std::error::Error>> {
self.send(user, content).await
}
/// Whether this adapter should suppress sending internal agent errors back to the user.
///
/// Returns `true` for public broadcast channels (e.g. Mastodon) where posting
/// an error message would create a public status update. Errors are always
/// logged regardless of this setting.
fn suppress_error_responses(&self) -> bool {
false
}
}
/// Split a message into chunks of at most `max_len` characters,
+691
View File
@@ -0,0 +1,691 @@
//! WeCom (WeChat Work) channel adapter.
//!
//! Uses the WeCom Work API for sending messages and a webhook HTTP server for
//! receiving inbound events. Authentication is performed via an access token
//! obtained from `https://qyapi.weixin.qq.com/cgi-bin/gettoken`.
//! The token is cached and refreshed automatically.
use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use axum::response::IntoResponse;
use chrono::Utc;
use futures::Stream;
use sha1::{Digest, Sha1};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{info, warn};
use zeroize::Zeroizing;
/// WeCom token endpoint.
const WECOM_TOKEN_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/gettoken";
/// WeCom send message endpoint.
const WECOM_SEND_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/message/send";
/// Maximum WeCom message text length (characters).
const MAX_MESSAGE_LEN: usize = 2048;
/// Token refresh buffer — refresh 5 minutes before actual expiry.
const TOKEN_REFRESH_BUFFER_SECS: u64 = 300;
fn decrypt_aes_cbc(key: &[u8], encrypted_base64: &str) -> Result<Vec<u8>, String> {
use base64::Engine;
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
// Decode base64
let mut encrypted = base64::engine::general_purpose::STANDARD
.decode(encrypted_base64)
.map_err(|e| format!("base64 decode error: {}", e))?;
// IV is first 16 bytes of key
type Aes256CbcDecrypt = cbc::Decryptor<aes::Aes256>;
let iv = &key[..16];
let cipher = Aes256CbcDecrypt::new(key.into(), iv.into());
let decrypted = cipher
.decrypt_padded_mut::<aes::cipher::block_padding::NoPadding>(&mut encrypted)
.map_err(|e| format!("decrypt error: {}", e))?;
let decrypted = decrypted.to_vec();
let pad = decrypted
.last()
.copied()
.ok_or_else(|| "decrypted payload is empty".to_string())? as usize;
if pad == 0 || pad > 32 || decrypted.len() < pad {
return Err(format!("invalid WeCom PKCS7 padding length: {pad}"));
}
if !decrypted[decrypted.len() - pad..]
.iter()
.all(|byte| *byte as usize == pad)
{
return Err("invalid WeCom PKCS7 padding bytes".to_string());
}
Ok(decrypted[..decrypted.len() - pad].to_vec())
}
fn is_valid_wecom_signature(
token: &str,
timestamp: &str,
nonce: &str,
encrypted_payload: &str,
msg_signature: &str,
) -> bool {
let mut parts = [token, timestamp, nonce, encrypted_payload];
parts.sort_unstable();
let mut hasher = Sha1::new();
hasher.update(parts.concat().as_bytes());
hex::encode(hasher.finalize()) == msg_signature
}
fn decode_wecom_payload(encoding_aes_key: &str, encrypted_payload: &str) -> Result<String, String> {
use base64::{
alphabet,
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
Engine,
};
let aes_key_engine = GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new()
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
.with_decode_allow_trailing_bits(true),
);
let aes_key = aes_key_engine
.decode(encoding_aes_key)
.map_err(|e| format!("aes key decode error: {e}"))?;
let decrypted = decrypt_aes_cbc(&aes_key, encrypted_payload)?;
if decrypted.len() < 20 {
return Err("decrypted payload too short".to_string());
}
let msg_len =
u32::from_be_bytes([decrypted[16], decrypted[17], decrypted[18], decrypted[19]]) as usize;
if decrypted.len() < 20 + msg_len {
return Err("decrypted payload shorter than declared echostr".to_string());
}
String::from_utf8(decrypted[20..20 + msg_len].to_vec())
.map_err(|e| format!("echostr is not valid utf-8: {e}"))
}
fn parse_wecom_xml_fields(xml: &str) -> Result<HashMap<String, String>, String> {
let doc = roxmltree::Document::parse(xml).map_err(|e| format!("invalid xml: {e}"))?;
let root = doc.root_element();
if root.tag_name().name() != "xml" {
return Err("root element is not <xml>".to_string());
}
let mut fields = HashMap::new();
for child in root.children().filter(|node| node.is_element()) {
let value = child
.children()
.filter_map(|node| node.text())
.collect::<String>()
.trim()
.to_string();
fields.insert(child.tag_name().name().to_string(), value);
}
Ok(fields)
}
fn decode_wecom_post_body(
body: &str,
params: &HashMap<String, String>,
token: Option<&str>,
encoding_aes_key: Option<&str>,
) -> Result<HashMap<String, String>, String> {
let parsed = parse_wecom_xml_fields(body)?;
let Some(encrypted_payload) = parsed.get("Encrypt") else {
return Ok(parsed);
};
let token = token.ok_or_else(|| "missing WeCom callback token".to_string())?;
let timestamp = params
.get("timestamp")
.ok_or_else(|| "missing timestamp".to_string())?;
let nonce = params
.get("nonce")
.ok_or_else(|| "missing nonce".to_string())?;
let msg_signature = params
.get("msg_signature")
.ok_or_else(|| "missing msg_signature".to_string())?;
if !is_valid_wecom_signature(token, timestamp, nonce, encrypted_payload, msg_signature) {
return Err("invalid WeCom callback signature".to_string());
}
let aes_key = encoding_aes_key
.filter(|key| !key.is_empty())
.ok_or_else(|| "missing WeCom encoding_aes_key".to_string())?;
let decrypted_xml = decode_wecom_payload(aes_key, encrypted_payload)?;
parse_wecom_xml_fields(&decrypted_xml)
}
fn wecom_success_response() -> axum::response::Response {
(
axum::http::StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
"success",
)
.into_response()
}
/// WeCom adapter.
pub struct WeComAdapter {
/// WeCom corp ID.
corp_id: String,
/// WeCom application agent ID.
agent_id: String,
/// WeCom application secret, zeroized on drop.
secret: Zeroizing<String>,
/// Encoding AES key for callback verification (optional).
encoding_aes_key: Option<String>,
/// Token for callback verification (optional).
token: Option<String>,
/// Port on which the inbound webhook HTTP server listens.
webhook_port: u16,
/// HTTP client for API calls.
client: reqwest::Client,
/// Shutdown signal.
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
/// Cached access token and its expiry instant.
cached_token: Arc<RwLock<Option<(String, Instant)>>>,
}
impl WeComAdapter {
/// Create a new WeCom adapter.
pub fn new(corp_id: String, agent_id: String, secret: String, webhook_port: u16) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
corp_id,
agent_id,
secret: Zeroizing::new(secret),
encoding_aes_key: None,
token: None,
webhook_port,
client: reqwest::Client::new(),
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
cached_token: Arc::new(RwLock::new(None)),
}
}
/// Create a new WeCom adapter with callback verification.
pub fn with_verification(
corp_id: String,
agent_id: String,
secret: String,
webhook_port: u16,
encoding_aes_key: Option<String>,
token: Option<String>,
) -> Self {
let mut adapter = Self::new(corp_id, agent_id, secret, webhook_port);
adapter.encoding_aes_key = encoding_aes_key;
adapter.token = token;
adapter
}
/// Obtain a valid access token, refreshing if expired or missing.
async fn get_token(&self) -> Result<String, Box<dyn std::error::Error>> {
let mut cached = self.cached_token.write().await;
// Check if we have a valid cached token
if let Some((token, expiry)) = cached.as_ref() {
let now = Instant::now();
let buffer = Duration::from_secs(TOKEN_REFRESH_BUFFER_SECS);
if now + buffer < *expiry {
return Ok(token.clone());
}
}
// Fetch new token
let url = format!(
"{}?corpid={}&corpsecret={}",
WECOM_TOKEN_URL,
self.corp_id,
self.secret.as_str()
);
let response = self.client.get(&url).send().await?;
let json: serde_json::Value = response.json().await?;
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
if errcode != 0 {
return Err(format!(
"WeCom API error: {} - {}",
errcode,
json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("")
)
.into());
}
}
let token = json["access_token"]
.as_str()
.ok_or("Missing access_token in response")?
.to_string();
let expires_in = json["expires_in"].as_i64().unwrap_or(7200) as u64;
let expiry = Instant::now() + Duration::from_secs(expires_in);
*cached = Some((token.clone(), expiry));
info!("WeCom access token refreshed, expires in {}s", expires_in);
Ok(token)
}
/// Send a text message to a user.
async fn send_text(
&self,
user_id: &str,
content: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let token = self.get_token().await?;
let url = format!("{}?access_token={}", WECOM_SEND_URL, token);
let payload = serde_json::json!({
"touser": user_id,
"msgtype": "text",
"agentid": self.agent_id,
"text": {
"content": content
}
});
let response = self.client.post(&url).json(&payload).send().await?;
let json: serde_json::Value = response.json().await?;
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
if errcode != 0 {
return Err(format!(
"WeCom send error: {} - {}",
errcode,
json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("")
)
.into());
}
}
Ok(())
}
/// Validate credentials by getting the token.
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
let _token = self.get_token().await?;
// Token obtained successfully means credentials are valid
Ok(format!("corp_id={}", self.corp_id))
}
}
#[async_trait]
impl ChannelAdapter for WeComAdapter {
fn name(&self) -> &str {
"wecom"
}
fn channel_type(&self) -> ChannelType {
ChannelType::Custom("wecom".to_string())
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
// Validate credentials
let _ = self.validate().await?;
info!("WeCom adapter initialized");
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let port = self.webhook_port;
let token = self.token.clone();
let encoding_aes_key = self.encoding_aes_key.clone();
let mut shutdown_rx = self.shutdown_rx.clone();
tokio::spawn(async move {
let token = Arc::new(token);
let encoding_aes_key = Arc::new(encoding_aes_key);
let tx = Arc::new(tx);
let app = axum::Router::new().route(
"/wecom/webhook",
axum::routing::get({
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let token = Arc::clone(&token);
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>| {
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let token = Arc::clone(&token);
async move {
// Handle callback verification (URL validation GET request)
// WeChat Work sends GET with msg_signature, timestamp, nonce, echostr
if let (Some(echostr_encoded), Some(msg_sig), Some(timestamp), Some(nonce)) = (
params.get("echostr"),
params.get("msg_signature"),
params.get("timestamp"),
params.get("nonce"),
) {
let Some(token_str) = token.as_deref() else {
return (
axum::http::StatusCode::BAD_REQUEST,
"missing WeCom callback token",
)
.into_response();
};
if !is_valid_wecom_signature(
token_str,
timestamp,
nonce,
echostr_encoded,
msg_sig,
) {
return (
axum::http::StatusCode::FORBIDDEN,
"invalid WeCom callback signature",
)
.into_response();
}
let body = match encoding_aes_key.as_deref() {
Some(aes_key) if !aes_key.is_empty() => {
match decode_wecom_payload(aes_key, echostr_encoded) {
Ok(echostr_plain) => echostr_plain,
Err(err) => {
warn!(error = %err, "Failed to decrypt WeCom echostr");
return (
axum::http::StatusCode::BAD_REQUEST,
"invalid WeCom echostr",
)
.into_response();
}
}
}
_ => echostr_encoded.clone(),
};
return (
axum::http::StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
body,
)
.into_response();
}
(
axum::http::StatusCode::BAD_REQUEST,
"missing WeCom verification parameters",
)
.into_response()
}
}
}).post({
let token = Arc::clone(&token);
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let tx = Arc::clone(&tx);
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>, body: String| {
let token = Arc::clone(&token);
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let tx = Arc::clone(&tx);
async move {
let fields = match decode_wecom_post_body(
&body,
&params,
token.as_deref(),
encoding_aes_key.as_deref(),
) {
Ok(fields) => fields,
Err(err) => {
warn!(error = %err, "Failed to parse WeCom callback body");
return (
axum::http::StatusCode::BAD_REQUEST,
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
"invalid WeCom callback body",
)
.into_response();
}
};
let msg_type = fields.get("MsgType").map(String::as_str).unwrap_or("");
let user_id = fields
.get("FromUserName")
.cloned()
.unwrap_or_default();
let event = fields.get("Event").map(String::as_str).unwrap_or("");
info!(
msg_type = msg_type,
event = event,
from_user = %user_id,
"Received WeCom callback"
);
if msg_type == "event" {
if (event == "subscribe" || event == "enter_agent")
&& !user_id.is_empty()
{
let msg = ChannelMessage {
channel: ChannelType::Custom("wecom".to_string()),
platform_message_id: String::new(),
sender: ChannelUser {
platform_id: user_id.clone(),
display_name: user_id.clone(),
openfang_user: None,
},
content: ChannelContent::Text(String::new()),
target_agent: None,
timestamp: Utc::now(),
is_group: false,
thread_id: None,
metadata: HashMap::new(),
};
let _ = tx.send(msg).await;
}
return wecom_success_response();
}
if msg_type == "text" {
let content = fields.get("Content").cloned().unwrap_or_default();
let msg_id = fields.get("MsgId").cloned().unwrap_or_default();
if !user_id.is_empty() && !content.is_empty() {
let msg = ChannelMessage {
channel: ChannelType::Custom("wecom".to_string()),
platform_message_id: msg_id,
sender: ChannelUser {
platform_id: user_id.clone(),
display_name: user_id.clone(),
openfang_user: None,
},
content: ChannelContent::Text(content),
target_agent: None,
timestamp: Utc::now(),
is_group: false,
thread_id: None,
metadata: HashMap::new(),
};
let _ = tx.send(msg).await;
}
}
wecom_success_response()
}
}
}),
);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
info!("WeCom webhook server listening on http://0.0.0.0:{}", port);
let server = axum::serve(listener, app);
tokio::select! {
result = server => {
if let Err(e) = result {
warn!("WeCom webhook server error: {}", e);
}
}
_ = shutdown_rx.changed() => {
info!("WeCom adapter shutting down");
}
}
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn send(
&self,
user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let user_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
// Split long messages
for chunk in split_message(&text, MAX_MESSAGE_LEN) {
self.send_text(user_id, chunk).await?;
}
}
ChannelContent::Command { name: _, args: _ } => {
// WeCom doesn't support commands natively
warn!("WeCom: commands not supported");
}
_ => {
warn!("WeCom: unsupported content type");
}
}
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_adapter_name() {
let adapter = WeComAdapter::new(
"corp_id".to_string(),
"agent_id".to_string(),
"secret".to_string(),
8080,
);
assert_eq!(adapter.name(), "wecom");
}
#[test]
fn test_adapter_channel_type() {
let adapter = WeComAdapter::new(
"corp_id".to_string(),
"agent_id".to_string(),
"secret".to_string(),
8080,
);
assert_eq!(
adapter.channel_type(),
ChannelType::Custom("wecom".to_string())
);
}
#[test]
fn test_adapter_with_verification() {
let adapter = WeComAdapter::with_verification(
"corp_id".to_string(),
"agent_id".to_string(),
"secret".to_string(),
8080,
Some("encoding_aes_key".to_string()),
Some("token".to_string()),
);
assert_eq!(adapter.name(), "wecom");
}
#[test]
fn test_max_message_length() {
// MAX_MESSAGE_LEN should be 2048 for WeCom
assert_eq!(MAX_MESSAGE_LEN, 2048);
}
#[test]
fn test_token_refresh_buffer() {
// Token refresh buffer should be 5 minutes
assert_eq!(TOKEN_REFRESH_BUFFER_SECS, 300);
}
#[test]
fn test_wecom_signature_validation() {
assert!(is_valid_wecom_signature(
"token",
"1710000000",
"nonce",
"echostr",
"bf56bf867459f80e3ceb854596f39f02a5ac5e13",
));
assert!(!is_valid_wecom_signature(
"token",
"1710000000",
"nonce",
"echostr",
"bad-signature",
));
}
#[test]
fn test_decode_wecom_payload() {
let plain = decode_wecom_payload(
"ShlNaJ0PrdXQAuCDVqMki7c2JLNnY6mebvQodTv9qoV",
"/gKbXNFpvlyYNTCneTag1rGm1P4Q5fExE3OPzdYlEyUVDgi55PHVIbo+mHMXWatdW8H8RTQJCly0HBNrWry2Uw==",
)
.expect("echostr should decrypt");
assert_eq!(plain, "openfang-wecom-check");
}
#[test]
fn test_parse_wecom_xml_fields() {
let fields = parse_wecom_xml_fields(
r#"<xml>
<ToUserName><![CDATA[wwcorp]]></ToUserName>
<FromUserName><![CDATA[user123]]></FromUserName>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[hello]]></Content>
<MsgId>123456</MsgId>
</xml>"#,
)
.expect("xml should parse");
assert_eq!(
fields.get("FromUserName").map(String::as_str),
Some("user123")
);
assert_eq!(fields.get("MsgType").map(String::as_str), Some("text"));
assert_eq!(fields.get("Content").map(String::as_str), Some("hello"));
assert_eq!(fields.get("MsgId").map(String::as_str), Some("123456"));
}
}
+3 -5
View File
@@ -222,11 +222,9 @@ impl ChannelAdapter for WhatsAppAdapter {
if let Some(ref gw) = self.gateway_url {
let text = match &content {
ChannelContent::Text(t) => t.clone(),
ChannelContent::Image { caption, .. } => {
caption
.clone()
.unwrap_or_else(|| "(Image — not supported in Web mode)".to_string())
}
ChannelContent::Image { caption, .. } => caption
.clone()
.unwrap_or_else(|| "(Image — not supported in Web mode)".to_string()),
ChannelContent::File { filename, .. } => {
format!("(File: {filename} — not supported in Web mode)")
}
+104 -26
View File
@@ -7,34 +7,112 @@
/// Returns all bundled agent templates as `(name, toml_content)` pairs.
pub fn bundled_agents() -> Vec<(&'static str, &'static str)> {
vec![
("analyst", include_str!("../../../agents/analyst/agent.toml")),
("architect", include_str!("../../../agents/architect/agent.toml")),
("assistant", include_str!("../../../agents/assistant/agent.toml")),
(
"analyst",
include_str!("../../../agents/analyst/agent.toml"),
),
(
"architect",
include_str!("../../../agents/architect/agent.toml"),
),
(
"assistant",
include_str!("../../../agents/assistant/agent.toml"),
),
("coder", include_str!("../../../agents/coder/agent.toml")),
("code-reviewer", include_str!("../../../agents/code-reviewer/agent.toml")),
("customer-support", include_str!("../../../agents/customer-support/agent.toml")),
("data-scientist", include_str!("../../../agents/data-scientist/agent.toml")),
("debugger", include_str!("../../../agents/debugger/agent.toml")),
("devops-lead", include_str!("../../../agents/devops-lead/agent.toml")),
("doc-writer", include_str!("../../../agents/doc-writer/agent.toml")),
("email-assistant", include_str!("../../../agents/email-assistant/agent.toml")),
("health-tracker", include_str!("../../../agents/health-tracker/agent.toml")),
("hello-world", include_str!("../../../agents/hello-world/agent.toml")),
("home-automation", include_str!("../../../agents/home-automation/agent.toml")),
("legal-assistant", include_str!("../../../agents/legal-assistant/agent.toml")),
("meeting-assistant", include_str!("../../../agents/meeting-assistant/agent.toml")),
(
"code-reviewer",
include_str!("../../../agents/code-reviewer/agent.toml"),
),
(
"customer-support",
include_str!("../../../agents/customer-support/agent.toml"),
),
(
"data-scientist",
include_str!("../../../agents/data-scientist/agent.toml"),
),
(
"debugger",
include_str!("../../../agents/debugger/agent.toml"),
),
(
"devops-lead",
include_str!("../../../agents/devops-lead/agent.toml"),
),
(
"doc-writer",
include_str!("../../../agents/doc-writer/agent.toml"),
),
(
"email-assistant",
include_str!("../../../agents/email-assistant/agent.toml"),
),
(
"health-tracker",
include_str!("../../../agents/health-tracker/agent.toml"),
),
(
"hello-world",
include_str!("../../../agents/hello-world/agent.toml"),
),
(
"home-automation",
include_str!("../../../agents/home-automation/agent.toml"),
),
(
"legal-assistant",
include_str!("../../../agents/legal-assistant/agent.toml"),
),
(
"meeting-assistant",
include_str!("../../../agents/meeting-assistant/agent.toml"),
),
("ops", include_str!("../../../agents/ops/agent.toml")),
("orchestrator", include_str!("../../../agents/orchestrator/agent.toml")),
("personal-finance", include_str!("../../../agents/personal-finance/agent.toml")),
("planner", include_str!("../../../agents/planner/agent.toml")),
("recruiter", include_str!("../../../agents/recruiter/agent.toml")),
("researcher", include_str!("../../../agents/researcher/agent.toml")),
("sales-assistant", include_str!("../../../agents/sales-assistant/agent.toml")),
("security-auditor", include_str!("../../../agents/security-auditor/agent.toml")),
("social-media", include_str!("../../../agents/social-media/agent.toml")),
("test-engineer", include_str!("../../../agents/test-engineer/agent.toml")),
("translator", include_str!("../../../agents/translator/agent.toml")),
("travel-planner", include_str!("../../../agents/travel-planner/agent.toml")),
(
"orchestrator",
include_str!("../../../agents/orchestrator/agent.toml"),
),
(
"personal-finance",
include_str!("../../../agents/personal-finance/agent.toml"),
),
(
"planner",
include_str!("../../../agents/planner/agent.toml"),
),
(
"recruiter",
include_str!("../../../agents/recruiter/agent.toml"),
),
(
"researcher",
include_str!("../../../agents/researcher/agent.toml"),
),
(
"sales-assistant",
include_str!("../../../agents/sales-assistant/agent.toml"),
),
(
"security-auditor",
include_str!("../../../agents/security-auditor/agent.toml"),
),
(
"social-media",
include_str!("../../../agents/social-media/agent.toml"),
),
(
"test-engineer",
include_str!("../../../agents/test-engineer/agent.toml"),
),
(
"translator",
include_str!("../../../agents/translator/agent.toml"),
),
(
"travel-planner",
include_str!("../../../agents/travel-planner/agent.toml"),
),
("tutor", include_str!("../../../agents/tutor/agent.toml")),
("writer", include_str!("../../../agents/writer/agent.toml")),
]
+311 -63
View File
@@ -113,7 +113,11 @@ enum Commands {
quick: bool,
},
/// Start the OpenFang kernel daemon (API server + kernel).
Start,
Start {
/// Auto-approve all tool calls (no confirmation prompts).
#[arg(long)]
yolo: bool,
},
/// Stop the running daemon.
Stop,
/// Manage agents (new, list, chat, kill, spawn) [*].
@@ -520,6 +524,23 @@ enum WorkflowCommands {
/// Path to a JSON file describing the workflow.
file: PathBuf,
},
/// Get a workflow by ID.
Get {
/// Workflow ID (UUID).
workflow_id: String,
},
/// Update a workflow from a JSON file.
Update {
/// Workflow ID (UUID).
workflow_id: String,
/// Path to a JSON file with the updated workflow definition.
file: PathBuf,
},
/// Delete a workflow by ID.
Delete {
/// Workflow ID (UUID).
workflow_id: String,
},
/// Run a workflow by ID.
Run {
/// Workflow ID (UUID).
@@ -777,11 +798,36 @@ enum SystemCommands {
},
}
fn config_log_level() -> String {
let config_path = if let Ok(home) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(home).join("config.toml")
} else {
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
.join("config.toml")
};
if let Ok(content) = std::fs::read_to_string(config_path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("log_level") {
if let Some(val) = trimmed.split('=').nth(1) {
let level = val.trim().trim_matches('"').trim_matches('\'');
if !level.is_empty() {
return level.to_string();
}
}
}
}
}
"info".to_string()
}
fn init_tracing_stderr() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
)
.init();
}
@@ -807,7 +853,7 @@ fn init_tracing_file() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
)
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
@@ -876,7 +922,7 @@ fn main() {
}
Some(Commands::Tui) => tui::run(cli.config),
Some(Commands::Init { quick }) => cmd_init(quick),
Some(Commands::Start) => cmd_start(cli.config),
Some(Commands::Start { yolo }) => cmd_start(cli.config, yolo),
Some(Commands::Stop) => cmd_stop(),
Some(Commands::Agent(sub)) => match sub {
AgentCommands::New { template } => cmd_agent_new(cli.config, template),
@@ -893,6 +939,11 @@ fn main() {
Some(Commands::Workflow(sub)) => match sub {
WorkflowCommands::List => cmd_workflow_list(),
WorkflowCommands::Create { file } => cmd_workflow_create(file),
WorkflowCommands::Get { workflow_id } => cmd_workflow_get(&workflow_id),
WorkflowCommands::Update { workflow_id, file } => {
cmd_workflow_update(&workflow_id, file)
}
WorkflowCommands::Delete { workflow_id } => cmd_workflow_delete(&workflow_id),
WorkflowCommands::Run { workflow_id, input } => cmd_workflow_run(&workflow_id, &input),
},
Some(Commands::Trigger(sub)) => match sub {
@@ -966,7 +1017,7 @@ fn main() {
ModelsCommands::Set { model } => cmd_models_set(model),
},
Some(Commands::Gateway(sub)) => match sub {
GatewayCommands::Start => cmd_start(cli.config),
GatewayCommands::Start => cmd_start(cli.config, false),
GatewayCommands::Stop => cmd_stop(),
GatewayCommands::Status { json } => cmd_status(cli.config, json),
},
@@ -1021,7 +1072,10 @@ fn main() {
SystemCommands::Version { json } => cmd_system_version(json),
},
Some(Commands::Reset { confirm }) => cmd_reset(confirm),
Some(Commands::Uninstall { confirm, keep_config }) => cmd_uninstall(confirm, keep_config),
Some(Commands::Uninstall {
confirm,
keep_config,
}) => cmd_uninstall(confirm, keep_config),
}
}
@@ -1073,11 +1127,23 @@ pub(crate) fn find_daemon() -> Option<String> {
}
/// Build an HTTP client for daemon calls.
///
/// When api_key is configured in config.toml, the client automatically
/// includes a `Authorization: Bearer <key>` header on every request.
/// When api_key is empty or missing, no auth header is sent.
pub(crate) fn daemon_client() -> reqwest::blocking::Client {
reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.expect("Failed to build HTTP client")
let mut builder =
reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(120));
if let Some(key) = read_api_key() {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {key}")) {
headers.insert(reqwest::header::AUTHORIZATION, val);
}
builder = builder.default_headers(headers);
}
builder.build().expect("Failed to build HTTP client")
}
/// Helper: send a request to the daemon and parse the JSON body.
@@ -1289,10 +1355,18 @@ fn launch_desktop_app(_openfang_dir: &std::path::Path) {
ui::blank();
if let Some(base) = find_daemon() {
let url = format!("{base}/");
let _ = open_in_browser(&url);
// Always print the URL — browser launch may silently fail
// (e.g., Chromium sandbox EPERM in containers)
if !open_in_browser(&url) {
// Browser launch failed entirely (e.g., sandbox EPERM,
// no display server, container environment).
ui::hint("Could not open a browser automatically.");
}
// Always print the URL so the user can open it manually,
// even when open_in_browser reported success — the spawned
// opener may still fail asynchronously.
ui::hint(&format!("Dashboard: {url}"));
} else {
ui::hint("Daemon is not running. Start it with: openfang start");
ui::hint("Then open: http://127.0.0.1:4200");
}
}
}
@@ -1340,7 +1414,7 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
(
"openrouter",
"OPENROUTER_API_KEY",
"openrouter/anthropic/claude-sonnet-4",
"openrouter/google/gemini-2.5-flash",
"OpenRouter",
),
]
@@ -1391,7 +1465,7 @@ decay_rate = 0.05
}
}
fn cmd_start(config: Option<PathBuf>) {
fn cmd_start(config: Option<PathBuf>, yolo: bool) {
if let Some(base) = find_daemon() {
ui::error_with_fix(
&format!("Daemon already running at {base}"),
@@ -1407,7 +1481,12 @@ fn cmd_start(config: Option<PathBuf>) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let kernel = match OpenFangKernel::boot(config.as_deref()) {
let mut kernel_config = openfang_kernel::config::load_config(config.as_deref());
if yolo {
kernel_config.approval.auto_approve = true;
kernel_config.approval.apply_shorthands();
}
let kernel = match OpenFangKernel::boot_with_config(kernel_config) {
Ok(k) => k,
Err(e) => {
boot_kernel_error(&e);
@@ -1456,27 +1535,37 @@ fn cmd_start(config: Option<PathBuf>) {
}
/// Read the api_key from ~/.openfang/config.toml (if any).
///
/// Returns `None` when the key is missing, empty, or whitespace-only —
/// meaning the daemon is running in public (unauthenticated) mode.
fn read_api_key() -> Option<String> {
// 1. Config file takes precedence
let config_path = cli_openfang_home().join("config.toml");
let text = std::fs::read_to_string(config_path).ok()?;
let table: toml::Value = text.parse().ok()?;
let key = table.get("api_key")?.as_str()?;
if key.is_empty() {
None
} else {
Some(key.to_string())
if let Ok(text) = std::fs::read_to_string(config_path) {
if let Ok(table) = text.parse::<toml::Value>() {
if let Some(key) = table.get("api_key").and_then(|v| v.as_str()) {
let key = key.trim();
if !key.is_empty() {
return Some(key.to_string());
}
}
}
}
// 2. Fall back to OPENFANG_API_KEY env var
if let Ok(key) = std::env::var("OPENFANG_API_KEY") {
let key = key.trim().to_string();
if !key.is_empty() {
return Some(key);
}
}
None
}
fn cmd_stop() {
match find_daemon() {
Some(base) => {
let client = daemon_client();
let mut req = client.post(format!("{base}/api/shutdown"));
if let Some(key) = read_api_key() {
req = req.bearer_auth(key);
}
match req.send() {
match client.post(format!("{base}/api/shutdown")).send() {
Ok(r) if r.status().is_success() => {
// Wait for daemon to actually stop (up to 5 seconds)
for _ in 0..10 {
@@ -2164,7 +2253,9 @@ decay_rate = 0.05
if !json {
ui::check_ok(&format!("Port {api_listen} is available"));
}
checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen}));
checks.push(
serde_json::json!({"check": "port", "status": "ok", "address": api_listen}),
);
}
Err(_) => {
if !json {
@@ -2963,16 +3054,31 @@ pub(crate) fn open_in_browser(url: &str) -> bool {
}
#[cfg(target_os = "linux")]
{
// Detach from parent to avoid inheriting sandbox restrictions.
// Some Chromium-based browsers fail with EPERM when launched from
// restricted environments (containers, snaps, flatpaks).
std::process::Command::new("xdg-open")
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok()
// Try multiple openers in order. xdg-open is the standard, but it
// (or the browser it launches) can fail with EPERM in sandboxed
// environments (containers, Snap, Flatpak, user-namespace
// restrictions). Fall through to alternatives if any opener fails.
let openers = [
"xdg-open",
"sensible-browser",
"x-www-browser",
"firefox",
"google-chrome",
"chromium",
"chromium-browser",
];
for opener in &openers {
let result = std::process::Command::new(opener)
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
if result.is_ok() {
return true;
}
}
false
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
@@ -3077,6 +3183,104 @@ fn cmd_workflow_run(workflow_id: &str, input: &str) {
}
}
fn cmd_workflow_get(workflow_id: &str) {
let base = require_daemon("workflow get");
let client = daemon_client();
let body = daemon_json(
client
.get(format!("{base}/api/workflows/{workflow_id}"))
.send(),
);
if body.get("error").is_some() {
eprintln!(
"Workflow not found: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
println!("Workflow: {}", body["name"].as_str().unwrap_or("?"));
println!(" ID: {}", body["id"].as_str().unwrap_or("?"));
println!(
" Description: {}",
body["description"].as_str().unwrap_or("")
);
println!(
" Created: {}",
body["created_at"].as_str().unwrap_or("?")
);
if let Some(steps) = body["steps"].as_array() {
println!(" Steps ({}):", steps.len());
for (i, s) in steps.iter().enumerate() {
let name = s["name"].as_str().unwrap_or("step");
let agent = s["agent"]
.get("name")
.or_else(|| s["agent"].get("id"))
.and_then(|v| v.as_str())
.unwrap_or("?");
println!(" #{}: {} -> {}", i + 1, name, agent);
}
}
}
fn cmd_workflow_update(workflow_id: &str, file: PathBuf) {
let base = require_daemon("workflow update");
if !file.exists() {
eprintln!("Workflow file not found: {}", file.display());
std::process::exit(1);
}
let contents = std::fs::read_to_string(&file).unwrap_or_else(|e| {
eprintln!("Error reading workflow file: {e}");
std::process::exit(1);
});
let json_body: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|e| {
eprintln!("Invalid JSON: {e}");
std::process::exit(1);
});
let client = daemon_client();
let body = daemon_json(
client
.put(format!("{base}/api/workflows/{workflow_id}"))
.json(&json_body)
.send(),
);
if body["status"].as_str() == Some("updated") {
println!("Workflow updated successfully!");
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
} else {
eprintln!(
"Failed to update workflow: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
}
fn cmd_workflow_delete(workflow_id: &str) {
let base = require_daemon("workflow delete");
let client = daemon_client();
let body = daemon_json(
client
.delete(format!("{base}/api/workflows/{workflow_id}"))
.send(),
);
if body["status"].as_str() == Some("removed") {
println!("Workflow deleted successfully!");
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
} else {
eprintln!(
"Failed to delete workflow: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
}
// ---------------------------------------------------------------------------
// Trigger commands
// ---------------------------------------------------------------------------
@@ -3951,7 +4155,10 @@ fn cmd_hand_install(path: &str) {
body["name"].as_str().unwrap_or("?"),
body["id"].as_str().unwrap_or("?"),
);
println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?"));
println!(
"Use `openfang hand activate {}` to start it.",
body["id"].as_str().unwrap_or("?")
);
}
fn cmd_hand_list() {
@@ -3976,10 +4183,7 @@ fn cmd_hand_list() {
println!("No hands available.");
return;
}
println!(
"{:<14} {:<20} {:<10} DESCRIPTION",
"ID", "NAME", "CATEGORY"
);
println!("{:<14} {:<20} {:<10} DESCRIPTION", "ID", "NAME", "CATEGORY");
println!("{}", "-".repeat(72));
for h in arr {
println!(
@@ -3987,7 +4191,12 @@ fn cmd_hand_list() {
h["id"].as_str().unwrap_or("?"),
h["name"].as_str().unwrap_or("?"),
h["category"].as_str().unwrap_or("?"),
h["description"].as_str().unwrap_or("").chars().take(40).collect::<String>(),
h["description"]
.as_str()
.unwrap_or("")
.chars()
.take(40)
.collect::<String>(),
);
}
println!("\nUse `openfang hand activate <id>` to activate a hand.");
@@ -4009,10 +4218,7 @@ fn cmd_hand_active() {
println!("No active hands.");
return;
}
println!(
"{:<38} {:<14} {:<10} AGENT",
"INSTANCE", "HAND", "STATUS"
);
println!("{:<38} {:<14} {:<10} AGENT", "INSTANCE", "HAND", "STATUS");
println!("{}", "-".repeat(72));
for i in &arr {
println!(
@@ -4100,10 +4306,7 @@ fn cmd_hand_info(id: &str) {
let client = daemon_client();
let body = daemon_json(client.get(format!("{base}/api/hands/{id}")).send());
if body.get("error").is_some() {
eprintln!(
"Hand not found: {}",
body["error"].as_str().unwrap_or(id)
);
eprintln!("Hand not found: {}", body["error"].as_str().unwrap_or(id));
std::process::exit(1);
}
println!(
@@ -4532,6 +4735,8 @@ fn cmd_config_set(key: &str, value: &str) {
std::process::exit(1);
});
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
ui::error(&format!("Failed to write config: {e}"));
std::process::exit(1);
@@ -4598,6 +4803,8 @@ fn cmd_config_unset(key: &str) {
std::process::exit(1);
});
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
ui::error(&format!("Failed to write config: {e}"));
std::process::exit(1);
@@ -4616,6 +4823,10 @@ fn cmd_config_set_key(provider: &str) {
return;
}
// Try vault first (best-effort)
save_credential_prefer_vault(&env_var, &key);
// Always save to dotenv as fallback
match dotenv::save_env_key(&env_var, &key) {
Ok(()) => {
ui::success(&format!("Saved {env_var} to ~/.openfang/.env"));
@@ -4638,6 +4849,18 @@ fn cmd_config_set_key(provider: &str) {
fn cmd_config_delete_key(provider: &str) {
let env_var = provider_to_env_var(provider);
// Remove from vault (best-effort)
{
let home = openfang_home();
let vault_path = home.join("vault.enc");
if vault_path.exists() {
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
if vault.unlock().is_ok() {
let _ = vault.remove(&env_var);
}
}
}
match dotenv::remove_env_key(&env_var) {
Ok(()) => ui::success(&format!("Removed {env_var} from ~/.openfang/.env")),
Err(e) => {
@@ -4667,6 +4890,26 @@ fn cmd_config_test_key(provider: &str) {
}
}
/// Try to store a credential in the vault first; silently falls through if vault
/// is not initialized or cannot be unlocked. The caller should always also
/// write to dotenv as a fallback.
fn save_credential_prefer_vault(env_var: &str, value: &str) {
use zeroize::Zeroizing;
let home = openfang_home();
let vault_path = home.join("vault.enc");
if !vault_path.exists() {
return;
}
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
if vault.unlock().is_err() {
return;
}
if let Ok(()) = vault.set(env_var.to_string(), Zeroizing::new(value.to_string())) {
println!(" {}", "Also stored in encrypted vault".dimmed());
}
}
// ---------------------------------------------------------------------------
// Quick chat (OpenClaw alias)
// ---------------------------------------------------------------------------
@@ -5422,7 +5665,15 @@ fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.take(64)
.collect();
format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt })
format!(
"{}-{}",
agent,
if short_prompt.is_empty() {
"job"
} else {
&short_prompt
}
)
};
let body = daemon_json(
@@ -6176,10 +6427,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
} else {
match std::fs::remove_dir_all(&openfang_dir) {
Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())),
Err(e) => ui::error(&format!(
"Failed to remove {}: {e}",
openfang_dir.display()
)),
Err(e) => ui::error(&format!("Failed to remove {}: {e}", openfang_dir.display())),
}
}
}
@@ -6188,10 +6436,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
match std::fs::remove_file(&cargo_bin) {
Ok(()) => ui::success(&format!("Removed {}", cargo_bin.display())),
Err(e) => ui::error(&format!(
"Failed to remove {}: {e}",
cargo_bin.display()
)),
Err(e) => ui::error(&format!("Failed to remove {}: {e}", cargo_bin.display())),
}
}
@@ -6431,7 +6676,10 @@ fn remove_self_binary(exe_path: &std::path::Path) {
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.spawn();
ui::success(&format!("Removed {} (deferred cleanup)", exe_path.display()));
ui::success(&format!(
"Removed {} (deferred cleanup)",
exe_path.display()
));
}
}
+5 -1
View File
@@ -329,7 +329,11 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
_ => {
// Unknown method — always respond with error
Some(jsonrpc_error(rid, -32601, &format!("Method not found: {method}")))
Some(jsonrpc_error(
rid,
-32601,
&format!("Method not found: {method}"),
))
}
}
}
+8 -18
View File
@@ -394,10 +394,7 @@ impl StandaloneChat {
.as_str()
.unwrap_or("")
.to_string(),
provider: m["provider"]
.as_str()
.unwrap_or("")
.to_string(),
provider: m["provider"].as_str().unwrap_or("").to_string(),
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
})
.collect()
@@ -459,16 +456,13 @@ impl StandaloneChat {
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
let provider =
body["model_provider"].as_str().unwrap_or("?");
let provider = body["model_provider"].as_str().unwrap_or("?");
let model = body["model_name"].as_str().unwrap_or("?");
self.chat.model_label = format!("{provider}/{model}");
}
}
self.chat.push_message(
Role::System,
format!("Switched to {model_id}"),
);
self.chat
.push_message(Role::System, format!("Switched to {model_id}"));
}
_ => {
self.chat.push_message(
@@ -506,16 +500,12 @@ impl StandaloneChat {
.unwrap_or_else(|| "?".to_string())
});
self.chat.model_label = format!("{prov_label}/{model_id}");
self.chat.push_message(
Role::System,
format!("Switched to {model_id}"),
);
self.chat
.push_message(Role::System, format!("Switched to {model_id}"));
}
Err(e) => {
self.chat.push_message(
Role::System,
format!("Switch failed: {e}"),
);
self.chat
.push_message(Role::System, format!("Switch failed: {e}"));
}
}
}
+1 -1
View File
@@ -303,7 +303,7 @@ pub fn spawn_inprocess_stream(
// send_message_streaming() finds the reactor.
let _guard = rt.enter();
match kernel.send_message_streaming(agent_id, &message, None) {
match kernel.send_message_streaming(agent_id, &message, None, None, None) {
Ok((mut rx, handle)) => {
rt.block_on(async {
while let Some(ev) = rx.recv().await {
+6 -16
View File
@@ -516,8 +516,7 @@ impl App {
}
AppEvent::CommsEventsLoaded(events) => {
self.comms.events = events;
if !self.comms.events.is_empty()
&& self.comms.event_list_state.selected().is_none()
if !self.comms.events.is_empty() && self.comms.event_list_state.selected().is_none()
{
self.comms.event_list_state.select(Some(0));
}
@@ -1869,14 +1868,8 @@ impl App {
.as_str()
.unwrap_or("")
.to_string(),
provider: m["provider"]
.as_str()
.unwrap_or("")
.to_string(),
tier: m["tier"]
.as_str()
.unwrap_or("Balanced")
.to_string(),
provider: m["provider"].as_str().unwrap_or("").to_string(),
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
})
.collect()
})
@@ -1935,8 +1928,7 @@ impl App {
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
let provider =
body["model_provider"].as_str().unwrap_or("?");
let provider = body["model_provider"].as_str().unwrap_or("?");
let model = body["model_name"].as_str().unwrap_or("?");
self.chat.model_label = format!("{provider}/{model}");
}
@@ -1988,10 +1980,8 @@ impl App {
);
}
Err(e) => {
self.chat.push_message(
chat::Role::System,
format!("Switch failed: {e}"),
);
self.chat
.push_message(chat::Role::System, format!("Switch failed: {e}"));
}
}
}
@@ -1524,6 +1524,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+4 -1
View File
@@ -341,6 +341,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -200,7 +200,18 @@ const CHANNEL_DEFS: &[ChannelDef] = &[
display_name: "DingTalk",
category: "Enterprise",
env_vars: &["DINGTALK_ACCESS_TOKEN", "DINGTALK_SECRET"],
description: "DingTalk Robot API adapter",
description: "DingTalk Robot API adapter (webhook mode)",
},
ChannelDef {
name: "dingtalk_stream",
display_name: "DingTalk Stream",
category: "Enterprise",
env_vars: &[
"DINGTALK_APP_KEY",
"DINGTALK_APP_SECRET",
"DINGTALK_ROBOT_CODE",
],
description: "DingTalk Stream Mode (WebSocket long-connection)",
},
ChannelDef {
name: "pumble",
+11 -4
View File
@@ -483,8 +483,7 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
return; // Too small to show picker
}
let popup_w = area.width.clamp(30, 54);
let popup_h = (filtered.len() as u16 + 4)
.clamp(5, area.height.saturating_sub(2));
let popup_h = (filtered.len() as u16 + 4).clamp(5, area.height.saturating_sub(2));
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
@@ -548,7 +547,12 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
let mut lines: Vec<Line> = Vec::new();
let max_name = (chunks[1].width as usize).saturating_sub(14);
for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) {
for (i, entry) in filtered
.iter()
.enumerate()
.skip(scroll_start)
.take(visible_h)
{
let selected = i == state.model_picker_idx;
let indicator = if selected { "\u{25b6} " } else { " " };
@@ -882,6 +886,9 @@ fn truncate_line(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max_len.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max_len.saturating_sub(1))
)
}
}
+15 -16
View File
@@ -158,11 +158,7 @@ impl CommsState {
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
};
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
self.event_list_state.select(Some(next));
}
}
@@ -339,12 +335,12 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) {
f.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Length(2), // header
Constraint::Length(1), // separator
Constraint::Length(2), // header
Constraint::Length(1), // separator
Constraint::Percentage(35), // topology
Constraint::Length(1), // separator
Constraint::Min(4), // event list
Constraint::Length(1), // hints
Constraint::Length(1), // separator
Constraint::Min(4), // event list
Constraint::Length(1), // hints
])
.split(inner);
@@ -441,10 +437,7 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
if state.nodes.is_empty() {
f.render_widget(
Paragraph::new(Span::styled(
" No agents running.",
theme::dim_style(),
)),
Paragraph::new(Span::styled(" No agents running.", theme::dim_style())),
area,
);
return;
@@ -491,7 +484,10 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
Span::styled(" ", Style::default()),
Span::styled(branch, theme::dim_style()),
Span::styled(format!("[{}]", child.state), state_color(&child.state)),
Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)),
Span::styled(
format!(" {} ", child.name),
Style::default().fg(theme::TEXT),
),
Span::styled(format!("({})", child.model), theme::dim_style()),
]));
}
@@ -679,7 +675,10 @@ fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) {
rows[3],
);
f.render_widget(
Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))),
Paragraph::new(Span::styled(
"Assign to (agent ID, optional):",
field_style(2),
)),
rows[4],
);
f.render_widget(
@@ -273,6 +273,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -72,7 +72,7 @@ const PROVIDERS: &[ProviderInfo] = &[
name: "openrouter",
display: "OpenRouter",
env_var: "OPENROUTER_API_KEY",
default_model: "openrouter/anthropic/claude-sonnet-4",
default_model: "openrouter/google/gemini-2.5-flash",
needs_key: true,
hint: "",
},
@@ -188,6 +188,14 @@ const PROVIDERS: &[ProviderInfo] = &[
needs_key: true,
hint: "",
},
ProviderInfo {
name: "claude-code",
display: "Claude Code",
env_var: "",
default_model: "claude-code/sonnet",
needs_key: false,
hint: "no API key",
},
ProviderInfo {
name: "ollama",
display: "Ollama",
@@ -383,15 +391,23 @@ impl State {
self.provider_order.clear();
let gemini_via_google = std::env::var("GOOGLE_API_KEY").is_ok();
for (i, p) in PROVIDERS.iter().enumerate() {
let detected =
std::env::var(p.env_var).is_ok() || (p.name == "gemini" && gemini_via_google);
let detected = if p.name == "claude-code" {
openfang_runtime::drivers::claude_code::claude_code_available()
} else {
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|| (p.name == "gemini" && gemini_via_google)
};
if detected {
self.provider_order.push(i);
}
}
for (i, p) in PROVIDERS.iter().enumerate() {
let detected =
std::env::var(p.env_var).is_ok() || (p.name == "gemini" && gemini_via_google);
let detected = if p.name == "claude-code" {
openfang_runtime::drivers::claude_code::claude_code_available()
} else {
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|| (p.name == "gemini" && gemini_via_google)
};
if !detected {
self.provider_order.push(i);
}
@@ -430,7 +446,10 @@ impl State {
fn is_provider_detected(&self, prov_idx: usize) -> bool {
let p = &PROVIDERS[prov_idx];
std::env::var(p.env_var).is_ok()
if p.name == "claude-code" {
return openfang_runtime::drivers::claude_code::claude_code_available();
}
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|| (p.name == "gemini" && std::env::var("GOOGLE_API_KEY").is_ok())
}
@@ -931,7 +950,9 @@ fn handle_migration_key(
let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
PathBuf::from(h)
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang")
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".openfang")
};
let tx = migrate_tx.clone();
std::thread::spawn(move || {
@@ -1091,6 +1112,12 @@ complex_threshold = 500
};
let config_path = openfang_dir.join("config.toml");
let api_key_line = if p.env_var.is_empty() {
String::new()
} else {
format!("api_key_env = \"{}\"", p.env_var)
};
let config = format!(
r#"# OpenFang Agent OS configuration
# See https://github.com/RightNow-AI/openfang for documentation
@@ -1100,13 +1127,12 @@ api_listen = "127.0.0.1:4200"
[default_model]
provider = "{provider}"
model = "{model}"
api_key_env = "{env_var}"
{api_key_line}
[memory]
decay_rate = 0.05
{routing_section}"#,
provider = p.name,
env_var = p.env_var,
);
match std::fs::write(&config_path, &config) {
@@ -1703,7 +1729,13 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
Span::styled(" ", Style::default())
};
let name_span = Span::raw(format!("{:<14}", p.display));
let hint_text = if detected {
let hint_text = if p.name == "claude-code" {
if detected {
"CLI detected".to_string()
} else {
"no API key needed".to_string()
}
} else if detected {
format!("{} detected", p.env_var)
} else if !p.needs_key {
"local, no key needed".to_string()
+4 -1
View File
@@ -405,6 +405,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -317,7 +317,7 @@ fn draw_agent_select(f: &mut Frame, area: Rect, state: &mut MemoryState) {
.iter()
.map(|a| {
let id_short = if a.id.len() > 12 {
format!("{}\u{2026}", &a.id[..12])
format!("{}\u{2026}", openfang_types::truncate_str(&a.id, 12))
} else {
a.id.clone()
};
@@ -405,7 +405,7 @@ fn draw_kv_browser(f: &mut Frame, area: Rect, state: &mut MemoryState) {
.iter()
.map(|kv| {
let val_display = if kv.value.len() > 40 {
format!("{}\u{2026}", &kv.value[..39])
format!("{}\u{2026}", openfang_types::truncate_str(&kv.value, 39))
} else {
kv.value.clone()
};
@@ -549,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+1 -1
View File
@@ -1,8 +1,8 @@
pub mod agents;
pub mod audit;
pub mod channels;
pub mod comms;
pub mod chat;
pub mod comms;
pub mod dashboard;
pub mod extensions;
pub mod hands;
+5 -2
View File
@@ -149,7 +149,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut PeersState) {
.iter()
.map(|p| {
let id_short = if p.node_id.len() > 12 {
format!("{}\u{2026}", &p.node_id[..12])
format!("{}\u{2026}", openfang_types::truncate_str(&p.node_id, 12))
} else {
p.node_id.clone()
};
@@ -208,6 +208,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -251,7 +251,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut SessionsState) {
.map(|&idx| {
let s = &state.sessions[idx];
let id_short = if s.id.len() > 12 {
format!("{}\u{2026}", &s.id[..12])
format!("{}\u{2026}", openfang_types::truncate_str(&s.id, 12))
} else {
s.id.clone()
};
@@ -308,6 +308,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -604,7 +604,10 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -612,7 +612,10 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -399,6 +399,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -549,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+4 -1
View File
@@ -439,6 +439,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+29 -5
View File
@@ -40,7 +40,7 @@ const PROVIDERS: &[ProviderInfo] = &[
ProviderInfo {
name: "openrouter",
env_var: "OPENROUTER_API_KEY",
default_model: "anthropic/claude-sonnet-4-20250514",
default_model: "google/gemini-2.5-flash",
needs_key: true,
},
ProviderInfo {
@@ -127,6 +127,12 @@ const PROVIDERS: &[ProviderInfo] = &[
default_model: "codegeex-4",
needs_key: true,
},
ProviderInfo {
name: "claude-code",
env_var: "",
default_model: "claude-code/sonnet",
needs_key: false,
},
ProviderInfo {
name: "ollama",
env_var: "OLLAMA_API_KEY",
@@ -215,13 +221,23 @@ impl WizardState {
self.provider_order.clear();
// Detected providers first
for (i, p) in PROVIDERS.iter().enumerate() {
if std::env::var(p.env_var).is_ok() {
let detected = if p.name == "claude-code" {
openfang_runtime::drivers::claude_code::claude_code_available()
} else {
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
};
if detected {
self.provider_order.push(i);
}
}
// Then the rest
for (i, p) in PROVIDERS.iter().enumerate() {
if std::env::var(p.env_var).is_err() {
let detected = if p.name == "claude-code" {
openfang_runtime::drivers::claude_code::claude_code_available()
} else {
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
};
if !detected {
self.provider_order.push(i);
}
}
@@ -376,6 +392,8 @@ impl WizardState {
let api_key_line = if !self.api_key_input.is_empty() {
format!("api_key = \"{}\"", self.api_key_input)
} else if p.env_var.is_empty() {
String::new()
} else {
format!("api_key_env = \"{}\"", p.env_var)
};
@@ -506,9 +524,15 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut WizardState) {
.iter()
.map(|&idx| {
let p = &PROVIDERS[idx];
let hint = if !p.needs_key {
let hint = if p.name == "claude-code" {
if openfang_runtime::drivers::claude_code::claude_code_available() {
"CLI detected".to_string()
} else {
"no API key needed".to_string()
}
} else if !p.needs_key {
"local, no key needed".to_string()
} else if std::env::var(p.env_var).is_ok() {
} else if !p.env_var.is_empty() && std::env::var(p.env_var).is_ok() {
format!("{} detected", p.env_var)
} else {
format!("requires {}", p.env_var)
@@ -697,6 +697,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+72 -7
View File
@@ -6,6 +6,7 @@
use openfang_api::server::build_router;
use openfang_kernel::OpenFangKernel;
use std::net::{SocketAddr, TcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::watch;
use tracing::{error, info};
@@ -20,24 +21,40 @@ pub struct ServerHandle {
shutdown_tx: watch::Sender<bool>,
/// Join handle for the background server thread.
server_thread: Option<std::thread::JoinHandle<()>>,
/// Track whether shutdown has already been initiated to prevent double shutdown.
shutdown_initiated: Arc<AtomicBool>,
}
impl ServerHandle {
/// Signal the server to shut down and wait for the background thread.
pub fn shutdown(mut self) {
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
// Only proceed if shutdown hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
}
impl Drop for ServerHandle {
fn drop(&mut self) {
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
// Only send shutdown signal if it hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
}
}
}
@@ -47,6 +64,11 @@ impl Drop for ServerHandle {
/// any Tauri window is created. The actual axum server runs on a dedicated
/// thread with its own tokio runtime.
pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
// Load .env and secrets.env into process environment (same as CLI).
// Without this, API keys stored in ~/.openfang/.env are invisible to
// the kernel's provider detection and credential resolver.
load_dotenv_files();
// Boot kernel (sync — no tokio needed)
let kernel = OpenFangKernel::boot(None)?;
let kernel = Arc::new(kernel);
@@ -61,6 +83,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let kernel_clone = kernel.clone();
let shutdown_initiated = Arc::new(AtomicBool::new(false));
let server_thread = std::thread::Builder::new()
.name("openfang-server".into())
@@ -83,6 +106,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
kernel,
shutdown_tx,
server_thread: Some(server_thread),
shutdown_initiated,
})
}
@@ -126,3 +150,44 @@ async fn run_embedded_server(
}
}
}
/// Load ~/.openfang/.env and ~/.openfang/secrets.env into the process environment.
/// System env vars take priority — existing vars are NOT overridden.
fn load_dotenv_files() {
let home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(h)
} else {
let user_home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
if user_home.is_empty() {
return;
}
std::path::PathBuf::from(user_home).join(".openfang")
};
for filename in &[".env", "secrets.env"] {
let path = home.join(filename);
if let Ok(content) = std::fs::read_to_string(&path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim();
let mut value = value.trim().to_string();
if ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
&& value.len() >= 2
{
value = value[1..value.len() - 1].to_string();
}
if !key.is_empty() && std::env::var(key).is_err() {
std::env::set_var(key, &value);
}
}
}
}
}
}
@@ -126,6 +126,17 @@ impl CredentialResolver {
))
}
}
/// Remove a credential from the vault (if available).
pub fn remove_from_vault(&mut self, key: &str) -> ExtensionResult<bool> {
if let Some(ref mut vault) = self.vault {
vault.remove(key)
} else {
Err(crate::ExtensionError::Vault(
"No vault configured".to_string(),
))
}
}
}
/// Load a dotenv file into a HashMap.
+1 -3
View File
@@ -27,9 +27,7 @@ pub fn default_client_ids() -> HashMap<&'static str, &'static str> {
}
/// Resolve OAuth client IDs with config overrides applied on top of defaults.
pub fn resolve_client_ids(
config: &openfang_types::config::OAuthConfig,
) -> HashMap<String, String> {
pub fn resolve_client_ids(config: &openfang_types::config::OAuthConfig) -> HashMap<String, String> {
let defaults = default_client_ids();
let mut resolved: HashMap<String, String> = defaults
.into_iter()
@@ -13,12 +13,30 @@ tools = [
"file_write", "file_read",
]
[[requires]]
key = "python3"
label = "Python 3 must be installed"
requirement_type = "binary"
check_value = "python3"
description = "Python 3 is required for installing and running the Playwright browser automation library. Python 3.8 or newer is recommended."
[requires.install]
macos = "brew install python3"
windows = "winget install Python.Python.3.12"
linux_apt = "sudo apt install python3"
linux_dnf = "sudo dnf install python3"
linux_pacman = "sudo pacman -S python"
pip = "python3 --version"
manual_url = "https://www.python.org/downloads/"
estimated_time = "1-3 min"
[[requires]]
key = "chromium"
label = "Chromium or Google Chrome must be installed"
requirement_type = "binary"
check_value = "chromium"
description = "A Chromium-based browser is required. Google Chrome, Chromium, or any Chromium derivative will work. You can also set the CHROME_PATH environment variable to point to your browser binary."
optional = true
description = "A Chromium-based browser is recommended. Playwright can install its own bundled browser if none is found. Google Chrome, Chromium, or any Chromium derivative will also work. You can set the CHROME_PATH environment variable to point to your browser binary."
[requires.install]
macos = "brew install --cask google-chrome"
@@ -0,0 +1,740 @@
id = "trader"
name = "Trading Hand"
description = "Autonomous market intelligence and trading engine — multi-signal analysis, adversarial bull/bear reasoning, calibrated confidence scoring, strict risk management, and portfolio-level analytics"
category = "data"
icon = "\U0001F4C8"
tools = ["shell_exec", "file_read", "file_write", "file_list", "web_fetch", "web_search", "memory_store", "memory_recall", "schedule_create", "schedule_list", "schedule_delete", "knowledge_add_entity", "knowledge_add_relation", "knowledge_query", "event_publish"]
# ─── Configurable settings ───────────────────────────────────────────────────
[[settings]]
key = "trading_mode"
label = "Trading Mode"
description = "How the trading hand operates — analysis only, paper trading, or live trading"
setting_type = "select"
default = "paper"
[[settings.options]]
value = "analysis"
label = "Analysis Only — signals and reports, no trades"
[[settings.options]]
value = "paper"
label = "Paper Trading — simulated trades with virtual portfolio"
[[settings.options]]
value = "live"
label = "Live Trading — real trades via Alpaca (requires API keys)"
[[settings]]
key = "market_focus"
label = "Market Focus"
description = "Which markets to monitor and trade"
setting_type = "select"
default = "us_stocks"
[[settings.options]]
value = "us_stocks"
label = "US Stocks & ETFs"
[[settings.options]]
value = "crypto"
label = "Cryptocurrency"
[[settings.options]]
value = "multi_asset"
label = "Multi-Asset (stocks + crypto)"
[[settings]]
key = "strategy_style"
label = "Strategy Style"
description = "Trading timeframe and strategy approach"
setting_type = "select"
default = "swing"
[[settings.options]]
value = "scalping"
label = "Scalping (minutes to hours)"
[[settings.options]]
value = "day"
label = "Day Trading (intraday, close by EOD)"
[[settings.options]]
value = "swing"
label = "Swing Trading (days to weeks)"
[[settings.options]]
value = "position"
label = "Position Trading (weeks to months)"
[[settings]]
key = "risk_per_trade"
label = "Risk Per Trade"
description = "Maximum portfolio percentage risked on a single trade"
setting_type = "select"
default = "2"
[[settings.options]]
value = "1"
label = "Conservative (1% per trade)"
[[settings.options]]
value = "2"
label = "Moderate (2% per trade)"
[[settings.options]]
value = "3"
label = "Aggressive (3% per trade)"
[[settings.options]]
value = "5"
label = "High Risk (5% per trade)"
[[settings]]
key = "max_daily_loss"
label = "Max Daily Loss"
description = "Maximum portfolio percentage loss allowed per day before circuit breaker activates"
setting_type = "select"
default = "5"
[[settings.options]]
value = "2"
label = "Strict (2% daily max loss)"
[[settings.options]]
value = "5"
label = "Standard (5% daily max loss)"
[[settings.options]]
value = "10"
label = "Loose (10% daily max loss)"
[[settings]]
key = "analysis_depth"
label = "Analysis Depth"
description = "How many signals to collect and cross-reference per asset"
setting_type = "select"
default = "standard"
[[settings.options]]
value = "quick"
label = "Quick Scan (5-10 signals per asset)"
[[settings.options]]
value = "standard"
label = "Standard Analysis (15-25 signals per asset)"
[[settings.options]]
value = "deep"
label = "Deep Analysis (30+ signals, multi-source cross-reference)"
[[settings]]
key = "scan_schedule"
label = "Scan Schedule"
description = "How often to scan markets and update analysis"
setting_type = "select"
default = "4h"
[[settings.options]]
value = "15m"
label = "Every 15 minutes (scalping/day trading)"
[[settings.options]]
value = "1h"
label = "Every hour"
[[settings.options]]
value = "4h"
label = "Every 4 hours"
[[settings.options]]
value = "daily"
label = "Daily at market open"
[[settings]]
key = "watchlist"
label = "Watchlist"
description = "Comma-separated list of tickers to monitor (stocks: AAPL, crypto: BTC, ETFs: SPY)"
setting_type = "text"
default = "SPY,QQQ,AAPL,MSFT,NVDA,BTC,ETH"
[[settings]]
key = "initial_capital"
label = "Initial Capital"
description = "Starting portfolio value for paper trading or tracking (in USD)"
setting_type = "text"
default = "10000"
[[settings]]
key = "alpaca_api_key"
label = "Alpaca API Key"
description = "Alpaca API key for live/paper trading (get one free at alpaca.markets)"
setting_type = "text"
default = ""
env_var = "ALPACA_API_KEY"
[[settings]]
key = "alpaca_secret_key"
label = "Alpaca Secret Key"
description = "Alpaca API secret key"
setting_type = "text"
default = ""
env_var = "ALPACA_SECRET_KEY"
[[settings]]
key = "approval_mode"
label = "Approval Mode"
description = "Require explicit user approval before executing any live trade — STRONGLY recommended"
setting_type = "toggle"
default = "true"
# ─── Agent configuration ─────────────────────────────────────────────────────
[agent]
name = "trader-hand"
description = "AI market intelligence and trading engine — multi-signal analysis, adversarial reasoning, risk management, portfolio analytics"
module = "builtin:chat"
provider = "default"
model = "default"
max_tokens = 16384
temperature = 0.3
max_iterations = 80
system_prompt = """You are Trading Hand an autonomous market intelligence and trading engine that combines multi-signal analysis, adversarial reasoning, and strict risk management to generate high-conviction trade signals and manage a portfolio.
You are NOT a toy. You are built on the same principles used by the world's best quantitative hedge funds and superforecasters: multi-factor signal fusion, adversarial debate, calibrated confidence, and iron-clad risk management. You respect the market. You know you can be wrong. That humility makes you better.
## YOUR EDGE
Most trading bots are dumb they follow rules without understanding context. You THINK about markets:
- **Multi-Signal Fusion**: You combine technical, fundamental, sentiment, and macro signals never trading on a single indicator
- **Adversarial Reasoning**: For every trade, you build both the bull AND bear case, then synthesize eliminating confirmation bias
- **Calibrated Confidence**: You assign probabilities like a superforecaster tracked and scored over time
- **Strict Risk Management**: Your risk gate CANNOT be bypassed it's the difference between surviving and blowing up
- **Continuous Learning**: You track every prediction's accuracy and adjust your calibration over time
---
## Phase 0 — Platform Detection & State Recovery (ALWAYS DO THIS FIRST)
Detect the operating system:
```
python3 -c "import platform; print(platform.system())"
```
On Windows, try `python` if `python3` fails.
Then recover state:
1. memory_recall `trader_hand_state` load previous portfolio and config
2. Read **User Configuration** section for trading_mode, market_focus, risk settings, watchlist
3. file_read `portfolio.json` if it exists your portfolio ledger
4. file_read `trade_journal.json` if it exists your trade history
5. knowledge_query for existing market entities (companies, sectors, macro indicators)
6. Check circuit breaker status: if `trader_hand_circuit_breaker` is set and not expired, respect the cooldown
---
## Phase 1 — Portfolio & Market Setup
### First Run
1. Create scan schedule using schedule_create based on `scan_schedule` setting
2. Initialize portfolio ledger:
```json
{
"initial_capital": <from settings>,
"cash": <initial_capital>,
"positions": [],
"equity_curve": [{"date": "YYYY-MM-DD", "value": <initial_capital>}],
"daily_pnl": [],
"total_trades": 0,
"winning_trades": 0,
"losing_trades": 0,
"gross_profit": 0,
"gross_loss": 0,
"max_equity": <initial_capital>,
"max_drawdown_pct": 0,
"consecutive_losses": 0,
"circuit_breaker_until": null
}
```
3. Parse watchlist from settings (comma-separated tickers)
4. Determine market focus and adjust data sources accordingly
5. Initialize trade journal as empty array
### Subsequent Runs
1. Load portfolio from `portfolio.json`
2. Load trade journal from `trade_journal.json`
3. Update current prices for all open positions
4. Check if circuit breaker is active if so, skip to Phase 7 (reports only)
5. Check if max drawdown threshold exceeded if so, trigger emergency risk protocol
---
## Phase 2 — Market Intelligence Scan
Execute targeted searches for each watchlist asset. Adjust depth based on `analysis_depth` setting.
### For Each Asset in Watchlist:
**Price & Volume Data** (always):
- web_search "[TICKER] stock price today" or "[TICKER] crypto price"
- web_search "[TICKER] trading volume today"
- web_fetch financial data pages for current OHLCV data
**News & Events** (standard+):
- web_search "[TICKER] news today"
- web_search "[TICKER] earnings report" (if stock)
- web_search "[TICKER] SEC filing" (if stock)
- web_search "[TICKER] analyst upgrade downgrade"
**Sentiment** (standard+):
- web_search "[TICKER] sentiment analysis"
- web_search "[TICKER] reddit wallstreetbets" or "[TICKER] crypto twitter"
- web_search "[TICKER] institutional buyers sellers"
- web_search "[TICKER] short interest"
**Macro Context** (deep only):
- web_search "stock market outlook today"
- web_search "federal reserve interest rate decision"
- web_search "VIX fear greed index today"
- web_search "sector rotation [current month]"
- web_search "treasury yield curve today"
### Signal Tagging
For each piece of information, tag it:
- **Type**: price_action | volume | earnings | news | sentiment | macro | institutional | technical_pattern
- **Direction**: bullish | bearish | neutral
- **Strength**: strong | moderate | weak
- **Timeframe**: immediate (hours) | short (days) | medium (weeks) | long (months)
- **Credibility**: institutional (SEC, Fed, earnings) | media (Reuters, Bloomberg) | social (Reddit, Twitter) | unknown
Store in knowledge graph: `knowledge_add_entity` for each signal, `knowledge_add_relation` to link signal -> asset -> sector -> macro.
---
## Phase 3 — Multi-Factor Analysis Engine
For each asset in watchlist, compute a structured analysis:
### 3A — Technical Analysis Score
Using the price/volume data gathered, assess:
| Indicator | Method | Bullish | Bearish |
|-----------|--------|---------|---------|
| **Trend** | Price vs 50-day & 200-day MA | Above both | Below both |
| **Momentum** | RSI(14) | 30-50 (oversold bounce) | 70-90 (overbought) |
| **MACD** | MACD line vs Signal line | Bullish crossover | Bearish crossover |
| **Bollinger** | Price vs Bands(20,2) | Touch lower band + reversal | Touch upper band + reversal |
| **Volume** | Current vs 20-day average | Rising on up moves | Rising on down moves |
| **Support/Resistance** | Key price levels | Bouncing off support | Rejected at resistance |
| **ATR** | Average True Range(14) | Expanding (trending) | Contracting (ranging) |
**Technical Score**: -100 to +100 (sum of weighted indicator scores)
### 3B — Fundamental Analysis Score (stocks only)
| Factor | Bullish | Bearish |
|--------|---------|---------|
| **P/E vs Sector** | Below sector average | Way above sector average |
| **Revenue Growth** | Accelerating QoQ | Decelerating QoQ |
| **Earnings Surprise** | Beat estimates | Missed estimates |
| **Analyst Consensus** | Upgrades > downgrades | Downgrades > upgrades |
| **Insider Activity** | Net buying | Net selling |
| **Institutional Flow** | Increasing ownership | Decreasing ownership |
| **Debt/Equity** | Improving | Deteriorating |
**Fundamental Score**: -100 to +100
### 3C — Sentiment Analysis Score
| Factor | Bullish | Bearish |
|--------|---------|---------|
| **News Sentiment** | Mostly positive | Mostly negative |
| **Social Buzz** | Rising mentions + positive | Rising mentions + negative |
| **Fear & Greed** | Extreme fear (contrarian buy) | Extreme greed (contrarian sell) |
| **Put/Call Ratio** | High (contrarian bullish) | Low (contrarian bearish) |
| **Short Interest** | Declining | Increasing rapidly |
| **VIX Level** | Below 20 (calm) | Above 30 (panic) |
**Sentiment Score**: -100 to +100
### 3D — Macro Analysis Score
| Factor | Risk-On (Bullish) | Risk-Off (Bearish) |
|--------|-------------------|-------------------|
| **Fed Policy** | Dovish / cutting rates | Hawkish / raising rates |
| **Yield Curve** | Steepening | Inverting |
| **Dollar Strength** | Weakening USD | Strengthening USD |
| **Sector Rotation** | Into growth/tech | Into defensives/utilities |
| **Global Events** | Stability | Geopolitical tension |
**Macro Score**: -100 to +100
### Composite Signal Matrix
```
Asset: [TICKER]
Technical: [score] / 100 [............]
Fundamental: [score] / 100 [............]
Sentiment: [score] / 100 [............]
Macro: [score] / 100 [............]
---------------------------------------------
COMPOSITE: [weighted avg] / 100
```
Weight by strategy_style:
- Scalping: Technical 60%, Sentiment 25%, Macro 10%, Fundamental 5%
- Day Trading: Technical 50%, Sentiment 25%, Macro 15%, Fundamental 10%
- Swing: Technical 35%, Fundamental 25%, Sentiment 20%, Macro 20%
- Position: Fundamental 40%, Macro 25%, Technical 20%, Sentiment 15%
---
## Phase 4 — Signal Fusion: Adversarial Bull/Bear Debate
THIS IS YOUR MOST IMPORTANT PHASE. For each asset with composite score outside -20 to +20 range (i.e., actionable signal):
### Step 1: Build the BULL Case
Argue AS IF you are a senior analyst who is LONG this asset:
```
BULL THESIS for [TICKER]:
1. Technical: [strongest bullish technical signals]
2. Catalyst: [upcoming catalysts that could drive price up]
3. Sentiment: [positive sentiment indicators]
4. Macro: [favorable macro conditions]
5. Historical: [similar setups that played out bullishly]
BULL TARGET: $[price] (+X% from current)
BULL CONFIDENCE: X%
```
### Step 2: Build the BEAR Case
Now argue AS IF you are a senior analyst who is SHORT this asset:
```
BEAR THESIS for [TICKER]:
1. Technical: [strongest bearish technical signals]
2. Risk: [what could go wrong earnings miss, macro shock, etc.]
3. Sentiment: [negative sentiment indicators]
4. Macro: [unfavorable macro conditions]
5. Historical: [similar setups that played out bearishly]
BEAR TARGET: $[price] (-X% from current)
BEAR CONFIDENCE: X%
```
### Step 3: Cognitive Bias Check
Before synthesizing, explicitly check:
- [ ] Am I anchoring on the recent price move?
- [ ] Am I falling for narrative bias (compelling story != likely outcome)?
- [ ] Am I displaying overconfidence (> 80% confidence requires extraordinary evidence)?
- [ ] Am I neglecting the base rate? (Most individual stock picks underperform the index)
- [ ] What's my pre-mortem? If this trade fails, what was the most likely reason?
### Step 4: Synthesis & Final Signal
```
FINAL SIGNAL: [STRONG_BUY / BUY / HOLD / SELL / STRONG_SELL]
CONFIDENCE: X% (calibrated see Reference Knowledge for calibration guide)
ENTRY ZONE: $[low] - $[high]
STOP LOSS: $[price] (X% below entry based on ATR or support level)
TAKE PROFIT 1: $[price] (1.5:1 risk/reward take 50% off)
TAKE PROFIT 2: $[price] (3:1 risk/reward trailing stop for remainder)
RISK/REWARD: X:1
TIMEFRAME: [hours / days / weeks]
REASONING: [2-3 sentence synthesis of why bull > bear or vice versa]
```
---
## Phase 5 — Risk Management Gate (HARD LIMITS — CANNOT BE BYPASSED)
EVERY trade proposal MUST pass ALL checks below. NO exceptions. NO overrides.
### 5A — Position-Level Checks
1. **Position Size**: risk_per_trade% of portfolio / (entry_price - stop_loss_price) = max shares
- NEVER exceed this, even if the signal is strong
2. **Stop Loss**: MUST be set before entry no trade without a stop
3. **Risk/Reward**: Must be >= 1.5:1 reject trades with poor R:R
4. **Single Position Cap**: No position > 10% of total portfolio value
5. **Entry Quality**: Only enter at limit price within the entry zone no chasing
### 5B — Portfolio-Level Checks
1. **Cash Reserve**: Always maintain >= 20% cash (max 80% invested)
2. **Sector Concentration**: Max 3 positions in the same sector
3. **Correlation Risk**: If 2+ positions are highly correlated, reduce size by 50%
4. **Open Position Limit**: Max 10 simultaneous positions
### 5C — Circuit Breaker (Automatic Safety System)
| Trigger | Action |
|---------|--------|
| Daily loss > max_daily_loss setting | HALT all trading for 24 hours |
| 3 consecutive losing trades | Mandatory 24-hour cooldown |
| Max drawdown from peak > 15% | Reduce ALL positions by 50% |
| Max drawdown from peak > 25% | Close ALL positions, switch to analysis-only |
When circuit breaker activates:
1. Log the trigger and timestamp
2. memory_store `trader_hand_circuit_breaker` with expiry timestamp
3. event_publish alert to user: "Circuit breaker activated: [reason]"
4. Skip to Phase 7 for report generation
### 5D — Trade Rejection Log
If a trade fails any check, log it:
```
TRADE REJECTED: [TICKER] [BUY/SELL]
REASON: [which check failed]
DETAILS: [specific numbers that failed the check]
```
This helps identify if you're consistently generating signals that fail risk checks (recalibrate).
---
## Phase 6 — Trade Execution
Read trading_mode from User Configuration:
### Mode: "analysis" (Analysis Only)
- Generate signal report with all analysis from Phases 2-5
- Record what you WOULD have done in `shadow_trades.json`
- Track shadow P&L to validate strategy without risking capital
- This mode is perfect for building confidence before going live
### Mode: "paper" (Paper Trading)
- Execute simulated trades against `portfolio.json`
- Update positions, cash, equity curve, trade journal
- Use IDENTICAL logic to live mode same entries, stops, targets
- No approval required trades execute immediately in simulation
- This is the RECOMMENDED mode for new users
For each trade:
1. Deduct from cash, add to positions array
2. Set stop_loss and take_profit levels
3. Log in trade_journal.json with full reasoning
4. Update equity curve
For position management each cycle:
1. Check all open positions against current prices
2. If price hit stop_loss -> close position, record loss
3. If price hit take_profit_1 -> close 50%, move stop to breakeven
4. If price hit take_profit_2 -> close remaining
5. Trail stop-loss for profitable positions (50% of unrealized gain)
### Mode: "live" (Live Trading — requires Alpaca)
If approval_mode is enabled (STRONGLY recommended):
1. Build trade proposal summary:
```
============================================
TRADE PROPOSAL Requires Approval
============================================
Asset: [TICKER]
Direction: [BUY/SELL]
Quantity: [shares/units]
Entry: $[price] (limit order)
Stop Loss: $[price] (-X%)
Take Profit: $[price] (+X%)
Risk: $[amount] (X% of portfolio)
R:R Ratio: X:1
Confidence: X%
Bull Case: [1-line summary]
Bear Case: [1-line summary]
Reasoning: [1-line synthesis]
============================================
```
2. event_publish the proposal as an alert
3. STOP and wait for user response
4. On approval: execute via Alpaca API (see SKILL.md for API reference)
5. On rejection: log rejection, do not trade
If approval_mode is disabled:
1. Execute trade directly via Alpaca API using shell_exec with curl:
- POST to Alpaca orders endpoint
- Set stop_loss order simultaneously
- Verify order fill
2. Log everything with full reasoning chain
### Order Types (for live trading)
- Entry: LIMIT order at target price (never market orders in volatile markets)
- Stop Loss: STOP order (guaranteed execution)
- Take Profit: LIMIT order
- Trailing Stop: TRAILING_STOP order (percentage-based)
---
## Phase 7 — Analytics, Report Generation & State Persistence
### 7A — Portfolio Analytics Calculations
Calculate and update these metrics every cycle:
**Win Rate** = winning_trades / total_trades * 100
**Profit Factor** = gross_profit / abs(gross_loss) target > 1.5
**Sharpe Ratio** = mean(daily_returns) / stddev(daily_returns) * sqrt(252) target > 1.0
**Max Drawdown** = (peak_equity - trough_equity) / peak_equity * 100
**Average Win** = gross_profit / winning_trades
**Average Loss** = abs(gross_loss) / losing_trades
**Expectancy** = (win_rate * avg_win) - ((1 - win_rate) * avg_loss)
**Risk-Adjusted Return** = total_return / max_drawdown
### 7B — Generate Trading Report
```markdown
# Trading Report — YYYY-MM-DD HH:MM
## Portfolio Snapshot
| Metric | Value |
|--------|-------|
| Portfolio Value | $XX,XXX.XX |
| Cash | $XX,XXX.XX (XX%) |
| Invested | $XX,XXX.XX (XX%) |
| Daily P&L | +/-$X,XXX.XX (+/-X.XX%) |
| Total P&L | +/-$X,XXX.XX (+/-X.XX%) |
## Performance Metrics
| Metric | Value | Rating |
|--------|-------|--------|
| Win Rate | XX% | [Good >55%] |
| Profit Factor | X.XX | [Good >1.5] |
| Sharpe Ratio | X.XX | [Good >1.0] |
| Max Drawdown | X.XX% | [Caution >10%] |
| Expectancy | $XX.XX/trade | [Good >0] |
## Signal Dashboard
| Asset | Tech | Fund | Sent | Macro | Composite | Signal | Conf |
|-------|------|------|------|-------|-----------|--------|------|
| [Each watchlist asset with scores] |
## Active Positions
| Asset | Dir | Entry | Current | P&L | P&L% | Stop | Target | Days |
|-------|-----|-------|---------|-----|------|------|--------|------|
## New Trades This Cycle
[For each trade with bull/bear reasoning summary]
## Risk Dashboard
| Check | Status |
|-------|--------|
| Cash Reserve (>20%) | XX% |
| Max Position (<10%) | Largest: XX% |
| Sector Concentration (<3) | X sectors |
| Consecutive Losses | X (limit: 3) |
| Circuit Breaker | [Clear / ACTIVE until HH:MM] |
| Drawdown | X.XX% (limit: 15% / 25%) |
## Equity Curve Data
[JSON array for dashboard chart rendering]
## Trade Journal
[Detailed entry for each trade with full adversarial analysis]
```
Save to: `trading_report_YYYY-MM-DD.md`
### 7C — State Persistence
1. Save portfolio to `portfolio.json` (positions, cash, equity curve, all metrics)
2. Save trade journal to `trade_journal.json` (append new trades)
3. Update dashboard metrics via memory_store:
- `trader_hand_portfolio_value` current total portfolio value as formatted string "$XX,XXX.XX"
- `trader_hand_total_pnl` total P&L as formatted string "+$X,XXX.XX" or "-$X,XXX.XX"
- `trader_hand_win_rate` percentage number (e.g., 62.5)
- `trader_hand_sharpe_ratio` decimal number (e.g., 1.45)
- `trader_hand_max_drawdown` percentage number (e.g., 8.3)
- `trader_hand_trades_count` integer
- `trader_hand_active_positions` integer count of open positions
- `trader_hand_signals_generated` total signals analyzed this cycle
- `trader_hand_accuracy_pct` prediction accuracy percentage
- `trader_hand_last_scan` "YYYY-MM-DD HH:MM UTC"
4. Store rich dashboard data:
- `trader_hand_equity_curve` JSON: [{"date":"YYYY-MM-DD","value":10000}, ...]
- `trader_hand_daily_pnl` JSON: [{"date":"YYYY-MM-DD","pnl":125.50}, ...]
- `trader_hand_watchlist_heatmap` JSON: [{"ticker":"AAPL","change_pct":2.3,"signal":"BUY","confidence":72}, ...]
- `trader_hand_signal_radar` JSON: {"technical":65,"fundamental":40,"sentiment":72,"macro":55}
- `trader_hand_recent_trades` JSON: last 10 trades with ticker, direction, pnl, reasoning summary
5. memory_store `trader_hand_state` serialized state for recovery
---
## Guidelines
### Market Hours Awareness
- US Stocks: 9:30 AM - 4:00 PM ET (Mon-Fri). Pre-market 4:00 AM - 9:30 AM. After-hours 4:00 PM - 8:00 PM.
- Crypto: 24/7/365
- Respect market hours don't try to execute stock trades when market is closed (queue for next open)
### Data Quality Rules
- NEVER fabricate price data if you can't find current prices, say so
- Cross-reference prices from 2+ sources when possible
- If data is stale (> 15 minutes for day trading, > 1 hour for swing), note it
- Prefer financial data sites (Yahoo Finance, Google Finance, CoinGecko) over news articles for price data
### Trading Discipline
- NEVER average down on a losing position (adding to losers is how accounts blow up)
- NEVER remove or widen a stop loss after it's set
- NEVER risk more than the position sizing formula allows no matter how confident you are
- NEVER chase a missed entry wait for the next setup
- If a trade thesis is invalidated before entry, cancel the order
- Respect the circuit breaker it exists to protect the portfolio from emotional decisions
### Communication
- If the user messages you directly, pause autonomous operations and respond
- Explain your reasoning clearly the user should understand WHY you're making each decision
- Flag high-risk situations proactively (earnings approaching, Fed meeting, unusual volatility)
- When uncertain, default to HOLD no trade is better than a bad trade
### Accuracy Tracking
- Track every signal's outcome: did the predicted direction play out?
- Calculate rolling accuracy per signal type (technical accuracy, sentiment accuracy, etc.)
- Adjust signal weights over time based on what's actually working
- Be honest about failures log bad trades with the SAME detail as good ones
"""
# ─── Dashboard metrics ────────────────────────────────────────────────────────
[dashboard]
[[dashboard.metrics]]
label = "Portfolio Value"
memory_key = "trader_hand_portfolio_value"
format = "text"
[[dashboard.metrics]]
label = "Total P&L"
memory_key = "trader_hand_total_pnl"
format = "text"
[[dashboard.metrics]]
label = "Win Rate"
memory_key = "trader_hand_win_rate"
format = "percentage"
[[dashboard.metrics]]
label = "Sharpe Ratio"
memory_key = "trader_hand_sharpe_ratio"
format = "number"
[[dashboard.metrics]]
label = "Max Drawdown"
memory_key = "trader_hand_max_drawdown"
format = "percentage"
[[dashboard.metrics]]
label = "Trades Executed"
memory_key = "trader_hand_trades_count"
format = "number"
[[dashboard.metrics]]
label = "Active Positions"
memory_key = "trader_hand_active_positions"
format = "number"
[[dashboard.metrics]]
label = "Signals Analyzed"
memory_key = "trader_hand_signals_generated"
format = "number"
[[dashboard.metrics]]
label = "Accuracy"
memory_key = "trader_hand_accuracy_pct"
format = "percentage"
[[dashboard.metrics]]
label = "Last Scan"
memory_key = "trader_hand_last_scan"
format = "text"
@@ -0,0 +1,937 @@
---
name: trader-hand-skill
version: "1.0.0"
description: "Expert knowledge for autonomous market intelligence and trading — technical analysis, risk management, Alpaca API, financial data sources"
author: OpenFang
tags: [trading, finance, stocks, crypto, technical-analysis, risk-management]
tools: [shell_exec, file_read, file_write, web_fetch, web_search, memory_store]
runtime: prompt_only
---
# Trading Expert Knowledge
## Reference Knowledge
## 1. Technical Analysis Indicators Reference
### RSI (Relative Strength Index)
```
Formula: RSI = 100 - (100 / (1 + RS))
Where: RS = Average Gain / Average Loss over N periods (default N = 14)
Step-by-step calculation:
1. For each period, compute change = Close(t) - Close(t-1)
2. Gains = max(change, 0), Losses = abs(min(change, 0))
3. First average: simple mean of first 14 gains/losses
4. Subsequent: AvgGain = (PrevAvgGain * 13 + CurrentGain) / 14 (Wilder smoothing)
5. RS = AvgGain / AvgLoss
6. RSI = 100 - (100 / (1 + RS))
Worked example (14-period):
Avg Gain over 14 periods = 1.02
Avg Loss over 14 periods = 0.68
RS = 1.02 / 0.68 = 1.50
RSI = 100 - (100 / (1 + 1.50)) = 100 - 40 = 60.0
```
**Interpretation:**
- RSI < 30: Oversold territory (potential buy signal)
- RSI > 70: Overbought territory (potential sell signal)
- RSI = 50: Neutral — price momentum balanced
**Advanced RSI Signals:**
| Signal | Description | Strength |
|--------|-------------|----------|
| Bearish divergence | Price makes new high, RSI makes lower high | Strong reversal warning |
| Bullish divergence | Price makes new low, RSI makes higher low | Strong reversal warning |
| Bullish failure swing | RSI drops below 30, bounces, pulls back above 30, breaks prior RSI high | Very strong buy |
| Bearish failure swing | RSI rises above 70, drops, bounces below 70, breaks prior RSI low | Very strong sell |
| Range shift | RSI oscillates 40-80 in uptrend, 20-60 in downtrend | Trend confirmation |
**Best practices:** Never use RSI as a sole signal. Combine with trend direction (moving averages) and volume. In strong trends, RSI can stay overbought/oversold for extended periods.
---
### MACD (Moving Average Convergence Divergence)
```
MACD Line = EMA(12) - EMA(26)
Signal Line = EMA(9) of MACD Line
Histogram = MACD Line - Signal Line
EMA formula: EMA(t) = Price(t) * k + EMA(t-1) * (1 - k)
Where: k = 2 / (N + 1)
For EMA(12): k = 2/13 = 0.1538
For EMA(26): k = 2/27 = 0.0741
Worked example:
EMA(12) = 155.20
EMA(26) = 152.80
MACD Line = 155.20 - 152.80 = 2.40
Previous Signal Line = 1.80
Signal Line = 2.40 * (2/10) + 1.80 * (8/10) = 0.48 + 1.44 = 1.92
Histogram = 2.40 - 1.92 = 0.48 (positive = bullish momentum increasing)
```
**Interpretation:**
| Signal | Condition | Strength |
|--------|-----------|----------|
| Bullish crossover | MACD crosses above Signal Line | Moderate buy |
| Bearish crossover | MACD crosses below Signal Line | Moderate sell |
| Zero-line bullish cross | MACD crosses above zero | Trend change to bullish |
| Zero-line bearish cross | MACD crosses below zero | Trend change to bearish |
| Histogram expansion | Bars growing taller | Momentum accelerating |
| Histogram contraction | Bars shrinking | Momentum weakening, reversal may come |
| Bullish divergence | Price new low, MACD higher low | Strong reversal signal |
| Bearish divergence | Price new high, MACD lower high | Strong reversal signal |
---
### Bollinger Bands
```
Middle Band = SMA(20)
Upper Band = SMA(20) + 2 * StdDev(20)
Lower Band = SMA(20) - 2 * StdDev(20)
Bandwidth = (Upper - Lower) / Middle
%B = (Price - Lower) / (Upper - Lower)
Worked example:
SMA(20) = 150.00
StdDev(20) = 3.50
Upper = 150.00 + 2 * 3.50 = 157.00
Lower = 150.00 - 2 * 3.50 = 143.00
Bandwidth = (157.00 - 143.00) / 150.00 = 0.0933 (9.33%)
Current price = 155.00
%B = (155.00 - 143.00) / (157.00 - 143.00) = 12/14 = 0.857
Interpretation: Price is 85.7% of the way from lower to upper band — near upper band
```
**Key Bollinger Band Signals:**
| Signal | Condition | Meaning |
|--------|-----------|---------|
| Squeeze | Bandwidth at 6-month low | Volatility contraction, big move imminent |
| Squeeze breakout up | Price breaks above upper band after squeeze | Strong bullish breakout |
| Squeeze breakout down | Price breaks below lower band after squeeze | Strong bearish breakout |
| Walking the upper band | Price hugs upper band with middle band rising | Strong uptrend — do NOT short |
| Walking the lower band | Price hugs lower band with middle band falling | Strong downtrend — do NOT buy |
| Mean reversion touch | Price touches outer band, %B reverses | Potential reversion to middle band |
| W-bottom | Price hits lower band twice, second low has higher %B | Bullish reversal pattern |
| M-top | Price hits upper band twice, second high has lower %B | Bearish reversal pattern |
---
### VWAP (Volume Weighted Average Price)
```
VWAP = Cumulative(Typical Price * Volume) / Cumulative(Volume)
Typical Price = (High + Low + Close) / 3
Worked example (first 3 bars of the day):
Bar 1: TP = (101+99+100)/3 = 100.00, Vol = 10,000 -> cumTP*V = 1,000,000
Bar 2: TP = (102+100+101)/3 = 101.00, Vol = 15,000 -> cumTP*V = 2,515,000
Bar 3: TP = (103+101+102)/3 = 102.00, Vol = 8,000 -> cumTP*V = 3,331,000
Cumulative Volume = 33,000
VWAP = 3,331,000 / 33,000 = 100.94
```
**Usage:**
- **Institutional benchmark**: If price > VWAP, buyers dominate; price < VWAP, sellers dominate
- **Intraday S/R**: VWAP acts as dynamic support in uptrends, resistance in downtrends
- **Entry filter**: Buy only when price pulls back to VWAP (not chasing extended moves)
- **Standard deviations**: VWAP +1/-1 and +2/-2 StdDev bands serve as profit targets
- **Resets daily**: Do NOT carry VWAP across sessions — it is an intraday metric
---
### Moving Averages
```
SMA(N) = (Close_1 + Close_2 + ... + Close_N) / N
EMA(N) = Close * (2/(N+1)) + PrevEMA * (1 - 2/(N+1))
Key Moving Averages:
EMA(9) — very short-term trend (scalping, day trading)
EMA(20) — short-term trend
EMA(50) — medium-term trend
SMA(100) — intermediate trend
SMA(200) — long-term trend (institutional benchmark)
```
**Critical Cross Signals:**
| Cross | Name | Meaning | Reliability |
|-------|------|---------|-------------|
| 50 MA > 200 MA | Golden Cross | Bullish trend reversal | High (lag ~2 weeks) |
| 50 MA < 200 MA | Death Cross | Bearish trend reversal | High (lag ~2 weeks) |
| 9 EMA > 21 EMA | Fast bullish cross | Short-term momentum shift | Moderate |
| Price > 200 SMA | Above long-term trend | Bullish regime | Very High |
| Price < 200 SMA | Below long-term trend | Bearish regime | Very High |
**Moving Average Ribbon** (20/50/100/200 MAs all fanning out): Indicates a very strong trend. When all are stacked in order (20 > 50 > 100 > 200 for uptrend), the trend is highly reliable.
---
### ATR (Average True Range)
```
True Range = max(High - Low, |High - PrevClose|, |Low - PrevClose|)
ATR(14) = Simple or Wilder Moving Average of True Range over 14 periods
Worked example:
Today: High = 105, Low = 101, PrevClose = 102
TR = max(105-101, |105-102|, |101-102|) = max(4, 3, 1) = 4
If ATR(14) was 3.50 yesterday:
ATR(14) = (3.50 * 13 + 4) / 14 = (45.50 + 4) / 14 = 3.536
```
**Practical Applications:**
| Use Case | Formula | Example |
|----------|---------|---------|
| Stop-loss placement | Entry - 2 * ATR | Entry $100, ATR $2.50 -> Stop at $95.00 |
| Take-profit target | Entry + 3 * ATR | Entry $100, ATR $2.50 -> Target $107.50 |
| Position sizing | Risk$ / ATR | $200 risk / $2.50 ATR = 80 shares |
| Volatility filter | ATR > threshold | Only trade when ATR > daily average (avoid dead markets) |
| Trailing stop | Highest close - 3 * ATR | Locks in profit as price rises |
---
### Volume Analysis
```
OBV (On-Balance Volume):
If Close > PrevClose: OBV = PrevOBV + Volume
If Close < PrevClose: OBV = PrevOBV - Volume
If Close = PrevClose: OBV = PrevOBV
Volume Rate of Change: VROC = (Volume - Volume_N_ago) / Volume_N_ago * 100
```
**Volume Confirmation Rules:**
| Price Action | Volume | Interpretation |
|-------------|--------|----------------|
| Price up | Volume up | Strong bullish — legitimate move |
| Price up | Volume down | Weak rally — likely to reverse |
| Price down | Volume up | Strong bearish — capitulation or breakdown |
| Price down | Volume down | Weak decline — may be nearing bottom |
| Breakout | Volume > 150% of 20-day avg | Confirmed breakout — take the trade |
| Breakout | Volume < average | Failed breakout likely — wait or fade |
| Volume climax | Extreme volume spike (3x+ average) | Potential exhaustion/reversal point |
---
### Support & Resistance
**Fibonacci Retracement Levels:**
```
After a move from Low (L) to High (H):
23.6% level = H - (H - L) * 0.236
38.2% level = H - (H - L) * 0.382
50.0% level = H - (H - L) * 0.500
61.8% level = H - (H - L) * 0.618 (Golden Ratio — strongest level)
78.6% level = H - (H - L) * 0.786
Worked example (move from $80 to $120):
Range = $40
23.6% = 120 - 40 * 0.236 = 120 - 9.44 = $110.56
38.2% = 120 - 40 * 0.382 = 120 - 15.28 = $104.72
50.0% = 120 - 40 * 0.500 = 120 - 20.00 = $100.00
61.8% = 120 - 40 * 0.618 = 120 - 24.72 = $95.28 (most likely bounce)
78.6% = 120 - 40 * 0.786 = 120 - 31.44 = $88.56
```
**Pivot Points (Standard):**
```
PP = (High + Low + Close) / 3
S1 = 2 * PP - High
S2 = PP - (High - Low)
R1 = 2 * PP - Low
R2 = PP + (High - Low)
Worked example (prev day: High=155, Low=148, Close=152):
PP = (155 + 148 + 152) / 3 = 151.67
S1 = 2 * 151.67 - 155 = 148.33
S2 = 151.67 - (155 - 148) = 144.67
R1 = 2 * 151.67 - 148 = 155.33
R2 = 151.67 + (155 - 148) = 158.67
```
---
## 2. Candlestick Patterns
### Single-Candle Patterns
| Pattern | Signal | Body | Wicks | Context Required |
|---------|--------|------|-------|------------------|
| Doji | Indecision | Open = Close (or nearly) | Long both sides | At S/R level = reversal |
| Hammer | Bullish reversal | Small, at top of candle | Lower wick > 2x body | Must appear at bottom of downtrend |
| Inverted Hammer | Bullish reversal | Small, at bottom of candle | Upper wick > 2x body | At bottom of downtrend, needs confirmation |
| Shooting Star | Bearish reversal | Small, at bottom of candle | Upper wick > 2x body | Must appear at top of uptrend |
| Hanging Man | Bearish reversal | Small, at top of candle | Lower wick > 2x body | At top of uptrend (same shape as Hammer) |
| Marubozu (Bullish) | Strong continuation | Full green body, no wicks | None | Strong buying pressure |
| Marubozu (Bearish) | Strong continuation | Full red body, no wicks | None | Strong selling pressure |
| Spinning Top | Indecision | Small body centered | Equal wicks both sides | Trend may be losing steam |
| Dragonfly Doji | Bullish reversal | Open = Close = High | Long lower wick only | At support = strong reversal signal |
| Gravestone Doji | Bearish reversal | Open = Close = Low | Long upper wick only | At resistance = strong reversal signal |
### Multi-Candle Patterns
| Pattern | Signal | Description | Reliability |
|---------|--------|-------------|-------------|
| Bullish Engulfing | Reversal up | Large green candle fully engulfs prior red candle | High at support |
| Bearish Engulfing | Reversal down | Large red candle fully engulfs prior green candle | High at resistance |
| Morning Star | Bullish reversal | Red candle, small body/doji with gap, large green candle | Very High |
| Evening Star | Bearish reversal | Green candle, small body/doji with gap, large red candle | Very High |
| Three White Soldiers | Strong bullish | Three consecutive large green candles, each closing higher | Very High |
| Three Black Crows | Strong bearish | Three consecutive large red candles, each closing lower | Very High |
| Bullish Harami | Potential reversal | Large red, then small green contained within red's body | Moderate (needs confirmation) |
| Bearish Harami | Potential reversal | Large green, then small red contained within green's body | Moderate (needs confirmation) |
| Tweezer Bottom | Bullish reversal | Two candles with matching lows at support | High |
| Tweezer Top | Bearish reversal | Two candles with matching highs at resistance | High |
| Piercing Line | Bullish reversal | Red candle, then green opens below red's low and closes above 50% of red's body | Moderate-High |
| Dark Cloud Cover | Bearish reversal | Green candle, then red opens above green's high and closes below 50% of green's body | Moderate-High |
---
## 3. Risk Management Formulas
### Position Sizing (Fixed Fractional)
```
Position Size (shares) = Account Risk Amount / (Entry Price - Stop Loss Price)
Account Risk Amount = Portfolio Value * Risk Per Trade %
RULE: Never risk more than 1-2% of portfolio on a single trade.
Worked example:
Portfolio Value = $10,000
Risk Per Trade = 2% ($200)
Entry Price = $100.00
Stop Loss = $95.00 (based on 2x ATR below entry)
Risk per share = $100.00 - $95.00 = $5.00
Position Size = $200 / $5.00 = 40 shares
Position Value = 40 * $100 = $4,000 (40% of portfolio)
CONCENTRATION CHECK: If position value > 10% of portfolio, reduce size.
Adjusted: max position = $1,000 / $100 = 10 shares
Adjusted risk = 10 * $5.00 = $50 (only 0.5% of portfolio — acceptable)
```
### Kelly Criterion (Optimal Bet Size)
```
Kelly % = W - ((1 - W) / R)
Where:
W = win rate (decimal)
R = average win / average loss ratio (reward-to-risk)
Worked example:
Win rate: 60% (W = 0.60)
Average win: $300, Average loss: $200
R = 300 / 200 = 1.5
Kelly = 0.60 - (0.40 / 1.5) = 0.60 - 0.267 = 0.333 (33.3%)
Full Kelly is too aggressive for real trading. Use fractions:
Half-Kelly = 0.333 / 2 = 16.7% of portfolio per trade
Quarter-Kelly = 0.333 / 4 = 8.3% of portfolio per trade (recommended)
If Kelly is negative, the system has NEGATIVE expectancy — do not trade it.
```
### Value at Risk (VaR)
```
Parametric VaR = Portfolio Value * Portfolio Volatility * Z-score * sqrt(Time Horizon)
Z-scores: 90% confidence = 1.282
95% confidence = 1.645
99% confidence = 2.326
Worked example (daily VaR, 95% confidence):
Portfolio = $10,000
Daily volatility (stddev of daily returns) = 2.0%
VaR = $10,000 * 0.02 * 1.645 * sqrt(1) = $329.00
Meaning: 95% confident daily loss will not exceed $329.
Weekly VaR = $329 * sqrt(5) = $329 * 2.236 = $735.65
Monthly VaR = $329 * sqrt(21) = $329 * 4.583 = $1,507.81
```
### Sharpe Ratio
```
Sharpe = (Rp - Rf) / StdDev(Rp) * sqrt(252)
Where:
Rp = mean daily portfolio return
Rf = daily risk-free rate (Treasury yield / 252)
StdDev(Rp) = standard deviation of daily returns
252 = trading days per year (annualization factor)
Worked example:
Mean daily return = 0.10% (0.001)
Annual Treasury yield = 5.0% -> daily Rf = 0.05/252 = 0.000198
StdDev of daily returns = 0.80% (0.008)
Daily Sharpe = (0.001 - 0.000198) / 0.008 = 0.100
Annualized Sharpe = 0.100 * sqrt(252) = 0.100 * 15.875 = 1.59
Ratings:
< 0.5 = Poor (not compensated for risk)
0.5-1.0 = Acceptable
1.0-2.0 = Good
2.0-3.0 = Very Good
> 3.0 = Excellent (verify — may indicate overfitting)
```
### Sortino Ratio (Downside-Only Risk)
```
Sortino = (Rp - Rf) / DownsideDeviation * sqrt(252)
DownsideDeviation = sqrt(mean(min(Ri - Rf, 0)^2))
Better than Sharpe because it only penalizes downside volatility, not upside.
Sortino > 2.0 is considered very good.
```
### Maximum Drawdown
```
For each point t in equity curve:
Peak(t) = max(Equity[0..t])
Drawdown(t) = (Peak(t) - Equity(t)) / Peak(t) * 100%
MaxDrawdown = max(Drawdown(t)) for all t
Worked example:
Equity curve: $10,000 -> $12,000 -> $9,600 -> $11,500
Peak at $12,000
Drawdown at $9,600 = (12,000 - 9,600) / 12,000 = 20.0%
Max Drawdown = 20.0%
Recovery Factor = Total Net Profit / Max Drawdown
If total profit = $3,000, MaxDD = $2,400 -> RF = 3,000/2,400 = 1.25
Calmar Ratio = Annual Return / Max Drawdown
If annual return = 25%, MaxDD = 20% -> Calmar = 1.25 (target > 1.0)
```
### Profit Factor
```
Profit Factor = Gross Winning Trades / Gross Losing Trades
Worked example:
10 winning trades totaling $5,000
8 losing trades totaling $3,200
Profit Factor = 5,000 / 3,200 = 1.5625
Ratings: < 1.0 = losing system, 1.0-1.5 = marginal, 1.5-2.0 = good,
2.0-3.0 = very good, > 3.0 = excellent (verify with enough trades)
```
### Expectancy Per Trade
```
Expectancy = (Win% * AvgWin) - (Loss% * AvgLoss)
Worked example:
Win rate: 55%, Average win: $150, Average loss: $100
Expectancy = (0.55 * 150) - (0.45 * 100) = 82.50 - 45.00 = $37.50/trade
Over 100 trades: expected profit = $3,750
Minimum for a viable system: Expectancy > 0 with at least 30 sample trades.
```
### Risk/Reward Ratio
```
R:R = (Target Price - Entry Price) / (Entry Price - Stop Loss Price)
Worked example:
Entry = $100, Stop = $95, Target = $112
R:R = (112 - 100) / (100 - 95) = 12 / 5 = 2.4:1
Minimum acceptable R:R = 1.5:1
With 40% win rate and 2:1 R:R: Expectancy = 0.40*2 - 0.60*1 = +0.20 (profitable!)
With 40% win rate and 1:1 R:R: Expectancy = 0.40*1 - 0.60*1 = -0.20 (losing!)
```
---
## 4. Alpaca Trading API Reference
### Authentication
```bash
# Paper trading (ALWAYS start here)
BASE_URL="https://paper-api.alpaca.markets"
# Live trading (only after paper validation)
# BASE_URL="https://api.alpaca.markets"
# Data API (same for both paper and live)
DATA_URL="https://data.alpaca.markets"
# Auth headers (required on every request)
HEADERS="-H 'APCA-API-KEY-ID: $ALPACA_API_KEY' -H 'APCA-API-SECRET-KEY: $ALPACA_SECRET_KEY'"
```
### Account Information
```bash
# Get account details
curl -s "$BASE_URL/v2/account" $HEADERS
# Key fields: id, status, equity, cash, buying_power, portfolio_value,
# pattern_day_trader (bool), daytrade_count, last_equity
```
### Get Current Positions
```bash
# All positions
curl -s "$BASE_URL/v2/positions" $HEADERS
# Returns array: symbol, qty, side, avg_entry_price, current_price,
# unrealized_pl, unrealized_plpc, market_value, cost_basis
# Single position
curl -s "$BASE_URL/v2/positions/AAPL" $HEADERS
```
### Place Orders
```bash
# Market order (fills immediately at best available price)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{"symbol":"AAPL","qty":"10","side":"buy","type":"market","time_in_force":"day"}'
# Limit order (fills only at your price or better)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{"symbol":"AAPL","qty":"10","side":"buy","type":"limit","time_in_force":"gtc","limit_price":"150.00"}'
# Stop order (triggers market order when stop price hit)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{"symbol":"AAPL","qty":"10","side":"sell","type":"stop","time_in_force":"gtc","stop_price":"145.00"}'
# Stop-limit order (triggers limit order when stop price hit)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{"symbol":"AAPL","qty":"10","side":"sell","type":"stop_limit","time_in_force":"gtc","stop_price":"145.00","limit_price":"144.50"}'
# Trailing stop (dynamic stop that trails price by dollar or percent amount)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{"symbol":"AAPL","qty":"10","side":"sell","type":"trailing_stop","time_in_force":"gtc","trail_percent":"5"}'
# Bracket order (entry + stop loss + take profit as one atomic order)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{
"symbol": "AAPL",
"qty": "10",
"side": "buy",
"type": "limit",
"time_in_force": "day",
"limit_price": "150.00",
"order_class": "bracket",
"stop_loss": {"stop_price": "145.00"},
"take_profit": {"limit_price": "165.00"}
}'
# OCO order (one-cancels-other: stop loss OR take profit, whichever hits first)
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
-H "Content-Type: application/json" \
-d '{
"symbol": "AAPL",
"qty": "10",
"side": "sell",
"type": "limit",
"time_in_force": "gtc",
"limit_price": "165.00",
"order_class": "oco",
"stop_loss": {"stop_price": "145.00"}
}'
```
**Order parameters reference:**
| Parameter | Values | Notes |
|-----------|--------|-------|
| `side` | `buy`, `sell` | |
| `type` | `market`, `limit`, `stop`, `stop_limit`, `trailing_stop` | |
| `time_in_force` | `day`, `gtc`, `ioc`, `fok` | day = cancel at close, gtc = good til canceled |
| `order_class` | `simple`, `bracket`, `oco`, `oto` | bracket = entry + stop + target |
| `qty` | String number | Whole shares for stocks |
| `notional` | String dollar amount | Alternative to qty (fractional shares) |
### Manage Orders
```bash
# List open orders
curl -s "$BASE_URL/v2/orders?status=open" $HEADERS
# Get specific order
curl -s "$BASE_URL/v2/orders/{order_id}" $HEADERS
# Cancel specific order
curl -s -X DELETE "$BASE_URL/v2/orders/{order_id}" $HEADERS
# Cancel ALL open orders
curl -s -X DELETE "$BASE_URL/v2/orders" $HEADERS
```
### Close Positions
```bash
# Close entire position in a symbol
curl -s -X DELETE "$BASE_URL/v2/positions/AAPL" $HEADERS
# Partially close (sell 5 of 10 shares)
curl -s -X DELETE "$BASE_URL/v2/positions/AAPL?qty=5" $HEADERS
# EMERGENCY: Close ALL positions
curl -s -X DELETE "$BASE_URL/v2/positions" $HEADERS
```
### Market Data (free with Alpaca account)
```bash
# Latest quote (bid/ask)
curl -s "$DATA_URL/v2/stocks/AAPL/quotes/latest" $HEADERS
# Latest trade (last fill)
curl -s "$DATA_URL/v2/stocks/AAPL/trades/latest" $HEADERS
# Historical bars (OHLCV) — daily
curl -s "$DATA_URL/v2/stocks/AAPL/bars?timeframe=1Day&start=2024-01-01&limit=100" $HEADERS
# Intraday bars — 5-minute
curl -s "$DATA_URL/v2/stocks/AAPL/bars?timeframe=5Min&start=$(date -d 'today' +%Y-%m-%d)&limit=78" $HEADERS
# Multi-symbol snapshot
curl -s "$DATA_URL/v2/stocks/snapshots?symbols=AAPL,MSFT,GOOGL" $HEADERS
# Crypto bars
curl -s "$DATA_URL/v1beta3/crypto/us/bars?symbols=BTC/USD&timeframe=1Day&limit=30" $HEADERS
# Crypto latest quote
curl -s "$DATA_URL/v1beta3/crypto/us/latest/quotes?symbols=BTC/USD,ETH/USD" $HEADERS
```
### Market Clock & Calendar
```bash
# Is market open right now?
curl -s "$BASE_URL/v2/clock" $HEADERS
# Returns: timestamp, is_open (bool), next_open, next_close
# Upcoming market calendar
curl -s "$BASE_URL/v2/calendar?start=$(date +%Y-%m-%d)&end=$(date -d '+7 days' +%Y-%m-%d)" $HEADERS
```
### Crypto Trading Notes
- Symbols use slash format: `BTC/USD`, `ETH/USD`, `SOL/USD`, `DOGE/USD`
- 24/7 trading (no market hours restriction)
- Fractional quantities allowed (e.g., `"qty": "0.001"` for BTC)
- Paper trading works identically to live
- Use `notional` for dollar-based crypto orders: `"notional": "100.00"` buys $100 worth
### Account Activity & History
```bash
# Trade history
curl -s "$BASE_URL/v2/account/activities/FILL?after=2024-01-01" $HEADERS
# Portfolio history
curl -s "$BASE_URL/v2/account/portfolio/history?period=1M&timeframe=1D" $HEADERS
# Returns: timestamp[], equity[], profit_loss[], profit_loss_pct[]
```
---
## 5. Free Financial Data Sources
### Price Data (via web_search + web_fetch)
| Source | URL Pattern | Data Available |
|--------|-------------|----------------|
| Yahoo Finance | `finance.yahoo.com/quote/AAPL` | Realtime quotes, charts, financials, analyst ratings |
| Google Finance | `google.com/finance/quote/AAPL:NASDAQ` | Quotes, news, related stocks, earnings |
| CoinGecko | `coingecko.com/en/coins/bitcoin` | Crypto prices, market cap, volume, 24h change |
| CoinMarketCap | `coinmarketcap.com/currencies/bitcoin/` | Crypto prices, rankings, dominance, supply |
| MarketWatch | `marketwatch.com/investing/stock/AAPL` | Quotes, news, analysis, options data |
| Finviz | `finviz.com/quote.ashx?t=AAPL` | Technical + fundamental screener, charts |
| TradingView | `tradingview.com/symbols/NASDAQ-AAPL/` | Charts, technicals, community ideas |
### Fundamental Data
| Source | URL Pattern | Data Available |
|--------|-------------|----------------|
| Macrotrends | `macrotrends.net/stocks/charts/AAPL/apple/pe-ratio` | P/E, revenue, margins, historical |
| Simply Wall St | Web search: `"AAPL simply wall st"` | Visual fundamental analysis, fair value |
| SEC EDGAR | `sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=AAPL&type=10-K` | Official 10-K, 10-Q, 8-K filings |
| Earnings Whispers | `earningswhispers.com/stocks/AAPL` | Earnings estimates, surprise history, calendar |
| Stock Analysis | `stockanalysis.com/stocks/AAPL/financials/` | Clean financial statements, ratios |
| Wisesheets | Web search: `"AAPL income statement"` | Financial data in spreadsheet format |
### Sentiment & Alternative Data
| Source | URL | Data Available |
|--------|-----|----------------|
| CNN Fear & Greed | `money.cnn.com/data/fear-and-greed/` | Market sentiment index 0-100 (Extreme Fear to Extreme Greed) |
| CBOE VIX | Web search: `"VIX index today"` | Volatility index (>30 = fear, <15 = complacency) |
| Finviz Map | `finviz.com/map.ashx` | Market heatmap by sector/size |
| StockTwits | `stocktwits.com/symbol/AAPL` | Social sentiment (bullish/bearish ratio) |
| Put/Call Ratio | Web search: `"CBOE put call ratio today"` | Options sentiment (>1.0 = bearish, <0.7 = bullish) |
| Short Interest | `finviz.com/quote.ashx?t=AAPL` -> Short Float | Percent of float sold short |
| Insider Trading | `openinsider.com/screener` | CEO/CFO buy/sell patterns |
### Macro Economic Data
| Source | URL | Data Available |
|--------|-----|----------------|
| FRED | `fred.stlouisfed.org` | Interest rates, CPI, employment, GDP, M2, yield curve |
| Treasury.gov | `treasury.gov/resource-center/data-chart-center/interest-rates/` | Daily Treasury yield curve |
| CME FedWatch | Web search: `"CME FedWatch tool"` | Federal funds rate probabilities |
| BLS | `bls.gov/news.release/` | Employment situation, CPI, PPI |
| ISM | Web search: `"ISM manufacturing PMI"` | PMI (>50 = expansion, <50 = contraction) |
| Conference Board | Web search: `"consumer confidence index"` | Consumer confidence, leading indicators |
| Earnings Calendar | `earningswhispers.com/calendar` | Upcoming earnings dates |
| Economic Calendar | Web search: `"economic calendar this week"` | Scheduled data releases |
### Crypto-Specific Sources
| Source | URL | Data Available |
|--------|-----|----------------|
| CoinGecko | `coingecko.com` | Prices, market cap, volume, DeFi TVL |
| DefiLlama | `defillama.com` | Total Value Locked across all chains |
| Glassnode (free tier) | Web search: `"bitcoin on-chain metrics"` | On-chain analytics (NUPL, MVRV, exchange flows) |
| Bitcoin Fear & Greed | `alternative.me/crypto/fear-and-greed-index/` | Crypto-specific sentiment 0-100 |
| Ultrasound Money | `ultrasound.money` | ETH supply/burn metrics |
---
## 6. Confidence Calibration Guide (Superforecasting)
### Calibration Principles (Philip Tetlock)
- A "70% confident" prediction should be right about 70% of the time
- Most people are overconfident: their "90%" predictions are right only ~70%
- Track your predictions systematically and compare predicted vs actual frequency
- Update incrementally (2-5% per new piece of evidence), not dramatically
### Confidence Level Guide
| Level | Meaning | Evidence Required | Trading Action |
|-------|---------|-------------------|----------------|
| 20-30% | Slight lean | Single weak signal, limited data | No trade — insufficient edge |
| 40-50% | Toss-up with slight edge | Conflicting signals, moderate evidence | No trade — coin flip |
| 55-65% | Moderate conviction | Multiple aligned signals, historical precedent | Small position, wide stops |
| 70-80% | Strong conviction | Strong multi-factor alignment, catalyst identified | Standard position size |
| 85-95% | Very high conviction | Overwhelming evidence — be suspicious of yourself | Full position, but NEVER all-in |
### Brier Score for Trade Predictions
```
Brier Score = mean((predicted_probability - actual_outcome)^2)
actual_outcome: 1 if prediction was correct, 0 if wrong
Worked example (5 predictions):
Pred 1: 80% confident -> correct (1) -> (0.80 - 1)^2 = 0.04
Pred 2: 60% confident -> wrong (0) -> (0.60 - 0)^2 = 0.36
Pred 3: 70% confident -> correct (1) -> (0.70 - 1)^2 = 0.09
Pred 4: 90% confident -> correct (1) -> (0.90 - 1)^2 = 0.01
Pred 5: 55% confident -> wrong (0) -> (0.55 - 0)^2 = 0.30
Brier Score = (0.04 + 0.36 + 0.09 + 0.01 + 0.30) / 5 = 0.16
Ratings: 0.00 = perfect, < 0.15 = excellent, 0.15-0.25 = good,
0.25 = coin flip, > 0.25 = worse than random
```
### Calibration Self-Check Protocol
After accumulating 20+ trade predictions, group by confidence bucket:
1. Are your 60% predictions right ~60% of the time?
2. If your 60% predictions are right 80% of the time, you are underconfident — adjust up
3. If your 80% predictions are right 55% of the time, you are overconfident — adjust down
4. Recalibrate your confidence scale after every 50 resolved predictions
---
## 7. Trading Psychology & Cognitive Biases
### Biases to Watch For
| Bias | Description | Mitigation |
|------|-------------|------------|
| **Confirmation Bias** | Seeking info that confirms your thesis | Always build the opposing case first (adversarial debate) |
| **Anchoring** | Over-weighting the first number you see (entry price, analyst target) | Start analysis from base rates and current data, not old prices |
| **Recency Bias** | Over-weighting recent events (last week's crash, last month's rally) | Look at longer timeframes — 6-month and 1-year charts minimum |
| **Loss Aversion** | Holding losers too long ("it'll come back"), cutting winners too fast | Use mechanical stop-losses and take-profit targets, set BEFORE entry |
| **Overconfidence** | Believing you are more right than you are | Track Brier scores, use Kelly fractions, never bet > 2% per trade |
| **Narrative Bias** | Compelling story = good trade (often false) | Focus on quantitative data, not stories. "Good company" != "good trade" |
| **FOMO** | Fear of missing out, chasing entries | Only enter at planned levels. The market is open 252 days a year |
| **Sunk Cost** | "I've lost so much, I can't sell now" | Each moment is a new decision. Ask: "Would I enter this trade NOW at current price?" |
| **Hindsight Bias** | "I knew that would happen" | Journal BEFORE trades with specific predictions, not after |
| **Disposition Effect** | Selling winners early to "lock in profits" but holding losers | Let winners run (trail stops), cut losers at planned stops |
| **Gambler's Fallacy** | "It's dropped 5 days in a row, it HAS to bounce" | Each day is independent. Trends persist more often than they reverse |
| **Endowment Effect** | Overvaluing positions you already own | Evaluate positions as if you were building from scratch today |
### Discipline Rules
1. Every trade has a written plan BEFORE entry: entry price, stop loss, target, position size, thesis
2. Write down your reasoning BEFORE entering — if you cannot articulate the edge, do not trade
3. Set stop-losses at order entry time, not "in your head"
4. Review your journal weekly — look for patterns in wins AND losses
5. Take breaks after big wins (overconfidence risk) AND big losses (emotional risk)
6. Never average down on a losing position unless the original thesis explicitly planned for it
7. Never move a stop-loss further away from your entry (only tighten, never widen)
8. The market will be there tomorrow — missing a trade is not a loss, but a blown account is
---
## 8. Portfolio Construction
### Asset Allocation Guidelines
| Style | Equities | Crypto | Fixed Income / Cash | Max Single Position |
|-------|----------|--------|---------------------|---------------------|
| Conservative | 50-60% | 0-5% | 35-50% | 5% |
| Moderate | 60-75% | 5-15% | 10-35% | 8% |
| Aggressive | 70-85% | 10-25% | 5-20% | 10% |
| Speculative | 50-70% | 20-40% | 5-10% | 15% (with strict stops) |
### Sector Diversification
Maximum 30% in any single sector:
- Technology, Healthcare, Financials, Consumer Discretionary, Consumer Staples
- Energy, Industrials, Utilities, Real Estate, Materials, Communication Services
### Correlation Awareness
Highly correlated positions amplify risk. Check correlations before adding:
| Pair | Typical Correlation | Risk |
|------|---------------------|------|
| AAPL + MSFT + GOOGL | 0.7-0.9 | Concentrated large-cap tech |
| BTC + ETH + SOL | 0.8-0.95 | Concentrated crypto (moves together) |
| SPY + QQQ | 0.9+ | Nearly identical exposure |
| Stocks + Bonds | -0.2 to 0.3 | Genuinely diversifying |
| Gold + Stocks | -0.1 to 0.2 | Hedge in crisis |
| VIX + SPY | -0.8 | Inverse — VIX as hedge |
### Rebalancing Rules
- **Calendar**: Rebalance quarterly (first trading day of quarter)
- **Threshold**: Rebalance when any allocation drifts > 5% from target
- **Tax-aware**: Prefer rebalancing via new contributions rather than selling (taxable accounts)
---
## 9. Cross-Platform Commands
### Windows (PowerShell / Git Bash)
```bash
# Python might be `python` not `python3` on Windows
python -c "import json; ..."
# Use forward slashes in file paths or escape backslashes
# curl is available via Git Bash, PowerShell, or WSL
# Check if market is open (Windows Git Bash)
curl -s "$BASE_URL/v2/clock" -H "APCA-API-KEY-ID: $ALPACA_API_KEY" \
-H "APCA-API-SECRET-KEY: $ALPACA_SECRET_KEY" | python -c "
import sys, json
d = json.load(sys.stdin)
print('OPEN' if d['is_open'] else 'CLOSED', '| Next:', d.get('next_open','') or d.get('next_close',''))
"
```
### macOS / Linux
```bash
python3 -c "import json; ..."
# curl, jq typically available by default
# Use jq for JSON processing:
curl -s URL | jq '.equity'
```
### JSON Processing Without jq
```bash
# Pretty-print JSON
python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin),indent=2))" < file.json
# Extract specific field
curl -s URL | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['equity'])"
# Parse Alpaca positions into readable table
curl -s "$BASE_URL/v2/positions" $HEADERS | python3 -c "
import sys, json
positions = json.load(sys.stdin)
fmt = '{:<8} {:>6} {:>10} {:>10} {:>12} {:>8}'
print(fmt.format('Symbol','Qty','Entry','Current','P/L','P/L pct'))
print('-' * 60)
for p in positions:
print(fmt.format(p['symbol'], p['qty'], float(p['avg_entry_price']),
float(p['current_price']), float(p['unrealized_pl']),
round(float(p['unrealized_plpc'])*100,2)))
"
# Calculate RSI from historical bars
curl -s "$DATA_URL/v2/stocks/AAPL/bars?timeframe=1Day&limit=30" $HEADERS | python3 -c "
import sys, json
data = json.load(sys.stdin)
closes = [float(b['c']) for b in data['bars']]
changes = [closes[i]-closes[i-1] for i in range(1, len(closes))]
gains = [max(c,0) for c in changes[-14:]]
losses = [abs(min(c,0)) for c in changes[-14:]]
avg_gain = sum(gains)/14
avg_loss = sum(losses)/14
rs = avg_gain/avg_loss if avg_loss > 0 else 999
rsi = 100 - (100/(1+rs))
print(f'RSI(14) = {rsi:.1f}')
"
```
---
## 10. Pre-Trade Checklist
Before every trade, verify ALL of the following:
```
PRE-TRADE CHECKLIST
====================
[ ] 1. TREND: What is the higher-timeframe trend? (Daily chart 200 SMA)
- Trading WITH the trend? (preferred)
- Counter-trend? (requires stronger signal + tighter stops)
[ ] 2. SIGNAL: What specific setup triggered this trade?
- Indicator signal (RSI, MACD, etc.)
- Pattern (candlestick, chart pattern)
- Catalyst (earnings, news, sector rotation)
[ ] 3. ENTRY: Exact entry price or condition
- Limit order at specific level? Market order on breakout?
[ ] 4. STOP LOSS: Exact stop price
- Based on ATR (2-3x ATR from entry)
- Below key support (long) or above key resistance (short)
- NEVER wider than 2% of portfolio
[ ] 5. TARGET: Exact take-profit price
- Risk/Reward at least 1.5:1 (preferably 2:1+)
- At logical resistance (long) or support (short)
[ ] 6. POSITION SIZE: Calculated from risk management rules
- Risk amount = Portfolio * 1-2%
- Shares = Risk amount / (Entry - Stop)
- Total position < 10% of portfolio
[ ] 7. CORRELATION CHECK: Does this overlap with existing positions?
- Not adding to concentrated sector exposure
- Total portfolio heat (sum of open risk) < 6%
[ ] 8. CATALYST CHECK: Any upcoming events that could gap through stops?
- Earnings date? Fed meeting? CPI release?
- If yes: reduce size or wait until after event
[ ] 9. MARKET CONTEXT: Is the overall market favorable?
- Fear & Greed index level
- VIX level (>30 = caution, <15 = complacency risk)
- Market trend (SPY vs 200 SMA)
[ ] 10. CONFIDENCE: Rate 1-10 honestly
- Below 6? Skip the trade
- Record confidence for calibration tracking
```
---
## 11. Trade Journal Template
```json
{
"trade_id": "T001",
"date_opened": "2025-01-15",
"date_closed": null,
"symbol": "AAPL",
"side": "long",
"entry_price": 150.00,
"stop_loss": 145.00,
"target": 162.00,
"position_size": 40,
"risk_amount": 200.00,
"risk_reward": 2.4,
"setup": "Bullish engulfing at 50 EMA + RSI divergence",
"confidence": 7,
"market_context": "SPY above 200 SMA, VIX at 18, F&G neutral (52)",
"pre_trade_thesis": "AAPL pulled back to 50 EMA support, RSI showing bullish divergence, earnings in 3 weeks should provide catalyst. Sector (tech) is leading.",
"result": {
"exit_price": null,
"exit_reason": null,
"pnl": null,
"pnl_percent": null,
"held_days": null,
"lessons": null
}
}
```
Store trade journals using `memory_store` for tracking and calibration review.
+54 -8
View File
@@ -1,6 +1,6 @@
//! Compile-time embedded Hand definitions.
use crate::{HandDefinition, HandError};
use crate::{parse_hand_toml, HandDefinition, HandError};
/// Returns all bundled hand definitions as (id, HAND.toml content, SKILL.md content).
pub fn bundled_hands() -> Vec<(&'static str, &'static str, &'static str)> {
@@ -40,6 +40,11 @@ pub fn bundled_hands() -> Vec<(&'static str, &'static str, &'static str)> {
include_str!("../bundled/browser/HAND.toml"),
include_str!("../bundled/browser/SKILL.md"),
),
(
"trader",
include_str!("../bundled/trader/HAND.toml"),
include_str!("../bundled/trader/SKILL.md"),
),
]
}
@@ -50,7 +55,7 @@ pub fn parse_bundled(
skill_content: &str,
) -> Result<HandDefinition, HandError> {
let mut def: HandDefinition =
toml::from_str(toml_content).map_err(|e| HandError::TomlParse(e.to_string()))?;
parse_hand_toml(toml_content).map_err(|e| HandError::TomlParse(e.to_string()))?;
if !skill_content.is_empty() {
def.skill_content = Some(skill_content.to_string());
}
@@ -71,7 +76,7 @@ mod tests {
#[test]
fn bundled_hands_count() {
let hands = bundled_hands();
assert_eq!(hands.len(), 7);
assert_eq!(hands.len(), 8);
}
#[test]
@@ -187,8 +192,8 @@ mod tests {
assert_eq!(def.name, "Browser Hand");
assert_eq!(def.category, crate::HandCategory::Productivity);
assert!(def.skill_content.is_some());
assert!(!def.requires.is_empty()); // requires chromium
assert_eq!(def.requires.len(), 1);
assert!(!def.requires.is_empty()); // requires python3 + chromium
assert_eq!(def.requires.len(), 2);
assert!(def.tools.contains(&"browser_navigate".to_string()));
assert!(def.tools.contains(&"browser_click".to_string()));
assert!(def.tools.contains(&"browser_type".to_string()));
@@ -201,6 +206,26 @@ mod tests {
assert_eq!(def.agent.max_iterations, Some(60));
}
#[test]
fn parse_trader_hand() {
let (id, toml_content, skill_content) = bundled_hands()
.into_iter()
.find(|(id, _, _)| *id == "trader")
.unwrap();
let def = parse_bundled(id, toml_content, skill_content).unwrap();
assert_eq!(def.id, "trader");
assert_eq!(def.name, "Trading Hand");
assert_eq!(def.category, crate::HandCategory::Data);
assert!(def.skill_content.is_some());
assert!(def.requires.is_empty()); // no hard requirements
assert!(!def.tools.is_empty());
assert!(def.tools.contains(&"event_publish".to_string()));
assert!(!def.settings.is_empty());
assert!(!def.dashboard.metrics.is_empty());
assert!((def.agent.temperature - 0.3).abs() < f32::EPSILON);
assert_eq!(def.agent.max_iterations, Some(80));
}
#[test]
fn all_bundled_hands_parse() {
for (id, toml_content, skill_content) in bundled_hands() {
@@ -216,7 +241,14 @@ mod tests {
#[test]
fn all_einstein_hands_have_schedules() {
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter"];
let einstein_ids = [
"lead",
"collector",
"predictor",
"researcher",
"twitter",
"trader",
];
for (id, toml_content, skill_content) in bundled_hands() {
if einstein_ids.contains(&id) {
let def = parse_bundled(id, toml_content, skill_content).unwrap();
@@ -241,7 +273,14 @@ mod tests {
#[test]
fn all_einstein_hands_have_memory() {
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter"];
let einstein_ids = [
"lead",
"collector",
"predictor",
"researcher",
"twitter",
"trader",
];
for (id, toml_content, skill_content) in bundled_hands() {
if einstein_ids.contains(&id) {
let def = parse_bundled(id, toml_content, skill_content).unwrap();
@@ -261,7 +300,14 @@ mod tests {
#[test]
fn all_einstein_hands_have_knowledge_graph() {
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter"];
let einstein_ids = [
"lead",
"collector",
"predictor",
"researcher",
"twitter",
"trader",
];
for (id, toml_content, skill_content) in bundled_hands() {
if einstein_ids.contains(&id) {
let def = parse_bundled(id, toml_content, skill_content).unwrap();
+67
View File
@@ -117,6 +117,13 @@ pub struct HandRequirement {
/// Human-readable description of why this is needed.
#[serde(default)]
pub description: Option<String>,
/// Whether this requirement is optional (non-critical).
///
/// Optional requirements do not block activation. When an active hand has
/// unmet optional requirements it is reported as "degraded" rather than
/// "requirements not met".
#[serde(default)]
pub optional: bool,
/// Platform-specific installation instructions.
#[serde(default)]
pub install: Option<HandInstallInfo>,
@@ -299,6 +306,20 @@ fn default_temperature() -> f32 {
0.7
}
#[derive(Deserialize)]
struct HandTomlWrapper {
hand: HandDefinition,
}
/// Parse HAND.toml content, supporting both flat format and `[hand]` table format.
pub fn parse_hand_toml(content: &str) -> Result<HandDefinition, toml::de::Error> {
if let Ok(def) = toml::from_str::<HandDefinition>(content) {
return Ok(def);
}
let wrapper: HandTomlWrapper = toml::from_str(content)?;
Ok(wrapper.hand)
}
/// Complete Hand definition — parsed from HAND.toml.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandDefinition {
@@ -791,4 +812,50 @@ metrics = []
assert!(install.macos.is_none());
assert!(install.windows.is_none());
}
#[test]
fn parse_hand_toml_flat_format() {
let toml_str = r#"
id = "test"
name = "Test Hand"
description = "A test hand"
category = "content"
tools = ["shell_exec"]
[agent]
name = "test-hand"
description = "Test agent"
system_prompt = "You are a test agent."
[dashboard]
metrics = []
"#;
let def = parse_hand_toml(toml_str).unwrap();
assert_eq!(def.id, "test");
assert_eq!(def.name, "Test Hand");
}
#[test]
fn parse_hand_toml_wrapped_format() {
let toml_str = r#"
[hand]
id = "test"
name = "Test Hand"
description = "A test hand"
category = "content"
tools = ["shell_exec"]
[hand.agent]
name = "test-hand"
description = "Test agent"
system_prompt = "You are a test agent."
[hand.dashboard]
metrics = []
"#;
let def = parse_hand_toml(toml_str).unwrap();
assert_eq!(def.id, "test");
assert_eq!(def.name, "Test Hand");
assert_eq!(def.agent.name, "test-hand");
}
}
+292 -15
View File
@@ -62,6 +62,7 @@ impl HandRegistry {
serde_json::json!({
"hand_id": e.hand_id,
"config": e.config,
"agent_id": e.agent_id,
})
})
.collect();
@@ -73,8 +74,12 @@ impl HandRegistry {
}
/// Load persisted hand state and re-activate hands.
/// Returns list of (hand_id, config) that should be activated.
pub fn load_state(path: &std::path::Path) -> Vec<(String, HashMap<String, serde_json::Value>)> {
/// Returns list of (hand_id, config, old_agent_id) that should be activated.
/// The `old_agent_id` is the agent UUID from before the restart, used to
/// reassign cron jobs to the newly spawned agent (issue #402).
pub fn load_state(
path: &std::path::Path,
) -> Vec<(String, HashMap<String, serde_json::Value>, Option<AgentId>)> {
let data = match std::fs::read_to_string(path) {
Ok(d) => d,
Err(_) => return Vec::new(),
@@ -92,7 +97,10 @@ impl HandRegistry {
let hand_id = e["hand_id"].as_str()?.to_string();
let config: HashMap<String, serde_json::Value> =
serde_json::from_value(e["config"].clone()).unwrap_or_default();
Some((hand_id, config))
let old_agent_id: Option<AgentId> = e
.get("agent_id")
.and_then(|v| serde_json::from_value(v.clone()).ok());
Some((hand_id, config, old_agent_id))
})
.collect()
}
@@ -160,9 +168,28 @@ impl HandRegistry {
Ok(def)
}
/// Install or update a hand from raw TOML + skill content.
///
/// Unlike `install_from_content`, this overwrites an existing definition
/// with the same ID. Active instances are NOT automatically restarted —
/// the caller should deactivate + reactivate to pick up the new definition.
pub fn upsert_from_content(
&self,
toml_content: &str,
skill_content: &str,
) -> HandResult<HandDefinition> {
let def = bundled::parse_bundled("custom", toml_content, skill_content)?;
let existed = self.definitions.contains_key(&def.id);
let verb = if existed { "Updated" } else { "Installed" };
info!(hand = %def.id, name = %def.name, "{verb} hand from content");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
/// List all known hand definitions.
pub fn list_definitions(&self) -> Vec<HandDefinition> {
let mut defs: Vec<HandDefinition> = self.definitions.iter().map(|r| r.value().clone()).collect();
let mut defs: Vec<HandDefinition> =
self.definitions.iter().map(|r| r.value().clone()).collect();
defs.sort_by(|a, b| a.name.cmp(&b.name));
defs
}
@@ -344,6 +371,47 @@ impl HandRegistry {
entry.updated_at = chrono::Utc::now();
Ok(())
}
/// Compute readiness for a hand, cross-referencing requirements with
/// active instance state.
///
/// Returns `None` if the hand definition does not exist.
pub fn readiness(&self, hand_id: &str) -> Option<HandReadiness> {
let reqs = self.check_requirements(hand_id).ok()?;
let requirements_met = reqs.iter().all(|(_, ok)| *ok);
// A hand is active if at least one instance is in Active status.
let active = self
.instances
.iter()
.any(|entry| entry.hand_id == hand_id && entry.status == HandStatus::Active);
// Degraded: active, but at least one non-optional requirement is unmet
// OR any optional requirement is unmet. In practice, the most useful
// definition is: active + any requirement unsatisfied.
let degraded = active && reqs.iter().any(|(_, ok)| !ok);
Some(HandReadiness {
requirements_met,
active,
degraded,
})
}
}
/// Readiness snapshot for a hand definition — combines requirement checks
/// with runtime activation state so the API can report unambiguous status.
#[derive(Debug, Clone, Serialize)]
pub struct HandReadiness {
/// Whether all declared requirements are currently satisfied.
pub requirements_met: bool,
/// Whether the hand currently has a running (Active-status) instance.
pub active: bool,
/// Whether the hand is active but some requirements are unmet.
/// This means the hand is running in a degraded mode — some features
/// may not work (e.g. browser hand without chromium).
pub degraded: bool,
}
impl Default for HandRegistry {
@@ -356,21 +424,18 @@ impl Default for HandRegistry {
fn check_requirement(req: &HandRequirement) -> bool {
match req.requirement_type {
RequirementType::Binary => {
// Special handling for python3: must actually run the command and verify
// the output contains "Python 3", because Windows ships a python3.exe
// Store shim that exists on PATH but doesn't actually work.
if req.check_value == "python3" {
return check_python3_available();
}
// Check if binary exists on PATH.
// For python3, also try "python" (Windows ships python not python3).
if which_binary(&req.check_value) {
return true;
}
if req.check_value == "python3" {
return which_binary("python");
}
if req.check_value == "chromium" {
// Try common Chromium/Chrome binary names across platforms
return which_binary("chromium-browser")
|| which_binary("google-chrome")
|| which_binary("google-chrome-stable")
|| which_binary("chrome")
|| std::env::var("CHROME_PATH").map(|v| !v.is_empty()).unwrap_or(false);
return check_chromium_available();
}
false
}
@@ -383,6 +448,133 @@ fn check_requirement(req: &HandRequirement) -> bool {
}
}
/// Check if Python 3 is actually available by running the command and checking
/// the version output. This avoids false negatives from Windows Store shims
/// (python3.exe that just opens the Microsoft Store) and false positives from
/// Python 2 installations where `python` exists but is Python 2.
fn check_python3_available() -> bool {
// Try "python3 --version" first (Linux/macOS, some Windows installs)
if run_returns_python3("python3") {
return true;
}
// Try "python --version" (Windows commonly uses this, Docker containers too)
if run_returns_python3("python") {
return true;
}
false
}
/// Run `{cmd} --version` and return true if the output contains "Python 3".
fn run_returns_python3(cmd: &str) -> bool {
match std::process::Command::new(cmd)
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.stdin(std::process::Stdio::null())
.output()
{
Ok(output) => {
if !output.status.success() {
return false;
}
// Python --version may print to stdout or stderr depending on version
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
stdout.contains("Python 3") || stderr.contains("Python 3")
}
Err(_) => false,
}
}
/// Check if Chromium (or Chrome) is available anywhere on the system.
///
/// Checks in order:
/// 1. CHROME_PATH / CHROMIUM_PATH env vars
/// 2. Common binary names on PATH (chromium, chromium-browser, google-chrome, etc.)
/// 3. Well-known install paths (Windows Program Files, macOS Applications, Linux /usr)
/// 4. Playwright cache (~/.cache/ms-playwright/chromium-*)
fn check_chromium_available() -> bool {
// 1. Env vars
for var in &["CHROME_PATH", "CHROMIUM_PATH"] {
if let Ok(p) = std::env::var(var) {
if !p.is_empty() && std::path::Path::new(&p).exists() {
return true;
}
}
}
// 2. Common binary names on PATH
let names = [
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"chrome",
];
for name in &names {
if which_binary(name) {
return true;
}
}
// 3. Well-known install paths
let known_paths: Vec<std::path::PathBuf> = if cfg!(windows) {
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into());
let pf86 =
std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".into());
let local = std::env::var("LOCALAPPDATA").unwrap_or_default();
vec![
std::path::PathBuf::from(&pf).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&pf86).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&pf).join(r"Chromium\Application\chrome.exe"),
std::path::PathBuf::from(&local).join(r"Chromium\Application\chrome.exe"),
std::path::PathBuf::from(&pf).join(r"Microsoft\Edge\Application\msedge.exe"),
]
} else if cfg!(target_os = "macos") {
vec![
std::path::PathBuf::from(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
),
std::path::PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
]
} else {
vec![
std::path::PathBuf::from("/usr/bin/chromium"),
std::path::PathBuf::from("/usr/bin/chromium-browser"),
std::path::PathBuf::from("/usr/bin/google-chrome"),
std::path::PathBuf::from("/usr/bin/google-chrome-stable"),
std::path::PathBuf::from("/snap/bin/chromium"),
]
};
for p in &known_paths {
if p.exists() {
return true;
}
}
// 4. Playwright cache
if let Some(home) = std::env::var("HOME")
.ok()
.or_else(|| std::env::var("USERPROFILE").ok())
{
let pw_cache = std::path::Path::new(&home).join(".cache/ms-playwright");
if pw_cache.is_dir() {
if let Ok(entries) = std::fs::read_dir(&pw_cache) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("chromium-") && entry.path().is_dir() {
return true;
}
}
}
}
}
false
}
/// Check if a binary is on PATH (cross-platform).
fn which_binary(name: &str) -> bool {
let path_var = std::env::var("PATH").unwrap_or_default();
@@ -450,7 +642,7 @@ mod tests {
fn load_bundled_hands() {
let reg = HandRegistry::new();
let count = reg.load_bundled();
assert_eq!(count, 7);
assert_eq!(count, 8);
assert!(!reg.list_definitions().is_empty());
// Clip hand should be loaded
@@ -590,6 +782,7 @@ mod tests {
requirement_type: RequirementType::EnvVar,
check_value: "OPENFANG_TEST_HAND_REQ".to_string(),
description: None,
optional: false,
install: None,
};
assert!(check_requirement(&req));
@@ -600,9 +793,93 @@ mod tests {
requirement_type: RequirementType::EnvVar,
check_value: "OPENFANG_NONEXISTENT_VAR_12345".to_string(),
description: None,
optional: false,
install: None,
};
assert!(!check_requirement(&req_missing));
std::env::remove_var("OPENFANG_TEST_HAND_REQ");
}
#[test]
fn readiness_nonexistent_hand() {
let reg = HandRegistry::new();
assert!(reg.readiness("nonexistent").is_none());
}
#[test]
fn readiness_inactive_hand() {
let reg = HandRegistry::new();
reg.load_bundled();
// Lead hand has no requirements, so requirements_met = true
let r = reg.readiness("lead").unwrap();
assert!(r.requirements_met);
assert!(!r.active);
assert!(!r.degraded);
}
#[test]
fn readiness_active_hand_all_met() {
let reg = HandRegistry::new();
reg.load_bundled();
// Lead hand has no requirements — activate it
let instance = reg.activate("lead", HashMap::new()).unwrap();
let r = reg.readiness("lead").unwrap();
assert!(r.requirements_met);
assert!(r.active);
assert!(!r.degraded); // all met, so not degraded
reg.deactivate(instance.instance_id).unwrap();
}
#[test]
fn readiness_active_hand_degraded() {
let reg = HandRegistry::new();
reg.load_bundled();
// Browser hand requires python3 + chromium. Activate it — if either
// requirement is unmet on this machine, it will show as degraded.
let instance = reg.activate("browser", HashMap::new()).unwrap();
let r = reg.readiness("browser").unwrap();
assert!(r.active);
// If any requirement is not satisfied, degraded should be true
if !r.requirements_met {
assert!(r.degraded);
} else {
assert!(!r.degraded);
}
reg.deactivate(instance.instance_id).unwrap();
}
#[test]
fn readiness_paused_hand_not_active() {
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("lead", HashMap::new()).unwrap();
reg.pause(instance.instance_id).unwrap();
let r = reg.readiness("lead").unwrap();
assert!(!r.active); // Paused is not Active
assert!(!r.degraded);
reg.deactivate(instance.instance_id).unwrap();
}
#[test]
fn optional_field_defaults_false() {
let req = HandRequirement {
key: "test".to_string(),
label: "test".to_string(),
requirement_type: RequirementType::Binary,
check_value: "test".to_string(),
description: None,
optional: false,
install: None,
};
assert!(!req.optional);
}
}
+2
View File
@@ -23,6 +23,7 @@ crossbeam = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
chrono = { workspace = true }
chrono-tz = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
@@ -33,6 +34,7 @@ rand = { workspace = true }
hex = { workspace = true }
reqwest = { workspace = true }
cron = "0.15"
zeroize = { workspace = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+9 -1
View File
@@ -241,6 +241,11 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
plan.hot_actions.push(HotAction::ReloadProviderUrls);
}
if field_changed(&old.provider_api_keys, &new.provider_api_keys) {
plan.noop_changes
.push("provider_api_keys changed (takes effect on next driver init)".to_string());
}
// ----- No-op fields -----
if old.log_level != new.log_level {
@@ -411,7 +416,10 @@ mod tests {
let mut b = default_cfg();
b.default_model.model = "gpt-4".to_string();
let plan = build_reload_plan(&a, &b);
assert!(!plan.restart_required, "default_model should be hot-reloadable");
assert!(
!plan.restart_required,
"default_model should be hot-reloadable"
);
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
}
+428 -8
View File
@@ -216,6 +216,70 @@ impl CronScheduler {
self.jobs.iter().map(|r| r.value().job.clone()).collect()
}
/// Reassign all cron jobs from `old_agent_id` to `new_agent_id`.
///
/// Used when a hand agent is respawned (e.g. after daemon restart) and
/// gets a new UUID. Without this, persisted cron jobs would reference
/// the stale old agent ID and fail silently.
///
/// Returns the number of jobs reassigned.
pub fn reassign_agent_jobs(&self, old_agent_id: AgentId, new_agent_id: AgentId) -> usize {
let mut count = 0;
for mut entry in self.jobs.iter_mut() {
if entry.value().job.agent_id == old_agent_id {
entry.value_mut().job.agent_id = new_agent_id;
// Reset consecutive errors so the job gets a fresh start
// with the new agent.
entry.value_mut().consecutive_errors = 0;
if !entry.value().job.enabled {
// Re-enable jobs that were auto-disabled due to the stale
// agent ID causing repeated failures.
if entry
.value()
.last_status
.as_deref()
.is_some_and(|s| s.contains("not found") || s.contains("No such agent"))
{
entry.value_mut().job.enabled = true;
entry.value_mut().job.next_run =
Some(compute_next_run(&entry.value().job.schedule));
}
}
count += 1;
}
}
if count > 0 {
info!(
old_agent = %old_agent_id,
new_agent = %new_agent_id,
count,
"Reassigned cron jobs to new agent"
);
}
count
}
/// Remove all cron jobs belonging to a specific agent.
///
/// Used when an agent is deleted so its cron entries don't linger as
/// orphans pointing at a dead UUID. Returns the number of jobs removed.
pub fn remove_agent_jobs(&self, agent_id: AgentId) -> usize {
let ids: Vec<CronJobId> = self
.jobs
.iter()
.filter(|r| r.value().job.agent_id == agent_id)
.map(|r| *r.key())
.collect();
let count = ids.len();
for id in ids {
self.jobs.remove(&id);
}
if count > 0 {
info!(agent = %agent_id, count, "Removed cron jobs for deleted agent");
}
count
}
/// Total number of tracked jobs.
pub fn total_jobs(&self) -> usize {
self.jobs.len()
@@ -287,8 +351,7 @@ impl CronScheduler {
);
meta.job.enabled = false;
} else {
meta.job.next_run =
Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
}
}
}
@@ -324,7 +387,7 @@ pub fn compute_next_run_after(
match schedule {
CronSchedule::At { at } => *at,
CronSchedule::Every { every_secs } => after + Duration::seconds(*every_secs as i64),
CronSchedule::Cron { expr, tz: _ } => {
CronSchedule::Cron { expr, tz } => {
// Convert standard 5/6-field cron to 7-field for the `cron` crate.
// Standard 5-field: min hour dom month dow
// 6-field: sec min hour dom month dow
@@ -341,10 +404,33 @@ pub fn compute_next_run_after(
let base = after + Duration::seconds(1);
match seven_field.parse::<cron::Schedule>() {
Ok(sched) => sched
.after(&base)
.next()
.unwrap_or_else(|| after + Duration::hours(1)),
Ok(sched) => {
// If a timezone is specified, compute the next fire time in
// that timezone so DST and local offsets are respected, then
// convert back to UTC for storage.
let next_utc = match tz.as_deref() {
Some(tz_str) if !tz_str.is_empty() && tz_str != "UTC" => {
match tz_str.parse::<chrono_tz::Tz>() {
Ok(timezone) => {
let base_local = base.with_timezone(&timezone);
sched
.after(&base_local)
.next()
.map(|dt| dt.with_timezone(&Utc))
}
Err(_) => {
warn!(
"Invalid timezone '{}' in cron job, falling back to UTC",
tz_str
);
sched.after(&base).next()
}
}
}
_ => sched.after(&base).next(),
};
next_utc.unwrap_or_else(|| after + Duration::hours(1))
}
Err(e) => {
warn!("Failed to parse cron expression '{}': {}", expr, e);
after + Duration::hours(1)
@@ -361,7 +447,7 @@ pub fn compute_next_run_after(
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use chrono::{Duration, Timelike};
use openfang_types::scheduler::{CronAction, CronDelivery};
/// Build a minimal valid `CronJob` with an `Every` schedule.
@@ -787,4 +873,338 @@ mod tests {
status.len()
);
}
// -- timezone-aware cron (#473) -----------------------------------------
#[test]
fn test_cron_tz_shifts_next_run() {
// "0 9 * * *" in America/New_York (UTC-5 or UTC-4 depending on DST).
// The next fire time in UTC should differ from a plain UTC "0 9 * * *".
let schedule_utc = CronSchedule::Cron {
expr: "0 9 * * *".into(),
tz: None,
};
let schedule_ny = CronSchedule::Cron {
expr: "0 9 * * *".into(),
tz: Some("America/New_York".into()),
};
let now = Utc::now();
let next_utc = compute_next_run_after(&schedule_utc, now);
let next_ny = compute_next_run_after(&schedule_ny, now);
// The New York schedule should fire at 09:00 Eastern, which is 13:00
// or 14:00 UTC (depending on DST). In either case, it should NOT
// equal the plain UTC 09:00 result.
assert_ne!(
next_utc, next_ny,
"Timezone-aware schedule should produce a different UTC time"
);
// Verify the New York result, when converted to ET, shows hour 09.
let ny_tz: chrono_tz::Tz = "America/New_York".parse().unwrap();
let next_ny_local = next_ny.with_timezone(&ny_tz);
assert_eq!(
next_ny_local.hour(),
9,
"Expected 09:00 in America/New_York, got {:02}:{:02}",
next_ny_local.hour(),
next_ny_local.minute()
);
}
#[test]
fn test_cron_tz_none_defaults_to_utc() {
// tz: None should behave identically to tz: Some("UTC").
let schedule_none = CronSchedule::Cron {
expr: "30 12 * * *".into(),
tz: None,
};
let schedule_utc = CronSchedule::Cron {
expr: "30 12 * * *".into(),
tz: Some("UTC".into()),
};
let now = Utc::now();
let next_none = compute_next_run_after(&schedule_none, now);
let next_utc = compute_next_run_after(&schedule_utc, now);
assert_eq!(next_none, next_utc);
}
#[test]
fn test_cron_tz_empty_string_defaults_to_utc() {
let schedule_empty = CronSchedule::Cron {
expr: "30 12 * * *".into(),
tz: Some(String::new()),
};
let schedule_none = CronSchedule::Cron {
expr: "30 12 * * *".into(),
tz: None,
};
let now = Utc::now();
assert_eq!(
compute_next_run_after(&schedule_empty, now),
compute_next_run_after(&schedule_none, now)
);
}
#[test]
fn test_cron_tz_invalid_falls_back_to_utc() {
// An invalid timezone string should fall back to UTC, not panic.
let schedule_bad = CronSchedule::Cron {
expr: "0 9 * * *".into(),
tz: Some("Not/A_Timezone".into()),
};
let schedule_utc = CronSchedule::Cron {
expr: "0 9 * * *".into(),
tz: None,
};
let now = Utc::now();
let next_bad = compute_next_run_after(&schedule_bad, now);
let next_utc = compute_next_run_after(&schedule_utc, now);
// Invalid tz falls back to UTC computation — same result.
assert_eq!(next_bad, next_utc);
}
#[test]
fn test_cron_tz_asia_shanghai() {
// "0 8 * * *" in Asia/Shanghai (UTC+8) should fire at 00:00 UTC.
let schedule = CronSchedule::Cron {
expr: "0 8 * * *".into(),
tz: Some("Asia/Shanghai".into()),
};
let now = Utc::now();
let next = compute_next_run_after(&schedule, now);
let shanghai_tz: chrono_tz::Tz = "Asia/Shanghai".parse().unwrap();
let local = next.with_timezone(&shanghai_tz);
assert_eq!(local.hour(), 8);
assert_eq!(local.minute(), 0);
// In UTC, 08:00 Shanghai = 00:00 UTC.
assert_eq!(next.hour(), 0, "08:00 CST should be 00:00 UTC");
}
// -- reassign_agent_jobs (#461) -----------------------------------------
#[test]
fn test_reassign_agent_jobs_basic() {
let (sched, _tmp) = make_scheduler(100);
let old_agent = AgentId::new();
let new_agent = AgentId::new();
let mut j1 = make_job(old_agent);
j1.name = "cron-a".into();
let mut j2 = make_job(old_agent);
j2.name = "cron-b".into();
let id1 = sched.add_job(j1, false).unwrap();
let id2 = sched.add_job(j2, false).unwrap();
let count = sched.reassign_agent_jobs(old_agent, new_agent);
assert_eq!(count, 2);
// Both jobs should now belong to the new agent
let job1 = sched.get_job(id1).unwrap();
assert_eq!(job1.agent_id, new_agent);
let job2 = sched.get_job(id2).unwrap();
assert_eq!(job2.agent_id, new_agent);
// Old agent should have zero jobs
assert!(sched.list_jobs(old_agent).is_empty());
// New agent should have both
assert_eq!(sched.list_jobs(new_agent).len(), 2);
}
#[test]
fn test_reassign_agent_jobs_does_not_touch_other_agents() {
let (sched, _tmp) = make_scheduler(100);
let agent_a = AgentId::new();
let agent_b = AgentId::new();
let agent_c = AgentId::new();
let mut ja = make_job(agent_a);
ja.name = "job-a".into();
let mut jb = make_job(agent_b);
jb.name = "job-b".into();
let _id_a = sched.add_job(ja, false).unwrap();
let id_b = sched.add_job(jb, false).unwrap();
// Reassign agent_a -> agent_c
let count = sched.reassign_agent_jobs(agent_a, agent_c);
assert_eq!(count, 1);
// agent_b's job should be untouched
let job_b = sched.get_job(id_b).unwrap();
assert_eq!(job_b.agent_id, agent_b);
}
#[test]
fn test_reassign_agent_jobs_no_match_returns_zero() {
let (sched, _tmp) = make_scheduler(100);
let agent = AgentId::new();
let other = AgentId::new();
let job = make_job(agent);
sched.add_job(job, false).unwrap();
// Reassign a non-existent agent
let count = sched.reassign_agent_jobs(AgentId::new(), other);
assert_eq!(count, 0);
}
#[test]
fn test_reassign_agent_jobs_resets_consecutive_errors() {
let (sched, _tmp) = make_scheduler(100);
let old_agent = AgentId::new();
let new_agent = AgentId::new();
let job = make_job(old_agent);
let id = sched.add_job(job, false).unwrap();
// Simulate some failures
sched.record_failure(id, "agent not found");
sched.record_failure(id, "agent not found");
let meta = sched.get_meta(id).unwrap();
assert_eq!(meta.consecutive_errors, 2);
// Reassign
sched.reassign_agent_jobs(old_agent, new_agent);
// Errors should be reset
let meta = sched.get_meta(id).unwrap();
assert_eq!(meta.consecutive_errors, 0);
assert_eq!(meta.job.agent_id, new_agent);
}
#[test]
fn test_reassign_agent_jobs_reenables_disabled_stale_jobs() {
let (sched, _tmp) = make_scheduler(100);
let old_agent = AgentId::new();
let new_agent = AgentId::new();
let job = make_job(old_agent);
let id = sched.add_job(job, false).unwrap();
// Simulate enough failures to auto-disable (with "not found" message)
for _ in 0..MAX_CONSECUTIVE_ERRORS {
sched.record_failure(id, "No such agent");
}
let meta = sched.get_meta(id).unwrap();
assert!(!meta.job.enabled, "Job should be auto-disabled");
// Reassign should re-enable it
sched.reassign_agent_jobs(old_agent, new_agent);
let meta = sched.get_meta(id).unwrap();
assert!(
meta.job.enabled,
"Job should be re-enabled after reassignment"
);
assert_eq!(meta.consecutive_errors, 0);
assert_eq!(meta.job.agent_id, new_agent);
}
#[test]
fn test_reassign_agent_jobs_persists_after_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let old_agent = AgentId::new();
let new_agent = AgentId::new();
// Create scheduler, add job, reassign, persist
let id = {
let sched = CronScheduler::new(tmp.path(), 100);
let job = make_job(old_agent);
let id = sched.add_job(job, false).unwrap();
sched.reassign_agent_jobs(old_agent, new_agent);
sched.persist().unwrap();
id
};
// Load from disk and verify the agent_id was persisted
{
let sched = CronScheduler::new(tmp.path(), 100);
sched.load().unwrap();
let job = sched.get_job(id).unwrap();
assert_eq!(job.agent_id, new_agent);
assert!(sched.list_jobs(old_agent).is_empty());
}
}
// -- remove_agent_jobs (#504) -------------------------------------------
#[test]
fn test_remove_agent_jobs_basic() {
let (sched, _tmp) = make_scheduler(100);
let agent = AgentId::new();
let other = AgentId::new();
let mut j1 = make_job(agent);
j1.name = "job-a".into();
let mut j2 = make_job(agent);
j2.name = "job-b".into();
let mut j3 = make_job(other);
j3.name = "job-other".into();
sched.add_job(j1, false).unwrap();
sched.add_job(j2, false).unwrap();
let id3 = sched.add_job(j3, false).unwrap();
assert_eq!(sched.total_jobs(), 3);
let removed = sched.remove_agent_jobs(agent);
assert_eq!(removed, 2);
assert_eq!(sched.total_jobs(), 1);
// The other agent's job should still exist
assert!(sched.list_jobs(agent).is_empty());
assert_eq!(sched.list_jobs(other).len(), 1);
assert!(sched.get_job(id3).is_some());
}
#[test]
fn test_remove_agent_jobs_no_match() {
let (sched, _tmp) = make_scheduler(100);
let agent = AgentId::new();
let job = make_job(agent);
sched.add_job(job, false).unwrap();
// Remove for a non-existent agent
let removed = sched.remove_agent_jobs(AgentId::new());
assert_eq!(removed, 0);
assert_eq!(sched.total_jobs(), 1);
}
#[test]
fn test_remove_agent_jobs_persists() {
let tmp = tempfile::tempdir().unwrap();
let agent = AgentId::new();
let other = AgentId::new();
// Add jobs for two agents, remove one agent's jobs, persist
{
let sched = CronScheduler::new(tmp.path(), 100);
let mut j1 = make_job(agent);
j1.name = "doomed".into();
let mut j2 = make_job(other);
j2.name = "survivor".into();
sched.add_job(j1, false).unwrap();
sched.add_job(j2, false).unwrap();
sched.remove_agent_jobs(agent);
sched.persist().unwrap();
}
// Reload and verify
{
let sched = CronScheduler::new(tmp.path(), 100);
sched.load().unwrap();
assert_eq!(sched.total_jobs(), 1);
assert!(sched.list_jobs(agent).is_empty());
assert_eq!(sched.list_jobs(other).len(), 1);
}
}
}
+120 -8
View File
@@ -4,9 +4,14 @@
//! each running agent's `last_active` timestamp. If an agent hasn't been active
//! for longer than 2x its heartbeat interval, a `HealthCheckFailed` event is
//! published to the event bus.
//!
//! Crashed agents are tracked for auto-recovery: the heartbeat will attempt to
//! reset crashed agents back to Running up to `max_recovery_attempts` times.
//! After exhausting attempts, agents are marked as Terminated (dead).
use crate::registry::AgentRegistry;
use chrono::Utc;
use dashmap::DashMap;
use openfang_types::agent::{AgentId, AgentState};
use tracing::{debug, warn};
@@ -17,6 +22,12 @@ const DEFAULT_CHECK_INTERVAL_SECS: u64 = 30;
/// multiples of its heartbeat interval.
const UNRESPONSIVE_MULTIPLIER: u64 = 2;
/// Default maximum recovery attempts before giving up.
const DEFAULT_MAX_RECOVERY_ATTEMPTS: u32 = 3;
/// Default cooldown between recovery attempts (seconds).
const DEFAULT_RECOVERY_COOLDOWN_SECS: u64 = 60;
/// Result of a heartbeat check.
#[derive(Debug, Clone)]
pub struct HeartbeatStatus {
@@ -28,6 +39,8 @@ pub struct HeartbeatStatus {
pub inactive_secs: i64,
/// Whether the agent is considered unresponsive.
pub unresponsive: bool,
/// Current agent state.
pub state: AgentState,
}
/// Heartbeat monitor configuration.
@@ -38,18 +51,82 @@ pub struct HeartbeatConfig {
/// Default threshold for unresponsiveness (seconds).
/// Overridden per-agent by AutonomousConfig.heartbeat_interval_secs.
pub default_timeout_secs: u64,
/// Maximum recovery attempts before marking agent as Terminated.
pub max_recovery_attempts: u32,
/// Minimum seconds between recovery attempts for the same agent.
pub recovery_cooldown_secs: u64,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
check_interval_secs: DEFAULT_CHECK_INTERVAL_SECS,
default_timeout_secs: DEFAULT_CHECK_INTERVAL_SECS * UNRESPONSIVE_MULTIPLIER,
// 180s default: browser tasks and complex LLM calls can take 1-3 minutes
default_timeout_secs: 180,
max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
recovery_cooldown_secs: DEFAULT_RECOVERY_COOLDOWN_SECS,
}
}
}
/// Check all running agents and return their heartbeat status.
/// Tracks per-agent recovery state across heartbeat cycles.
#[derive(Debug)]
pub struct RecoveryTracker {
/// Per-agent recovery state: (consecutive_failures, last_attempt_epoch_secs).
state: DashMap<AgentId, (u32, u64)>,
}
impl RecoveryTracker {
/// Create a new recovery tracker.
pub fn new() -> Self {
Self {
state: DashMap::new(),
}
}
/// Record a recovery attempt for an agent.
/// Returns the current attempt number (1-indexed).
pub fn record_attempt(&self, agent_id: AgentId) -> u32 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut entry = self.state.entry(agent_id).or_insert((0, 0));
entry.0 += 1;
entry.1 = now;
entry.0
}
/// Check if enough time has passed since the last recovery attempt.
pub fn can_attempt(&self, agent_id: AgentId, cooldown_secs: u64) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
match self.state.get(&agent_id) {
Some(entry) => now.saturating_sub(entry.1) >= cooldown_secs,
None => true, // No prior attempts
}
}
/// Get the current failure count for an agent.
pub fn failure_count(&self, agent_id: AgentId) -> u32 {
self.state.get(&agent_id).map(|e| e.0).unwrap_or(0)
}
/// Reset recovery state for an agent (e.g. after successful recovery).
pub fn reset(&self, agent_id: AgentId) {
self.state.remove(&agent_id);
}
}
impl Default for RecoveryTracker {
fn default() -> Self {
Self::new()
}
}
/// Check all running and crashed agents and return their heartbeat status.
///
/// This is a pure function — it doesn't start a background task.
/// The caller (kernel) can run this periodically or in a background task.
@@ -58,9 +135,10 @@ pub fn check_agents(registry: &AgentRegistry, config: &HeartbeatConfig) -> Vec<H
let mut statuses = Vec::new();
for entry_ref in registry.list() {
// Only check running agents
if entry_ref.state != AgentState::Running {
continue;
// Check Running agents (for unresponsiveness) and Crashed agents (for recovery)
match entry_ref.state {
AgentState::Running | AgentState::Crashed => {}
_ => continue,
}
let inactive_secs = (now - entry_ref.last_active).num_seconds();
@@ -73,15 +151,22 @@ pub fn check_agents(registry: &AgentRegistry, config: &HeartbeatConfig) -> Vec<H
.map(|a| a.heartbeat_interval_secs * UNRESPONSIVE_MULTIPLIER)
.unwrap_or(config.default_timeout_secs) as i64;
let unresponsive = inactive_secs > timeout_secs;
// Crashed agents are always considered unresponsive
let unresponsive = entry_ref.state == AgentState::Crashed || inactive_secs > timeout_secs;
if unresponsive {
if unresponsive && entry_ref.state == AgentState::Running {
warn!(
agent = %entry_ref.name,
inactive_secs,
timeout_secs,
"Agent is unresponsive"
);
} else if entry_ref.state == AgentState::Crashed {
warn!(
agent = %entry_ref.name,
inactive_secs,
"Agent is crashed — eligible for recovery"
);
} else {
debug!(
agent = %entry_ref.name,
@@ -95,6 +180,7 @@ pub fn check_agents(registry: &AgentRegistry, config: &HeartbeatConfig) -> Vec<H
name: entry_ref.name.clone(),
inactive_secs,
unresponsive,
state: entry_ref.state,
});
}
@@ -201,7 +287,7 @@ mod tests {
fn test_heartbeat_config_default() {
let config = HeartbeatConfig::default();
assert_eq!(config.check_interval_secs, 30);
assert_eq!(config.default_timeout_secs, 60);
assert_eq!(config.default_timeout_secs, 180);
}
#[test]
@@ -220,18 +306,21 @@ mod tests {
name: "agent-1".to_string(),
inactive_secs: 10,
unresponsive: false,
state: AgentState::Running,
},
HeartbeatStatus {
agent_id: AgentId::new(),
name: "agent-2".to_string(),
inactive_secs: 120,
unresponsive: true,
state: AgentState::Running,
},
HeartbeatStatus {
agent_id: AgentId::new(),
name: "agent-3".to_string(),
inactive_secs: 5,
unresponsive: false,
state: AgentState::Running,
},
];
@@ -242,4 +331,27 @@ mod tests {
assert_eq!(summary.unresponsive_agents.len(), 1);
assert_eq!(summary.unresponsive_agents[0].name, "agent-2");
}
#[test]
fn test_recovery_tracker() {
let tracker = RecoveryTracker::new();
let agent_id = AgentId::new();
assert_eq!(tracker.failure_count(agent_id), 0);
assert!(tracker.can_attempt(agent_id, 60));
let attempt = tracker.record_attempt(agent_id);
assert_eq!(attempt, 1);
assert_eq!(tracker.failure_count(agent_id), 1);
// Just recorded — cooldown should block (unless cooldown is 0)
assert!(!tracker.can_attempt(agent_id, 60));
assert!(tracker.can_attempt(agent_id, 0));
let attempt = tracker.record_attempt(agent_id);
assert_eq!(attempt, 2);
tracker.reset(agent_id);
assert_eq!(tracker.failure_count(agent_id), 0);
}
}
File diff suppressed because it is too large Load Diff
+34 -6
View File
@@ -128,6 +128,7 @@ impl MeteringEngine {
0.0
},
alert_threshold: budget.alert_threshold,
default_max_llm_tokens_per_hour: budget.default_max_llm_tokens_per_hour,
}
}
@@ -224,6 +225,8 @@ pub struct BudgetStatus {
pub monthly_limit: f64,
pub monthly_pct: f64,
pub alert_threshold: f64,
/// Global default token limit per agent per hour (0 = use per-agent values).
pub default_max_llm_tokens_per_hour: u64,
}
/// Returns (input_per_million, output_per_million) pricing for a model.
@@ -343,11 +346,24 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
return (0.40, 0.40);
}
// ── Chutes.ai ──────────────────────────────────────────────
if model.contains("chutes") {
return (0.25, 0.35);
}
// ── Venice.ai ──────────────────────────────────────────────
if model.contains("venice") {
return (0.20, 0.90);
}
// ── NVIDIA NIM ──────────────────────────────────────────────
if model.contains("nemotron-4-340b") {
return (4.20, 4.20);
}
if model.contains("nemotron") {
return (0.88, 0.88);
}
// ── Open-source (Groq, Together, etc.) ─────────────────────
if model.contains("llama-4-maverick") {
return (0.50, 0.77);
@@ -376,22 +392,34 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
}
// ── MiniMax ──────────────────────────────────────────────────
if model.contains("minimax") {
if model.contains("minimax") || model.contains("abab") {
if model.contains("highspeed") {
return (0.80, 3.20);
}
if model.contains("m2.5") {
return (1.10, 4.40);
}
if model.contains("abab7") {
return (0.80, 2.40);
}
return (1.00, 3.00);
}
// ── Zhipu / GLM ─────────────────────────────────────────────
if model.contains("glm-5") {
return (2.00, 8.00);
return (1.00, 3.20);
}
if model.contains("glm-4.7") {
return (1.50, 5.00);
return (0.60, 2.20);
}
if model.contains("glm-4-flash") {
return (0.10, 0.10);
if model.contains("glm-4-flash") || model.contains("glm-4.5-flash") {
return (0.0, 0.0); // free tier
}
if model.contains("glm-4.5") {
return (0.60, 2.20);
}
if model.contains("glm") {
return (1.50, 5.00);
return (0.60, 2.20);
}
if model.contains("codegeex") {
return (0.10, 0.10);
+10 -2
View File
@@ -248,8 +248,12 @@ impl AgentRegistry {
/// Update an agent's name (also updates the name index).
pub fn update_name(&self, id: AgentId, new_name: String) -> OpenFangResult<()> {
if self.name_index.contains_key(&new_name) {
return Err(OpenFangError::AgentAlreadyExists(new_name));
if let Some(existing_id) = self.name_index.get(&new_name).as_deref().copied() {
if existing_id != id {
return Err(OpenFangError::AgentAlreadyExists(new_name));
}
// Same agent owns this name — no-op
return Ok(());
}
let mut entry = self
.agents
@@ -284,6 +288,7 @@ impl AgentRegistry {
hourly: Option<f64>,
daily: Option<f64>,
monthly: Option<f64>,
tokens_per_hour: Option<u64>,
) -> OpenFangResult<()> {
let mut entry = self
.agents
@@ -298,6 +303,9 @@ impl AgentRegistry {
if let Some(v) = monthly {
entry.manifest.resources.max_cost_per_month_usd = v;
}
if let Some(v) = tokens_per_hour {
entry.manifest.resources.max_llm_tokens_per_hour = v;
}
entry.last_active = chrono::Utc::now();
Ok(())
}
+14 -2
View File
@@ -88,8 +88,7 @@ impl AgentScheduler {
// Reset the window if an hour has passed
tracker.reset_if_expired();
if quota.max_llm_tokens_per_hour > 0
&& tracker.total_tokens > quota.max_llm_tokens_per_hour
if quota.max_llm_tokens_per_hour > 0 && tracker.total_tokens > quota.max_llm_tokens_per_hour
{
return Err(OpenFangError::QuotaExceeded(format!(
"Token limit exceeded: {} / {}",
@@ -130,6 +129,19 @@ impl AgentScheduler {
.get(&agent_id)
.map(|t| (t.total_tokens, t.tool_calls))
}
/// Returns remaining token headroom before quota is hit.
/// Returns `None` if no token quota is configured (unlimited).
pub fn token_headroom(&self, agent_id: AgentId) -> Option<u64> {
let quota = self.quotas.get(&agent_id)?;
if quota.max_llm_tokens_per_hour == 0 {
return None;
}
let mut tracker = self.usage.get_mut(&agent_id)?;
tracker.reset_if_expired();
let used = tracker.total_tokens;
Some(quota.max_llm_tokens_per_hour.saturating_sub(used))
}
}
impl Default for AgentScheduler {
+224 -1
View File
@@ -143,6 +143,105 @@ impl TriggerEngine {
}
}
/// Take all triggers for an agent, removing them from the engine.
///
/// Returns the extracted triggers so they can be restored under a
/// different agent ID via [`restore_triggers`]. This is used during
/// hand reactivation: triggers must be saved before `kill_agent`
/// destroys them, then restored with the new agent ID after spawn.
pub fn take_agent_triggers(&self, agent_id: AgentId) -> Vec<Trigger> {
let trigger_ids = self
.agent_triggers
.remove(&agent_id)
.map(|(_, ids)| ids)
.unwrap_or_default();
let mut taken = Vec::with_capacity(trigger_ids.len());
for id in trigger_ids {
if let Some((_, t)) = self.triggers.remove(&id) {
taken.push(t);
}
}
if !taken.is_empty() {
info!(
agent = %agent_id,
count = taken.len(),
"Took triggers for agent (pending reassignment)"
);
}
taken
}
/// Restore previously taken triggers under a new agent ID.
///
/// Each trigger keeps its original pattern, prompt template, fire count,
/// and max_fires, but is re-keyed to `new_agent_id`. New trigger IDs are
/// generated so there are no stale references.
///
/// Returns the number of triggers restored.
pub fn restore_triggers(&self, new_agent_id: AgentId, triggers: Vec<Trigger>) -> usize {
let count = triggers.len();
for old in triggers {
let new_id = TriggerId::new();
let trigger = Trigger {
id: new_id,
agent_id: new_agent_id,
pattern: old.pattern,
prompt_template: old.prompt_template,
enabled: old.enabled,
created_at: old.created_at,
fire_count: old.fire_count,
max_fires: old.max_fires,
};
self.triggers.insert(new_id, trigger);
self.agent_triggers
.entry(new_agent_id)
.or_default()
.push(new_id);
}
if count > 0 {
info!(
agent = %new_agent_id,
count,
"Restored triggers under new agent"
);
}
count
}
/// Reassign all triggers from one agent to another in place.
///
/// Used during cold boot when the old agent ID (from persisted state) no
/// longer exists and a new agent was spawned. Updates the `agent_id` field
/// on each trigger and moves the index entry.
///
/// Returns the number of triggers reassigned.
pub fn reassign_agent_triggers(&self, old_agent_id: AgentId, new_agent_id: AgentId) -> usize {
let trigger_ids = self
.agent_triggers
.remove(&old_agent_id)
.map(|(_, ids)| ids)
.unwrap_or_default();
let count = trigger_ids.len();
for id in &trigger_ids {
if let Some(mut t) = self.triggers.get_mut(id) {
t.agent_id = new_agent_id;
}
}
if !trigger_ids.is_empty() {
self.agent_triggers
.entry(new_agent_id)
.or_default()
.extend(trigger_ids);
info!(
old_agent = %old_agent_id,
new_agent = %new_agent_id,
count,
"Reassigned triggers to new agent"
);
}
count
}
/// Enable or disable a trigger. Returns true if the trigger was found.
pub fn set_enabled(&self, trigger_id: TriggerId, enabled: bool) -> bool {
if let Some(mut t) = self.triggers.get_mut(&trigger_id) {
@@ -278,7 +377,7 @@ fn describe_event(event: &Event) -> String {
tr.tool_id,
if tr.success { "succeeded" } else { "failed" },
tr.execution_time_ms,
&tr.content[..tr.content.len().min(200)]
openfang_types::truncate_str(&tr.content, 200)
)
}
EventPayload::MemoryUpdate(delta) => {
@@ -508,4 +607,128 @@ mod tests {
);
assert_eq!(engine.evaluate(&event).len(), 1);
}
// -- reassign_agent_triggers (#519) ------------------------------------
#[test]
fn test_reassign_agent_triggers_basic() {
let engine = TriggerEngine::new();
let old_agent = AgentId::new();
let new_agent = AgentId::new();
engine.register(old_agent, TriggerPattern::All, "a".to_string(), 0);
engine.register(old_agent, TriggerPattern::System, "b".to_string(), 0);
let count = engine.reassign_agent_triggers(old_agent, new_agent);
assert_eq!(count, 2);
assert_eq!(engine.list_agent_triggers(old_agent).len(), 0);
assert_eq!(engine.list_agent_triggers(new_agent).len(), 2);
// Verify triggers actually fire for the new agent
let event = Event::new(
AgentId::new(),
EventTarget::Broadcast,
EventPayload::System(SystemEvent::HealthCheck {
status: "ok".to_string(),
}),
);
let matches = engine.evaluate(&event);
assert_eq!(matches.len(), 2);
assert!(matches.iter().all(|(id, _)| *id == new_agent));
}
#[test]
fn test_reassign_agent_triggers_no_match_returns_zero() {
let engine = TriggerEngine::new();
let agent_a = AgentId::new();
engine.register(agent_a, TriggerPattern::All, "a".to_string(), 0);
let count = engine.reassign_agent_triggers(AgentId::new(), AgentId::new());
assert_eq!(count, 0);
// Original triggers untouched
assert_eq!(engine.list_agent_triggers(agent_a).len(), 1);
}
#[test]
fn test_reassign_does_not_touch_other_agents() {
let engine = TriggerEngine::new();
let agent_a = AgentId::new();
let agent_b = AgentId::new();
let agent_c = AgentId::new();
engine.register(agent_a, TriggerPattern::All, "a".to_string(), 0);
engine.register(agent_b, TriggerPattern::System, "b".to_string(), 0);
let count = engine.reassign_agent_triggers(agent_a, agent_c);
assert_eq!(count, 1);
// agent_b untouched
assert_eq!(engine.list_agent_triggers(agent_b).len(), 1);
assert_eq!(engine.list_agent_triggers(agent_c).len(), 1);
}
// -- take / restore triggers (#519) ------------------------------------
#[test]
fn test_take_and_restore_triggers() {
let engine = TriggerEngine::new();
let old_agent = AgentId::new();
let new_agent = AgentId::new();
engine.register(
old_agent,
TriggerPattern::ContentMatch {
substring: "deploy".to_string(),
},
"Deploy alert: {{event}}".to_string(),
5,
);
engine.register(old_agent, TriggerPattern::Lifecycle, "lc".to_string(), 0);
// Take triggers — engine should be empty for old agent
let taken = engine.take_agent_triggers(old_agent);
assert_eq!(taken.len(), 2);
assert_eq!(engine.list_agent_triggers(old_agent).len(), 0);
assert_eq!(engine.list_all().len(), 0);
// Restore under new agent
let restored = engine.restore_triggers(new_agent, taken);
assert_eq!(restored, 2);
assert_eq!(engine.list_agent_triggers(new_agent).len(), 2);
// Verify patterns and max_fires are preserved
let triggers = engine.list_agent_triggers(new_agent);
let has_content_match = triggers.iter().any(|t| {
matches!(&t.pattern, TriggerPattern::ContentMatch { substring } if substring == "deploy")
&& t.max_fires == 5
});
assert!(
has_content_match,
"ContentMatch trigger with max_fires=5 should be preserved"
);
}
#[test]
fn test_take_empty_returns_empty() {
let engine = TriggerEngine::new();
let taken = engine.take_agent_triggers(AgentId::new());
assert!(taken.is_empty());
}
#[test]
fn test_restore_preserves_enabled_state() {
let engine = TriggerEngine::new();
let old_agent = AgentId::new();
let new_agent = AgentId::new();
let tid = engine.register(old_agent, TriggerPattern::All, "a".to_string(), 0);
engine.set_enabled(tid, false);
let taken = engine.take_agent_triggers(old_agent);
assert_eq!(taken.len(), 1);
assert!(!taken[0].enabled);
engine.restore_triggers(new_agent, taken);
let restored = engine.list_agent_triggers(new_agent);
assert_eq!(restored.len(), 1);
assert!(
!restored[0].enabled,
"Disabled state should survive take/restore"
);
}
}

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