Compare commits

...
146 Commits
Author SHA1 Message Date
jaberjaber23 618e83714c fix: version bump to 0.5.5, SSRF allowlist, Ollama context, embedding detection
- Bump workspace version and Tauri config to 0.5.5 (fixes users stuck on 0.5.1)
- Add ssrf_allowed_hosts config for self-hosted K8s environments (Jerry Jaz)
- Raise Ollama discovered model defaults to 128K context / 16K output (Cureator)
- Expand embedding auto-detection: OpenAI, Groq, Mistral, Together, Fireworks, Cohere, then local providers (Thunder Guardian)

All tests passing. 9 files changed, 272 insertions.
2026-03-30 21:30:48 +03:00
jaberjaber23 64631a31e6 fix: resolve 5 bugs + close 1 resolved (#771, #811, #752, #772, #661)
- #771: Fix Qwen tool_calls orphaning after context overflow. Added safe drain boundaries
  in compactor and context_overflow to avoid splitting tool pairs. Added missing
  validate_and_repair call in streaming loop.
- #811: LINE webhook signature now uses raw request bytes (not re-serialized JSON) for
  HMAC. Channel secret is trimmed. Debug logging added for mismatches.
- #752: Local skill install now hot-reloads kernel via POST /api/skills/reload. TUI skill
  list fixed to parse wrapper object. ClawHub install also triggers reload.
- #772: exec_policy mode=full now bypasses approval gate for shell_exec tools. Non-shell
  tools like file_delete still respect approval settings.
- #661: Closed as resolved by #770 splice() reactivity fix and #836 tool ID fix.

All tests passing. 10 files changed, 436 insertions.
2026-03-28 00:44:12 +03:00
jaberjaber23 9fef6d6c91 fix: resolve 5 bugs + close 1 resolved (#875, #872, #867, #824, #833, #766)
- #875: Install script uses robust sed parsing instead of fragile cut for version detection
- #872: Session endpoint returns full tool results (removed 2000-char truncation)
- #867: agent_send/agent_spawn get 600s timeout (was 120s), regular tools keep 120s
- #824: Doctor workspace skills count uses direct return value from load_workspace_skills
- #833: Model switching respects provider via new find_model_for_provider() lookup
- #766: Closed as resolved by combined heartbeat fixes (v0.5.3 + merged PRs)

All tests passing. Live tested with daemon.
2026-03-27 22:42:24 +03:00
Jaber Jaber f98bc330d4 Merge pull request #859 from RightNow-AI/dependabot/cargo/governor-0.10.4
build(deps): bump governor from 0.8.1 to 0.10.4
2026-03-27 22:04:59 +03:00
Jaber Jaber 86694dd926 Merge pull request #862 from RightNow-AI/dependabot/cargo/toml-0.9.12spec-1.1.0
Bump toml from 0.8.2 to 0.9.12+spec-1.1.0
2026-03-27 22:04:55 +03:00
dependabot[bot] f8da17719e Bump governor from 0.8.1 to 0.10.4
Bumps [governor](https://github.com/boinkor-net/governor) from 0.8.1 to 0.10.4.
- [Release notes](https://github.com/boinkor-net/governor/releases)
- [Changelog](https://github.com/boinkor-net/governor/blob/master/release.toml)
- [Commits](https://github.com/boinkor-net/governor/compare/v0.8.1...v0.10.4)

---
updated-dependencies:
- dependency-name: governor
  dependency-version: 0.10.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 19:02:27 +00:00
Jaber Jaber a72e6087d8 Merge pull request #665 from tianrking/main
feat(channels): add MQTT pub/sub channel adapter
2026-03-27 22:00:33 +03:00
Jaber Jaber 3a64e322ad Merge pull request #778 from BaseDatum/feat/rmcp-protocol
feat: replace hand-rolled MCP transport with rmcp SDK
2026-03-27 22:00:18 +03:00
Sky Moore 9e2853a5f8 fix: resolve CI failures after rebase on upstream/main
- Add missing budget_config field to AppState in all 3 test files
- Fix redundant closures and unwrap_or_else in openfang-memory semantic.rs
- Fix needless_borrow in openfang-api routes.rs (toml::from_str)
- Update parse_researcher_hand test to match new max_iterations = 25
- Update tar to 0.4.45 to fix RUSTSEC-2026-0067 and RUSTSEC-2026-0068
- Apply cargo fmt fixes in ws.rs and feishu.rs
2026-03-27 18:40:33 +00:00
Sky Moore feecb60442 fix: use specific capability-denial assertion to avoid OS error false positive on Linux CI 2026-03-27 16:47:37 +00:00
Sky Moore e53e238e81 fix: cargo fmt and update rustls-webpki to 0.103.10 (RUSTSEC-2026-0049) 2026-03-27 16:47:35 +00:00
Sky Moore 991aea85ee feat: use rmcp for mcp protocol instead of hand rolled
Replace the custom JSON-RPC + stdio/SSE transport layer with the rmcp
SDK (crate 'rmcp').  This gives us spec-compliant Streamable-HTTP
transport, automatic Mcp-Session-Id tracking, SSE stream parsing, and
content-type negotiation out of the box while deleting ~300 lines of
hand-rolled plumbing.

Key changes:
- Add rmcp dependency with transport feature
- Replace McpTransportHandle enum with rmcp RunningService
- Replace manual JSON-RPC send_request/send_notification with rmcp client calls
- Add custom HTTP headers support for authenticated remote MCP servers
- Simplify tool discovery and invocation through rmcp's typed API
2026-03-27 16:47:32 +00:00
w0x7ce bfbf0bb892 feat(channels): add MQTT pub/sub channel adapter
Add generic MQTT 3.1.1/5.0 support for IoT and messaging integration:

- MqttConfig with broker_url, TLS, QoS, auth via env vars
- MqttAdapter implementing ChannelAdapter trait
- Support for text and JSON {"text": "..."} payloads
- Command messages via /command args syntax
- Auto-reconnect with exponential backoff
- Message chunking for long responses

Configuration example:
  [channels.mqtt]
  broker_url = "tcp://broker.hivemq.com:1883"
  subscribe_topic = "openfang/inbox"
  publish_topic = "openfang/outbox"
2026-03-27 22:08:18 +08:00
Jaber Jaber b6cb4cc2d9 Merge pull request #657 from xinuxZ/feat/feishu-websocket-receive-mode
feat(feishu): add WebSocket receive mode with protobuf framing
2026-03-27 16:44:31 +03:00
Jaber Jaber 827481633c Merge pull request #662 from lizekai-hash/feat/langchain-code-reviewer
feat(agents): add LangChain code review agent with A2A protocol
2026-03-27 16:44:22 +03:00
Jaber Jaber ad780b9cb4 Merge pull request #667 from bobbiejaxn/feat/http-memory-backend
feat: HTTP memory backend for SemanticStore
2026-03-27 16:44:17 +03:00
Jaber Jaber 4582ed16b0 Merge pull request #659 from zamal-db/feat/vertex-ai-oauth-v2
feat(drivers): add Vertex AI driver with OAuth authentication
2026-03-27 16:44:09 +03:00
Jaber Jaber f56505258d Merge pull request #673 from vnz/feat/cron-run-now
Implement "Run Now" for cron jobs
2026-03-27 16:42:47 +03:00
Jaber Jaber ddd1536bcb Merge pull request #702 from yaroslavyaroslav/codex/tlg-chat-enhancements
Expose Telegram slash commands via setMyCommands
2026-03-27 16:42:42 +03:00
Jaber Jaber 9fa5234061 Merge pull request #705 from apestchanker/fix/claude-code-system-prompt
fix(claude-code): pass system prompt via --system-prompt flag instead…
2026-03-27 16:42:33 +03:00
Jaber Jaber e21efa61ef Merge pull request #685 from Fail-Safe/fix/researcher-hand-defaults
fix: make heartbeat interval configurable and reduce researcher max_iterations
2026-03-27 16:39:31 +03:00
Jaber Jaber 3f72c5d918 Merge pull request #701 from Fail-Safe/fix/agent-modal-ui
fix: improve agent detail modal layout and fallback chain display
2026-03-27 16:39:23 +03:00
Jaber Jaber c286b88d54 Merge pull request #703 from Fail-Safe/fix/heartbeat-startup-false-positive
fix: reset last_active on agent restore to prevent heartbeat false-positives on startup
2026-03-27 16:39:19 +03:00
Jaber Jaber 22c08c2325 Merge pull request #668 from lc-soft/fix-runtime-page-style
fix runtime page stat card layout
2026-03-27 16:39:13 +03:00
Jaber Jaber f6493e8843 Merge pull request #682 from Fail-Safe/fix/tool-filter-case-insensitive
fix: make tool allowlist/blocklist matching case-insensitive
2026-03-27 16:39:08 +03:00
Jaber Jaber 1d2bfff8ea Merge pull request #680 from Fail-Safe/fix/docs-search-provider-duck-duck-go
fix(docs): correct search_provider value for DuckDuckGo
2026-03-27 16:39:02 +03:00
Jaber Jaber b967852891 Merge pull request #690 from lc-soft/fix-list-style
fix list style in message bubble
2026-03-27 16:38:59 +03:00
Jaber Jaber d95d9583b0 Merge pull request #696 from Abhishek21k/fix(#660)/notion-api-token-fix
Fix Notion MCP server env var name (NOTION_API_KEY → NOTION_TOKEN)
2026-03-27 16:38:53 +03:00
Jaber Jaber 8c0cce3ac5 Merge pull request #737 from octo-patch/feature/add-minimax-m2.7
feat: add MiniMax-M2.7 as new flagship model
2026-03-27 16:25:35 +03:00
Jaber Jaber f036bd54e3 Merge pull request #710 from Reaster0/fix/fallback-default-provider-resolution
fix(kernel): resolve "default" provider in fallback_models before driver init
2026-03-27 16:25:30 +03:00
Jaber Jaber 77da90f3f8 Merge pull request #709 from Fail-Safe/fix/touch-agent-before-llm-call
fix: stamp last_active before LLM call to prevent mid-iteration heartbeat timeouts
2026-03-27 16:25:26 +03:00
Jaber Jaber b0b6f84492 Merge pull request #762 from lc-soft/fix/mobile-menu-btn-overlap
fix: resolve page-header overlap and overflow
2026-03-27 16:25:23 +03:00
Jaber Jaber 0da8e32a51 Merge pull request #870 from lc-soft/fix/wizard-provider-api-key-test
Clean fix for provider reset during API key test. Reviewed and approved.
2026-03-27 16:16:49 +03:00
Liu 7410faa96d fix(wizard): prevent provider reset to first item during API_KEY test 2026-03-27 10:57:40 +08:00
Jaber Jaber e880dfa3e7 Merge pull request #777 from ANierbeck/main
Expose all agent templates in the web interface
2026-03-27 05:37:24 +03:00
Jaber Jaber 7791b3f170 Merge pull request #768 from voidborne-d/fix/matrix-self-message-loop
fix(matrix): prevent bot self-reply loop with user_id mismatch and event dedup
2026-03-27 05:37:19 +03:00
Jaber Jaber e58039c83e Merge pull request #789 from pbranchu/fix/mcp-response-matching
Fix MCP bridge dropping tool results when servers send notifications
2026-03-27 05:37:15 +03:00
Jaber Jaber 9b0a7d2f61 Merge pull request #790 from pbranchu/fix/sender-identity
Prepend sender identity to channel messages for agent context
2026-03-27 05:37:10 +03:00
Jaber Jaber 86fe4929e9 Merge pull request #775 from pbranchu/config-heartbeat-timeout
Expose heartbeat default_timeout_secs in config.toml
2026-03-27 05:36:32 +03:00
Jaber Jaber 9993718d9c Merge pull request #779 from Mohl/fix/streamable-http-mcp
fix(mcp): handle Streamable HTTP MCP responses with SSE framing
2026-03-27 05:36:28 +03:00
Jaber Jaber 0bf2f61ab1 Merge pull request #782 from rager306/fix/safe-budget-mutation
fix: replace unsafe Arc mutation in PUT /api/budget with RwLock
2026-03-27 05:36:24 +03:00
Jaber Jaber 51eff0d75f Merge pull request #783 from rager306/fix/csp-nonce
fix: replace unsafe-inline CSP with per-request nonce
2026-03-27 05:36:21 +03:00
Jaber Jaber a30cce129e Merge pull request #788 from pbranchu/fix/gemini-empty-parts
Fix Gemini driver crash on content entries without parts
2026-03-27 05:35:02 +03:00
Jaber Jaber 54885d8a1c Merge pull request #765 from felix307253927/pr-main-0320
fix: Fix the issue of duplicate tool calls with identical arguments i…
2026-03-27 05:34:59 +03:00
Jaber Jaber 617b4f81d8 Merge pull request #764 from felix307253927/pr-main-320
fix: The command succeeded, yet the model keeps calling it repeatedly.
2026-03-27 05:34:55 +03:00
Jaber Jaber a0f829383c Merge pull request #776 from felix307253927/pr-main-321
fix: Empty string IDs are overwritten, leading to inconsistencies in …
2026-03-27 05:34:51 +03:00
Jaber Jaber 6083c24484 Merge pull request #801 from b4iterdev/main
feat: add statically compiled native-tls to binary
2026-03-27 05:34:48 +03:00
Jaber Jaber 1964545f35 Merge pull request #814 from szponeczek/feat/infisical-sync-hand-clean
Adds infisical-sync hand. Declarative only, strong security posture.
2026-03-27 04:53:23 +03:00
Jaber Jaber fc7e971d7e Merge pull request #806 from ilteoood/main
Adds NVIDIA NIM support to CLI wizard.
2026-03-27 04:53:20 +03:00
Jaber Jaber 86309c8e40 Merge pull request #838 from turbolego/fix_clippy_linting_errors
Trivial lint and clippy fixes.
2026-03-27 04:52:53 +03:00
Jaber Jaber 282ad3a960 Merge pull request #832 from felix307253927/pr-main-324
Fixes unicode filename upload via multipart/form-data.
2026-03-27 04:52:50 +03:00
Jaber Jaber d6f857eee2 Merge pull request #830 from lc-soft/fix/tool-input-json-format
Clean 3-line fix for object-type tool input in formatToolJson.
2026-03-27 04:52:46 +03:00
Jaber Jaber 751b420b39 Merge pull request #796 from pbranchu/fix/gemini-turn-sanitization
Fixes Gemini INVALID_ARGUMENT crash after message trimming.
2026-03-27 04:51:28 +03:00
Jaber Jaber 5b2be80399 Merge pull request #803 from jam676767/fix/claude-code-empty-response-295
Critical fix for claude-code driver deadlock and empty responses.
2026-03-27 04:51:26 +03:00
Jaber Jaber 6ae8dd4cfd Merge pull request #860 from RightNow-AI/dependabot/cargo/clap_complete-4.6.0
Minor version bump. CI passes.
2026-03-27 04:50:13 +03:00
Jaber Jaber 25a66df41b Merge pull request #861 from RightNow-AI/dependabot/cargo/openssl-0.10.76
Security patch. Reviewed and approved.
2026-03-27 04:50:10 +03:00
dependabot[bot] 5212730773 Bump toml from 0.8.2 to 0.9.12+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 0.8.2 to 0.9.12+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.2...toml-v0.9.12)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.12+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 01:10:12 +00:00
jaberjaber23andClaude Opus 4.6 9b7496947b fix: resolve 7 more bugs (#825, #828, #856, #770, #774, #851/#808, #785)
- #825: Doctor now surfaces blocked workspace skills count in injection scan
- #828: Skill install detects Git URLs (https://, git@) and clones before install
- #856: Custom model names preserved — user-defined models take priority over builtins
- #770: Dashboard WS streaming now triggers Alpine.js reactivity via splice()
- #774: tool_use.input always normalized to JSON object (fixes Anthropic API errors)
- #851/#808: Global skills loaded for all agents; workspace skills properly override globals
- #785: Gemini streaming SSE parser handles \r\n line endings (fixes empty response loop)

All 2,186 tests passing. Live tested with daemon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 04:04:17 +03:00
dependabot[bot] 895e94ccac Bump openssl from 0.10.75 to 0.10.76
Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.75 to 0.10.76.
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.75...openssl-v0.10.76)

---
updated-dependencies:
- dependency-name: openssl
  dependency-version: 0.10.76
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 08:19:40 +00:00
dependabot[bot] da9d8a84f1 Bump clap_complete from 4.5.66 to 4.6.0
Bumps [clap_complete](https://github.com/clap-rs/clap) from 4.5.66 to 4.6.0.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.66...clap_complete-v4.6.0)

---
updated-dependencies:
- dependency-name: clap_complete
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 08:19:25 +00:00
jaberjaber23 e7b9143423 fix: resolve 6 more bugs (#845, #844, #823, #767, #802, #816)
- #845: Model fallback chain now retries with fallback_models on ModelNotFound
- #844: Heartbeat skips idle agents that never received a message (no more crash loops)
- #823: Doctor --json outputs clean JSON to stdout, tracing to stderr, BrokenPipe handled
- #767: Workflows page scrollable with flex layout fix
- #802: Model dropdown handles object options (no more [object Object] for Ollama)
- #816: Spawn wizard provider dropdown loads dynamically from /api/providers (43 providers)

All 829+ tests passing. Live tested with daemon.
2026-03-26 05:26:57 +03:00
jaberjaber23 604e4ea7e3 fix: resolve 6 open bugs (#834, #805, #820, #848, #826, #836)
- #834: Remove 3 decommissioned Groq models (gemma2-9b-it, llama-3.2-1b/3b-preview)
- #805: Ollama streaming parser now checks both reasoning_content and reasoning fields
- #820: Browser Hand checks python3 before python, fix optional dep logic
- #848: Hand continuous interval changed from 60s to 3600s to prevent credit waste
- #826: Doctor command no longer reports all_ok when provider key is rejected
- #836: WebSocket tool events now include tool call ID for concurrent call correlation

All 825+ tests passing. Verified live with daemon.
2026-03-26 03:33:07 +03:00
turbolego c926372d81 lint fixes for 'cargo clippy --workspace --all-targets -- -D warnings' and 'cargo test --workspace' 2026-03-24 22:23:06 +01:00
Felix da12f47369 fix: Fix the error when uploading files with Unicode characters in filenames 2026-03-24 19:33:12 +08:00
Liu b7c81965a1 fix: format object-type tool input correctly in formatToolJson 2026-03-24 17:05:44 +08:00
Claw Kowalski f65dc775eb fix(hands): remove deployment-specific language from infisical-sync 2026-03-23 15:01:29 -04:00
Claw Kowalski c59041a09d fix(hands): workspaceId → projectId in list query string 2026-03-23 14:59:47 -04:00
Claw Kowalski 715f37effc feat(hands): add infisical-sync Hand
- Implement create-with-PATCH-on-conflict push pattern (POST 409 → PATCH)
- Migrate push and delete endpoints from deprecated v3 to v4 API
- Replace workspaceId with projectId in push/delete API calls
2026-03-23 14:50:22 -04:00
Matteo Pietro Dazzi 570e1941b2 Merge pull request #1 from ilteoood/copilot/implement-nvidia-provider-functionality
feat: add NVIDIA NIM to CLI provider selection wizards
2026-03-23 09:47:36 +01:00
copilot-swe-agent[bot]andilteoood 4c700c8d2d fix: use existing nvidia/llama-3.1-nemotron-70b-instruct as default model
Use a model that already exists in the catalog instead of the
non-existent meta/llama-3.3-70b-instruct.

Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/541bf2ad-f8d8-488c-84a2-4ca71e66730f
2026-03-23 08:36:36 +00:00
copilot-swe-agent[bot]andilteoood 5ae554ed51 feat: add NVIDIA NIM to CLI provider selection wizards
Add NVIDIA NIM as a selectable provider in both the setup wizard
and init wizard CLI screens, using NVIDIA_API_KEY env var and
meta/llama-3.3-70b-instruct as the default model.

Closes #787

Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/541bf2ad-f8d8-488c-84a2-4ca71e66730f
2026-03-23 08:35:33 +00:00
jam 62b697c90e fix(claude-code): extract assistant text from nested message.content in stream()
Claude CLI ≥2.x emits type=assistant events where the response text is
inside message.content[{"type":"text","text":"..."}] rather than a flat
content string. The old handler only checked event.content, so every
token was silently dropped and streaming always returned an empty response.

The handler now checks the flat content field first (backward-compatible),
then falls back to joining all text blocks from message.content[].

Refs: RightNow-AI/openfang#295
2026-03-23 09:35:22 +01:00
jam 4b3b602457 fix(claude-code): inject HOME and null stdin in stream() subprocess
Mirror the same environment fixes applied to complete(): inject HOME so
the CLI locates ~/.claude/credentials when running as a service, and set
stdin to null so the process does not block on interactive input.

Refs: RightNow-AI/openfang#295
2026-03-23 09:35:05 +01:00
jam d7bd5c6636 fix(claude-code): prevent pipe-buffer deadlock in complete() via concurrent drain
When complete() called child.wait() before reading stdout/stderr, large
responses (>64 KB) caused a deadlock: the subprocess blocked on write()
because the OS pipe buffer was full, and wait() never returned.

Fix by spawning two tokio tasks to drain stdout/stderr concurrently with
child.wait(), then collecting after the process exits.

Also inject HOME from home_dir() so the CLI finds ~/.claude/credentials
when OpenFang runs as a service, and set stdin to null so the CLI does
not stall waiting for interactive input.

Refs: RightNow-AI/openfang#295
2026-03-23 09:34:55 +01:00
jam 66e6eb2509 fix(claude-code): add message field to ClaudeStreamEvent for nested assistant content
Newer Claude CLI versions (≥2.x) emit assistant responses inside a nested
`message.content[].text` structure in stream-json events, rather than a
flat `content` string.

Add ClaudeMessageBlock and ClaudeAssistantMessage structs, plus a new
`message` field on ClaudeStreamEvent, so the stream handler can extract
text from both layouts.

Refs: RightNow-AI/openfang#295
2026-03-23 09:33:35 +01:00
jamandClaude 1365fc9635 fix(claude-code): add #[serde(default)] to ClaudeJsonOutput.result
Without this attribute, serde treats a missing `result` field as a
deserialization error even though `Option<T>` implies the field is
optional.  Some Claude CLI versions emit the response in `content` or
`text` rather than `result`; the silent parse failure caused the
driver to fall through to a plain-text read which could be empty,
triggering the "model returned an empty response" guard in the agent
loop.

Closes #295.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-23 09:31:37 +01:00
b4iterdev 78669863b7 feat: add statically compiled native-tls to binary 2026-03-23 14:32:48 +07:00
Philippe BranchuandClaude Opus 4.6 316bbe11c3 Add tests for sanitize_gemini_turns
- test_sanitize_drops_orphaned_function_call
- test_sanitize_keeps_valid_function_call_response_pair
- test_sanitize_drops_orphaned_function_response
- test_sanitize_merges_consecutive_same_role
- test_sanitize_empty_input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:43:03 +00:00
Philippe BranchuandClaude Opus 4.6 ff00499e8e Fix Gemini INVALID_ARGUMENT crash after message trimming
Add sanitize_gemini_turns() to enforce Gemini's strict turn-ordering
constraints after message history is trimmed. This merges consecutive
same-role turns, drops orphaned functionCall/functionResponse parts,
and removes empty turns. Also adds #[serde(default)] on GeminiContent.parts
and fixes two tests that were missing required ToolResult messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:31:40 +00:00
Philippe BranchuandClaude Opus 4.6 1a5ae4e3ce Prepend sender identity to channel messages for agent context
The bridge now prefixes messages with [From: Name <email>] so agents
know who is speaking. Essential for multi-user rooms and for agents
that need to act on behalf of specific users (e.g., checking the
correct email account or calendar).

Updated bridge integration tests to match the new format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:49:37 +00:00
Philippe BranchuandClaude Opus 4.6 bf9066a602 Fix MCP bridge dropping tool results from servers that send notifications
Read response lines until finding a JSON-RPC response matching the
request ID. Previously, the bridge read one line and assumed it was
the response, causing "No result from MCP tools/call" when MCP
servers send notifications or log lines before the actual result.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:49:28 +00:00
Philippe BranchuandClaude Opus 4.6 acf51e02a8 Fix Gemini driver crash on content entries without parts
Add #[serde(default)] to GeminiContent.parts so responses with
empty or missing parts arrays deserialize as empty Vec instead
of failing with "missing field parts".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:47:56 +00:00
copilot-swe-agent[bot]andilteoood 1c9d53df11 chore: remove generated linux-schema.json build artifact
Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/7944f50c-bf8e-47ca-a936-cc6c562e36ca
2026-03-22 15:23:09 +00:00
copilot-swe-agent[bot]andilteoood b298c4c273 feat: add NVIDIA NIM provider with ZeroClaw-recommended models
- Add NVIDIA_API_KEY to .env.example
- Add 3 new ZeroClaw-recommended models: meta/llama-3.3-70b-instruct,
  nvidia/llama-3.3-nemotron-super-49b-v1.5,
  nvidia/llama-3.1-nemotron-ultra-253b-v1
- Add nemotron, nemotron-super, nemotron-ultra aliases
- Add NVIDIA NIM provider section to docs/providers.md (provider #21)
- Add NVIDIA NIM models to Model Catalog table
- Add aliases to Aliases table
- Add NVIDIA NIM to Environment Variables Summary
- Update provider/model/alias counts

Closes #787

Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/7944f50c-bf8e-47ca-a936-cc6c562e36ca
2026-03-22 15:22:34 +00:00
copilot-swe-agent[bot] f407a41a98 Initial plan 2026-03-22 15:02:53 +00:00
rager306 173c843107 fix: replace unsafe-inline CSP with per-request nonce
The dashboard CSP uses 'unsafe-inline' for script-src, which permits
any inline <script> block to execute — including attacker-injected
scripts if any endpoint reflects user input (agent names, message
content, channel descriptions, etc.).

Replace with a per-request cryptographic nonce (UUID v4):
- webchat_page generates a unique nonce on every request
- All <script> tags embed the nonce at compile time via __NONCE__ placeholder
- CSP becomes: script-src 'self' 'nonce-{nonce}' 'unsafe-eval'
  ('unsafe-eval' is still required for Alpine.js x-data expressions)
- API endpoints receive a strict default-src 'none'; frame-ancestors 'none'
  policy instead of the permissive dashboard policy

This is a standard CSP Level 2 hardening; all modern browsers support nonces.
2026-03-22 16:18:15 +07:00
rager306 b3787e07ea fix: replace unsafe Arc mutation in update_budget with RwLock
PUT /api/budget casts &Arc<AppState> to *mut KernelConfig and mutates
the budget fields through a raw pointer. This is unsound: AppState is
shared across Tokio worker threads, so two concurrent PUT /api/budget
requests cause a data race on the same memory location.

Replace with Arc<tokio::sync::RwLock<BudgetConfig>> stored on AppState,
initialized from kernel.config.budget at startup. All readers use
.read().await and all writers use .write().await. No unsafe code remains
in the budget update path.

Fixes: data race / undefined behaviour under concurrent budget updates
2026-03-22 16:16:09 +07:00
Alaundo 935c8cad88 fix(mcp): handle Streamable HTTP MCP responses with SSE framing
MCP servers using Streamable HTTP (e.g., Hindsight) wrap JSON-RPC
responses in SSE framing (event: message\ndata: {...}\n\n). The SSE
transport handler expected raw JSON, causing 'Invalid MCP SSE JSON-RPC
response' errors when connecting to these servers.

Extract the JSON payload from SSE data: lines before deserializing.
Falls back to raw body parsing for servers that return plain JSON.

Fixes connection to MCP servers implementing the Streamable HTTP
transport (MCP spec 2025-03-26).
2026-03-21 20:15:54 +01:00
anierbeck d95270da5a Fix agent template spawning
- Ensure all templates have manifest_toml field
- Use spawnFromTemplate for templates with manifest_toml
- Fix spawnBuiltin to handle missing fields gracefully
- Update HTML template to call correct spawn method
2026-03-21 18:16:16 +01:00
anierbeck 7a2211d0f4 Expose agent templates in web interface
- Replace hardcoded list of 6 templates with all 30+ available templates
- Add category information to templates
- Combine static and dynamic templates with static templates displayed first
- Add loading and error states for template list
- Fix showDetail method for agent configuration
2026-03-21 17:03:09 +01:00
Felix e14885fa80 fix: Empty string IDs are overwritten, leading to inconsistencies in certain models. 2026-03-21 21:02:59 +08:00
pbranchuandClaude Opus 4.6 ccbaf90a24 Expose heartbeat default_timeout_secs in config.toml
Add a [heartbeat] section to KernelConfig so users can tune the
inactivity timeout that determines when agents are marked unresponsive.
Reactive agents (hands) that sit idle between infrequent requests were
getting marked as crashed after the hardcoded 180s default, causing
the first request after idle to fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:45:47 +00:00
anierbeck f66c2525cb Expose agent templates in web interface
- Modified agents.js to fetch templates dynamically from /api/templates endpoint
- Updated API endpoint to include category information for templates
- Added category mapping logic for proper template categorization
- Updated HTML template to handle loading and error states
- Implemented fallback to hardcoded templates if API fails

This change replaces the hardcoded list of 6 templates with all 32 available
agent templates from the agents/ directory, making the web interface more
dynamic and maintainable.
2026-03-21 12:14:48 +01:00
d 🔹 842d932ae6 fix(matrix): prevent bot self-reply loop with user_id mismatch and event dedup
Two fixes for the Matrix bot stuck in infinite reply loop (#757):

1. Use validated user ID from /whoami instead of config value for
   self-message filtering. Matrix server delegation or casing
   differences can cause the configured user_id to not match the
   sender field in timeline events, so the bot processes its own
   replies and enters an infinite loop.

2. Add event ID dedup set to prevent re-processing the same event
   on sync token races or reconnects. This is a defense-in-depth
   measure that also protects against edge cases where /sync returns
   overlapping event windows.

Fixes #757
2026-03-20 16:13:22 +00:00
Felix 37d1c822f2 fix: Fix the issue of duplicate tool calls with identical arguments in certain scenarios. 2026-03-20 19:31:37 +08:00
Felix 865fd28704 fix: The command succeeded, yet the model keeps calling it repeatedly. 2026-03-20 18:22:08 +08:00
Liu 43a92a764f fix: resolve page-header overlap and overflow 2026-03-20 16:07:40 +08:00
PR Bot 17f783073e fix: correct MiniMax M2.7 model specifications per official docs
- Pricing: $0.30/$1.20 per 1M tokens (was $1.10/$4.40)
- Context window: 204,800 tokens (was 1,048,576)
- Max output: 131,072 tokens (was 16,384)
- Vision: false — M2.7 is text-only (was true)
2026-03-20 13:11:34 +08:00
jaberjaber23 db86ff4ce3 bump v0.5.1 2026-03-20 03:48:59 +03:00
Jaber Jaber ee042769e2 Merge pull request #711 from Reaster0/fix/matrix-configurable-auto-accept-invites
fix(matrix): make auto_accept_invites configurable, default to false
2026-03-20 03:13:33 +03:00
Jaber Jaber 41ffb8537a Merge pull request #742 from RightNow-AI/dependabot/cargo/zip-4.6.1
Bump zip from 2.4.2 to 4.6.1
2026-03-20 03:13:22 +03:00
Jaber Jaber 80658c94e3 Merge pull request #744 from RightNow-AI/dependabot/cargo/roxmltree-0.21.1
Bump roxmltree from 0.20.0 to 0.21.1
2026-03-20 03:13:11 +03:00
Jaber Jaber c35301e155 Merge pull request #740 from RightNow-AI/dependabot/github_actions/docker/setup-buildx-action-4
Bump docker/setup-buildx-action from 3 to 4
2026-03-20 03:13:02 +03:00
Jaber Jaber 14f0421e7b Merge pull request #741 from RightNow-AI/dependabot/github_actions/docker/build-push-action-7
Bump docker/build-push-action from 6 to 7
2026-03-20 03:12:52 +03:00
Jaber Jaber 3f772b5b27 Merge pull request #713 from CastleOneX/pr/approvals-visibility
Fix invisible approval requests in dashboard
2026-03-20 03:12:42 +03:00
Jaber Jaber a12547081a Merge pull request #714 from CastleOneX/pr/provider-model-normalization
Normalize provider-backed model updates
2026-03-20 03:12:32 +03:00
Jaber Jaber 63f4befe80 Merge pull request #748 from lc-soft/fix/katex-load
Load KaTeX on demand to prevent first-paint blocking
2026-03-20 03:12:21 +03:00
Jaber Jaber 0f25386e2e Merge pull request #750 from lc-soft/fix/settings-page-error
fix: settingsLoading -> loading
2026-03-20 03:12:11 +03:00
jaberjaber23 7f752dde99 bump v0.5.0 2026-03-20 00:46:15 +03:00
jaberjaber23 93ef98a429 bug fixes 2026-03-20 00:33:20 +03:00
Liu b71bd801fb fix(api): settingsLoading -> loading 2026-03-19 19:29:09 +08:00
Liu 9badeb243e fix(api): load KaTeX on demand to prevent first-paint blocking 2026-03-19 19:12:25 +08:00
dependabot[bot] 9a683ec511 Bump roxmltree from 0.20.0 to 0.21.1
Bumps [roxmltree](https://github.com/RazrFalcon/roxmltree) from 0.20.0 to 0.21.1.
- [Changelog](https://github.com/RazrFalcon/roxmltree/blob/master/CHANGELOG.md)
- [Commits](https://github.com/RazrFalcon/roxmltree/compare/v0.20.0...v0.21.1)

---
updated-dependencies:
- dependency-name: roxmltree
  dependency-version: 0.21.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:18:28 +00:00
dependabot[bot] eaa89defd1 Bump zip from 2.4.2 to 4.6.1
Bumps [zip](https://github.com/zip-rs/zip2) from 2.4.2 to 4.6.1.
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v2.4.2...v4.6.1)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 4.6.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:17:54 +00:00
dependabot[bot] d245059a01 Bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:17:10 +00:00
dependabot[bot] c30bf3e557 Bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:17:06 +00:00
PR Bot dd95f24980 feat: add MiniMax-M2.7 as new flagship model and update default alias
- Add MiniMax-M2.7 model entry (Frontier tier, 1M context, vision+tools)
- Update default 'minimax' alias to resolve to MiniMax-M2.7
- Add 'minimax-m2.7' alias for explicit model selection
- Add M2.7 pricing in metering (same as M2.5: $1.10/$4.40 per 1M tokens)
- Update model catalog tests for M2.7 as new default
- Increment MiniMax model count from 6 to 7
2026-03-19 11:48:22 +08:00
vnzandClaude Opus 4.6 1cf36241e4 Apply rustfmt to kernel.rs (fixes CI format check)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 2ab31f3d3e Apply rustfmt to changed files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 2915cb2113 Fix failed manual run pushing next_run and premature last_run in UI
- record_failure() now only recomputes next_run when the job is already
  overdue (next_run <= now), preserving the scheduled fire time when a
  manual run fails before the job's natural next_run
- Remove premature job.last_run update in scheduler.js — the job runs
  asynchronously so last_run should only reflect the server-side
  completion timestamp on the next data refresh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 7b1057df0c Add complete JSON response examples to cron endpoint docs
- GET /api/cron/jobs: show actual {jobs: [...], total} wrapper and
  document the ?agent_id query filter
- POST /api/cron/jobs: fix status code to 201 Created, show the actual
  {result: "<stringified-json>"} response shape
- GET /api/cron/jobs/{id}/status: show full JobMeta structure with
  nested job object, one_shot, last_status, consecutive_errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 0b99ac4071 Replace racy get_job + reserve_run with atomic try_claim_for_run
The previous sequence — get_job (read lock), check enabled, reserve_run
(write lock) — had a TOCTOU window where another request could disable
or delete the job between the check and the reservation.

Replace with CronScheduler::try_claim_for_run() which holds a single
DashMap write lock for the existence check, enabled guard, and next_run
advancement. Returns a typed ClaimError (NotFound | Disabled) so the
route handler maps directly to HTTP status codes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 19260945bd Implement "Run Now" for cron jobs
Add POST /api/cron/jobs/{id}/run endpoint that triggers a cron job
immediately without waiting for its next scheduled fire time. The job
executes asynchronously in the background and its status can be polled
via the existing /status endpoint.

Key changes:
- Extract per-job execution logic from the inline cron tick loop into
  a reusable `cron_run_job()` method on OpenFangKernel, called by both
  the background scheduler and the new API endpoint
- Add `reserve_run()` on CronScheduler to pre-advance next_run for
  overdue jobs before spawning manual runs, preventing duplicate
  execution from the scheduler tick (only advances when next_run <= now
  to avoid skipping imminent scheduled runs)
- Fix dashboard scheduler.js to call the correct cron API endpoint
  instead of the legacy /api/schedules/ path
- Document all cron/scheduler endpoints in api-reference.md

Partially addresses upstream issue #634.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
jaberjaber23 93ea832394 bump v0.4.9 2026-03-19 02:04:30 +03:00
jaberjaber23 38d42c4d9b image pipeline 2026-03-19 01:34:31 +03:00
jaberjaber23 2d02ba22fb lockfile sync 2026-03-19 01:03:35 +03:00
jaberjaber23 91d8734198 community docs 2026-03-19 00:26:41 +03:00
Irwin 12ab5f1a93 Normalize provider-backed model updates
Resolve catalog display names and aliases to canonical model IDs when spawning or updating agents, keep provider-specific api_key_env hints in sync when switching providers, and route explicit provider model changes through kernel model normalization instead of directly mutating the registry. This fixes cases like xAI agents carrying stale OpenAI auth hints or UI labels such as 'Grok 4.20' being treated as raw model IDs.

(cherry picked from commit 51ee6a5a927208ab0301a29fd2e40b29c9dfaf7d)
2026-03-18 16:37:54 +13:00
Irwin a5bc9f916a Fix invisible approval requests in dashboard
Keep a bounded recent approval history instead of dropping timed-out or resolved requests on the floor, return recent approvals from /api/approvals, and make the dashboard poll and badge pending approvals so shell_exec prompts do not disappear before the user ever sees them.

(cherry picked from commit 78dd9f99cc835e85e452889bf6cfced5137f8a4a)
2026-03-18 16:34:34 +13:00
reasterandClaude Opus 4.6 935f3fac8e fix(matrix): make auto_accept_invites configurable, default to false
MatrixAdapter hardcoded `auto_accept_invites: true`, meaning any
Matrix-connected instance would blindly join every room it was invited
to. This is a security concern for public-facing homeservers — a
malicious user could invite the bot into an arbitrary room and interact
with the agent without the operator's consent.

Changes:
- Add `auto_accept_invites: bool` to `MatrixConfig` in openfang-types,
  with `#[serde(default)]` defaulting to `false`.
- Thread the field through `MatrixAdapter::new()` instead of hardcoding.
- Wire it in `channel_bridge.rs` from `mx_config.auto_accept_invites`.
- Update tests to pass the new parameter.

Operators who want the old behaviour can set:
```toml
[channels.matrix]
auto_accept_invites = true
```

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:43:47 +01:00
reasterandClaude Opus 4.6 ad90d417cc fix(kernel): resolve "default" provider in fallback_models before driver init
The fallback model loop passed `provider = "default"` verbatim to
`create_driver()`, which only recognises real provider names (ollama,
openai, anthropic, …).  The primary model overlay at spawn_agent()
already resolves "default" → kernel config, but fallback_models was
skipped, causing every bundled agent with a "default" fallback to log:

    Fallback driver 'default' failed to init: Unknown provider 'default'

This meant agents had zero fallback drivers, silently degrading
resilience for anyone whose config.toml sets a non-standard default
provider (e.g. ollama pointing at a local proxy).

Changes:
- Mirror the primary-model overlay logic for fallback entries:
  resolve provider, model, api_key_env, and base_url from
  `config.default_model` when the fallback specifies "default" or empty.
- Inherit `base_url` from default_model before falling back to
  `lookup_provider_url()`, so custom endpoints propagate correctly.
- Use resolved values in `strip_provider_prefix()` and warn messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:42:09 +01:00
Alex Li 4eae7502d2 fix(claude-code): pass system prompt via --system-prompt flag instead of inlining in -p 2026-03-17 18:22:24 -03:00
Yaroslav Yashin d6326d8967 Expose Telegram slash commands 2026-03-17 22:53:49 +02:00
Abhishek Kumar 8ec3766da3 fix(notion): fixed the notion api call to mcp 2026-03-17 16:36:57 +05:30
Liu 90fc171e26 fix list style in message bubble 2026-03-17 17:06:43 +08:00
Mark BakerandClaude Sonnet 4.6 972a52ff9c fix: make heartbeat interval configurable and reduce researcher max_iterations
Two related issues with autonomous Hand agents:

1. heartbeat_interval_secs was hardcoded at 30s (the AutonomousConfig default)
   for all Hands, with no way to override it from HAND.toml. For agents that
   make long LLM calls, 30s causes false-positive recovery triggers during
   normal operation. Add heartbeat_interval_secs to HandAgentConfig so each
   Hand can declare an appropriate interval.

2. The researcher Hand shipped with max_iterations = 80 and a system prompt
   instructing exhaustive research (50+ sources). This combination was designed
   for cloud LLMs with 200K context windows. On any model with a 32K or smaller
   context window, 80 iterations × growing history guarantees context overflow
   before the task completes. Reduce to 25, which is sufficient for thorough
   research within a 32K budget.

researcher/HAND.toml changes:
- max_iterations: 80 → 25
- heartbeat_interval_secs: 120 (new field; 30s default was triggering false
  recovery during normal multi-minute LLM calls)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 6b78838416 fix: improve agent detail modal layout and fallback chain display
- Widen agent detail modal from 600px to 700px to better accommodate
  longer model names and the fallback chain editor
- Restructure the Fallbacks section in the Info tab: content div is now
  a column flex container (gap:6px) with margin-left:16px to create a
  clear visual column between the label and its content
- Prevent long provider/model badge strings from overflowing the right
  edge of the modal (word-break:break-all; white-space:normal on badge)
- Add flex-shrink:0 to the × delete button so it never gets squashed
  when a badge is long
- Wrap the "+ Add" button in a div so it stays left-aligned (column
  flex would otherwise stretch a bare button to full width)
- Replace margin-top with gap-based spacing on the fallback edit form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 13051b2f06 fix: reset last_active on agent restore to prevent heartbeat false-positives
When agents are loaded from persistent storage on daemon startup, their
last_active timestamp reflects when they were last active before the
previous shutdown. If the daemon was down for longer than the heartbeat
timeout (default 180 s), the first heartbeat tick immediately marks every
restored agent as unresponsive and triggers crash recovery — even though
all agents just started and haven't had a chance to run.

Fix: stamp last_active = Utc::now() alongside the state = Running reset
in the restore loop. This is consistent with how new agent spawns work
(they also set last_active to now) and gives each restored agent a clean
baseline from which the heartbeat can accurately track responsiveness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 aed4bf62ae fix: stamp last_active before LLM call to prevent mid-iteration heartbeat timeouts
Slow local models (e.g. 27B quantised MLX models) can take 3–4+ minutes
per iteration, well beyond the default 180s heartbeat timeout. Because
last_active was only updated at the end of an iteration — never during it —
the heartbeat monitor would flag the agent as unresponsive mid-call and
initiate crash/recovery while the loop was still running correctly.

Changes:
- Add `touch()` to `AgentRegistry`: refreshes `last_active` with no other
  side-effects.
- Add `touch_agent(&self, agent_id: &str)` to `KernelHandle` trait with a
  default no-op, so existing mock implementations require no changes.
- Implement `touch_agent` on `OpenFangKernel`: parses the UUID and
  delegates to `registry.touch()`.
- Call `kernel.touch_agent(agent_id)` at the top of each agent loop
  iteration, immediately before the `call_with_retry` LLM call. This
  resets the inactivity clock at the start of every iteration rather than
  only at completion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Liu a039e395f5 fix runtime page stat card layout 2026-03-16 11:44:22 +00:00
Your NameandClaude Opus 4.6 67b30c1549 feat: HTTP memory backend for shared memory infrastructure
Route SemanticStore remember/recall operations to the memory-api gateway
(PostgreSQL + pgvector + Jina AI embeddings) when backend=http is configured.

- Add backend, http_url, http_token_env fields to MemoryConfig
- Create http_client module with MemoryApiClient (reqwest::blocking)
- Add HTTP dispatch to SemanticStore with graceful SQLite fallback
- Wire MemoryConfig through MemorySubstrate::open() and kernel boot
- Add reqwest as optional dependency behind http-memory feature flag

Sessions, KV store, and knowledge graph remain local SQLite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:44:06 +01:00
Tsukimaru Oshawott 40bdf4316b feat(agents): add LangChain code review agent with A2A protocol
Add a Python-based code review agent powered by LangChain that
integrates with OpenFang via the A2A (Agent-to-Agent) protocol.

- agent.py: Core review logic with structured Chinese SYSTEM_PROMPT
  covering 6 dimensions (correctness, security, performance,
  maintainability, testing, style) and 4 severity levels
- server.py: FastAPI server exposing A2A-compatible endpoints
  (/.well-known/agent.json and /a2a JSON-RPC)
- workflow.json: OpenFang workflow definition for the review pipeline
- config.example.toml: Example A2A config for ~/.openfang/config.toml
- Supports OpenAI, DeepSeek, and Ollama backends

Made-with: Cursor
2026-03-16 14:33:41 +08:00
at384 e3c05a9d47 feat(drivers): add Vertex AI driver with OAuth authentication
Rebased on latest main (f1ca527) after codebase changes. This is a
fresh submission after PR #22 was closed as stale.

## Why This Feature

Enables enterprise GCP deployments using existing service accounts
instead of requiring separate Gemini API keys. Many organizations
already have GCP infrastructure and prefer OAuth-based auth.

## What's New

- VertexAIDriver with full streaming support
- OAuth 2.0 token caching (50 min TTL) with auto-refresh via gcloud
- Auto-detection of project_id from service account JSON
- Security: tokens stored with Zeroizing<String>
- Provider aliases: vertex-ai, vertex, google-vertex
- Compatible with new ContentBlock::provider_metadata field

## Testing

- 6 unit tests passing
- Clippy clean (no warnings)
- End-to-end tested with real GCP service account + gemini-2.0-flash
- Both streaming and non-streaming paths verified

## Usage

export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json
# Set provider=vertex-ai, model=gemini-2.0-flash in config.toml
2026-03-16 06:50:35 +01:00
Mark BakerandClaude Sonnet 4.6 6ab77612f5 fix: make tool allowlist/blocklist matching case-insensitive
Tool names stored via the dashboard can arrive in any case (e.g. uppercase
FILE_READ vs registered name file_read). The previous case-sensitive
comparison caused allowlisted tools to silently match nothing, giving the
agent an empty effective tool set with no error or warning.

Normalise both sides with to_lowercase() so the filter works regardless of
how the names were entered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 a3073007a1 fix(docs): correct search_provider value for DuckDuckGo
The docs listed `duckduckgo` as the config value but the actual serde
deserialization produces `duck_duck_go` — serde's rename_all = "snake_case"
on the DuckDuckGo enum variant inserts underscores at each word boundary.

Updated all three occurrences in configuration.md to match the real value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:00:00 -04:00
xinuxz a95fb4a96b feat(feishu): add WebSocket receive mode with protobuf framing
Add WebSocket long-connection receive mode for the Feishu/Lark adapter
as an alternative to webhook callbacks. WebSocket mode is enabled by
default, requiring no public IP or domain.

- FeishuConnectionMode enum (Webhook/WebSocket) with mode dispatch
- Protobuf binary frame parsing (prost) based on Feishu pbbp2 protocol
- Auto-reconnect, ping/pong heartbeat, ACK, multi-part payload combine
- handle_data_frame reuses parse_event() pipeline (dedup, group filter)
- FeishuMode config enum with bridge-layer adapter creation per mode
2026-03-16 10:37:01 +08:00
111 changed files with 16181 additions and 1880 deletions
+2 -2
View File
@@ -222,12 +222,12 @@ jobs:
- name: Set up QEMU (for arm64 emulation)
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Extract version
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- name: Build and push (multi-arch)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
push: true
Generated
+718 -216
View File
File diff suppressed because it is too large Load Diff
+13 -6
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.4.8"
version = "0.5.5"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -32,7 +32,7 @@ tokio-stream = "0.1"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
toml = "0.9"
rmp-serde = "1"
# Error handling
@@ -75,6 +75,7 @@ bytes = "1"
# Futures
futures = "0.3"
prost = "0.13"
# WebSocket client (for Discord/Slack gateway)
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
@@ -84,7 +85,7 @@ url = "2"
wasmtime = "41"
# HTTP server (for API daemon)
axum = { version = "0.8", features = ["ws"] }
axum = { version = "0.8", features = ["ws", "multipart"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "compression-br"] }
@@ -113,7 +114,7 @@ rand = "0.8"
zeroize = { version = "1", features = ["derive"] }
# Rate limiting
governor = "0.8"
governor = "0.10"
# Interactive CLI
ratatui = "0.29"
@@ -129,18 +130,24 @@ html-escape = "0.2"
# Lightweight regex
regex-lite = "0.1"
# MCP SDK (official Rust implementation)
rmcp = { version = "1.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "reqwest"] }
# Socket options (SO_REUSEADDR)
socket2 = "0.5"
# Zip archive extraction
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "4", default-features = false, features = ["deflate"] }
# Email (SMTP + IMAP)
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
imap = "2"
native-tls = "0.2"
native-tls = { version = "0.2", features = ["vendored"] }
mailparse = "0.16"
# MQTT client
rumqttc = "0.24"
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
openssl = { version = "0.10", features = ["vendored"] }
@@ -0,0 +1 @@
__pycache__/
+187
View File
@@ -0,0 +1,187 @@
"""
LangChain Code Review Agent — core review logic.
Supports OpenAI, Ollama, and any LangChain-compatible LLM.
"""
import os
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
SYSTEM_PROMPT = """\
You are a principal-level code reviewer with 15+ years of production experience \
across multiple languages (Python, Rust, TypeScript, Java, Go, C/C++).
You receive code snippets, diffs, or pull request descriptions and produce a \
structured, actionable review report.
You MUST respond in **中文**, but keep code snippets, variable names, and \
technical terms in their original language.
# ── 审核维度(按优先级排序) ──────────────────────────────
## 1. 正确性 (Correctness)
- 逻辑错误、off-by-one、边界条件
- 空指针 / None / undefined 未处理
- 错误处理不完整(吞异常、漏 catch、panic 路径)
- 并发问题:竞态条件、死锁、数据竞争
- 类型安全:隐式转换、溢出、精度丢失
- 资源泄漏:未关闭的文件/连接/锁
## 2. 安全性 (Security)
- SQL / NoSQL / OS 命令注入
- XSS、CSRF、SSRF
- 硬编码密钥、token、密码
- 不安全的反序列化
- 路径穿越(Path Traversal
- 缺少输入校验 / 输出编码
- 权限检查缺失或绕过
- 敏感数据明文日志
## 3. 性能 (Performance)
- 算法复杂度不合理(O(n²) 可优化为 O(n))
- 不必要的内存分配 / 拷贝
- N+1 查询、缺少批量操作
- 阻塞 I/O 在异步上下文中
- 缺少缓存 / 索引
- 热路径上的正则编译 / 反射
## 4. 可维护性 (Maintainability)
- 命名不清晰、缩写歧义
- 函数过长(>50行建议拆分)
- 重复代码(DRY 违反)
- 职责不单一(SRP 违反)
- 缺少必要注释(复杂业务逻辑、非显而易见的决策)
- 魔法数字 / 字符串
- 耦合过紧、依赖方向不合理
## 5. 测试 (Testing)
- 关键路径缺少单元测试
- 测试覆盖了 happy path 但遗漏了 edge case
- 测试中有硬编码依赖(时间、文件路径、网络)
- Mock 过度导致测试失去意义
## 6. 风格 (Style)
- 不符合语言惯例(Pythonic、Rust idiom 等)
- 格式不一致(应由 formatter 处理的除外)
- 不必要的复杂写法
# ── 严重级别 ──────────────────────────────────────────
| 级别 | 含义 | 是否阻塞合并 |
|------|------|-------------|
| 🔴 **[必须修复]** | 存在 bug、安全漏洞或数据丢失风险 | 是 |
| 🟡 **[建议修复]** | 不影响功能但会影响可维护性或性能 | 否,但强烈建议 |
| 🔵 **[小建议]** | 风格、命名等微小改进 | 否 |
| 🟢 **[亮点]** | 写得好的地方,值得肯定 | — |
# ── 输出格式 ──────────────────────────────────────────
严格按以下 Markdown 格式输出:
```
## 📋 总结
**结论**: [✅ 通过 / ⚠️ 需要修改 / 💬 仅评论]
**概述**: [1-2 句话总体评价]
**发现统计**: 🔴 X 个必须修复 | 🟡 X 个建议修复 | 🔵 X 个小建议 | 🟢 X 个亮点
---
## 🔍 详细发现
### 🔴 [必须修复] 问题标题
- **位置**: `文件名` 第 X-Y 行
- **问题**: 具体描述
- **原因**: 为什么这是个问题,可能造成什么后果
- **修复建议**:
(给出修复后的代码)
### 🟡 [建议修复] 问题标题
...
### 🔵 [小建议] 问题标题
...
### 🟢 [亮点] 优点标题
- **位置**: `文件名` 第 X-Y 行
- **说明**: 为什么这段代码写得好
---
## 📊 评分
| 维度 | 分数 | 说明 |
|------|------|------|
| 正确性 | X/10 | 一句话说明 |
| 安全性 | X/10 | 一句话说明 |
| 性能 | X/10 | 一句话说明 |
| 可维护性 | X/10 | 一句话说明 |
| 测试 | X/10 | 一句话说明 |
| **综合** | **X/10** | 一句话总结 |
```
# ── 审核原则 ──────────────────────────────────────────
1. **先肯定,再指出问题** — 不要只挑毛病,好的代码也要指出来
2. **解释 WHY,不仅是 WHAT** — 每个问题都要说清楚「为什么不好」和「可能导致什么后果」
3. **给出具体修复代码** — 不要只说"这里有问题",要给出改好后的写法
4. **区分严重级别** — 不要把小问题标成必须修复,也不要把严重 bug 标成小建议
5. **尊重作者** — 用建设性的语气,避免 "这是错的" 这种措辞,用 "这里可以改进为..."
6. **不纠结格式** — 如果项目有 formatter/linter,格式问题跳过
7. **关注变更本身** — 如果是 diff,只审核变更的部分,不要评论未修改的代码
8. **没有代码时** — 直接要求提交代码,不要编造审核结果"""
def _build_llm():
"""Build the LLM based on environment configuration."""
use_ollama = os.getenv("USE_OLLAMA", "").lower() in ("1", "true", "yes")
if use_ollama:
from langchain_ollama import ChatOllama
model = os.getenv("OLLAMA_MODEL", "qwen2.5")
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
return ChatOllama(model=model, base_url=base_url, temperature=0.2)
provider = os.getenv("LLM_PROVIDER", "openai").lower()
if provider == "deepseek":
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0.2,
max_tokens=4096,
)
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
temperature=0.2,
max_tokens=4096,
)
class CodeReviewAgent:
"""LangChain-based code review agent."""
def __init__(self):
self.llm = _build_llm()
self.prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
("human", "{input}"),
])
self.chain = self.prompt | self.llm | StrOutputParser()
def review(self, code_or_diff: str) -> str:
"""
Review the given code or diff.
Args:
code_or_diff: Source code, git diff, or PR description to review.
Returns:
Structured review report as markdown text.
"""
if not code_or_diff.strip():
return "No code provided. Please submit code or a diff to review."
return self.chain.invoke({"input": code_or_diff})
@@ -0,0 +1,10 @@
# Add this section to your ~/.openfang/config.toml
# to register the LangChain code review agent.
[a2a]
enabled = true
listen_path = "/a2a"
[[a2a.external_agents]]
name = "langchain-code-reviewer"
url = "http://127.0.0.1:9100"
@@ -0,0 +1,6 @@
langchain>=0.3
langchain-openai>=0.3
langchain-core>=0.3
langchain-ollama>=0.3
fastapi>=0.115
uvicorn>=0.34
+226
View File
@@ -0,0 +1,226 @@
"""
LangChain Code Review Agent — A2A-compatible server.
Exposes a code review agent via Google's A2A protocol so that
OpenFang workflows can call it as an external agent.
Start:
OPENAI_API_KEY=sk-xxx python server.py
# or with Ollama (no key needed):
USE_OLLAMA=1 python server.py
Endpoints:
GET /.well-known/agent.json — A2A Agent Card
POST /a2a — JSON-RPC task endpoint
"""
import os
import uuid
import asyncio
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn
from agent import CodeReviewAgent
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "9100"))
BASE_URL = os.getenv("BASE_URL", f"http://127.0.0.1:{PORT}")
app = FastAPI(title="LangChain Code Review Agent")
agent = CodeReviewAgent()
# In-memory task store
tasks: dict[str, dict] = {}
# ---------------------------------------------------------------------------
# A2A Agent Card
# ---------------------------------------------------------------------------
AGENT_CARD = {
"name": "langchain-code-reviewer",
"description": (
"LangChain-powered code review agent. "
"Analyzes code for bugs, security issues, performance problems, "
"and style violations. Returns structured review with severity levels."
),
"url": f"{BASE_URL}/a2a",
"version": "0.1.0",
"capabilities": {
"streaming": False,
"pushNotifications": False,
"stateTransitionHistory": True,
},
"skills": [
{
"id": "code-review",
"name": "Code Review",
"description": "Review code for correctness, security, performance, and style",
"tags": ["code", "review", "security", "quality"],
"examples": [
"Review this Python function for bugs",
"Check this Rust code for security issues",
"Analyze this PR diff for performance problems",
],
},
{
"id": "pr-review",
"name": "Pull Request Review",
"description": "Review a git diff / pull request",
"tags": ["pr", "diff", "git"],
"examples": [
"Review this PR diff",
"Analyze these changes",
],
},
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
}
@app.get("/.well-known/agent.json")
async def agent_card():
return JSONResponse(content=AGENT_CARD)
# ---------------------------------------------------------------------------
# A2A JSON-RPC Endpoint
# ---------------------------------------------------------------------------
@app.post("/a2a")
async def a2a_endpoint(request: Request):
body = await request.json()
jsonrpc = body.get("jsonrpc", "2.0")
req_id = body.get("id", 1)
method = body.get("method", "")
params = body.get("params", {})
if method == "tasks/send":
return await handle_tasks_send(jsonrpc, req_id, params)
elif method == "tasks/get":
return handle_tasks_get(jsonrpc, req_id, params)
elif method == "tasks/cancel":
return handle_tasks_cancel(jsonrpc, req_id, params)
else:
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
})
async def handle_tasks_send(jsonrpc: str, req_id: int, params: dict):
message = params.get("message", {})
session_id = params.get("sessionId")
task_id = str(uuid.uuid4())
text_parts = [
p["text"] for p in message.get("parts", []) if p.get("type") == "text"
]
user_input = "\n".join(text_parts)
task = {
"id": task_id,
"sessionId": session_id,
"status": {"state": "working", "message": None},
"messages": [message],
"artifacts": [],
}
tasks[task_id] = task
try:
review_result = await asyncio.to_thread(agent.review, user_input)
agent_message = {
"role": "agent",
"parts": [{"type": "text", "text": review_result}],
}
task["messages"].append(agent_message)
task["status"] = {"state": "completed", "message": None}
task["artifacts"] = [
{
"name": "code-review-report",
"description": "Structured code review report",
"parts": [{"type": "text", "text": review_result}],
"index": 0,
"lastChunk": True,
}
]
except Exception as e:
task["status"] = {"state": "failed", "message": str(e)}
task["messages"].append({
"role": "agent",
"parts": [{"type": "text", "text": f"Review failed: {e}"}],
})
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"result": task,
})
def handle_tasks_get(jsonrpc: str, req_id: int, params: dict):
task_id = params.get("id", "")
task = tasks.get(task_id)
if task is None:
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
})
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"result": task,
})
def handle_tasks_cancel(jsonrpc: str, req_id: int, params: dict):
task_id = params.get("id", "")
task = tasks.get(task_id)
if task is None:
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
})
task["status"] = {"state": "cancelled", "message": None}
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"result": task,
})
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
@app.get("/health")
async def health():
return {"status": "ok", "agent": "langchain-code-reviewer", "tasks": len(tasks)}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print(f"Starting LangChain Code Review Agent on {HOST}:{PORT}")
print(f"Agent Card: {BASE_URL}/.well-known/agent.json")
print(f"A2A endpoint: {BASE_URL}/a2a")
uvicorn.run(app, host=HOST, port=PORT)
@@ -0,0 +1,23 @@
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "langchain-code-review-pipeline",
"description": "Code review pipeline: uses LangChain external agent for deep review, then OpenFang Writer agent to format the final report.",
"created_at": "2026-03-16T00:00:00Z",
"steps": [
{
"name": "review-code",
"agent": { "name": "a2a-proxy" },
"prompt_template": "Use the a2a_send tool to send the following code to the external agent for code review. Set agent_name to langchain-code-reviewer and set message to the code below. Return the complete review result:\n\n{{input}}",
"mode": "sequential",
"timeout_secs": 300,
"output_var": "review_result"
},
{
"name": "format-report",
"agent": { "name": "Writer" },
"prompt_template": "Format the following code review into a clean, professional report. Preserve all severity levels and scores. Add a brief executive summary at the top:\n\n{{review_result}}",
"mode": "sequential",
"timeout_secs": 120
}
]
}
+70 -10
View File
@@ -30,6 +30,7 @@ use openfang_channels::messenger::MessengerAdapter;
use openfang_channels::reddit::RedditAdapter;
use openfang_channels::revolt::RevoltAdapter;
use openfang_channels::viber::ViberAdapter;
use openfang_types::config::FeishuMode;
// Wave 4
use openfang_channels::flock::FlockAdapter;
use openfang_channels::guilded::GuildedAdapter;
@@ -49,6 +50,7 @@ use openfang_channels::gitter::GitterAdapter;
use openfang_channels::gotify::GotifyAdapter;
use openfang_channels::linkedin::LinkedInAdapter;
use openfang_channels::mumble::MumbleAdapter;
use openfang_channels::mqtt::MqttAdapter;
use openfang_channels::ntfy::NtfyAdapter;
use openfang_channels::webhook::WebhookAdapter;
use openfang_channels::wecom::WeComAdapter;
@@ -809,6 +811,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
"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()),
"mqtt" => channels.mqtt.as_ref().map(|c| c.overrides.clone()),
_ => None,
}
}
@@ -1219,6 +1222,7 @@ pub async fn start_channel_bridge_with_config(
mx_config.user_id.clone(),
token,
mx_config.allowed_rooms.clone(),
mx_config.auto_accept_invites,
));
adapters.push((adapter, mx_config.default_agent.clone()));
}
@@ -1430,16 +1434,22 @@ pub async fn start_channel_bridge_with_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(),
));
let adapter = match fs_config.mode {
FeishuMode::Webhook => 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(),
)),
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket(
fs_config.app_id.clone(),
secret,
)),
};
adapters.push((adapter, fs_config.default_agent.clone()));
}
}
@@ -1670,6 +1680,25 @@ pub async fn start_channel_bridge_with_config(
}
}
// MQTT
if let Some(ref mq_config) = config.mqtt {
let username = read_token(&mq_config.username_env, "MQTT (username)");
let password = read_token(&mq_config.password_env, "MQTT (password)");
let adapter = Arc::new(MqttAdapter::new(
mq_config.broker_url.clone(),
mq_config.client_id.clone(),
mq_config.subscribe_topic.clone(),
mq_config.publish_topic.clone(),
username,
password,
mq_config.use_tls,
mq_config.keep_alive_secs,
mq_config.clean_session,
mq_config.qos,
));
adapters.push((adapter, mq_config.default_agent.clone()));
}
if adapters.is_empty() {
return (None, Vec::new());
}
@@ -1874,4 +1903,35 @@ mod tests {
assert!(config.channels.webhook.is_none());
assert!(config.channels.linkedin.is_none());
}
#[test]
fn test_feishu_bridge_mode_defaults_to_websocket() {
let config: openfang_types::config::KernelConfig = toml::from_str(
r#"
[channels.feishu]
app_id = "cli_test"
app_secret_env = "FEISHU_APP_SECRET"
"#,
)
.unwrap();
let feishu = config.channels.feishu.expect("feishu config should exist");
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
}
#[test]
fn test_feishu_bridge_mode_supports_websocket() {
let config: openfang_types::config::KernelConfig = toml::from_str(
r#"
[channels.feishu]
app_id = "cli_test"
app_secret_env = "FEISHU_APP_SECRET"
mode = "websocket"
"#,
)
.unwrap();
let feishu = config.channels.feishu.expect("feishu config should exist");
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
}
}
+10 -7
View File
@@ -236,13 +236,16 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
headers.insert("x-content-type-options", "nosniff".parse().unwrap());
headers.insert("x-frame-options", "DENY".parse().unwrap());
headers.insert("x-xss-protection", "1; mode=block".parse().unwrap());
// 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' '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(),
);
// The dashboard handler (webchat_page) sets its own nonce-based CSP.
// For all other responses (API endpoints), apply a strict default.
if !headers.contains_key("content-security-policy") {
headers.insert(
"content-security-policy",
"default-src 'none'; frame-ancestors 'none'"
.parse()
.unwrap(),
);
}
headers.insert(
"referrer-policy",
"strict-origin-when-cross-origin".parse().unwrap(),
+1 -1
View File
@@ -379,7 +379,7 @@ async fn stream_response(
let (mut rx, _handle) = state
.kernel
.send_message_streaming(agent_id, message, Some(kernel_handle), None, None)
.send_message_streaming(agent_id, message, Some(kernel_handle), None, None, None)
.map_err(|e| format!("Streaming setup failed: {e}"))?;
let (tx, stream_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(64);
+1
View File
@@ -29,6 +29,7 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
("POST", p) if p.contains("/run") => NonZeroU32::new(100).unwrap(),
("POST", "/api/skills/install") => NonZeroU32::new(50).unwrap(),
("POST", "/api/skills/uninstall") => NonZeroU32::new(10).unwrap(),
("POST", "/api/skills/reload") => NonZeroU32::new(5).unwrap(),
("POST", "/api/migrate") => NonZeroU32::new(100).unwrap(),
("PUT", p) if p.contains("/update") => NonZeroU32::new(10).unwrap(),
_ => NonZeroU32::new(5).unwrap(),
+358 -134
View File
@@ -1,11 +1,12 @@
//! Route handlers for the OpenFang API.
use crate::types::*;
use axum::extract::{Path, Query, State};
use axum::extract::{Multipart, Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use dashmap::DashMap;
use openfang_channels::bridge::channel_command_specs;
use openfang_kernel::triggers::{TriggerId, TriggerPattern};
use openfang_kernel::workflow::{
ErrorMode, StepAgent, StepMode, Workflow, WorkflowId, WorkflowStep,
@@ -40,6 +41,9 @@ pub struct AppState {
/// Avoids blocking the `/api/providers` endpoint on TCP timeouts to
/// unreachable local services. 60-second TTL.
pub provider_probe_cache: openfang_runtime::provider_health::ProbeCache,
/// Thread-safe mutable budget config. Updated via PUT /api/budget.
/// Initialized from `kernel.config.budget` at startup.
pub budget_config: Arc<tokio::sync::RwLock<openfang_types::config::BudgetConfig>>,
}
/// POST /api/agents — Spawn a new agent.
@@ -358,21 +362,28 @@ pub async fn send_message(
);
}
// Resolve file attachments into image content blocks
if !req.attachments.is_empty() {
// Resolve file attachments into image content blocks.
// Pass them as content_blocks so the LLM receives them in the current turn
// (not as a separate session message which the LLM may not process).
let content_blocks = if !req.attachments.is_empty() {
let image_blocks = resolve_attachments(&req.attachments);
if !image_blocks.is_empty() {
inject_attachments_into_session(&state.kernel, agent_id, image_blocks);
if image_blocks.is_empty() {
None
} else {
Some(image_blocks)
}
}
} else {
None
};
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_with_handle(
.send_message_with_handle_and_blocks(
agent_id,
&req.message,
Some(kernel_handle),
content_blocks,
req.sender_id,
req.sender_name,
)
@@ -568,9 +579,7 @@ pub async fn get_agent_session(
msg.get_mut("tools").and_then(|v| v.as_array_mut())
{
if let Some(tool_obj) = tools_arr.get_mut(tool_idx) {
let preview: String =
result.chars().take(2000).collect();
tool_obj["result"] = serde_json::Value::String(preview);
tool_obj["result"] = serde_json::Value::String(result.clone());
tool_obj["is_error"] =
serde_json::Value::Bool(*is_error);
}
@@ -841,7 +850,26 @@ pub async fn create_workflow(
created_at: chrono::Utc::now(),
};
let id = state.kernel.register_workflow(workflow).await;
let id = state.kernel.register_workflow(workflow.clone()).await;
// Persist workflow to disk so it survives daemon restarts (#751)
let wf_dir = state
.kernel
.config
.workflows_dir
.clone()
.unwrap_or_else(|| state.kernel.config.home_dir.join("workflows"));
if let Err(e) = std::fs::create_dir_all(&wf_dir) {
tracing::warn!("Failed to create workflows dir: {e}");
} else {
let wf_path = wf_dir.join(format!("{}.json", id));
if let Ok(json) = serde_json::to_string_pretty(&workflow) {
if let Err(e) = std::fs::write(&wf_path, json) {
tracing::warn!("Failed to persist workflow {id}: {e}");
}
}
}
(
StatusCode::CREATED,
Json(serde_json::json!({"workflow_id": id.to_string()})),
@@ -1412,6 +1440,7 @@ pub async fn send_message_stream(
Some(kernel_handle),
req.sender_id,
req.sender_name,
None, // SSE streaming doesn't support image attachments yet
) {
Ok(pair) => pair,
Err(e) => {
@@ -1858,6 +1887,7 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[
ChannelField { key: "app_id", label: "App ID", field_type: FieldType::Text, env_var: None, required: true, placeholder: "cli_abc123", advanced: false },
ChannelField { key: "app_secret_env", label: "App Secret", field_type: FieldType::Secret, env_var: Some("FEISHU_APP_SECRET"), required: true, placeholder: "abc123...", advanced: false },
ChannelField { key: "region", label: "Region", field_type: FieldType::Text, env_var: None, required: false, placeholder: "cn or intl", advanced: false },
ChannelField { key: "mode", label: "Receive Mode", field_type: FieldType::Text, env_var: None, required: false, placeholder: "webhook|websocket", advanced: true },
ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8453", advanced: true },
ChannelField { key: "webhook_path", label: "Webhook Path", field_type: FieldType::Text, env_var: None, required: false, placeholder: "/feishu/webhook", advanced: true },
ChannelField { key: "verification_token", label: "Verification Token", field_type: FieldType::Text, env_var: None, required: false, placeholder: "verify-token", advanced: true },
@@ -1866,7 +1896,7 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[
ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true },
],
setup_steps: &["Create an app at open.feishu.cn (CN) or open.larksuite.com (International)", "Copy App ID and Secret", "Set region: cn (Feishu) or intl (Lark)"],
config_template: "[channels.feishu]\napp_id = \"\"\napp_secret_env = \"FEISHU_APP_SECRET\"\nregion = \"cn\"",
config_template: "[channels.feishu]\napp_id = \"\"\napp_secret_env = \"FEISHU_APP_SECRET\"\nregion = \"cn\"\nmode = \"websocket\"",
},
ChannelMeta {
name: "dingtalk", display_name: "DingTalk", icon: "DT",
@@ -2287,6 +2317,23 @@ fn find_channel_meta(name: &str) -> Option<&'static ChannelMeta> {
CHANNEL_REGISTRY.iter().find(|c| c.name == name)
}
#[cfg(test)]
mod channel_meta_tests {
use super::*;
#[test]
fn feishu_channel_meta_includes_mode_field() {
let meta = find_channel_meta("feishu").expect("feishu channel meta should exist");
assert!(meta.fields.iter().any(|f| f.key == "mode"));
}
#[test]
fn feishu_channel_meta_template_includes_websocket_mode_default() {
let meta = find_channel_meta("feishu").expect("feishu channel meta should exist");
assert!(meta.config_template.contains("mode = \"websocket\""));
}
}
/// Serialize a channel's config to a JSON Value for pre-populating dashboard forms.
fn channel_config_values(
config: &openfang_types::config::ChannelsConfig,
@@ -3081,15 +3128,21 @@ pub async fn list_templates() -> impl IntoResponse {
.to_string_lossy()
.to_string();
let description = std::fs::read_to_string(&manifest_path)
.ok()
.and_then(|content| toml::from_str::<AgentManifest>(&content).ok())
let manifest_content = std::fs::read_to_string(&manifest_path).ok();
let description = manifest_content
.as_ref()
.and_then(|content| toml::from_str::<AgentManifest>(content).ok())
.map(|m| m.description)
.unwrap_or_default();
// Add category based on template name
let category = get_template_category(&name);
templates.push(serde_json::json!({
"name": name,
"description": description,
"category": category,
"manifest_toml": manifest_content.unwrap_or_default(),
}));
}
}
@@ -3102,6 +3155,24 @@ pub async fn list_templates() -> impl IntoResponse {
}))
}
fn get_template_category(name: &str) -> &str {
match name {
"hello-world" | "assistant" => "General",
"researcher" | "analyst" => "Research",
"coder" | "debugger" | "devops-lead" => "Development",
"writer" | "doc-writer" => "Writing",
"ops" | "planner" => "Operations",
"architect" | "security-auditor" => "Development",
"code-reviewer" | "data-scientist" | "test-engineer" => "Development",
"legal-assistant" | "email-assistant" | "social-media" => "Business",
"customer-support" | "sales-assistant" | "recruiter" => "Business",
"meeting-assistant" => "Business",
"translator" | "tutor" | "health-tracker" => "General",
"personal-finance" | "travel-planner" | "home-automation" => "General",
_ => "General",
}
}
/// GET /api/templates/:name — Get template details.
pub async fn get_template(Path(name): Path<String>) -> impl IntoResponse {
let agents_dir = openfang_kernel::config::openfang_home().join("agents");
@@ -3503,6 +3574,15 @@ pub async fn uninstall_skill(
}
}
/// POST /api/skills/reload — Hot-reload the skill registry from disk.
///
/// Called by the CLI after `openfang skill install` to notify the running
/// daemon that new skill files were added to the skills directory (#752).
pub async fn reload_skills(State(state): State<Arc<AppState>>) -> impl IntoResponse {
state.kernel.reload_skills();
Json(serde_json::json!({"status": "reloaded"}))
}
/// GET /api/marketplace/search — Search the FangHub marketplace.
pub async fn marketplace_search(
Query(params): Query<HashMap<String, String>>,
@@ -3821,6 +3901,9 @@ pub async fn clawhub_install(
match client.install(&req.slug, &skills_dir).await {
Ok(result) => {
// Hot-reload so agents see the new skill immediately (#752)
state.kernel.reload_skills();
let warnings: Vec<serde_json::Value> = result
.warnings
.iter()
@@ -4802,6 +4885,12 @@ pub async fn list_mcp_servers(State(state): State<Arc<AppState>>) -> impl IntoRe
"url": url,
})
}
openfang_types::config::McpTransportEntry::Http { url } => {
serde_json::json!({
"type": "http",
"url": url,
})
}
};
serde_json::json!({
"name": s.name,
@@ -5243,10 +5332,8 @@ pub async fn usage_daily(State(state): State<Arc<AppState>>) -> impl IntoRespons
/// GET /api/budget — Current budget status (limits, spend, % used).
pub async fn budget_status(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let status = state
.kernel
.metering
.budget_status(&state.kernel.config.budget);
let budget = state.budget_config.read().await;
let status = state.kernel.metering.budget_status(&budget);
Json(serde_json::to_value(&status).unwrap_or_default())
}
@@ -5255,34 +5342,26 @@ pub async fn update_budget(
State(state): State<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
// SAFETY: Budget config is updated in-place. Since KernelConfig is behind
// an Arc and we only have &self, we use ptr mutation (same pattern as OFP).
let config_ptr = &state.kernel.config as *const openfang_types::config::KernelConfig
as *mut openfang_types::config::KernelConfig;
// Apply updates
unsafe {
{
let mut budget = state.budget_config.write().await;
if let Some(v) = body["max_hourly_usd"].as_f64() {
(*config_ptr).budget.max_hourly_usd = v;
budget.max_hourly_usd = v;
}
if let Some(v) = body["max_daily_usd"].as_f64() {
(*config_ptr).budget.max_daily_usd = v;
budget.max_daily_usd = v;
}
if let Some(v) = body["max_monthly_usd"].as_f64() {
(*config_ptr).budget.max_monthly_usd = v;
budget.max_monthly_usd = v;
}
if let Some(v) = body["alert_threshold"].as_f64() {
(*config_ptr).budget.alert_threshold = v.clamp(0.0, 1.0);
budget.alert_threshold = v.clamp(0.0, 1.0);
}
if let Some(v) = body["default_max_llm_tokens_per_hour"].as_u64() {
(*config_ptr).budget.default_max_llm_tokens_per_hour = v;
budget.default_max_llm_tokens_per_hour = v;
}
}
let status = state
.kernel
.metering
.budget_status(&state.kernel.config.budget);
let budget = state.budget_config.read().await;
let status = state.kernel.metering.budget_status(&budget);
Json(serde_json::to_value(&status).unwrap_or_default())
}
@@ -8813,20 +8892,16 @@ pub async fn patch_agent_config(
if !new_model.is_empty() {
if let Some(ref new_provider) = req.provider {
if !new_provider.is_empty() {
// Explicit provider given — use it directly
if state
.kernel
.registry
.update_model_and_provider(
agent_id,
new_model.clone(),
new_provider.clone(),
)
.is_err()
// Explicit provider given — still route through set_agent_model
// so provider-specific auth/env hints stay in sync.
if let Err(e) =
state
.kernel
.set_agent_model(agent_id, new_model, Some(new_provider))
{
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found"})),
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e}")})),
);
}
} else {
@@ -9300,16 +9375,14 @@ fn is_allowed_content_type(ct: &str) -> bool {
/// POST /api/agents/{id}/upload — Upload a file attachment.
///
/// Accepts raw body bytes. The client must set:
/// - `Content-Type` header (e.g., `image/png`, `text/plain`, `application/pdf`)
/// - `X-Filename` header (original filename)
/// Accepts multipart/form-data. The client must include a file field with:
/// - `Content-Type` field (e.g., `image/png`, `text/plain`, `application/pdf`)
/// - `filename` attribute (original filename)
pub async fn upload_file(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
mut multipart: Multipart,
) -> impl IntoResponse {
// Validate agent ID format
let _agent_id: AgentId = match id.parse() {
Ok(id) => id,
Err(_) => {
@@ -9320,12 +9393,62 @@ pub async fn upload_file(
}
};
// Extract content type
let content_type = headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let mut file_data: Option<(Option<String>, String, axum::body::Bytes)> = None;
let mut filename_from_field: Option<String> = None;
while let Some(field) = multipart.next_field().await.transpose() {
let field = match field {
Ok(f) => f,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("Failed to read field: {}", e)})),
);
}
};
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
"file" => {
let filename_attr = field.file_name().map(|s| s.to_string());
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let bytes = match field.bytes().await {
Ok(b) => b,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(
serde_json::json!({"error": format!("Failed to read file: {}", e)}),
),
);
}
};
file_data = Some((filename_attr, content_type, bytes));
}
"filename" => {
filename_from_field = field.text().await.ok();
}
_ => {}
}
}
let (filename_attr, content_type, body) = match file_data {
Some(data) => data,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "No file provided"})),
);
}
};
let filename = filename_from_field
.or(filename_attr)
.unwrap_or_else(|| "upload".to_string());
if !is_allowed_content_type(&content_type) {
return (
@@ -9336,13 +9459,6 @@ pub async fn upload_file(
);
}
// Extract filename from header
let filename = headers
.get("X-Filename")
.and_then(|v| v.to_str().ok())
.unwrap_or("upload")
.to_string();
// Validate size
if body.len() > MAX_UPLOAD_SIZE {
return (
@@ -9487,25 +9603,28 @@ pub async fn serve_upload(Path(file_id): Path<String>) -> impl IntoResponse {
// Execution Approval System — backed by kernel.approval_manager
// ---------------------------------------------------------------------------
/// GET /api/approvals — List pending approval requests.
/// GET /api/approvals — List pending and recent approval requests.
///
/// Transforms field names to match the dashboard template expectations:
/// `action_summary` → `action`, `agent_id` → `agent_name`, `requested_at` → `created_at`.
pub async fn list_approvals(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pending = state.kernel.approval_manager.list_pending();
let total = pending.len();
let recent = state.kernel.approval_manager.list_recent(50);
// Resolve agent names for display
let registry_agents = state.kernel.registry.list();
let agent_name_for = |agent_id: &str| {
registry_agents
.iter()
.find(|ag| ag.id.to_string() == agent_id || ag.name == agent_id)
.map(|ag| ag.name.clone())
.unwrap_or_else(|| agent_id.to_string())
};
let approvals: Vec<serde_json::Value> = pending
let mut approvals: Vec<serde_json::Value> = pending
.into_iter()
.map(|a| {
let agent_name = registry_agents
.iter()
.find(|ag| ag.id.to_string() == a.agent_id || ag.name == a.agent_id)
.map(|ag| ag.name.as_str())
.unwrap_or(&a.agent_id);
let agent_name = agent_name_for(&a.agent_id);
serde_json::json!({
"id": a.id,
"agent_id": a.agent_id,
@@ -9523,6 +9642,42 @@ pub async fn list_approvals(State(state): State<Arc<AppState>>) -> impl IntoResp
})
.collect();
approvals.extend(recent.into_iter().map(|record| {
let request = record.request;
let agent_name = agent_name_for(&request.agent_id);
let status = match record.decision {
openfang_types::approval::ApprovalDecision::Approved => "approved",
openfang_types::approval::ApprovalDecision::Denied => "rejected",
openfang_types::approval::ApprovalDecision::TimedOut => "expired",
};
serde_json::json!({
"id": request.id,
"agent_id": request.agent_id,
"agent_name": agent_name,
"tool_name": request.tool_name,
"description": request.description,
"action_summary": request.action_summary,
"action": request.action_summary,
"risk_level": request.risk_level,
"requested_at": request.requested_at,
"created_at": request.requested_at,
"timeout_secs": request.timeout_secs,
"status": status,
"decided_at": record.decided_at,
"decided_by": record.decided_by,
})
}));
approvals.sort_by(|a, b| {
let a_pending = a["status"].as_str() == Some("pending");
let b_pending = b["status"].as_str() == Some("pending");
b_pending
.cmp(&a_pending)
.then_with(|| b["created_at"].as_str().cmp(&a["created_at"].as_str()))
});
let total = approvals.len();
Json(serde_json::json!({"approvals": approvals, "total": total}))
}
@@ -9709,78 +9864,84 @@ pub async fn config_schema(State(state): State<Arc<AppState>>) -> impl IntoRespo
.collect();
drop(catalog);
// Helper: normalize field definitions to objects with {name, type, label}
// so the frontend template can iterate and render inputs correctly.
let f = |name: &str, ftype: &str, label: &str| -> serde_json::Value {
serde_json::json!({"name": name, "type": ftype, "label": label})
};
Json(serde_json::json!({
"sections": {
"general": {
"root_level": true,
"fields": {
"api_listen": "string",
"api_key": "string",
"log_level": "string"
}
"fields": [
f("api_listen", "string", "API Listen Address"),
f("api_key", "string", "API Key"),
f("log_level", "string", "Log Level")
]
},
"default_model": {
"hot_reloadable": true,
"fields": {
"provider": { "type": "select", "options": provider_options },
"model": { "type": "select", "options": model_options },
"api_key_env": "string",
"base_url": "string"
}
"fields": [
{ "name": "provider", "type": "select", "label": "Provider", "options": provider_options },
{ "name": "model", "type": "select", "label": "Model", "options": model_options },
f("api_key_env", "string", "API Key Env Var"),
f("base_url", "string", "Base URL")
]
},
"memory": {
"fields": {
"decay_rate": "number",
"vector_dims": "number"
}
"fields": [
f("decay_rate", "number", "Decay Rate"),
f("vector_dims", "number", "Vector Dimensions")
]
},
"web": {
"fields": {
"provider": "string",
"timeout_secs": "number",
"max_results": "number"
}
"fields": [
f("provider", "string", "Search Provider"),
f("timeout_secs", "number", "Timeout (seconds)"),
f("max_results", "number", "Max Results")
]
},
"browser": {
"fields": {
"headless": "boolean",
"timeout_secs": "number",
"executable_path": "string"
}
"fields": [
f("headless", "boolean", "Headless Mode"),
f("timeout_secs", "number", "Timeout (seconds)"),
f("executable_path", "string", "Chrome/Chromium Path")
]
},
"network": {
"fields": {
"enabled": "boolean",
"listen_addr": "string",
"shared_secret": "string"
}
"fields": [
f("enabled", "boolean", "Enable OFP Network"),
f("listen_addr", "string", "Listen Address"),
f("shared_secret", "string", "Shared Secret")
]
},
"extensions": {
"fields": {
"auto_connect": "boolean",
"health_check_interval_secs": "number"
}
"fields": [
f("auto_connect", "boolean", "Auto Connect"),
f("health_check_interval_secs", "number", "Health Check Interval (s)")
]
},
"vault": {
"fields": {
"path": "string"
}
"fields": [
f("path", "string", "Vault Path")
]
},
"a2a": {
"fields": {
"enabled": "boolean",
"name": "string",
"description": "string",
"url": "string"
}
"fields": [
f("enabled", "boolean", "Enable A2A"),
f("name", "string", "Agent Name"),
f("description", "string", "Description"),
f("url", "string", "URL")
]
},
"channels": {
"fields": {
"telegram": "object",
"discord": "object",
"slack": "object",
"whatsapp": "object"
}
"fields": [
f("telegram", "object", "Telegram"),
f("discord", "object", "Discord"),
f("slack", "object", "Slack"),
f("whatsapp", "object", "WhatsApp")
]
}
}
}))
@@ -10116,6 +10277,66 @@ pub async fn cron_job_status(
}
}
// ---------------------------------------------------------------------------
// Run cron job on demand
// ---------------------------------------------------------------------------
/// POST /api/cron/jobs/{id}/run — Trigger a cron job immediately.
///
/// Returns `{"status": "triggered", "job_id": "..."}` and spawns the execution
/// in the background. The job's status can be polled via
/// `GET /api/cron/jobs/{id}/status`.
pub async fn run_cron_job(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let uuid = match uuid::Uuid::parse_str(&id) {
Ok(u) => u,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"status": "error", "error": "Invalid job ID"})),
);
}
};
let job_id = openfang_types::scheduler::CronJobId(uuid);
// Atomically check existence + enabled + reserve next_run in one lock hold.
let job = match state.kernel.cron_scheduler.try_claim_for_run(job_id) {
Ok(j) => j,
Err(openfang_kernel::cron::ClaimError::NotFound) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"status": "error", "error": "Job not found"})),
);
}
Err(openfang_kernel::cron::ClaimError::Disabled) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"status": "error", "error": "Job is disabled"})),
);
}
};
// Spawn execution in the background so we don't block the HTTP response.
let kernel = Arc::clone(&state.kernel);
let job_name = job.name.clone();
tokio::spawn(async move {
match kernel.cron_run_job(&job).await {
Ok(_) => tracing::info!(job = %job_name, "On-demand cron job completed"),
Err(e) => tracing::warn!(job = %job_name, error = %e, "On-demand cron job failed"),
}
});
(
StatusCode::OK,
Json(serde_json::json!({
"status": "triggered",
"job_id": id,
})),
)
}
// ---------------------------------------------------------------------------
// Webhook trigger endpoints
// ---------------------------------------------------------------------------
@@ -10471,21 +10692,24 @@ pub async fn pairing_notify(
/// GET /api/commands — List available chat commands (for dynamic slash menu).
pub async fn list_commands(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let mut commands = vec![
serde_json::json!({"cmd": "/help", "desc": "Show available commands"}),
serde_json::json!({"cmd": "/new", "desc": "Reset session (clear history)"}),
serde_json::json!({"cmd": "/compact", "desc": "Trigger LLM session compaction"}),
serde_json::json!({"cmd": "/model", "desc": "Show or switch model (/model [name])"}),
serde_json::json!({"cmd": "/stop", "desc": "Cancel current agent run"}),
serde_json::json!({"cmd": "/usage", "desc": "Show session token usage & cost"}),
serde_json::json!({"cmd": "/think", "desc": "Toggle extended thinking (/think [on|off|stream])"}),
let mut commands: Vec<serde_json::Value> = channel_command_specs()
.iter()
.map(|spec| {
serde_json::json!({
"cmd": format!("/{}", spec.name),
"desc": spec.desc,
"source": "channel",
})
})
.collect();
commands.extend([
serde_json::json!({"cmd": "/context", "desc": "Show context window usage & pressure"}),
serde_json::json!({"cmd": "/verbose", "desc": "Cycle tool detail level (/verbose [off|on|full])"}),
serde_json::json!({"cmd": "/queue", "desc": "Check if agent is processing"}),
serde_json::json!({"cmd": "/status", "desc": "Show system status"}),
serde_json::json!({"cmd": "/clear", "desc": "Clear chat display"}),
serde_json::json!({"cmd": "/exit", "desc": "Disconnect from agent"}),
];
]);
// Add skill-registered tool names as potential commands
if let Ok(registry) = state.kernel.skill_registry.read() {
+9
View File
@@ -51,6 +51,7 @@ pub async fn build_router(
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(kernel.config.budget.clone())),
});
// CORS: allow localhost origins by default. If API key is set, the API
@@ -335,6 +336,10 @@ pub async fn build_router(
"/api/skills/uninstall",
axum::routing::post(routes::uninstall_skill),
)
.route(
"/api/skills/reload",
axum::routing::post(routes::reload_skills),
)
.route(
"/api/marketplace/search",
axum::routing::get(routes::marketplace_search),
@@ -586,6 +591,10 @@ pub async fn build_router(
"/api/cron/jobs/{id}/status",
axum::routing::get(routes::cron_job_status),
)
.route(
"/api/cron/jobs/{id}/run",
axum::routing::post(routes::run_cron_job),
)
// Webhook trigger endpoints (external event injection)
.route("/hooks/wake", axum::routing::post(routes::webhook_wake))
.route("/hooks/agent", axum::routing::post(routes::webhook_agent))
+36 -11
View File
@@ -15,7 +15,13 @@
use axum::http::header;
use axum::response::IntoResponse;
/// Nonce placeholder in compile-time HTML, replaced at request time.
const NONCE_PLACEHOLDER: &str = "__NONCE__";
/// Compile-time ETag based on the crate version.
/// Not used for the dashboard page (nonce prevents caching) but retained
/// for potential future use by static asset handlers.
#[allow(dead_code)]
const ETAG: &str = concat!("\"openfang-", env!("CARGO_PKG_VERSION"), "\"");
/// Embedded logo PNG for single-binary deployment.
@@ -76,18 +82,35 @@ pub async fn sw_js() -> impl IntoResponse {
/// GET / — Serve the OpenFang Dashboard single-page application.
///
/// Returns the full SPA with ETag header based on package version for caching.
/// Generates a unique CSP nonce on every request and injects it into both
/// the `<script>` tags and the `Content-Security-Policy` header. This
/// replaces `'unsafe-inline'` so only our own scripts execute.
pub async fn webchat_page() -> impl IntoResponse {
let nonce = uuid::Uuid::new_v4().to_string();
let html = WEBCHAT_HTML.replace(NONCE_PLACEHOLDER, &nonce);
let csp = format!(
"default-src 'self'; \
script-src 'self' 'nonce-{nonce}' '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'"
);
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::ETAG, ETAG),
(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
(
header::CACHE_CONTROL,
"public, max-age=3600, must-revalidate",
header::HeaderName::from_static("content-security-policy"),
csp,
),
(header::CACHE_CONTROL, "no-store".to_string()),
],
WEBCHAT_HTML,
html,
)
}
@@ -110,23 +133,25 @@ const WEBCHAT_HTML: &str = concat!(
"\n</style>\n",
include_str!("../static/index_body.html"),
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/marked.min.js"),
"\n</script>\n",
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/highlight.min.js"),
"\n</script>\n",
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/chart.umd.min.js"),
"\n</script>\n",
// App code
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/js/api.js"),
"\n",
include_str!("../static/js/app.js"),
"\n",
include_str!("../static/js/pages/overview.js"),
"\n",
include_str!("../static/js/katex.js"),
"\n",
include_str!("../static/js/pages/chat.js"),
"\n",
include_str!("../static/js/pages/agents.js"),
@@ -160,7 +185,7 @@ const WEBCHAT_HTML: &str = concat!(
include_str!("../static/js/pages/runtime.js"),
"\n</script>\n",
// Alpine.js MUST be last — it processes x-data and fires alpine:init
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/alpine.min.js"),
"\n</script>\n",
"</body></html>"
+18 -8
View File
@@ -439,6 +439,7 @@ async fn handle_text_message(
// Resolve file attachments into image content blocks
let mut has_images = false;
let mut ws_content_blocks: Option<Vec<openfang_types::message::ContentBlock>> = None;
if let Some(attachments) = parsed["attachments"].as_array() {
let refs: Vec<crate::types::AttachmentRef> = attachments
.iter()
@@ -448,11 +449,7 @@ async fn handle_text_message(
let image_blocks = crate::routes::resolve_attachments(&refs);
if !image_blocks.is_empty() {
has_images = true;
crate::routes::inject_attachments_into_session(
&state.kernel,
agent_id,
image_blocks,
);
ws_content_blocks = Some(image_blocks);
}
}
}
@@ -508,6 +505,7 @@ async fn handle_text_message(
Some(kernel_handle),
None,
None,
ws_content_blocks,
) {
Ok((mut rx, handle)) => {
// Forward stream events to WebSocket with debouncing.
@@ -1000,11 +998,14 @@ async fn handle_command(
fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_json::Value> {
match event {
StreamEvent::TextDelta { .. } => None, // Handled by debounce buffer
StreamEvent::ToolUseStart { name, .. } => Some(serde_json::json!({
StreamEvent::ToolUseStart { id, name, .. } => Some(serde_json::json!({
"type": "tool_start",
"id": id,
"tool": name,
})),
StreamEvent::ToolUseEnd { name, input, .. } if name == "canvas_present" => {
StreamEvent::ToolUseEnd {
id, name, input, ..
} if name == "canvas_present" => {
let html = input.get("html").and_then(|v| v.as_str()).unwrap_or("");
let title = input
.get("title")
@@ -1012,12 +1013,15 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.unwrap_or("Canvas");
Some(serde_json::json!({
"type": "canvas",
"id": id,
"canvas_id": uuid::Uuid::new_v4().to_string(),
"html": html,
"title": title,
}))
}
StreamEvent::ToolUseEnd { name, input, .. } => match verbose {
StreamEvent::ToolUseEnd {
id, name, input, ..
} => match verbose {
VerboseLevel::Off => None,
VerboseLevel::On => {
let input_preview: String = serde_json::to_string(input)
@@ -1027,6 +1031,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.collect();
Some(serde_json::json!({
"type": "tool_end",
"id": id,
"tool": name,
"input": input_preview,
}))
@@ -1039,18 +1044,21 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.collect();
Some(serde_json::json!({
"type": "tool_end",
"id": id,
"tool": name,
"input": input_preview,
}))
}
},
StreamEvent::ToolExecutionResult {
id,
name,
result_preview,
is_error,
} => match verbose {
VerboseLevel::Off => Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"is_error": is_error,
})),
@@ -1058,6 +1066,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
let truncated: String = result_preview.chars().take(200).collect();
Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"result": truncated,
"is_error": is_error,
@@ -1065,6 +1074,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
}
VerboseLevel::Full => Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"result": result_preview,
"is_error": is_error,
@@ -647,6 +647,11 @@ mark.search-highlight {
color: var(--text);
}
.message-bubble.markdown-body ul,
.message-bubble.markdown-body ol {
padding-left: 2em;
}
.copy-btn {
position: absolute;
top: 6px;
@@ -1274,8 +1279,11 @@ mark.search-highlight {
/* Utility */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.grid { display: grid; }
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
@@ -1283,6 +1291,7 @@ mark.search-highlight {
.mt-4 { margin-top: 16px; }
.mb-2 { margin-bottom: 8px; }
.mb-4 { margin-bottom: 16px; }
.mb-6 { margin-bottom: 24px; }
.text-dim { color: var(--text-dim); }
.text-sm { font-size: 11px; }
.text-xs { font-size: 10px; }
+18 -1
View File
@@ -243,6 +243,14 @@
z-index: 99;
}
.mobile-menu-btn {
position: fixed !important;
top: 12px;
left: 16px;
z-index: 98;
padding: 6px 10px !important;
}
/* Wide desktop — larger card grids */
@media (min-width: 1400px) {
.card-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
@@ -277,6 +285,8 @@
left: -300px;
}
.mobile-menu-btn { display: flex !important; }
/* Offset header content so it does not overlap the fixed mobile menu button. */
.page-header > :first-child { margin-left: 52px; }
}
@media (min-width: 769px) {
@@ -285,7 +295,14 @@
/* Mobile small screen */
@media (max-width: 480px) {
.page-header { flex-direction: column; gap: 8px; align-items: flex-start; padding: 12px 16px; }
.page-header {
gap: 8px;
padding: 12px 16px;
flex-wrap: wrap;
}
.page-header h2 {
line-height: 44px;
}
.page-body { padding: 12px; }
.stats-row { flex-wrap: wrap; }
.stat-card { min-width: 80px; flex: 1 1 40%; }
+43 -41
View File
@@ -111,6 +111,7 @@
<a class="nav-item" :class="{ active: page === 'approvals' }" @click="navigate('approvals')" :aria-current="page === 'approvals' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
<span class="nav-label">Approvals</span>
<span class="badge badge-warn" x-show="$store.app.pendingApprovalCount > 0" x-text="$store.app.pendingApprovalCount"></span>
</a>
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
@@ -200,7 +201,7 @@
<!-- Main Content -->
<main class="main-content">
<!-- Mobile menu button -->
<button class="mobile-menu-btn btn btn-ghost" @click="mobileMenuOpen = !mobileMenuOpen" style="position:fixed;top:8px;left:8px;z-index:98;padding:6px 10px">
<button class="mobile-menu-btn btn btn-ghost" @click="mobileMenuOpen = !mobileMenuOpen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
@@ -880,7 +881,7 @@
<div class="text-sm font-bold mb-2" style="color:var(--text-dim);letter-spacing:0.5px;font-size:11px;text-transform:uppercase" x-text="agents.length ? 'Or Start a New Agent' : 'Start Chatting'"></div>
<div class="card-grid">
<template x-for="t in builtinTemplates" :key="t.name">
<div class="card" style="cursor:pointer" @click="spawnBuiltin(t)">
<div class="card" style="cursor:pointer" @click="t.manifest_toml ? spawnFromTemplate(t) : spawnBuiltin(t)">
<div class="flex justify-between items-center mb-1">
<div class="card-header" style="margin:0;font-size:14px;font-weight:600" x-text="t.name"></div>
<span class="badge badge-dim" x-text="t.category"></span>
@@ -897,7 +898,7 @@
<!-- Agent detail modal with tabs (Info / Files / Config) -->
<template x-if="showDetailModal && detailAgent">
<div class="modal-overlay" @click.self="showDetailModal = false" @keydown.escape.window="showDetailModal = false">
<div class="modal" style="max-width:600px">
<div class="modal" style="max-width:700px">
<div class="modal-header">
<h3>
<span x-show="detailAgent.identity && detailAgent.identity.emoji" x-text="detailAgent.identity && detailAgent.identity.emoji" style="margin-right:6px"></span>
@@ -961,13 +962,13 @@
<!-- Fallback Model Chain -->
<div class="detail-row" style="align-items:flex-start">
<span class="detail-label">Fallbacks</span>
<div style="flex:1">
<div style="flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;margin-left:16px">
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
<div>
<div style="display:flex;flex-direction:column;gap:4px">
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
<div class="flex gap-1 items-center" style="margin-bottom:4px">
<span class="badge" style="font-size:11px;font-family:var(--font-mono)" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger)" @click="removeFallback(idx)">&times;</button>
<div class="flex gap-1 items-center" style="min-width:0">
<span class="badge" style="font-size:11px;font-family:var(--font-mono);word-break:break-all;white-space:normal" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger);flex-shrink:0" @click="removeFallback(idx)">&times;</button>
</div>
</template>
</div>
@@ -976,10 +977,12 @@
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
</template>
<template x-if="!editingFallback">
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px;margin-top:4px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
<div>
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
</div>
</template>
<template x-if="editingFallback">
<div class="flex gap-1 mt-1" style="align-items:center">
<div class="flex gap-1" style="align-items:center">
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
@@ -1154,28 +1157,26 @@
<div class="form-group">
<label>Provider</label>
<select class="form-select" x-model="spawnForm.provider">
<optgroup label="Cloud">
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="gemini">Google Gemini</option>
<option value="groq">Groq</option>
<option value="deepseek">DeepSeek</option>
<option value="openrouter">OpenRouter</option>
<option value="mistral">Mistral</option>
<option value="xai">xAI</option>
<option value="together">Together</option>
<option value="fireworks">Fireworks</option>
<option value="cerebras">Cerebras</option>
<option value="sambanova">SambaNova</option>
<option value="azure">Azure OpenAI</option>
<option value="nvidia">NVIDIA NIM</option>
</optgroup>
<optgroup label="Local">
<option value="ollama">Ollama</option>
<option value="lmstudio">LM Studio</option>
<option value="vllm">vLLM</option>
<option value="lemonade">Lemonade</option>
</optgroup>
<template x-if="spawnProvidersLoading">
<option disabled>Loading providers…</option>
</template>
<template x-if="!spawnProvidersLoading && spawnProviders.length === 0">
<option disabled>No providers available</option>
</template>
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => !p.is_local).length > 0">
<optgroup label="Cloud">
<template x-for="p in spawnProviders.filter(p => !p.is_local)" :key="p.id">
<option :value="p.id" x-text="p.display_name"></option>
</template>
</optgroup>
</template>
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => p.is_local).length > 0">
<optgroup label="Local">
<template x-for="p in spawnProviders.filter(p => p.is_local)" :key="p.id">
<option :value="p.id" x-text="p.display_name"></option>
</template>
</optgroup>
</template>
</select>
</div>
<div class="form-group">
@@ -1276,7 +1277,7 @@
<!-- Page: Approvals -->
<template x-if="page === 'approvals'">
<div x-data="approvalsPage" x-init="loadData()">
<div x-data="approvalsPage()" x-init="init()">
<div class="page-header">
<h2>Execution Approvals</h2>
<div class="flex items-center gap-2">
@@ -1298,6 +1299,7 @@
<button class="filter-pill" :class="{ active: filterStatus === 'pending' }" @click="filterStatus = 'pending'">Pending</button>
<button class="filter-pill" :class="{ active: filterStatus === 'approved' }" @click="filterStatus = 'approved'">Approved</button>
<button class="filter-pill" :class="{ active: filterStatus === 'rejected' }" @click="filterStatus = 'rejected'">Rejected</button>
<button class="filter-pill" :class="{ active: filterStatus === 'expired' }" @click="filterStatus = 'expired'">Expired</button>
</div>
<div x-show="filtered.length === 0" class="empty-state">
<h4>No approvals</h4>
@@ -1340,7 +1342,7 @@
<!-- Tab: List -->
<template x-if="wfTab === 'list'">
<div x-data="workflowsPage">
<div x-data="workflowsPage" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden">
<div class="page-body" x-init="loadWorkflows()">
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading workflows...</span></div>
<div x-show="!loading && loadError" class="error-state">
@@ -3366,7 +3368,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
</div>
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
<div x-show="!filteredModels.length && !settingsLoading" style="text-align:center;padding:32px 16px">
<div x-show="!filteredModels.length && !loading" style="text-align:center;padding:32px 16px">
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">&#x1F916;</div>
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
@@ -3400,7 +3402,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<input placeholder="Search tools..." x-model="toolSearch">
</div>
<div class="text-xs text-dim mb-2" x-text="filteredTools.length + ' of ' + tools.length + ' tools'"></div>
<div x-show="!filteredTools.length && !settingsLoading" style="text-align:center;padding:32px 16px">
<div x-show="!filteredTools.length && !loading" style="text-align:center;padding:32px 16px">
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">&#x1F527;</div>
<h3 style="margin:0 0 4px;font-size:14px" x-text="tools.length ? 'No tools match your search' : 'No tools available'"></h3>
<p class="text-xs text-dim" x-text="tools.length ? 'Try a different search term.' : 'Tools will appear once agents are configured.'"></p>
@@ -3449,7 +3451,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div class="card mb-4">
<div class="card-header" style="text-transform:capitalize" x-text="section.replace(/_/g, ' ')"></div>
<div class="detail-grid" style="margin-top:12px">
<template x-for="field in fields" :key="section + '.' + field.name">
<template x-for="field in (fields.fields || [])" :key="section + '.' + field.name">
<div class="detail-row" style="align-items:center">
<span class="detail-label" x-text="field.label || field.name"></span>
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
@@ -3469,8 +3471,8 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<select class="form-select" style="width:180px"
:value="configValues[section] && configValues[section][field.name]"
@change="configValues[section] = configValues[section] || {}; configValues[section][field.name] = $event.target.value; markConfigDirty(section, field.name)">
<template x-for="opt in field.options" :key="opt">
<option :value="opt" x-text="opt" :selected="configValues[section] && configValues[section][field.name] === opt"></option>
<template x-for="opt in field.options" :key="typeof opt === 'object' ? opt.id : opt">
<option :value="typeof opt === 'object' ? opt.id : opt" x-text="typeof opt === 'object' ? (opt.name || opt.display_name || opt.id) : opt" :selected="configValues[section] && configValues[section][field.name] === (typeof opt === 'object' ? opt.id : opt)"></option>
</template>
</select>
</template>
@@ -4312,7 +4314,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div x-data="logsPage">
<div class="page-header">
<h2>Logs</h2>
<div class="flex gap-2 items-center" x-show="tab === 'live'">
<div class="flex gap-2 items-center flex-wrap" x-show="tab === 'live'">
<!-- Connection status indicator -->
<span class="live-indicator" :class="connectionClass">
<span class="live-dot"></span>
@@ -4988,7 +4990,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div class="page-body" x-init="loadData()">
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading runtime info...</span></div>
<div x-show="!loading">
<div class="grid grid-cols-4" style="gap:16px;margin-bottom:24px">
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="card stat-card">
<div class="stat-label">Uptime</div>
<div class="stat-value" x-text="uptime"></div>
@@ -11,7 +11,4 @@
<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>
+23 -12
View File
@@ -224,9 +224,12 @@ var OpenFangAPI = (function() {
try {
var url = WS_BASE + '/api/agents/' + agentId + '/ws';
if (_authToken) url += '?token=' + encodeURIComponent(_authToken);
_ws = new WebSocket(url);
var socket = new WebSocket(url);
_ws = socket;
_ws.onopen = function() {
socket.onopen = function() {
// Guard: ignore if this socket was superseded by a newer connection
if (_ws !== socket) return;
_wsConnected = true;
_reconnectAttempts = 0;
setConnectionState('connected');
@@ -237,14 +240,20 @@ var OpenFangAPI = (function() {
if (_wsCallbacks.onOpen) _wsCallbacks.onOpen();
};
_ws.onmessage = function(e) {
socket.onmessage = function(e) {
try {
var data = JSON.parse(e.data);
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
} catch(err) { /* ignore parse errors */ }
} catch(parseErr) {
return; // Ignore malformed JSON frames
}
// Dispatch outside try/catch so handler errors are not swallowed
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
};
_ws.onclose = function(e) {
socket.onclose = function(e) {
// Guard: only update state if this is still the active socket.
// A superseded socket closing must not null-out the new connection.
if (_ws !== socket) return;
_wsConnected = false;
_ws = null;
if (_wsAgentId && _reconnectAttempts < MAX_RECONNECT && e.code !== 1000) {
@@ -265,7 +274,9 @@ var OpenFangAPI = (function() {
if (_wsCallbacks.onClose) _wsCallbacks.onClose();
};
_ws.onerror = function() {
socket.onerror = function() {
// Guard: ignore errors from superseded sockets
if (_ws !== socket) return;
_wsConnected = false;
if (_wsCallbacks.onError) _wsCallbacks.onError();
};
@@ -297,15 +308,15 @@ var OpenFangAPI = (function() {
function getToken() { return _authToken; }
function upload(agentId, file) {
var hdrs = {
'Content-Type': file.type || 'application/octet-stream',
'X-Filename': file.name
};
var hdrs = {};
if (_authToken) hdrs['Authorization'] = 'Bearer ' + _authToken;
var form = new FormData();
form.append('file', file);
form.append('filename', file.name);
return fetch(BASE + '/api/agents/' + agentId + '/upload', {
method: 'POST',
headers: hdrs,
body: file
body: form
}).then(function(r) {
if (!r.ok) throw new Error('Upload failed');
return r.json();
+24 -21
View File
@@ -66,26 +66,6 @@ function renderMarkdown(text) {
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) {
@@ -154,6 +134,8 @@ document.addEventListener('alpine:init', function() {
lastError: '',
version: '0.1.0',
agentCount: 0,
pendingApprovalCount: 0,
lastPendingApprovalSignature: '',
pendingAgent: null,
focusMode: localStorage.getItem('openfang-focus') === 'true',
showOnboarding: false,
@@ -174,6 +156,23 @@ document.addEventListener('alpine:init', function() {
} catch(e) { /* silent */ }
},
async refreshApprovals() {
try {
var data = await OpenFangAPI.get('/api/approvals');
var approvals = Array.isArray(data) ? data : (data.approvals || []);
var pending = approvals.filter(function(a) { return a.status === 'pending'; });
var signature = pending
.map(function(a) { return a.id; })
.sort()
.join(',');
if (pending.length > 0 && signature !== this.lastPendingApprovalSignature && typeof OpenFangToast !== 'undefined') {
OpenFangToast.warn('An agent is waiting for approval. Open Approvals to review.');
}
this.pendingApprovalCount = pending.length;
this.lastPendingApprovalSignature = signature;
} catch(e) { /* silent */ }
},
async checkStatus() {
try {
var s = await OpenFangAPI.get('/api/status');
@@ -370,9 +369,13 @@ function app() {
// Initial data load
this.pollStatus();
Alpine.store('app').refreshApprovals();
Alpine.store('app').checkOnboarding();
Alpine.store('app').checkAuth();
setInterval(function() { self.pollStatus(); }, 5000);
setInterval(function() {
self.pollStatus();
Alpine.store('app').refreshApprovals();
}, 5000);
},
navigate(p) {
+84
View File
@@ -0,0 +1,84 @@
// On-demand KaTeX loader and renderer for chat messages.
var KATEX_VERSION = '0.16.21';
var KATEX_CSS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.css';
var KATEX_JS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.js';
var KATEX_AUTORENDER_URL =
'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/contrib/auto-render.min.js';
var katexLoadPromise = null;
function hasLatexDelimiters(text) {
if (!text) return false;
return /\$\$|\\\[|\\\(|\$(?=\S)[^$\n]+\$/.test(text);
}
function loadScript(url) {
return new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = url;
script.async = true;
script.onload = function () {
resolve();
};
script.onerror = function () {
reject(new Error('Failed to load script: ' + url));
};
document.head.appendChild(script);
});
}
function ensureKatexLoaded() {
if (typeof renderMathInElement === 'function') return Promise.resolve(true);
if (katexLoadPromise) return katexLoadPromise;
katexLoadPromise = new Promise(function (resolve) {
var cssId = 'openfang-katex-css';
if (!document.getElementById(cssId)) {
var link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = KATEX_CSS_URL;
document.head.appendChild(link);
}
loadScript(KATEX_JS_URL)
.then(function () {
return loadScript(KATEX_AUTORENDER_URL);
})
.then(function () {
resolve(typeof renderMathInElement === 'function');
})
.catch(function () {
katexLoadPromise = null;
resolve(false);
});
});
return katexLoadPromise;
}
// 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) {
var target = el || document.getElementById('messages');
if (!target) return;
if (!hasLatexDelimiters(target.textContent || '')) return;
ensureKatexLoaded().then(function (ok) {
if (!ok || typeof renderMathInElement !== 'function') 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 */
}
});
}
+100 -107
View File
@@ -40,6 +40,8 @@ function agentsPage() {
},
// -- Multi-step wizard state --
spawnProviders: [], // populated from /api/providers on wizard open
spawnProvidersLoading: false,
spawnStep: 1,
spawnIdentity: { emoji: '', color: '#FF5C00', archetype: '' },
selectedPreset: '',
@@ -92,98 +94,12 @@ function agentsPage() {
selectedCategory: 'All',
searchQuery: '',
builtinTemplates: [
{
name: 'General Assistant',
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.'
},
{
name: 'Code Helper',
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.'
},
{
name: 'Researcher',
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
category: 'Research',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'research',
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.'
},
{
name: 'Writer',
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
category: 'Writing',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.'
},
{
name: 'Data Analyst',
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.'
},
{
name: 'DevOps Engineer',
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'automation',
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.'
},
{
name: 'Customer Support',
description: 'A professional, empathetic agent for handling customer inquiries and resolving issues.',
category: 'Business',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'messaging',
system_prompt: 'You are a professional customer support representative. Be empathetic, patient, and solution-oriented. Acknowledge concerns before offering solutions. Escalate complex issues appropriately.'
},
{
name: 'Tutor',
description: 'A patient educational agent that explains concepts step-by-step and adapts to the learner\'s level.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a patient and encouraging tutor. Explain concepts step by step, starting from fundamentals. Use analogies and examples. Check understanding before moving on. Adapt to the learner\'s pace.'
},
{
name: 'API Designer',
description: 'An agent specialized in RESTful API design, OpenAPI specs, and integration architecture.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are an API design expert. Help users design clean, consistent RESTful APIs following best practices. Cover endpoint naming, request/response schemas, error handling, and versioning.'
},
{
name: 'Meeting Notes',
description: 'Summarizes meeting transcripts into structured notes with action items and key decisions.',
category: 'Business',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'minimal',
system_prompt: 'You are a meeting summarizer. When given a meeting transcript or notes, produce a structured summary with: key decisions, action items (with owners), discussion highlights, and follow-up questions.'
}
],
builtinTemplates: [],
// Load templates from API
async init() {
await this.loadTemplates();
},
// ── Profile Descriptions ──
profileDescriptions: {
@@ -280,6 +196,7 @@ function agentsPage() {
this.loadError = '';
try {
await Alpine.store('app').refreshAgents();
await this.loadTemplates();
} catch(e) {
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
}
@@ -317,10 +234,73 @@ function agentsPage() {
OpenFangAPI.get('/api/templates'),
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
]);
this.tplTemplates = results[0].templates || [];
// Combine static and dynamic templates
this.builtinTemplates = [
{
name: 'General Assistant',
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.',
manifest_toml: 'name = "General Assistant"\ndescription = "A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
},
{
name: 'Code Helper',
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.',
manifest_toml: 'name = "Code Helper"\ndescription = "A programming-focused agent that writes, reviews, and debugs code across multiple languages."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
},
{
name: 'Researcher',
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
category: 'Research',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'research',
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.',
manifest_toml: 'name = "Researcher"\ndescription = "An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries."\nmodule = "builtin:chat"\nprofile = "research"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
},
{
name: 'Writer',
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
category: 'Writing',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.',
manifest_toml: 'name = "Writer"\ndescription = "A creative writing agent that helps with drafting, editing, and improving written content of all kinds."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
},
{
name: 'Data Analyst',
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.',
manifest_toml: 'name = "Data Analyst"\ndescription = "A data-focused agent that helps analyze datasets, create queries, and interpret statistical results."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
},
{
name: 'DevOps Engineer',
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'automation',
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.',
manifest_toml: 'name = "DevOps Engineer"\ndescription = "A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting."\nmodule = "builtin:chat"\nprofile = "automation"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
},
...results[0].templates || []
];
this.tplProviders = results[1].providers || [];
} catch(e) {
this.tplTemplates = [];
this.builtinTemplates = [];
this.tplLoadError = e.message || 'Could not load templates.';
}
this.tplLoading = false;
@@ -407,14 +387,22 @@ function agentsPage() {
this.spawnForm.model = 'llama-3.3-70b-versatile';
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
this.spawnForm.profile = 'full';
// Fetch status defaults and dynamic provider list concurrently
this.spawnProvidersLoading = true;
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 */ }
var results = await Promise.all([
OpenFangAPI.get('/api/status').catch(function() { return {}; }),
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
]);
var status = results[0];
var provData = results[1];
if (status.default_provider) this.spawnForm.provider = status.default_provider;
if (status.default_model) this.spawnForm.model = status.default_model;
this.spawnProviders = provData.providers || [];
} catch(e) {
this.spawnProviders = [];
}
this.spawnProvidersLoading = false;
},
nextStep() {
@@ -590,15 +578,20 @@ function agentsPage() {
},
// -- Template methods --
async spawnFromTemplate(name) {
async spawnFromTemplate(template) {
try {
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(name));
if (data.manifest_toml) {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: data.manifest_toml });
var manifestToml = template.manifest_toml;
if (!manifestToml) {
// If template doesn't have manifest_toml, fetch it from the API
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(template.name));
manifestToml = data.manifest_toml;
}
if (manifestToml) {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: manifestToml });
if (res.agent_id) {
OpenFangToast.success('Agent "' + (res.name || name) + '" spawned from template');
OpenFangToast.success('Agent "' + (res.name || template.name) + '" spawned from template');
await Alpine.store('app').refreshAgents();
this.chatWithAgent({ id: res.agent_id, name: res.name || name, model_provider: '?', model_name: '?' });
this.chatWithAgent({ id: res.agent_id, name: res.name || template.name, model_provider: '?', model_name: '?' });
}
}
} catch(e) {
@@ -7,6 +7,22 @@ function approvalsPage() {
filterStatus: 'all',
loading: true,
loadError: '',
refreshTimer: null,
init() {
var self = this;
this.loadData();
this.refreshTimer = setInterval(function() {
self.loadData();
}, 5000);
},
destroy() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
},
get filtered() {
var f = this.filterStatus;
+31 -8
View File
@@ -622,8 +622,12 @@ function chatPage() {
this.scrollToBottom();
this._resetTypingTimeout();
} else if (data.level) {
var lastThink = this.messages[this.messages.length - 1];
if (lastThink && lastThink.thinking) lastThink.text = 'Thinking (' + data.level + ')...';
var thinkIdx = this.messages.length - 1;
var lastThink = thinkIdx >= 0 ? this.messages[thinkIdx] : null;
if (lastThink && lastThink.thinking) {
lastThink.text = 'Thinking (' + data.level + ')...';
this.messages.splice(thinkIdx, 1, lastThink);
}
}
break;
@@ -636,9 +640,11 @@ function chatPage() {
}
this._resetTypingTimeout();
} else if (data.state === 'tool') {
var typingMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
var toolTypIdx = this.messages.length - 1;
var typingMsg = toolTypIdx >= 0 ? this.messages[toolTypIdx] : null;
if (typingMsg && (typingMsg.thinking || typingMsg.streaming)) {
typingMsg.text = 'Using ' + (data.tool || 'tool') + '...';
this.messages.splice(toolTypIdx, 1, typingMsg);
}
this._resetTypingTimeout();
} else if (data.state === 'stop') {
@@ -648,7 +654,8 @@ function chatPage() {
case 'phase':
// Show tool/phase progress so the user sees the agent is working
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
var phaseIdx = this.messages.length - 1;
var phaseMsg = phaseIdx >= 0 ? this.messages[phaseIdx] : null;
if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) {
// Skip phases that have no user-meaningful display text — "streaming"
// and "done" are lifecycle signals, not status to show in the chat bubble.
@@ -664,6 +671,7 @@ function chatPage() {
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
phaseMsg._reasoning += (data.detail || '') + '\n';
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
this.messages.splice(phaseIdx, 1, phaseMsg);
} else if (phaseMsg.thinking) {
// Only update text on messages still in thinking state (not yet
// receiving streamed content) to avoid overwriting accumulated text.
@@ -676,13 +684,15 @@ function chatPage() {
phaseDetail = data.detail || 'Working...';
}
phaseMsg.text = phaseDetail;
this.messages.splice(phaseIdx, 1, phaseMsg);
}
}
this.scrollToBottom();
break;
case 'text_delta':
var last = this.messages.length ? this.messages[this.messages.length - 1] : null;
var lastIdx = this.messages.length - 1;
var last = lastIdx >= 0 ? this.messages[lastIdx] : null;
if (last && last.streaming) {
if (last.thinking) { last.text = ''; last.thinking = false; }
// If we already detected a text-based tool call, skip further text
@@ -711,6 +721,10 @@ function chatPage() {
}
}
this.tokenCount = Math.round(last.text.length / 4);
// Force Alpine reactivity: splice-in-place so x-for re-renders
// this item. Direct property mutation on array elements may not
// trigger DOM updates from async WebSocket callbacks.
this.messages.splice(lastIdx, 1, last);
} else {
this.messages.push({ id: ++msgId, role: 'agent', text: data.content, meta: '', streaming: true, tools: [] });
}
@@ -718,17 +732,20 @@ function chatPage() {
break;
case 'tool_start':
var lastMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
var tsIdx = this.messages.length - 1;
var lastMsg = tsIdx >= 0 ? this.messages[tsIdx] : null;
if (lastMsg && lastMsg.streaming) {
if (!lastMsg.tools) lastMsg.tools = [];
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: true, input: '', result: '', is_error: false });
this.messages.splice(tsIdx, 1, lastMsg);
}
this.scrollToBottom();
break;
case 'tool_end':
// Tool call parsed by LLM — update tool card with input params
var lastMsg2 = this.messages.length ? this.messages[this.messages.length - 1] : null;
var teIdx = this.messages.length - 1;
var lastMsg2 = teIdx >= 0 ? this.messages[teIdx] : null;
if (lastMsg2 && lastMsg2.tools) {
for (var ti = lastMsg2.tools.length - 1; ti >= 0; ti--) {
if (lastMsg2.tools[ti].name === data.tool && lastMsg2.tools[ti].running) {
@@ -736,12 +753,14 @@ function chatPage() {
break;
}
}
this.messages.splice(teIdx, 1, lastMsg2);
}
break;
case 'tool_result':
// Tool execution completed — update tool card with result
var lastMsg3 = this.messages.length ? this.messages[this.messages.length - 1] : null;
var trIdx = this.messages.length - 1;
var lastMsg3 = trIdx >= 0 ? this.messages[trIdx] : null;
if (lastMsg3 && lastMsg3.tools) {
for (var ri = lastMsg3.tools.length - 1; ri >= 0; ri--) {
if (lastMsg3.tools[ri].name === data.tool && lastMsg3.tools[ri].running) {
@@ -770,6 +789,7 @@ function chatPage() {
break;
}
}
this.messages.splice(trIdx, 1, lastMsg3);
}
this.scrollToBottom();
break;
@@ -1125,6 +1145,9 @@ function chatPage() {
formatToolJson: function(text) {
if (!text) return '';
if (typeof text === 'object') {
return JSON.stringify(text, null, 2);
}
try { return JSON.stringify(JSON.parse(text), null, 2); }
catch(e) { return text; }
},
@@ -201,15 +201,17 @@ function schedulerPage() {
async runNow(job) {
this.runningJobId = job.id;
try {
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
if (result.status === 'completed') {
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
job.last_run = new Date().toISOString();
var result = await OpenFangAPI.post('/api/cron/jobs/' + job.id + '/run', {});
if (result.status === 'triggered' || result.status === 'completed') {
OpenFangToast.success('Job "' + (job.name || 'job') + '" triggered');
// Don't update job.last_run here — the job runs asynchronously in the
// background. The real last_run is set by the server on completion and
// will appear on the next data refresh.
} else {
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
OpenFangToast.error('Run failed: ' + (result.error || 'Unknown error'));
}
} catch(e) {
OpenFangToast.error('Run Now is not yet available for cron jobs');
OpenFangToast.error('Run failed: ' + (e.message || e));
}
this.runningJobId = '';
},
@@ -254,6 +254,15 @@ function wizardPage() {
this.error = '';
try {
await this.loadProviders();
// Pre-select first unconfigured provider, or first one
var unconfigured = this.providers.filter(function(p) {
return p.auth_status !== 'configured' && p.api_key_env;
});
if (unconfigured.length > 0) {
this.selectedProvider = unconfigured[0].id;
} else if (this.providers.length > 0) {
this.selectedProvider = this.providers[0].id;
}
} catch(e) {
this.error = e.message || 'Could not load setup data.';
}
@@ -313,15 +322,6 @@ function wizardPage() {
try {
var data = await OpenFangAPI.get('/api/providers');
this.providers = data.providers || [];
// Pre-select first unconfigured provider, or first one
var unconfigured = this.providers.filter(function(p) {
return p.auth_status !== 'configured' && p.api_key_env;
});
if (unconfigured.length > 0) {
this.selectedProvider = unconfigured[0].id;
} else if (this.providers.length > 0) {
this.selectedProvider = this.providers[0].id;
}
} catch(e) { this.providers = []; }
},
@@ -78,6 +78,7 @@ async fn start_test_server_with_provider(
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
@@ -707,6 +708,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let api_key = state.kernel.config.api_key.trim().to_string();
@@ -115,6 +115,7 @@ async fn test_full_daemon_lifecycle() {
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
@@ -240,6 +241,7 @@ async fn test_server_immediate_responsiveness() {
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
+1
View File
@@ -59,6 +59,7 @@ async fn start_test_server() -> TestServer {
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
+3 -1
View File
@@ -14,6 +14,7 @@ chrono = { workspace = true }
dashmap = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }
prost = { workspace = true }
reqwest = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
@@ -31,12 +32,13 @@ base64 = { workspace = true }
hex = { workspace = true }
html-escape = { workspace = true }
regex-lite = "0.1"
roxmltree = "0.20"
roxmltree = "0.21"
lettre = { workspace = true }
imap = { workspace = true }
native-tls = { workspace = true }
mailparse = { workspace = true }
rumqttc = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+141 -73
View File
@@ -13,6 +13,7 @@ use async_trait::async_trait;
use dashmap::DashMap;
use futures::StreamExt;
use openfang_types::agent::AgentId;
use openfang_types::approval::ApprovalRequest;
use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat};
use openfang_types::message::ContentBlock;
use std::sync::Arc;
@@ -20,6 +21,77 @@ use std::time::{Duration, Instant};
use tokio::sync::watch;
use tracing::{debug, error, info, warn};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ChatCommandSpec {
pub name: &'static str,
pub desc: &'static str,
pub help: &'static str,
pub section: &'static str,
}
const CHANNEL_COMMAND_SPECS: &[ChatCommandSpec] = &[
ChatCommandSpec { name: "start", desc: "Show welcome message", help: "/start - show welcome message", section: "General" },
ChatCommandSpec { name: "help", desc: "Show available commands", help: "/help - show this help", section: "General" },
ChatCommandSpec { name: "agents", desc: "List running agents", help: "/agents - list running agents", section: "Session" },
ChatCommandSpec { name: "agent", desc: "Select agent (/agent <name>)", help: "/agent <name> - select which agent to talk to", section: "Session" },
ChatCommandSpec { name: "new", desc: "Reset session (clear history)", help: "/new - reset session (clear messages)", section: "Session" },
ChatCommandSpec { name: "compact", desc: "Trigger LLM session compaction", help: "/compact - trigger LLM session compaction", section: "Session" },
ChatCommandSpec { name: "model", desc: "Show or switch model", help: "/model [name] - show or switch agent model", section: "Session" },
ChatCommandSpec { name: "stop", desc: "Cancel current agent run", help: "/stop - cancel current agent run", section: "Session" },
ChatCommandSpec { name: "usage", desc: "Show session usage and cost", help: "/usage - show session token usage and cost", section: "Session" },
ChatCommandSpec { name: "think", desc: "Toggle extended thinking", help: "/think [on|off] - toggle extended thinking", section: "Session" },
ChatCommandSpec { name: "status", desc: "Show system status", help: "/status - show system status", section: "Info" },
ChatCommandSpec { name: "models", desc: "List available AI models", help: "/models - list available AI models", section: "Info" },
ChatCommandSpec { name: "providers", desc: "Show configured providers", help: "/providers - show configured providers", section: "Info" },
ChatCommandSpec { name: "skills", desc: "List installed skills", help: "/skills - list installed skills", section: "Info" },
ChatCommandSpec { name: "hands", desc: "List available and active hands", help: "/hands - list available and active hands", section: "Info" },
ChatCommandSpec { name: "workflows", desc: "List workflows", help: "/workflows - list workflows", section: "Automation" },
ChatCommandSpec { name: "workflow", desc: "Run workflow (/workflow run <name> [input])", help: "/workflow run <name> [input] - run a workflow", section: "Automation" },
ChatCommandSpec { name: "triggers", desc: "List event triggers", help: "/triggers - list event triggers", section: "Automation" },
ChatCommandSpec { name: "trigger", desc: "Manage triggers", help: "/trigger add <agent> <pattern> <prompt> | /trigger del <id>", section: "Automation" },
ChatCommandSpec { name: "schedules", desc: "List cron jobs", help: "/schedules - list cron jobs", section: "Automation" },
ChatCommandSpec { name: "schedule", desc: "Manage schedules", help: "/schedule add <agent> <cron-5-fields> <message> | /schedule del <id> | /schedule run <id>", section: "Automation" },
ChatCommandSpec { name: "approvals", desc: "List pending approvals", help: "/approvals - list pending approvals", section: "Automation" },
ChatCommandSpec { name: "approve", desc: "Approve request", help: "/approve <id> - approve a request", section: "Automation" },
ChatCommandSpec { name: "reject", desc: "Reject request", help: "/reject <id> - reject a request", section: "Automation" },
ChatCommandSpec { name: "budget", desc: "Show spending limits and costs", help: "/budget - show spending limits and current costs", section: "Monitoring" },
ChatCommandSpec { name: "peers", desc: "Show OFP peer network status", help: "/peers - show OFP peer network status", section: "Monitoring" },
ChatCommandSpec { name: "a2a", desc: "List discovered external A2A agents", help: "/a2a - list discovered external A2A agents", section: "Monitoring" },
];
pub fn channel_command_specs() -> &'static [ChatCommandSpec] {
CHANNEL_COMMAND_SPECS
}
fn is_channel_command(name: &str) -> bool {
channel_command_specs().iter().any(|spec| spec.name == name)
}
fn format_channel_help() -> String {
let sections = ["General", "Session", "Info", "Automation", "Monitoring"];
let mut msg = String::from("OpenFang Bot Commands:");
for section in sections {
let commands: Vec<&ChatCommandSpec> = channel_command_specs()
.iter()
.filter(|spec| spec.section == section)
.collect();
if commands.is_empty() {
continue;
}
msg.push_str("\n\n");
msg.push_str(section);
msg.push_str(":\n");
for spec in commands {
msg.push_str(spec.help);
msg.push('\n');
}
msg.pop();
}
msg
}
/// Kernel operations needed by channel adapters.
///
/// Defined here to avoid circular deps (openfang-channels can't depend on openfang-kernel).
@@ -58,6 +130,20 @@ pub trait ChannelBridgeHandle: Send + Sync {
/// Spawn an agent by manifest name, returning its ID.
async fn spawn_agent_by_name(&self, manifest_name: &str) -> Result<AgentId, String>;
/// Transcribe raw audio bytes to text.
async fn transcribe_audio(
&self,
_audio_bytes: Vec<u8>,
_mime_type: &str,
) -> Result<String, String> {
Err("Audio transcription not available.".to_string())
}
/// List pending approval requests for a specific agent.
async fn pending_approvals_for_agent(&self, _agent_id: AgentId) -> Vec<ApprovalRequest> {
Vec::new()
}
/// Return uptime info string (e.g., "2h 15m, 5 agents").
async fn uptime_info(&self) -> String {
let agents = self.list_agents().await.unwrap_or_default();
@@ -396,6 +482,7 @@ fn channel_type_str(channel: &crate::types::ChannelType) -> &str {
crate::types::ChannelType::WebChat => "webchat",
crate::types::ChannelType::CLI => "cli",
crate::types::ChannelType::Custom(s) => s.as_str(),
_ => "unknown",
}
}
@@ -693,36 +780,7 @@ async fn dispatch_message(
vec![]
};
if matches!(
cmd,
"start"
| "help"
| "agents"
| "agent"
| "status"
| "models"
| "providers"
| "new"
| "compact"
| "model"
| "stop"
| "usage"
| "think"
| "skills"
| "hands"
| "workflows"
| "workflow"
| "triggers"
| "trigger"
| "schedules"
| "schedule"
| "approvals"
| "approve"
| "reject"
| "budget"
| "peers"
| "a2a"
) {
if is_channel_command(cmd) {
let result = handle_command(cmd, &args, handle, router, &message.sender).await;
send_response(adapter, &message.sender, result, thread_id, output_format).await;
return;
@@ -890,8 +948,24 @@ async fn dispatch_message(
// (which expire typing after ~5s) keep showing it during long LLM calls.
let typing_task = spawn_typing_loop(adapter_arc.clone(), message.sender.clone());
// Prepend sender context so the agent knows who is speaking.
// In group spaces this is essential for multi-user conversations.
let sender_name = &message.sender.display_name;
let sender_email = message
.metadata
.get("sender_email")
.and_then(|v| v.as_str());
let prefixed_text = if !sender_name.is_empty() {
match sender_email {
Some(email) => format!("[From: {sender_name} <{email}>] {text}"),
None => format!("[From: {sender_name}] {text}"),
}
} else {
text.clone()
};
// Send to agent and relay response
let result = handle.send_message(agent_id, &text).await;
let result = handle.send_message(agent_id, &prefixed_text).await;
// Stop the typing refresh now that we have a response
typing_task.abort();
@@ -1429,47 +1503,7 @@ async fn handle_command(
msg.push_str("\nCommands:\n/agents - list agents\n/agent <name> - select an agent\n/help - show this help");
msg
}
"help" => "OpenFang Bot Commands:\n\
\n\
Session:\n\
/agents - list running agents\n\
/agent <name> - select which agent to talk to\n\
/new - reset session (clear messages)\n\
/compact - trigger LLM session compaction\n\
/model [name] - show or switch agent model\n\
/stop - cancel current agent run\n\
/usage - show session token usage and cost\n\
/think [on|off] - toggle extended thinking\n\
\n\
Info:\n\
/models - list available AI models\n\
/providers - show configured providers\n\
/skills - list installed skills\n\
/hands - list available and active hands\n\
/status - show system status\n\
\n\
Automation:\n\
/workflows - list workflows\n\
/workflow run <name> [input] - run a workflow\n\
/triggers - list event triggers\n\
/trigger add <agent> <pattern> <prompt> - create trigger\n\
/trigger del <id> - remove trigger\n\
/schedules - list cron jobs\n\
/schedule add <agent> <cron-5-fields> <message> - create job\n\
/schedule del <id> - remove job\n\
/schedule run <id> - run job now\n\
/approvals - list pending approvals\n\
/approve <id> - approve a request\n\
/reject <id> - reject a request\n\
\n\
Monitoring:\n\
/budget - show spending limits and current costs\n\
/peers - show OFP peer network status\n\
/a2a - list discovered external A2A agents\n\
\n\
/start - show welcome message\n\
/help - show this help"
.to_string(),
"help" => format_channel_help(),
"status" => handle.uptime_info().await,
"agents" => {
let agents = handle.list_agents().await.unwrap_or_default();
@@ -1485,7 +1519,15 @@ async fn handle_command(
}
"agent" => {
if args.is_empty() {
return "Usage: /agent <name>".to_string();
let agents = handle.list_agents().await.unwrap_or_default();
if agents.is_empty() {
return "No agents running. Usage: /agent <name>".to_string();
}
let mut msg = "Usage: /agent <name>\n\nAvailable agents:\n".to_string();
for (_, name) in &agents {
msg.push_str(&format!(" - {name}\n"));
}
return msg.trim_end().to_string();
}
let agent_name = &args[0];
match handle.find_agent_by_name(agent_name).await {
@@ -1788,6 +1830,32 @@ mod tests {
assert_eq!(resolved, Some(agent_id));
}
#[tokio::test]
async fn test_handle_command_agent_without_args_lists_agents() {
let agent_id = AgentId::new();
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
agents: Mutex::new(vec![(agent_id, "coder".to_string())]),
});
let router = Arc::new(AgentRouter::new());
let sender = ChannelUser {
platform_id: "user1".to_string(),
display_name: "Test".to_string(),
openfang_user: None,
};
let result = handle_command("agent", &[], &handle, &router, &sender).await;
assert!(result.contains("Usage: /agent <name>"));
assert!(result.contains("coder"));
}
#[test]
fn test_channel_command_specs_include_agent_and_help() {
let specs = channel_command_specs();
assert!(specs.iter().any(|spec| spec.name == "help"));
assert!(specs.iter().any(|spec| spec.name == "agent"));
assert!(specs.iter().any(|spec| spec.name == "agents"));
}
#[test]
fn test_rate_limiter_allows_within_limit() {
let limiter = ChannelRateLimiter::default();
File diff suppressed because it is too large Load Diff
+1
View File
@@ -49,6 +49,7 @@ pub mod gitter;
pub mod gotify;
pub mod linkedin;
pub mod mumble;
pub mod mqtt;
pub mod ntfy;
pub mod webhook;
pub mod wecom;
+106 -9
View File
@@ -15,7 +15,7 @@ use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::{mpsc, watch};
use tracing::{info, warn};
use tracing::{debug, info, warn};
use zeroize::Zeroizing;
/// LINE push message API endpoint.
@@ -62,8 +62,8 @@ impl LineAdapter {
pub fn new(channel_secret: String, access_token: String, webhook_port: u16) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
channel_secret: Zeroizing::new(channel_secret),
access_token: Zeroizing::new(access_token),
channel_secret: Zeroizing::new(channel_secret.trim().to_string()),
access_token: Zeroizing::new(access_token.trim().to_string()),
webhook_port,
client: reqwest::Client::new(),
shutdown_tx: Arc::new(shutdown_tx),
@@ -96,12 +96,35 @@ impl LineAdapter {
// Constant-time comparison to prevent timing attacks
if result.len() != expected.len() {
debug!(
"LINE: signature length mismatch: computed={} received={}",
result.len(),
expected.len()
);
return false;
}
let mut diff = 0u8;
for (a, b) in result.iter().zip(expected.iter()) {
diff |= a ^ b;
}
if diff != 0 {
let computed = base64::engine::general_purpose::STANDARD.encode(&result);
// Log first/last 4 chars of each signature for debugging without leaking full HMAC
let comp_redacted = format!(
"{}...{}",
&computed[..4.min(computed.len())],
&computed[computed.len().saturating_sub(4)..]
);
let recv_redacted = format!(
"{}...{}",
&signature[..4.min(signature.len())],
&signature[signature.len().saturating_sub(4)..]
);
debug!(
"LINE: signature mismatch: computed={comp_redacted} received={recv_redacted} body_len={}",
body.len()
);
}
diff == 0
}
@@ -359,18 +382,18 @@ impl ChannelAdapter for LineAdapter {
let secret = Arc::clone(&channel_secret);
let tx = Arc::clone(&tx);
move |headers: axum::http::HeaderMap,
body: axum::extract::Json<serde_json::Value>| {
body: axum::body::Bytes| {
let secret = Arc::clone(&secret);
let tx = Arc::clone(&tx);
async move {
// Verify X-Line-Signature
// Verify X-Line-Signature using the raw request
// body bytes — NOT re-serialized JSON — because the
// HMAC must be computed over the exact bytes LINE sent.
let signature = headers
.get("x-line-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let body_bytes = serde_json::to_vec(&body.0).unwrap_or_default();
// Create a temporary adapter-like verifier
let adapter = LineAdapter {
channel_secret: secret.as_ref().clone(),
@@ -382,14 +405,23 @@ impl ChannelAdapter for LineAdapter {
};
if !signature.is_empty()
&& !adapter.verify_signature(&body_bytes, signature)
&& !adapter.verify_signature(&body, signature)
{
warn!("LINE: invalid webhook signature");
return axum::http::StatusCode::UNAUTHORIZED;
}
// Parse the raw bytes into JSON after signature verification
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => {
warn!("LINE: failed to parse webhook body as JSON: {e}");
return axum::http::StatusCode::BAD_REQUEST;
}
};
// Parse events array
if let Some(events) = body.0["events"].as_array() {
if let Some(events) = parsed["events"].as_array() {
for event in events {
if let Some(msg) = parse_line_event(event) {
let _ = tx.send(msg).await;
@@ -626,6 +658,71 @@ mod tests {
assert!(parse_line_event(&event).is_none());
}
#[test]
fn test_verify_signature_with_raw_body() {
// Verify that HMAC-SHA256 signature validation works with raw body bytes
let secret = "test-channel-secret";
let adapter = LineAdapter::new(secret.to_string(), "token".to_string(), 9000);
// Compute the expected signature manually
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let body = br#"{"events":[{"type":"message"}]}"#;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let expected_sig =
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
assert!(adapter.verify_signature(body, &expected_sig));
// Re-serialized JSON should NOT match (this was the bug)
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap();
let reserialized = serde_json::to_vec(&parsed).unwrap();
// The re-serialized form may differ in whitespace/key order
// If it happens to be identical for this input, the test still validates
// the core mechanism works with raw bytes
if reserialized != body.to_vec() {
assert!(!adapter.verify_signature(&reserialized, &expected_sig));
}
}
#[test]
fn test_channel_secret_trimmed() {
// Environment variables often have trailing newlines or spaces
let adapter = LineAdapter::new(
" my-secret\n".to_string(),
" my-token\r\n".to_string(),
9000,
);
assert_eq!(adapter.channel_secret.as_str(), "my-secret");
assert_eq!(adapter.access_token.as_str(), "my-token");
}
#[test]
fn test_verify_signature_bad_base64() {
let adapter = LineAdapter::new("secret".to_string(), "token".to_string(), 9000);
assert!(!adapter.verify_signature(b"body", "not-valid-base64!!!"));
}
#[test]
fn test_verify_signature_wrong_secret() {
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let body = b"test body";
let mut mac = HmacSha256::new_from_slice(b"wrong-secret").unwrap();
mac.update(body);
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
let adapter = LineAdapter::new("correct-secret".to_string(), "token".to_string(), 9000);
assert!(!adapter.verify_signature(body, &sig));
}
#[test]
fn test_parse_line_event_room_source() {
let event = serde_json::json!({
+59 -4
View File
@@ -46,6 +46,7 @@ impl MatrixAdapter {
user_id: String,
access_token: String,
allowed_rooms: Vec<String>,
auto_accept_invites: bool,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
@@ -57,7 +58,7 @@ impl MatrixAdapter {
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
since_token: Arc::new(RwLock::new(None)),
auto_accept_invites: true,
auto_accept_invites,
}
}
@@ -218,7 +219,11 @@ impl ChannelAdapter for MatrixAdapter {
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let homeserver = self.homeserver_url.clone();
let access_token = self.access_token.clone();
let user_id = self.user_id.clone();
// Use the validated user ID from /whoami instead of the config value.
// Matrix server delegation or casing differences can cause self.user_id
// to not match the sender field in timeline events, making the bot
// process its own replies in an infinite loop (see #757).
let user_id = validated_user;
let allowed_rooms = self.allowed_rooms.clone();
let client = self.client.clone();
let since_token = Arc::clone(&self.since_token);
@@ -235,6 +240,11 @@ impl ChannelAdapter for MatrixAdapter {
tokio::spawn(async move {
let mut backoff = Duration::from_secs(1);
// Track recently seen event IDs to prevent duplicate processing
// on sync token races or reconnects.
let mut seen_events: std::collections::HashSet<String> =
std::collections::HashSet::new();
const MAX_SEEN: usize = 500;
loop {
// Build /sync URL
@@ -325,6 +335,21 @@ impl ChannelAdapter for MatrixAdapter {
continue; // Skip own messages
}
// Dedup: skip events we've already processed.
let event_id_str =
event["event_id"].as_str().unwrap_or("").to_string();
if !event_id_str.is_empty() {
if seen_events.contains(&event_id_str) {
debug!("Matrix: skipping duplicate event {event_id_str}");
continue;
}
seen_events.insert(event_id_str.clone());
// Prevent unbounded growth
if seen_events.len() > MAX_SEEN {
seen_events.clear();
}
}
let content = event["content"]["body"].as_str().unwrap_or("");
if content.is_empty() {
continue;
@@ -345,7 +370,34 @@ impl ChannelAdapter for MatrixAdapter {
ChannelContent::Text(content.to_string())
};
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));
}
// FIX #2: Detect @mentions in message text.
let mut metadata = HashMap::new();
@@ -378,7 +430,7 @@ impl ChannelAdapter for MatrixAdapter {
let channel_msg = ChannelMessage {
channel: ChannelType::Matrix,
platform_message_id: event_id,
platform_message_id: event_id_str,
sender: ChannelUser {
platform_id: room_id.clone(),
display_name: sender.to_string(),
@@ -461,6 +513,7 @@ mod tests {
"@bot:matrix.org".to_string(),
"access_token".to_string(),
vec![],
false,
);
assert_eq!(adapter.name(), "matrix");
}
@@ -472,6 +525,7 @@ mod tests {
"@bot:matrix.org".to_string(),
"token".to_string(),
vec!["!room1:matrix.org".to_string()],
false,
);
assert!(adapter.is_allowed_room("!room1:matrix.org"));
assert!(!adapter.is_allowed_room("!room2:matrix.org"));
@@ -481,6 +535,7 @@ mod tests {
"@bot:matrix.org".to_string(),
"token".to_string(),
vec![],
false,
);
assert!(open.is_allowed_room("!any:matrix.org"));
}
+604
View File
@@ -0,0 +1,604 @@
//! MQTT channel adapter.
//!
//! Provides a generic MQTT pub/sub interface for IoT and messaging integration.
//! Supports standard MQTT 3.1.1/5.0 brokers with optional TLS and authentication.
//!
//! # Configuration
//!
//! ```toml
//! [channels.mqtt]
//! broker_url = "tcp://broker.hivemq.com:1883"
//! subscribe_topic = "openfang/inbox"
//! publish_topic = "openfang/outbox"
//! username_env = "MQTT_USERNAME"
//! password_env = "MQTT_PASSWORD"
//! use_tls = false
//! qos = 1
//! ```
//!
//! # Message Format
//!
//! Incoming messages are expected as UTF-8 text. The adapter supports:
//! - Plain text messages
//! - JSON payloads with `{"text": "message"}` format
//! - Command messages starting with `/`
use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use chrono::Utc;
use futures::Stream;
use rumqttc::{AsyncClient, Event, Incoming, MqttOptions, QoS};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{info, warn};
/// Maximum MQTT message length.
const MAX_MESSAGE_LEN: usize = 4096;
/// Type alias for the publish channel sender.
type PublishSender = Arc<RwLock<Option<mpsc::Sender<(String, String)>>>>;
/// MQTT pub/sub channel adapter.
///
/// Connects to an MQTT broker, subscribes to a topic for incoming messages,
/// and publishes responses to another topic.
pub struct MqttAdapter {
/// MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
broker_url: String,
/// Client identifier (auto-generated if empty).
client_id: String,
/// Topic to subscribe to for incoming messages.
subscribe_topic: String,
/// Topic to publish responses to.
publish_topic: String,
/// Optional username for authentication.
username: Option<String>,
/// Optional password for authentication.
password: Option<String>,
/// Use TLS/SSL connection.
use_tls: bool,
/// Keep-alive interval in seconds.
keep_alive: u16,
/// Clean session flag.
clean_session: bool,
/// QoS level for subscriptions.
qos: QoS,
/// Shutdown signal.
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
/// Sender for publishing messages (used to communicate with the event loop task).
publish_tx: PublishSender,
}
impl MqttAdapter {
/// Create a new MQTT adapter.
///
/// # Arguments
/// * `broker_url` - MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
/// * `client_id` - Client identifier (auto-generated if empty).
/// * `subscribe_topic` - Topic to subscribe to for incoming messages.
/// * `publish_topic` - Topic to publish responses to (defaults to subscribe_topic if empty).
/// * `username` - Optional username for authentication.
/// * `password` - Optional password for authentication.
/// * `use_tls` - Use TLS/SSL connection.
/// * `keep_alive` - Keep-alive interval in seconds.
/// * `clean_session` - Clean session flag.
/// * `qos` - QoS level (0, 1, or 2).
#[allow(clippy::too_many_arguments)]
pub fn new(
broker_url: String,
client_id: String,
subscribe_topic: String,
publish_topic: String,
username: Option<String>,
password: Option<String>,
use_tls: bool,
keep_alive: u16,
clean_session: bool,
qos: u8,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let publish_topic = if publish_topic.is_empty() {
subscribe_topic.clone()
} else {
publish_topic
};
let qos = match qos {
0 => QoS::AtMostOnce,
2 => QoS::ExactlyOnce,
_ => QoS::AtLeastOnce,
};
Self {
broker_url,
client_id,
subscribe_topic,
publish_topic,
username,
password,
use_tls,
keep_alive,
clean_session,
qos,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
publish_tx: Arc::new(RwLock::new(None)),
}
}
/// Parse broker URL into host and port.
fn parse_broker_url(&self) -> Result<(String, u16), Box<dyn std::error::Error>> {
let url = self.broker_url.trim();
// Handle different URL schemes
if let Some(rest) = url.strip_prefix("tcp://") {
Self::parse_host_port(rest, 1883)
} else if let Some(rest) = url.strip_prefix("ssl://") {
Self::parse_host_port(rest, 8883)
} else if self.use_tls && !url.contains("://") {
// Plain host with TLS flag
Self::parse_host_port(url, 8883)
} else if url.contains("://") {
Err(format!("Unsupported MQTT URL scheme: {url}").into())
} else {
// Plain host:port or just host (no TLS)
Self::parse_host_port(url, 1883)
}
}
/// Parse host:port string.
fn parse_host_port(s: &str, default_port: u16) -> Result<(String, u16), Box<dyn std::error::Error>> {
let s = s.trim();
if let Some(colon_pos) = s.rfind(':') {
let host = s[..colon_pos].to_string();
let port = s[colon_pos + 1..].parse::<u16>()?;
Ok((host, port))
} else {
Ok((s.to_string(), default_port))
}
}
/// Build MQTT options.
fn build_mqtt_options(&self) -> Result<MqttOptions, Box<dyn std::error::Error>> {
let (host, port) = self.parse_broker_url()?;
let client_id = if self.client_id.is_empty() {
format!("openfang-{}", uuid::Uuid::new_v4())
} else {
self.client_id.clone()
};
let mut options = MqttOptions::new(client_id, host, port);
options.set_keep_alive(Duration::from_secs(self.keep_alive as u64));
options.set_clean_session(self.clean_session);
if let (Some(user), Some(pass)) = (&self.username, &self.password) {
options.set_credentials(user, pass);
}
// Note: TLS support requires additional configuration with rustls
// For now, we use native TLS through the use_tls flag
if self.use_tls {
// rumqttc handles TLS automatically when using ssl:// or with explicit config
// This is a simplified approach; production use may need custom TLS config
}
Ok(options)
}
/// Parse incoming MQTT payload.
fn parse_payload(payload: &[u8]) -> Option<String> {
if payload.is_empty() {
return None;
}
// Try UTF-8 first
if let Ok(text) = std::str::from_utf8(payload) {
// Check for JSON format {"text": "message"}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(text) {
if let Some(text_val) = json.get("text").and_then(|v| v.as_str()) {
return Some(text_val.to_string());
}
}
return Some(text.to_string());
}
None
}
/// Publish a message to the configured topic.
async fn publish_message(&self, text: &str) -> Result<(), Box<dyn std::error::Error>> {
let tx_guard = self.publish_tx.read().await;
if let Some(tx) = tx_guard.as_ref() {
let chunks = split_message(text, MAX_MESSAGE_LEN);
for chunk in chunks {
tx.send((self.publish_topic.clone(), chunk.to_string()))
.await
.map_err(|e| format!("Failed to send publish request: {e}"))?;
}
Ok(())
} else {
Err("MQTT client not connected".into())
}
}
}
#[async_trait]
impl ChannelAdapter for MqttAdapter {
fn name(&self) -> &str {
"mqtt"
}
fn channel_type(&self) -> ChannelType {
ChannelType::Mqtt
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>> {
let options = self.build_mqtt_options()?;
let (client, mut eventloop) = AsyncClient::new(options, 10);
info!(
"MQTT adapter connecting to {} (subscribe: {}, publish: {})",
self.broker_url, self.subscribe_topic, self.publish_topic
);
// Subscribe to topic
client.subscribe(&self.subscribe_topic, self.qos).await?;
// Channel for incoming messages
let (msg_tx, rx) = mpsc::channel::<ChannelMessage>(256);
// Channel for outgoing publish requests
let (publish_tx, mut publish_rx) = mpsc::channel::<(String, String)>(64);
// Store the publish sender
{
let mut tx_guard = self.publish_tx.write().await;
*tx_guard = Some(publish_tx);
}
let subscribe_topic = self.subscribe_topic.clone();
let qos = self.qos;
let mut shutdown_rx = self.shutdown_rx.clone();
// Spawn the event loop task
tokio::spawn(async move {
let mut backoff = Duration::from_secs(1);
let max_backoff = Duration::from_secs(60);
loop {
if *shutdown_rx.borrow() {
info!("MQTT adapter shutting down");
break;
}
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!("MQTT adapter shutting down");
break;
}
}
publish_req = publish_rx.recv() => {
if let Some((topic, payload)) = publish_req {
if let Err(e) = client.publish(&topic, qos, false, payload).await {
warn!("MQTT publish error: {}", e);
}
}
}
event = eventloop.poll() => {
match event {
Ok(Event::Incoming(Incoming::Publish(publish))) => {
backoff = Duration::from_secs(1); // Reset backoff on success
let topic = publish.topic.clone();
if topic != subscribe_topic {
continue;
}
if let Some(text) = Self::parse_payload(&publish.payload) {
if text.is_empty() {
continue;
}
let content = if text.starts_with('/') {
let parts: Vec<&str> = text.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(text)
};
let msg = ChannelMessage {
channel: ChannelType::Mqtt,
platform_message_id: format!("{:?}", publish.pkid),
sender: ChannelUser {
platform_id: "mqtt-user".to_string(),
display_name: "MQTT User".to_string(),
openfang_user: None,
},
content,
target_agent: None,
timestamp: Utc::now(),
is_group: true,
thread_id: None,
metadata: {
let mut m = HashMap::new();
m.insert(
"topic".to_string(),
serde_json::Value::String(topic.clone()),
);
m.insert(
"qos".to_string(),
serde_json::Value::Number((publish.qos as i64).into()),
);
m
},
};
if msg_tx.send(msg).await.is_err() {
info!("MQTT receiver dropped, stopping");
return;
}
}
}
Ok(Event::Incoming(Incoming::ConnAck(_))) => {
info!("MQTT connected to broker");
backoff = Duration::from_secs(1);
}
Ok(Event::Incoming(Incoming::Disconnect)) => {
warn!("MQTT disconnected from broker");
}
Err(e) => {
warn!("MQTT connection error: {}, backing off for {:?}", e, backoff);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(max_backoff);
}
_ => {}
}
}
}
}
info!("MQTT event loop stopped");
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn send(
&self,
_user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let text = match content {
ChannelContent::Text(t) => t,
ChannelContent::Command { name, args } => {
if args.is_empty() {
format!("/{name}")
} else {
format!("/{} {}", name, args.join(" "))
}
}
_ => "(Unsupported content type)".to_string(),
};
self.publish_message(&text).await
}
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
// MQTT has no typing indicator concept.
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
// Clear the publish channel
let mut tx_guard = self.publish_tx.write().await;
*tx_guard = None;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mqtt_adapter_creation() {
let adapter = MqttAdapter::new(
"tcp://broker.hivemq.com:1883".to_string(),
"test-client".to_string(),
"test/topic".to_string(),
String::new(),
None,
None,
false,
60,
true,
1,
);
assert_eq!(adapter.name(), "mqtt");
assert_eq!(adapter.channel_type(), ChannelType::Mqtt);
assert_eq!(adapter.subscribe_topic, "test/topic");
assert_eq!(adapter.publish_topic, "test/topic"); // Falls back to subscribe_topic
}
#[test]
fn test_mqtt_adapter_with_separate_publish_topic() {
let adapter = MqttAdapter::new(
"tcp://broker.hivemq.com:1883".to_string(),
String::new(),
"inbox".to_string(),
"outbox".to_string(),
None,
None,
false,
60,
true,
1,
);
assert_eq!(adapter.subscribe_topic, "inbox");
assert_eq!(adapter.publish_topic, "outbox");
}
#[test]
fn test_parse_broker_url_tcp() {
let adapter = MqttAdapter::new(
"tcp://broker.example.com:1883".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
1,
);
let (host, port) = adapter.parse_broker_url().unwrap();
assert_eq!(host, "broker.example.com");
assert_eq!(port, 1883);
}
#[test]
fn test_parse_broker_url_tcp_default_port() {
let adapter = MqttAdapter::new(
"tcp://broker.example.com".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
1,
);
let (host, port) = adapter.parse_broker_url().unwrap();
assert_eq!(host, "broker.example.com");
assert_eq!(port, 1883);
}
#[test]
fn test_parse_broker_url_ssl() {
let adapter = MqttAdapter::new(
"ssl://broker.example.com:8883".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
true,
60,
true,
1,
);
let (host, port) = adapter.parse_broker_url().unwrap();
assert_eq!(host, "broker.example.com");
assert_eq!(port, 8883);
}
#[test]
fn test_parse_broker_url_plain_host() {
let adapter = MqttAdapter::new(
"broker.example.com".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
1,
);
let (host, port) = adapter.parse_broker_url().unwrap();
assert_eq!(host, "broker.example.com");
assert_eq!(port, 1883);
}
#[test]
fn test_parse_payload_text() {
let payload = b"Hello, MQTT!";
let result = MqttAdapter::parse_payload(payload);
assert_eq!(result, Some("Hello, MQTT!".to_string()));
}
#[test]
fn test_parse_payload_json() {
let payload = br#"{"text": "Hello from JSON"}"#;
let result = MqttAdapter::parse_payload(payload);
assert_eq!(result, Some("Hello from JSON".to_string()));
}
#[test]
fn test_parse_payload_empty() {
let payload = b"";
let result = MqttAdapter::parse_payload(payload);
assert!(result.is_none());
}
#[test]
fn test_qos_conversion() {
let adapter = MqttAdapter::new(
"tcp://broker.example.com".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
0,
);
assert_eq!(adapter.qos, QoS::AtMostOnce);
let adapter = MqttAdapter::new(
"tcp://broker.example.com".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
1,
);
assert_eq!(adapter.qos, QoS::AtLeastOnce);
let adapter = MqttAdapter::new(
"tcp://broker.example.com".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
2,
);
assert_eq!(adapter.qos, QoS::ExactlyOnce);
}
}
+1
View File
@@ -355,6 +355,7 @@ fn channel_type_to_str(ct: &ChannelType) -> &str {
ChannelType::WebChat => "webchat",
ChannelType::CLI => "cli",
ChannelType::Custom(s) => s.as_str(),
_ => "unknown",
}
}
+46
View File
@@ -3,12 +3,14 @@
//! Uses long-polling via `getUpdates` with exponential backoff on failures.
//! No external Telegram crate — just `reqwest` for full control over error handling.
use crate::bridge::channel_command_specs;
use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
LifecycleReaction,
};
use async_trait::async_trait;
use futures::Stream;
use serde::Serialize;
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
@@ -27,6 +29,12 @@ const LONG_POLL_TIMEOUT: u64 = 30;
/// Default Telegram Bot API base URL.
const DEFAULT_API_URL: &str = "https://api.telegram.org";
#[derive(Serialize)]
struct TelegramBotCommand<'a> {
command: &'a str,
description: &'a str,
}
/// Telegram Bot API adapter using long-polling.
pub struct TelegramAdapter {
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
@@ -96,6 +104,36 @@ impl TelegramAdapter {
Ok(bot_name)
}
async fn register_bot_commands(&self) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"{}/bot{}/setMyCommands",
self.api_base_url,
self.token.as_str()
);
let commands: Vec<TelegramBotCommand<'_>> = channel_command_specs()
.iter()
.map(|spec| TelegramBotCommand {
command: spec.name,
description: spec.desc,
})
.collect();
let resp: serde_json::Value = self
.client
.post(&url)
.json(&serde_json::json!({ "commands": commands }))
.send()
.await?
.json()
.await?;
if resp["ok"].as_bool() != Some(true) {
let desc = resp["description"].as_str().unwrap_or("unknown error");
return Err(format!("Telegram setMyCommands failed: {desc}").into());
}
Ok(())
}
/// Call `sendMessage` on the Telegram API.
///
/// When `thread_id` is provided, includes `message_thread_id` in the request
@@ -438,6 +476,14 @@ impl ChannelAdapter for TelegramAdapter {
}
}
match self.register_bot_commands().await {
Ok(()) => info!(
"Telegram: registered {} bot commands",
channel_command_specs().len()
),
Err(e) => warn!("Telegram: setMyCommands failed (non-fatal): {e}"),
}
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let token = self.token.clone();
+2
View File
@@ -23,6 +23,8 @@ pub enum ChannelType {
Mattermost,
WebChat,
CLI,
/// MQTT pub/sub messaging.
Mqtt,
Custom(String),
}
+6 -3
View File
@@ -258,7 +258,8 @@ impl ChannelAdapter for WhatsAppAdapter {
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
let resp = self.client
let resp = self
.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
@@ -284,7 +285,8 @@ impl ChannelAdapter for WhatsAppAdapter {
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
let resp = self.client
let resp = self
.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
@@ -310,7 +312,8 @@ impl ChannelAdapter for WhatsAppAdapter {
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
let resp = self.client
let resp = self
.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
@@ -229,14 +229,23 @@ async fn test_bridge_dispatch_text_message() {
let sent = adapter_ref.get_sent();
assert_eq!(sent.len(), 1, "Expected 1 response, got {}", sent.len());
assert_eq!(sent[0].0, "user1");
assert_eq!(sent[0].1, "Echo: Hello agent!");
// The bridge prepends sender identity: [From: Name] or [From: Name <email>]
assert!(
sent[0].1.contains("Hello agent!"),
"Response should contain original text, got: {}",
sent[0].1
);
// Verify: handle received the message
// Verify: handle received the message (with sender prefix)
{
let received = handle.received.lock().unwrap();
assert_eq!(received.len(), 1);
assert_eq!(received[0].0, agent_id);
assert_eq!(received[0].1, "Hello agent!");
assert!(
received[0].1.contains("Hello agent!"),
"Handle should receive text containing original message, got: {}",
received[0].1
);
}
manager.stop().await;
@@ -486,7 +495,10 @@ async fn test_bridge_manager_lifecycle() {
assert_eq!(sent.len(), 5, "Expected 5 responses, got {}", sent.len());
for (i, (_, text)) in sent.iter().enumerate() {
assert_eq!(*text, format!("Echo: message {i}"));
assert!(
text.contains(&format!("message {i}")),
"Expected 'message {i}' in: {text}"
);
}
// Stop — should complete without hanging
@@ -535,11 +547,19 @@ async fn test_bridge_multiple_adapters() {
let tg_sent = tg_ref.get_sent();
assert_eq!(tg_sent.len(), 1);
assert_eq!(tg_sent[0].1, "Echo: from telegram");
assert!(
tg_sent[0].1.contains("from telegram"),
"Expected 'from telegram' in: {}",
tg_sent[0].1
);
let dc_sent = dc_ref.get_sent();
assert_eq!(dc_sent.len(), 1);
assert_eq!(dc_sent[0].1, "Echo: from discord");
assert!(
dc_sent[0].1.contains("from discord"),
"Expected 'from discord' in: {}",
dc_sent[0].1
);
manager.stop().await;
}
+1
View File
@@ -31,3 +31,4 @@ openfang-runtime = { path = "../openfang-runtime" }
uuid = { workspace = true }
ratatui = { workspace = true }
colored = { workspace = true }
tempfile = { workspace = true }
+151 -26
View File
@@ -829,6 +829,7 @@ fn init_tracing_stderr() {
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
)
.with_writer(std::io::stderr)
.init();
}
@@ -869,6 +870,21 @@ fn init_tracing_file() {
}
}
/// Write `msg` to stdout, silently exiting with code 0 on BrokenPipe.
/// Use this instead of `println!` for machine-readable (JSON) output that is
/// commonly piped into other tools.
fn write_stdout_safe(msg: &str) {
let out = std::io::stdout();
let mut lock = out.lock();
if let Err(e) = writeln!(lock, "{}", msg) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
eprintln!("error: failed writing to stdout: {e}");
std::process::exit(1);
}
}
fn main() {
// Load ~/.openfang/.env into process environment (system env takes priority).
dotenv::load_dotenv();
@@ -2115,18 +2131,14 @@ fn cmd_doctor(json: bool, repair: bool) {
ui::check_ok(".env file (permissions fixed to 0600)");
}
repaired = true;
} else {
if !json {
ui::check_warn(&format!(
".env file has loose permissions ({:o}), should be 0600",
mode
));
}
}
} else {
if !json {
ui::check_ok(".env file");
} else if !json {
ui::check_warn(&format!(
".env file has loose permissions ({:o}), should be 0600",
mode
));
}
} else if !json {
ui::check_ok(".env file");
}
}
#[cfg(not(unix))]
@@ -2414,11 +2426,14 @@ decay_rate = 0.05
if !json {
ui::provider_status(name, env_var, true);
}
} else if !json {
ui::check_warn(&format!("{name} ({env_var}) - key rejected (401/403)"));
} else {
if !json {
ui::check_fail(&format!("{name} ({env_var}) - key rejected (401/403)"));
}
all_ok = false;
}
any_key_set = true;
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "warn" }, "live_test": !valid}));
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "fail" }, "live_test": !valid}));
} else {
if !json {
ui::provider_status(name, env_var, false);
@@ -2580,7 +2595,8 @@ decay_rate = 0.05
checks.push(serde_json::json!({"check": "mcp_server_config", "status": "warn", "name": server.name}));
}
}
openfang_types::config::McpTransportEntry::Sse { url } => {
openfang_types::config::McpTransportEntry::Sse { url }
| openfang_types::config::McpTransportEntry::Http { url } => {
if url.is_empty() {
if !json {
ui::check_warn(&format!(
@@ -2626,9 +2642,7 @@ decay_rate = 0.05
// Check workspace skills if home dir available
if skills_dir.exists() {
match skill_reg.load_workspace_skills(&skills_dir) {
Ok(_) => {
let total = skill_reg.count();
let ws_count = total.saturating_sub(bundled_count);
Ok(ws_count) => {
if ws_count > 0 {
if !json {
ui::check_ok(&format!("Workspace skills loaded: {ws_count}"));
@@ -2670,8 +2684,15 @@ decay_rate = 0.05
}
}
}
if injection_warnings > 0 {
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "warn", "warnings": injection_warnings}));
let blocked = skill_reg.blocked_count();
if injection_warnings > 0 || blocked > 0 {
let total_warnings = injection_warnings + blocked;
if blocked > 0 && !json {
ui::check_warn(&format!(
"{blocked} workspace skill(s) were blocked for critical prompt injection"
));
}
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "warn", "warnings": total_warnings, "blocked": blocked}));
} else {
if !json {
ui::check_ok("All skills pass prompt injection scan");
@@ -2909,19 +2930,20 @@ decay_rate = 0.05
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
write_stdout_safe(
&serde_json::to_string_pretty(&serde_json::json!({
"all_ok": all_ok,
"checks": checks,
}))
.unwrap_or_default()
.unwrap_or_default(),
);
} else {
println!();
if all_ok {
ui::success("All checks passed! OpenFang is ready.");
ui::hint("Start the daemon: openfang start");
if find_daemon().is_none() {
ui::hint("Start the daemon: openfang start");
}
} else if repaired {
ui::success("Repairs applied. Re-run `openfang doctor` to verify.");
} else {
@@ -3488,6 +3510,7 @@ fn cmd_skill_install(source: &str) {
std::process::exit(1);
}
println!("Installed OpenClaw skill: {}", manifest.skill.name);
notify_daemon_skill_reload();
}
Err(e) => {
eprintln!("Failed to convert OpenClaw skill: {e}");
@@ -3517,6 +3540,86 @@ fn cmd_skill_install(source: &str) {
"Installed skill: {} v{}",
manifest.skill.name, manifest.skill.version
);
notify_daemon_skill_reload();
} else if source.starts_with("https://")
|| source.starts_with("http://")
|| source.starts_with("git@")
{
// Git URL install — clone to temp dir then install from there
ui::step(&format!("Cloning skill from {source}..."));
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| {
eprintln!("Failed to create temp directory: {e}");
std::process::exit(1);
});
let clone_path = tmp_dir.path().join("skill");
let status = std::process::Command::new("git")
.args([
"clone",
"--depth",
"1",
source,
clone_path.to_str().unwrap(),
])
.status();
match status {
Ok(s) if s.success() => {}
Ok(_) => {
eprintln!("Failed to clone repository: {source}");
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to run git: {e}");
ui::hint("Make sure git is installed and available on your PATH.");
std::process::exit(1);
}
}
// Reuse the local directory install logic on the cloned repo
let manifest_path = clone_path.join("skill.toml");
if !manifest_path.exists() {
if openfang_skills::openclaw_compat::detect_openclaw_skill(&clone_path) {
println!("Detected OpenClaw skill format. Converting...");
match openfang_skills::openclaw_compat::convert_openclaw_skill(&clone_path) {
Ok(manifest) => {
let dest = skills_dir.join(&manifest.skill.name);
copy_dir_recursive(&clone_path, &dest);
if let Err(e) = openfang_skills::openclaw_compat::write_openfang_manifest(
&dest, &manifest,
) {
eprintln!("Failed to write manifest: {e}");
std::process::exit(1);
}
println!("Installed OpenClaw skill: {}", manifest.skill.name);
notify_daemon_skill_reload();
}
Err(e) => {
eprintln!("Failed to convert OpenClaw skill: {e}");
std::process::exit(1);
}
}
return;
}
eprintln!("No skill.toml found in cloned repository: {source}");
std::process::exit(1);
}
let toml_str = std::fs::read_to_string(&manifest_path).unwrap_or_else(|e| {
eprintln!("Error reading skill.toml: {e}");
std::process::exit(1);
});
let manifest: openfang_skills::SkillManifest =
toml::from_str(&toml_str).unwrap_or_else(|e| {
eprintln!("Error parsing skill.toml: {e}");
std::process::exit(1);
});
let dest = skills_dir.join(&manifest.skill.name);
copy_dir_recursive(&clone_path, &dest);
println!(
"Installed skill: {} v{}",
manifest.skill.name, manifest.skill.version
);
notify_daemon_skill_reload();
} else {
// Remote install from FangHub
println!("Installing {source} from FangHub...");
@@ -3525,7 +3628,10 @@ fn cmd_skill_install(source: &str) {
openfang_skills::marketplace::MarketplaceConfig::default(),
);
match rt.block_on(client.install(source, &skills_dir)) {
Ok(version) => println!("Installed {source} {version}"),
Ok(version) => {
println!("Installed {source} {version}");
notify_daemon_skill_reload();
}
Err(e) => {
eprintln!("Failed to install skill: {e}");
std::process::exit(1);
@@ -3534,6 +3640,25 @@ fn cmd_skill_install(source: &str) {
}
}
/// Notify the running daemon to hot-reload its skill registry after a CLI install.
///
/// If the daemon is not running, this is a no-op with a hint to the user.
fn notify_daemon_skill_reload() {
if let Some(base) = find_daemon() {
let client = daemon_client();
match client.post(format!("{base}/api/skills/reload")).send() {
Ok(resp) if resp.status().is_success() => {
ui::step("Daemon notified — skill registry reloaded.");
}
_ => {
ui::check_warn("Could not notify daemon. Restart with: openfang restart");
}
}
} else {
ui::hint("Start the daemon to make this skill available to agents: openfang start");
}
}
fn cmd_skill_list() {
let home = openfang_home();
let skills_dir = home.join("skills");
@@ -153,6 +153,7 @@ impl StandaloneChat {
name,
result_preview,
is_error,
..
} => {
self.chat.tool_result(&name, &result_preview, is_error);
}
+14 -4
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, None, None) {
match kernel.send_message_streaming(agent_id, &message, None, None, None, None) {
Ok((mut rx, handle)) => {
rt.block_on(async {
while let Some(ev) = rx.recv().await {
@@ -1417,14 +1417,24 @@ pub fn spawn_fetch_skills(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
let client = daemon_client();
if let Ok(resp) = client.get(format!("{base_url}/api/skills")).send() {
if let Ok(body) = resp.json::<serde_json::Value>() {
let skills: Vec<SkillInfo> = body
.as_array()
// API returns {"skills": [...], "total": N} — extract the inner array.
// Fall back to bare array for backward compat.
let items = body
.get("skills")
.and_then(|v| v.as_array())
.or_else(|| body.as_array());
let skills: Vec<SkillInfo> = items
.map(|arr| {
arr.iter()
.map(|s| SkillInfo {
name: s["name"].as_str().unwrap_or("").to_string(),
runtime: s["runtime"].as_str().unwrap_or("").to_string(),
source: s["source"].as_str().unwrap_or("").to_string(),
// "source" is an object {"type": "..."} — extract the type string
source: s["source"]["type"]
.as_str()
.or_else(|| s["source"].as_str())
.unwrap_or("")
.to_string(),
description: s["description"]
.as_str()
.unwrap_or("")
+1
View File
@@ -1183,6 +1183,7 @@ impl App {
name,
result_preview,
is_error,
..
} => {
self.chat.tool_result(&name, &result_preview, is_error);
}
@@ -188,6 +188,14 @@ const PROVIDERS: &[ProviderInfo] = &[
needs_key: true,
hint: "",
},
ProviderInfo {
name: "nvidia",
display: "NVIDIA NIM",
env_var: "NVIDIA_API_KEY",
default_model: "nvidia/llama-3.1-nemotron-70b-instruct",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "claude-code",
display: "Claude Code",
@@ -127,6 +127,12 @@ const PROVIDERS: &[ProviderInfo] = &[
default_model: "codegeex-4",
needs_key: true,
},
ProviderInfo {
name: "nvidia",
env_var: "NVIDIA_API_KEY",
default_model: "nvidia/llama-3.1-nemotron-70b-instruct",
needs_key: true,
},
ProviderInfo {
name: "claude-code",
env_var: "",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenFang",
"version": "0.1.0",
"version": "0.5.5",
"identifier": "ai.openfang.desktop",
"build": {},
"app": {
@@ -11,7 +11,7 @@ command = "npx"
args = ["-y", "@notionhq/notion-mcp-server"]
[[required_env]]
name = "NOTION_API_KEY"
name = "NOTION_TOKEN"
label = "Notion Integration Token"
help = "An internal integration token created in your Notion workspace settings"
is_secret = true
@@ -24,5 +24,5 @@ unhealthy_threshold = 3
setup_instructions = """
1. Go to https://www.notion.so/my-integrations and click 'New integration'.
2. Give it a name, select your workspace, and grant the required capabilities (Read/Update/Insert content).
3. Copy the Internal Integration Token and paste it into the NOTION_API_KEY field above. Then share relevant pages with the integration in Notion.
3. Copy the Internal Integration Token and paste it into the NOTION_TOKEN field above. Then share relevant pages with the integration in Notion.
"""
@@ -146,6 +146,9 @@ mod tests {
crate::McpTransportTemplate::Sse { .. } => {
panic!("{} unexpectedly uses SSE transport", id);
}
crate::McpTransportTemplate::Http { .. } => {
panic!("{} unexpectedly uses HTTP transport", id);
}
}
}
}
@@ -127,6 +127,13 @@ impl CredentialResolver {
}
}
/// Clear a credential from the in-memory dotenv cache.
/// Call this when a key is deleted via the dashboard so the resolver
/// doesn't return a stale value from the boot-time snapshot.
pub fn clear_dotenv_cache(&mut self, key: &str) {
self.dotenv.remove(key);
}
/// 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 {
+1 -1
View File
@@ -327,7 +327,7 @@ mod tests {
// Provide key directly
let mut keys = HashMap::new();
keys.insert("NOTION_API_KEY".to_string(), "ntn_test_key_123".to_string());
keys.insert("NOTION_TOKEN".to_string(), "ntn_test_key_123".to_string());
let result = install_integration(&mut registry, &mut resolver, "notion", &keys).unwrap();
assert_eq!(result.id, "notion");
+3
View File
@@ -88,6 +88,9 @@ pub enum McpTransportTemplate {
Sse {
url: String,
},
Http {
url: String,
},
}
/// An environment variable required by an integration.
@@ -191,6 +191,9 @@ impl IntegrationRegistry {
crate::McpTransportTemplate::Sse { url } => {
McpTransportEntry::Sse { url: url.clone() }
}
crate::McpTransportTemplate::Http { url } => {
McpTransportEntry::Http { url: url.clone() }
}
};
let env: Vec<String> = template
.required_env
@@ -202,6 +205,7 @@ impl IntegrationRegistry {
transport,
timeout_secs: 30,
env,
headers: Vec::new(),
})
})
.collect()
@@ -15,10 +15,10 @@ tools = [
[[requires]]
key = "python3"
label = "Python 3 must be installed"
label = "Python 3 must be installed (python3 or python)"
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."
description = "Python 3 is required for installing and running the Playwright browser automation library. Python 3.8 or newer is recommended. Either 'python3' or 'python' (pointing to Python 3) will be detected."
[requires.install]
macos = "brew install python3"
@@ -26,7 +26,6 @@ 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"
@@ -0,0 +1,412 @@
id = "infisical-sync"
name = "Infisical Sync Hand"
description = "Autonomous secrets synchronisation between a self-hosted Infisical instance and the agent's local credential vault. Keeps agents in sync with a shared Infisical instance as a single source of truth, and lets agents push new secrets back to Infisical."
category = "security"
icon = "🔐"
tools = [
# Core Einstein tools (schedule + memory + knowledge graph + event bus)
"schedule_create", "schedule_list", "schedule_delete",
"memory_store", "memory_recall",
"knowledge_add_entity", "knowledge_add_relation", "knowledge_query",
"event_publish",
# Infisical-specific tools
"shell_exec",
"file_read", "file_write",
"vault_set", "vault_get", "vault_list", "vault_delete",
]
# ─── Requirements ─────────────────────────────────────────────────────────────
[[requires]]
key = "INFISICAL_URL"
label = "Infisical Instance URL"
requirement_type = "env_var"
check_value = "INFISICAL_URL"
description = "Base URL of the self-hosted Infisical instance, e.g. https://infisical.example.com"
[[requires]]
key = "INFISICAL_CLIENT_ID"
label = "Infisical Machine Identity Client ID"
requirement_type = "env_var"
check_value = "INFISICAL_CLIENT_ID"
description = "Machine identity Client ID for this agent. Created in Infisical under Access Control → Machine Identities."
[[requires]]
key = "INFISICAL_CLIENT_SECRET"
label = "Infisical Machine Identity Client Secret"
requirement_type = "env_var"
check_value = "INFISICAL_CLIENT_SECRET"
description = "Machine identity Client Secret for this agent."
# ─── Settings ─────────────────────────────────────────────────────────────────
[[settings]]
key = "sync_interval_minutes"
label = "Sync Interval (minutes)"
description = "How often to pull secrets from Infisical into the local vault. Overridden by the INFISICAL_SYNC_INTERVAL env var when present."
setting_type = "select"
default = "15"
[[settings.options]]
value = "5"
label = "Every 5 minutes (high-frequency)"
[[settings.options]]
value = "15"
label = "Every 15 minutes (default)"
[[settings.options]]
value = "30"
label = "Every 30 minutes"
[[settings.options]]
value = "60"
label = "Every hour"
[[settings]]
key = "environment"
label = "Infisical Environment"
description = "The environment slug to sync from (e.g. prod, staging, dev). Can also be set via INFISICAL_ENVIRONMENT env var."
setting_type = "select"
default = "prod"
[[settings.options]]
value = "prod"
label = "Production"
[[settings.options]]
value = "staging"
label = "Staging"
[[settings.options]]
value = "dev"
label = "Development"
[[settings]]
key = "push_on_vault_write"
label = "Push on Vault Write"
description = "When the agent writes a new secret to the local vault, automatically push it to Infisical as well."
setting_type = "toggle"
default = "true"
[[settings]]
key = "delete_orphans"
label = "Delete Orphaned Local Secrets"
description = "Remove local vault entries that no longer exist in Infisical after a sync."
setting_type = "toggle"
default = "false"
# ─── Agent configuration ──────────────────────────────────────────────────────
[agent]
name = "infisical-sync-hand"
description = "Autonomous secrets sync agent — authenticates with Infisical, pulls secrets into the local vault, and pushes local secrets back to Infisical on demand."
module = "builtin:chat"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.1
max_iterations = 40
system_prompt = """You are Infisical Sync Hand an autonomous secrets synchronisation agent.
Your single purpose: keep the local credential vault in sync with a self-hosted Infisical instance and make Infisical the shared source of truth for every secret in your agent fleet.
You are security-critical. Never log secret values. Never expose credentials in error messages. Always authenticate before any Infisical API call.
---
## PHASE 0 — Startup & State Recovery
Run this every time you are activated (scheduled or on-demand).
1. **Read configuration** from User Configuration:
- `sync_interval_minutes` how often to schedule syncs
- `environment` Infisical environment slug
- `push_on_vault_write` whether to push on local write
- `delete_orphans` whether to delete orphaned local entries
2. **Read environment variables** (env vars override settings):
```
INFISICAL_URL base URL (required)
INFISICAL_CLIENT_ID machine identity client ID (required)
INFISICAL_CLIENT_SECRET machine identity client secret (required)
INFISICAL_PROJECT_ID project ID (optional; if absent, list all)
INFISICAL_ENVIRONMENT environment slug (default: "prod", overrides setting)
INFISICAL_SYNC_INTERVAL sync interval in minutes (overrides setting)
```
Read them with shell_exec:
```bash
echo "URL=$INFISICAL_URL ENV=$INFISICAL_ENVIRONMENT PROJECT=$INFISICAL_PROJECT_ID"
```
3. **Recover state** from memory:
```
memory_recall "infisical_sync_state"
memory_recall "infisical_sync_last_token_expiry"
```
4. **Check for existing schedule**:
```
schedule_list
```
If no `infisical-sync` schedule exists, create one (see Phase 1).
5. **Read sync state file** if present:
```
file_read "infisical_sync_state.json"
```
This file holds the last known secret hashes so we can skip unchanged values.
---
## PHASE 1 — Schedule Bootstrap (first run only)
If no schedule for this hand exists:
1. Determine interval: check `INFISICAL_SYNC_INTERVAL` env var; fall back to `sync_interval_minutes` setting.
2. Create schedule:
```
schedule_create
name: "infisical-sync"
interval_minutes: <interval>
description: "Pull secrets from Infisical into local vault"
```
3. Log to memory:
```
memory_store "infisical_sync_schedule_created" "<ISO timestamp>"
```
4. Add Infisical instance to knowledge graph:
```
knowledge_add_entity
type: "service"
name: "Infisical"
properties: { url: "<INFISICAL_URL>", environment: "<env>", project_id: "<project_id>" }
```
---
## PHASE 2 — Authentication
Obtain a short-lived access token using Universal Auth.
```bash
curl -s -X POST "$INFISICAL_URL/api/v1/auth/universal-auth/login" \
-H "Content-Type: application/json" \
-d "{\"clientId\":\"$INFISICAL_CLIENT_ID\",\"clientSecret\":\"$INFISICAL_CLIENT_SECRET\"}"
```
Parse the response and extract `accessToken`. If the call fails:
- Log to memory: `memory_store "infisical_sync_last_error" "auth_failed: <timestamp>"`
- Increment error counter in state
- `event_publish` an alert: "Infisical Sync: authentication failed — check INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET"
- STOP. Do not proceed. Do not crash.
Store the token in a local variable for use in subsequent API calls. **Never store the raw token in memory or the vault** it is ephemeral.
---
## PHASE 3 — Resolve Project
If `INFISICAL_PROJECT_ID` is set, use it directly.
If not set, list accessible projects:
```bash
curl -s -X GET "$INFISICAL_URL/api/v1/workspace" \
-H "Authorization: Bearer $ACCESS_TOKEN"
```
If multiple projects are returned, sync all of them. Store each project ID in the knowledge graph as a `secret_project` entity.
---
## PHASE 4 — Pull Secrets from Infisical
For each project ID, fetch all secrets:
```bash
curl -s -X GET \
"$INFISICAL_URL/api/v4/secrets?projectId=<PROJECT_ID>&environment=<ENV>&secretPath=/" \
-H "Authorization: Bearer $ACCESS_TOKEN"
```
The response contains an array of secrets. Each secret has:
- `secretKey` the name
- `secretValue` the value
- `id` internal Infisical ID
- `version` version number
**For each secret returned:**
1. Compute a hash of `secretKey + secretValue` to detect changes.
2. Compare with stored hash in `infisical_sync_state.json`.
3. If unchanged, skip.
4. If new or changed, write to local vault:
```
vault_set key=<secretKey> value=<secretValue>
```
5. Record entity in knowledge graph:
```
knowledge_add_entity
type: "secret"
name: <secretKey>
properties: { project_id: <id>, environment: <env>, version: <version>, last_synced: <ISO timestamp> }
```
**Orphan handling** (if `delete_orphans` setting is "true"):
1. Collect the set of all secret keys returned by Infisical.
2. Call `vault_list` to get all local keys.
3. For any local key NOT in the Infisical set, call `vault_delete`.
4. Log each deletion to memory.
---
## PHASE 5 — Push Secrets to Infisical (on-demand)
When the user (or another agent) asks you to share a secret with the fleet:
1. Read the secret from the local vault:
```
vault_get key=<secretName>
```
2. Push to Infisical using a create-then-update pattern:
**Step 1 Try to create (POST):**
```bash
HTTP_STATUS=$(curl -s -o /tmp/infisical_push_response.json -w "%{http_code}" \
-X POST "$INFISICAL_URL/api/v4/secrets/<SECRET_NAME>" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"projectId\":\"<PROJECT_ID>\",\"environment\":\"<ENV>\",\"secretValue\":\"<VALUE>\",\"secretPath\":\"/\"}")
```
**Step 2 If 409 (secret already exists), update via PATCH:**
```bash
if [ "$HTTP_STATUS" = "409" ]; then
HTTP_STATUS=$(curl -s -o /tmp/infisical_push_response.json -w "%{http_code}" \
-X PATCH "$INFISICAL_URL/api/v4/secrets/<SECRET_NAME>" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"projectId\":\"<PROJECT_ID>\",\"environment\":\"<ENV>\",\"secretValue\":\"<VALUE>\",\"secretPath\":\"/\"}")
fi
```
POST returns 201 on success. PATCH returns 200 on success. Any other status is an error log it and notify via `event_publish`.
3. On success, log to knowledge graph and memory. On failure, log the error and notify via `event_publish`.
**Never log secret values.** Use placeholders like `<redacted>` in all log messages and memory entries.
---
## PHASE 6 — Delete a Secret from Infisical (on-demand)
When asked to remove a secret from the shared fleet store:
1. Confirm with the user before deleting (event_publish a confirmation request).
2. On confirmation, call:
```bash
curl -s -X DELETE \
"$INFISICAL_URL/api/v4/secrets/<SECRET_NAME>?projectId=<PROJECT_ID>&environment=<ENV>&secretPath=/" \
-H "Authorization: Bearer $ACCESS_TOKEN"
```
3. Delete the local vault entry: `vault_delete key=<secretName>`
4. Update knowledge graph: remove the `secret` entity.
---
## PHASE 7 — State Persistence & Metrics
After every sync cycle:
1. **Update sync state file**:
```
file_write "infisical_sync_state.json" <JSON with secret keyhash map and sync timestamp>
```
2. **Update dashboard metrics via memory_store**:
- `infisical_sync_secrets_count` integer: number of secrets currently in vault
- `infisical_sync_last_sync` string: "YYYY-MM-DD HH:MM UTC"
- `infisical_sync_last_error` string: last error message (or "none")
- `infisical_sync_projects_count` integer: number of Infisical projects synced
- `infisical_sync_push_count` integer: cumulative secrets pushed to Infisical
- `infisical_sync_pull_count` integer: cumulative secrets pulled from Infisical
3. **Persist state summary**:
```
memory_store "infisical_sync_state" <JSON summary>
```
4. **Publish sync complete event**:
```
event_publish "infisical_sync_complete" { secrets_synced: N, project_ids: [...], timestamp: "..." }
```
---
## Error Handling
**Never crash.** Always catch errors gracefully:
- Authentication failure log + notify + stop cycle
- API rate limit (HTTP 429) wait 60 seconds, retry once, then log + stop
- Network timeout log + retry with 10-second delay once, then log + stop
- Partial sync failure log which keys failed, continue with remaining keys
- vault_set failure log + notify, do not mark as synced
All errors go to memory:
```
memory_store "infisical_sync_last_error" "<type>: <message> at <ISO timestamp>"
```
And if severity is high (auth failure, total API unreachable):
```
event_publish "infisical_sync_error" { error: "<type>", message: "<message>", timestamp: "..." }
```
---
## Security Rules
1. **Never** log, print, or store secret values use `<redacted>` in all messages.
2. **Never** expose secret values in `event_publish` payloads.
3. **Never** store the Infisical access token in memory or the vault it is session-local only.
4. Only sync secrets to/from the environment and project configured for this agent.
5. When unsure whether to push a secret, ask the user first via `event_publish`.
"""
# ─── Dashboard metrics ────────────────────────────────────────────────────────
[dashboard]
[[dashboard.metrics]]
label = "Secrets in Vault"
memory_key = "infisical_sync_secrets_count"
format = "number"
[[dashboard.metrics]]
label = "Last Sync"
memory_key = "infisical_sync_last_sync"
format = "text"
[[dashboard.metrics]]
label = "Last Error"
memory_key = "infisical_sync_last_error"
format = "text"
[[dashboard.metrics]]
label = "Projects Synced"
memory_key = "infisical_sync_projects_count"
format = "number"
[[dashboard.metrics]]
label = "Secrets Pushed"
memory_key = "infisical_sync_push_count"
format = "number"
[[dashboard.metrics]]
label = "Secrets Pulled"
memory_key = "infisical_sync_pull_count"
format = "number"
@@ -0,0 +1,317 @@
---
name: infisical-sync-skill
version: "1.0.0"
description: "Expert knowledge for the Infisical Sync Hand — Infisical API reference, vault operations, error patterns, security guidance"
author: OpenFang
tags: [secrets, infisical, vault, security, sync]
tools: [shell_exec, vault_set, vault_get, vault_list, vault_delete, memory_store, memory_recall]
runtime: prompt_only
---
# Infisical Sync Expert Knowledge
## 1. Infisical API Reference
### Base URL
All requests go to `$INFISICAL_URL`. This is the self-hosted instance base URL, e.g. `https://infisical.example.com`.
### Authentication — Universal Auth
Infisical uses Machine Identities with Universal Auth for agent-to-agent communication.
**Endpoint**: `POST /api/v1/auth/universal-auth/login`
**Request**:
```json
{
"clientId": "<INFISICAL_CLIENT_ID>",
"clientSecret": "<INFISICAL_CLIENT_SECRET>"
}
```
**Response** (success):
```json
{
"accessToken": "eyJ...",
"expiresIn": 7200,
"accessTokenMaxTTL": 43200,
"tokenType": "Bearer"
}
```
**curl example**:
```bash
RESPONSE=$(curl -s -X POST "$INFISICAL_URL/api/v1/auth/universal-auth/login" \
-H "Content-Type: application/json" \
-d "{\"clientId\":\"$INFISICAL_CLIENT_ID\",\"clientSecret\":\"$INFISICAL_CLIENT_SECRET\"}")
ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])")
```
Token lifetime: `expiresIn` seconds (usually 7200 = 2 hours). Re-authenticate when expired.
---
### List Secrets
**Endpoint**: `GET /api/v4/secrets`
**Query parameters**:
| Param | Required | Description |
|-------|----------|-------------|
| `projectId` | Yes | Infisical project ID |
| `environment` | Yes | Environment slug (e.g. `prod`, `staging`, `dev`) |
| `secretPath` | No | Path prefix, default `/` |
| `includeImports` | No | Include imported secrets, default `false` |
| `recursive` | No | Include secrets in sub-paths, default `false` |
**curl example**:
```bash
curl -s -X GET \
"$INFISICAL_URL/api/v4/secrets?projectId=$PROJECT_ID&environment=$ENVIRONMENT&secretPath=/" \
-H "Authorization: Bearer $ACCESS_TOKEN"
```
**Response shape**:
```json
{
"secrets": [
{
"id": "uuid",
"version": 1,
"secretKey": "DATABASE_URL",
"secretValue": "postgres://...",
"secretComment": "",
"environment": "prod",
"workspace": "uuid"
}
],
"imports": []
}
```
Parse with:
```bash
echo "$RESPONSE" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for s in data.get('secrets', []):
print(s['secretKey'])
"
```
---
### Create or Update a Secret
The API does **not** provide a single upsert endpoint. `POST` creates only (returns 409 if the secret already exists); `PATCH` updates only (returns 404 if missing). Use the create-then-update pattern:
**Step 1 — Try to create (POST)**
**Endpoint**: `POST /api/v4/secrets/{secretName}`
**Request body**:
```json
{
"projectId": "<PROJECT_ID>",
"environment": "<ENV>",
"secretValue": "<VALUE>",
"secretPath": "/"
}
```
```bash
HTTP_STATUS=$(curl -s -o /tmp/infisical_response.json -w "%{http_code}" \
-X POST "$INFISICAL_URL/api/v4/secrets/$SECRET_NAME" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"projectId\":\"$PROJECT_ID\",\"environment\":\"$ENVIRONMENT\",\"secretValue\":\"$SECRET_VALUE\",\"secretPath\":\"/\"}")
```
Returns `201` on success.
**Step 2 — If 409, update via PATCH**
**Endpoint**: `PATCH /api/v4/secrets/{secretName}`
```bash
if [ "$HTTP_STATUS" = "409" ]; then
HTTP_STATUS=$(curl -s -o /tmp/infisical_response.json -w "%{http_code}" \
-X PATCH "$INFISICAL_URL/api/v4/secrets/$SECRET_NAME" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"projectId\":\"$PROJECT_ID\",\"environment\":\"$ENVIRONMENT\",\"secretValue\":\"$SECRET_VALUE\",\"secretPath\":\"/\"}")
fi
```
Returns `200` on success. Any other status code is an error.
**Important**: URL-encode the secret name if it contains special characters.
---
### Delete a Secret
**Endpoint**: `DELETE /api/v4/secrets/{secretName}`
**Query parameters**: `projectId`, `environment`, `secretPath` (default `/`)
**curl example**:
```bash
curl -s -X DELETE \
"$INFISICAL_URL/api/v4/secrets/$SECRET_NAME?projectId=$PROJECT_ID&environment=$ENVIRONMENT&secretPath=/" \
-H "Authorization: Bearer $ACCESS_TOKEN"
```
---
### List Accessible Projects (Workspaces)
**Endpoint**: `GET /api/v1/workspace`
```bash
curl -s -X GET "$INFISICAL_URL/api/v1/workspace" \
-H "Authorization: Bearer $ACCESS_TOKEN"
```
Response: `{ "workspaces": [{ "id": "uuid", "name": "...", "environments": [...] }] }`
---
## 2. HTTP Error Codes
| Code | Meaning | Action |
|------|---------|--------|
| 200/201 | Success | Continue |
| 400 | Bad Request | Log the response body — likely malformed JSON or missing field |
| 401 | Unauthorized | Re-authenticate; token may have expired |
| 403 | Forbidden | Machine identity lacks permissions — check Infisical Access Control |
| 404 | Not Found | Secret or project doesn't exist |
| 429 | Rate Limited | Wait 60 seconds, retry once |
| 500/503 | Server Error | Log + retry once after 30 seconds; notify if still failing |
Always check HTTP status before trusting response body:
```bash
HTTP_STATUS=$(curl -s -o /tmp/infisical_response.json -w "%{http_code}" ...)
if [ "$HTTP_STATUS" != "200" ] && [ "$HTTP_STATUS" != "201" ]; then
# handle error
fi
RESPONSE=$(cat /tmp/infisical_response.json)
```
---
## 3. Sync State File Format
Stored at `infisical_sync_state.json`:
```json
{
"last_sync": "2025-01-15T10:30:00Z",
"project_ids": ["uuid1", "uuid2"],
"environment": "prod",
"secrets": {
"DATABASE_URL": {
"hash": "sha256_of_key_plus_value",
"version": 3,
"last_synced": "2025-01-15T10:30:00Z"
}
},
"error_count": 0,
"push_count": 12,
"pull_count": 47
}
```
**Hash computation** (to detect changes without storing values):
```bash
echo -n "DATABASE_URL:postgres://..." | sha256sum | awk '{print $1}'
```
Or with Python:
```python
import hashlib
h = hashlib.sha256(f"{key}:{value}".encode()).hexdigest()
```
---
## 4. Vault Operations Reference
The local vault provides encrypted key-value storage. All secrets synced from Infisical go here.
| Operation | Description |
|-----------|-------------|
| `vault_set key=K value=V` | Write or overwrite secret K |
| `vault_get key=K` | Read secret K |
| `vault_list` | List all keys (values not returned) |
| `vault_delete key=K` | Delete secret K |
**Bulk sync pattern**:
```
// Pull from Infisical → vault
for each (key, value) in infisical_secrets:
vault_set key=<key> value=<value>
// Optionally remove orphans
vault_list → local_keys
infisical_keys = set of keys returned by Infisical
for key in local_keys - infisical_keys:
vault_delete key=<key>
```
---
## 5. Security Checklist
Before every sync cycle, verify:
- [ ] `INFISICAL_URL` is set and non-empty
- [ ] `INFISICAL_CLIENT_ID` is set and non-empty
- [ ] `INFISICAL_CLIENT_SECRET` is set and non-empty
- [ ] The access token was freshly obtained this cycle (never reuse across cycles)
- [ ] No secret values appear in curl command echo output (use variables, not inline values)
- [ ] Response body is never logged verbatim (strip `secretValue` fields before logging)
---
## 6. Common Failure Modes
### "Failed to fetch secrets: 403 Forbidden"
The Machine Identity exists but lacks permissions. In Infisical:
1. Go to Access Control → Machine Identities
2. Find this agent's identity
3. Assign it the `member` role (or `viewer` for read-only) on the project
### "Connection refused / Could not connect to server"
`INFISICAL_URL` is wrong or the instance is down. Verify the URL is reachable:
```bash
curl -s "$INFISICAL_URL/api/status" | python3 -c "import sys,json; print(json.load(sys.stdin))"
```
### "invalid character in secret name"
Secret names in Infisical must match `[A-Z0-9_]`. If the vault has mixed-case keys, normalise before pushing:
```bash
echo "my_secret_key" | tr '[:lower:]' '[:upper:]'
```
### "accessToken undefined in response"
Authentication failed. The response body will contain an error message. Check:
1. `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET` are correct
2. The Machine Identity is not disabled in Infisical
3. The Machine Identity's token TTL hasn't been set to 0
---
## 7. Knowledge Graph Entities
Track fleet-wide secrets metadata without exposing values.
### Entity types
- `service` — the Infisical instance itself
- `secret_project` — an Infisical workspace/project
- `secret` — a named secret (key only, never value)
### Relation types
- `secret``belongs_to``secret_project`
- `secret_project``hosted_by``service`
- `secret``synced_to``agent_vault`
### Query examples
```
knowledge_query type=secret // list all known secrets
knowledge_query type=secret_project // list all projects
knowledge_query relation=belongs_to target=<project_id> // secrets in a project
```
@@ -161,7 +161,10 @@ provider = "default"
model = "default"
max_tokens = 16384
temperature = 0.3
max_iterations = 80
max_iterations = 25
# Researcher makes long LLM calls; 30s (kernel default) causes false-positive
# recovery triggers. 120s matches typical deep-research call latency.
heartbeat_interval_secs = 120
system_prompt = """You are Researcher Hand an autonomous deep research agent that conducts exhaustive investigations, cross-references sources, fact-checks claims, and produces comprehensive structured reports.
## Phase 0 — Platform Detection & Context (ALWAYS DO THIS FIRST)
+127 -2
View File
@@ -45,6 +45,11 @@ pub fn bundled_hands() -> Vec<(&'static str, &'static str, &'static str)> {
include_str!("../bundled/trader/HAND.toml"),
include_str!("../bundled/trader/SKILL.md"),
),
(
"infisical-sync",
include_str!("../bundled/infisical-sync/HAND.toml"),
include_str!("../bundled/infisical-sync/SKILL.md"),
),
]
}
@@ -76,7 +81,7 @@ mod tests {
#[test]
fn bundled_hands_count() {
let hands = bundled_hands();
assert_eq!(hands.len(), 8);
assert_eq!(hands.len(), 9);
}
#[test]
@@ -161,7 +166,7 @@ mod tests {
assert!(def.tools.contains(&"event_publish".to_string()));
assert!(!def.settings.is_empty());
assert!(!def.dashboard.metrics.is_empty());
assert_eq!(def.agent.max_iterations, Some(80));
assert_eq!(def.agent.max_iterations, Some(25));
}
#[test]
@@ -298,6 +303,126 @@ mod tests {
}
}
#[test]
fn parse_infisical_sync_hand() {
let (id, toml_content, skill_content) = bundled_hands()
.into_iter()
.find(|(id, _, _)| *id == "infisical-sync")
.expect("infisical-sync hand must be in bundled_hands()");
let def = parse_bundled(id, toml_content, skill_content).unwrap();
assert_eq!(def.id, "infisical-sync");
assert_eq!(def.name, "Infisical Sync Hand");
assert_eq!(def.category, crate::HandCategory::Security);
assert!(def.skill_content.is_some());
// Required env vars
assert!(
!def.requires.is_empty(),
"infisical-sync must declare env var requirements"
);
let req_keys: Vec<&str> = def.requires.iter().map(|r| r.key.as_str()).collect();
assert!(
req_keys.contains(&"INFISICAL_URL"),
"must require INFISICAL_URL"
);
assert!(
req_keys.contains(&"INFISICAL_CLIENT_ID"),
"must require INFISICAL_CLIENT_ID"
);
assert!(
req_keys.contains(&"INFISICAL_CLIENT_SECRET"),
"must require INFISICAL_CLIENT_SECRET"
);
// Einstein scheduling tools
assert!(
def.tools.contains(&"schedule_create".to_string()),
"must have schedule_create"
);
assert!(
def.tools.contains(&"schedule_list".to_string()),
"must have schedule_list"
);
assert!(
def.tools.contains(&"schedule_delete".to_string()),
"must have schedule_delete"
);
// Memory tools
assert!(
def.tools.contains(&"memory_store".to_string()),
"must have memory_store"
);
assert!(
def.tools.contains(&"memory_recall".to_string()),
"must have memory_recall"
);
// Knowledge graph tools
assert!(
def.tools.contains(&"knowledge_add_entity".to_string()),
"must have knowledge_add_entity"
);
assert!(
def.tools.contains(&"knowledge_add_relation".to_string()),
"must have knowledge_add_relation"
);
assert!(
def.tools.contains(&"knowledge_query".to_string()),
"must have knowledge_query"
);
// Event bus
assert!(
def.tools.contains(&"event_publish".to_string()),
"must have event_publish"
);
// Infisical-specific tools
assert!(
def.tools.contains(&"shell_exec".to_string()),
"must have shell_exec"
);
assert!(
def.tools.contains(&"vault_set".to_string()),
"must have vault_set"
);
assert!(
def.tools.contains(&"vault_get".to_string()),
"must have vault_get"
);
assert!(
def.tools.contains(&"vault_list".to_string()),
"must have vault_list"
);
assert!(
def.tools.contains(&"vault_delete".to_string()),
"must have vault_delete"
);
// Dashboard
assert!(
!def.dashboard.metrics.is_empty(),
"must have dashboard metrics"
);
let metric_keys: Vec<&str> = def
.dashboard
.metrics
.iter()
.map(|m| m.memory_key.as_str())
.collect();
assert!(
metric_keys.contains(&"infisical_sync_secrets_count"),
"must have secrets_count metric"
);
assert!(
metric_keys.contains(&"infisical_sync_last_sync"),
"must have last_sync metric"
);
// Agent config
assert!(
!def.agent.system_prompt.is_empty(),
"must have system_prompt"
);
assert!(
def.agent.temperature < 0.2,
"security hand should use low temperature"
);
}
#[test]
fn all_einstein_hands_have_knowledge_graph() {
let einstein_ids = [
+5
View File
@@ -293,6 +293,11 @@ pub struct HandAgentConfig {
pub system_prompt: String,
#[serde(default)]
pub max_iterations: Option<u32>,
/// Heartbeat interval in seconds for autonomous agents. Overrides the
/// AutonomousConfig default (30s), which is too aggressive for agents
/// making long LLM calls. Omit to use the kernel default.
#[serde(default)]
pub heartbeat_interval_secs: Option<u64>,
}
fn default_module() -> String {
+27 -12
View File
@@ -379,7 +379,10 @@ impl HandRegistry {
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);
// Only non-optional requirements gate readiness.
// Optional requirements (e.g. chromium for browser hand) are nice-to-have;
// missing them results in "degraded" status but not "requirements not met".
let requirements_met = reqs.iter().all(|(req, ok)| *ok || req.optional);
// A hand is active if at least one instance is in Active status.
let active = self
@@ -424,10 +427,13 @@ 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" {
// Special handling for python3 / python: must actually run the command
// and verify the output contains "Python 3", because:
// - Windows ships a python3.exe Store shim that doesn't actually work
// - Most modern Linux distros only ship "python3", not "python"
// - Some Docker images only have "python" pointing to Python 3
// Matches the detection logic in python_runtime.rs find_python_interpreter().
if req.check_value == "python3" || req.check_value == "python" {
return check_python3_available();
}
// Check if binary exists on PATH.
@@ -642,7 +648,7 @@ mod tests {
fn load_bundled_hands() {
let reg = HandRegistry::new();
let count = reg.load_bundled();
assert_eq!(count, 8);
assert_eq!(count, 9);
assert!(!reg.list_definitions().is_empty());
// Clip hand should be loaded
@@ -838,17 +844,26 @@ mod tests {
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.
// Browser hand requires python3 (non-optional) + chromium (optional).
// requirements_met only reflects non-optional requirements.
// degraded = active + any requirement (including optional) unsatisfied.
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);
// Check individual requirements
let reqs = reg.check_requirements("browser").unwrap();
let python_met = reqs.iter().any(|(req, ok)| req.key == "python3" && *ok);
let chromium_met = reqs.iter().any(|(req, ok)| req.key == "chromium" && *ok);
// requirements_met only gates on non-optional (python3)
assert_eq!(r.requirements_met, python_met);
// degraded = active + any requirement unsatisfied
if python_met && chromium_met {
assert!(!r.degraded); // all met, not degraded
} else {
assert!(!r.degraded);
assert!(r.degraded); // something is missing, degraded
}
reg.deactivate(instance.instance_id).unwrap();
+63 -1
View File
@@ -5,15 +5,19 @@ use dashmap::DashMap;
use openfang_types::approval::{
ApprovalDecision, ApprovalPolicy, ApprovalRequest, ApprovalResponse, RiskLevel,
};
use std::collections::VecDeque;
use tracing::{debug, info, warn};
use uuid::Uuid;
/// Max pending requests per agent.
const MAX_PENDING_PER_AGENT: usize = 5;
/// Max recent approval records to retain for history and UI visibility.
const MAX_RECENT_APPROVALS: usize = 100;
/// Manages approval requests with oneshot channels for blocking resolution.
pub struct ApprovalManager {
pending: DashMap<Uuid, PendingRequest>,
recent: std::sync::Mutex<VecDeque<ApprovalRecord>>,
policy: std::sync::RwLock<ApprovalPolicy>,
}
@@ -22,10 +26,19 @@ struct PendingRequest {
sender: tokio::sync::oneshot::Sender<ApprovalDecision>,
}
#[derive(Debug, Clone)]
pub struct ApprovalRecord {
pub request: ApprovalRequest,
pub decision: ApprovalDecision,
pub decided_at: chrono::DateTime<Utc>,
pub decided_by: Option<String>,
}
impl ApprovalManager {
pub fn new(policy: ApprovalPolicy) -> Self {
Self {
pending: DashMap::new(),
recent: std::sync::Mutex::new(VecDeque::new()),
policy: std::sync::RwLock::new(policy),
}
}
@@ -51,6 +64,7 @@ impl ApprovalManager {
let timeout = std::time::Duration::from_secs(req.timeout_secs);
let id = req.id;
let req_for_timeout = req.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
self.pending.insert(
@@ -69,7 +83,12 @@ impl ApprovalManager {
decision
}
_ => {
self.pending.remove(&id);
let request = self
.pending
.remove(&id)
.map(|(_, pending)| pending.request)
.unwrap_or(req_for_timeout);
self.push_recent(request, ApprovalDecision::TimedOut, None, Utc::now());
warn!(request_id = %id, "Approval request timed out");
ApprovalDecision::TimedOut
}
@@ -91,6 +110,12 @@ impl ApprovalManager {
decided_at: Utc::now(),
decided_by,
};
self.push_recent(
pending.request.clone(),
decision,
response.decided_by.clone(),
response.decided_at,
);
// Send decision to waiting agent (ignore error if receiver dropped)
let _ = pending.sender.send(decision);
info!(request_id = %request_id, ?decision, "Approval request resolved");
@@ -108,6 +133,12 @@ impl ApprovalManager {
.collect()
}
/// List recent non-pending approvals, newest first.
pub fn list_recent(&self, limit: usize) -> Vec<ApprovalRecord> {
let recent = self.recent.lock().unwrap_or_else(|e| e.into_inner());
recent.iter().take(limit).cloned().collect()
}
/// Number of pending requests.
pub fn pending_count(&self) -> usize {
self.pending.len()
@@ -135,6 +166,25 @@ impl ApprovalManager {
_ => RiskLevel::Low,
}
}
fn push_recent(
&self,
request: ApprovalRequest,
decision: ApprovalDecision,
decided_by: Option<String>,
decided_at: chrono::DateTime<Utc>,
) {
let mut recent = self.recent.lock().unwrap_or_else(|e| e.into_inner());
recent.push_front(ApprovalRecord {
request,
decision,
decided_at,
decided_by,
});
while recent.len() > MAX_RECENT_APPROVALS {
recent.pop_back();
}
}
}
// ---------------------------------------------------------------------------
@@ -243,6 +293,7 @@ mod tests {
fn test_list_pending_empty() {
let mgr = default_manager();
assert!(mgr.list_pending().is_empty());
assert!(mgr.list_recent(10).is_empty());
}
// -----------------------------------------------------------------------
@@ -293,6 +344,10 @@ mod tests {
assert_eq!(decision, ApprovalDecision::TimedOut);
// After timeout, pending map should be cleaned up
assert_eq!(mgr.pending_count(), 0);
let recent = mgr.list_recent(10);
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].decision, ApprovalDecision::TimedOut);
assert_eq!(recent[0].request.tool_name, "shell_exec");
}
// -----------------------------------------------------------------------
@@ -322,6 +377,10 @@ mod tests {
let decision = mgr.request_approval(req).await;
assert_eq!(decision, ApprovalDecision::Approved);
let recent = mgr.list_recent(10);
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].decision, ApprovalDecision::Approved);
assert_eq!(recent[0].decided_by.as_deref(), Some("admin"));
}
// -----------------------------------------------------------------------
@@ -343,6 +402,9 @@ mod tests {
let decision = mgr.request_approval(req).await;
assert_eq!(decision, ApprovalDecision::Denied);
let recent = mgr.list_recent(10);
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].decision, ApprovalDecision::Denied);
}
// -----------------------------------------------------------------------
+116 -1
View File
@@ -20,6 +20,15 @@ use tracing::{debug, info, warn};
/// Maximum consecutive errors before a job is auto-disabled.
const MAX_CONSECUTIVE_ERRORS: u32 = 5;
/// Error returned by [`CronScheduler::try_claim_for_run`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClaimError {
/// Job ID does not exist.
NotFound,
/// Job exists but is disabled.
Disabled,
}
// ---------------------------------------------------------------------------
// JobMeta — extra runtime state not stored in CronJob itself
// ---------------------------------------------------------------------------
@@ -306,6 +315,33 @@ impl CronScheduler {
due
}
/// Atomically look up a job, verify it is enabled, pre-advance `next_run`
/// if overdue, and return a snapshot of the job for execution.
///
/// This combines the existence check, enabled guard, and scheduler
/// reservation into a single `DashMap::get_mut` hold so no concurrent
/// request can disable/delete the job between the check and the claim.
///
/// `next_run` is only advanced when the job is already due
/// (`next_run <= now`), matching `due_jobs()`, so triggering a manual run
/// on a not-yet-due job won't skip the upcoming scheduled fire.
pub fn try_claim_for_run(&self, id: CronJobId) -> Result<CronJob, ClaimError> {
match self.jobs.get_mut(&id) {
None => Err(ClaimError::NotFound),
Some(mut entry) => {
let meta = entry.value_mut();
if !meta.job.enabled {
return Err(ClaimError::Disabled);
}
let now = Utc::now();
if meta.job.next_run.map(|t| t <= now).unwrap_or(false) {
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, now));
}
Ok(meta.job.clone())
}
}
}
// -- Outcome recording --------------------------------------------------
/// Record a successful execution for a job.
@@ -351,7 +387,13 @@ impl CronScheduler {
);
meta.job.enabled = false;
} else {
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
// Only recompute next_run if the job was already overdue. This
// preserves the scheduled fire time when a manual (on-demand) run
// fails before the job's natural next_run.
let now = Utc::now();
if meta.job.next_run.map(|t| t <= now).unwrap_or(true) {
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, now));
}
}
}
}
@@ -1207,4 +1249,77 @@ mod tests {
assert_eq!(sched.list_jobs(other).len(), 1);
}
}
// -- try_claim_for_run ---------------------------------------------------
#[test]
fn try_claim_not_found() {
let (sched, _tmp) = make_scheduler(100);
let id = CronJobId::new();
assert!(matches!(
sched.try_claim_for_run(id),
Err(ClaimError::NotFound)
));
}
#[test]
fn try_claim_disabled() {
let (sched, _tmp) = make_scheduler(100);
let agent = AgentId::new();
let mut job = make_job(agent);
job.enabled = false;
let id = sched.add_job(job, false).unwrap();
assert!(matches!(
sched.try_claim_for_run(id),
Err(ClaimError::Disabled)
));
}
#[test]
fn try_claim_skips_not_yet_due_job() {
let (sched, _tmp) = make_scheduler(100);
let agent = AgentId::new();
let mut job = make_job(agent);
job.schedule = CronSchedule::Every { every_secs: 3600 };
let id = sched.add_job(job, false).unwrap();
let original_next_run = sched.get_job(id).unwrap().next_run;
assert!(original_next_run.is_some());
// Manual trigger on a not-yet-due job should NOT move next_run.
let claimed = sched.try_claim_for_run(id).unwrap();
assert_eq!(claimed.id, id);
let after = sched.get_job(id).unwrap().next_run;
assert_eq!(
original_next_run, after,
"try_claim must not move next_run for a job that is not yet due"
);
}
#[test]
fn try_claim_advances_overdue_job() {
let (sched, _tmp) = make_scheduler(100);
let agent = AgentId::new();
let mut job = make_job(agent);
job.schedule = CronSchedule::Every { every_secs: 3600 };
let id = sched.add_job(job, false).unwrap();
// Force next_run into the past to simulate an overdue job.
if let Some(mut meta) = sched.jobs.get_mut(&id) {
meta.job.next_run = Some(Utc::now() - Duration::seconds(10));
}
let before = sched.get_job(id).unwrap().next_run.unwrap();
assert!(before < Utc::now(), "precondition: job should be overdue");
let claimed = sched.try_claim_for_run(id).unwrap();
assert_eq!(claimed.id, id);
let after = sched.get_job(id).unwrap().next_run.unwrap();
assert!(
after > Utc::now(),
"try_claim should advance next_run past now for overdue jobs"
);
}
}
+188
View File
@@ -126,6 +126,12 @@ impl Default for RecoveryTracker {
}
}
/// Grace period (seconds): if an agent's `last_active` is within this window
/// of `created_at`, it has never genuinely processed a message and should not
/// be flagged as unresponsive. This covers the small gap between registration
/// and the initial `set_state(Running)` call.
const IDLE_GRACE_SECS: i64 = 10;
/// Check all running and crashed agents and return their heartbeat status.
///
/// This is a pure function — it doesn't start a background task.
@@ -151,6 +157,30 @@ 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;
// --- Skip idle agents that have never genuinely processed a message ---
//
// When an agent is spawned, both `created_at` and `last_active` are set
// to now. Administrative operations (set_state, etc.) bump `last_active`
// by a tiny amount. If `last_active` is still within IDLE_GRACE_SECS of
// `created_at`, the agent was never active beyond its initial startup and
// should NOT be flagged as unresponsive. This prevents disabled/unused
// agents from entering an infinite crash-recover loop (GitHub #844).
//
// Periodic / Hand agents with long schedule intervals (e.g. 3600s) are
// also covered: they sit idle between ticks and their `last_active` stays
// near `created_at` until the first tick fires.
let never_active =
(entry_ref.last_active - entry_ref.created_at).num_seconds() <= IDLE_GRACE_SECS;
if never_active && entry_ref.state == AgentState::Running {
debug!(
agent = %entry_ref.name,
inactive_secs,
"Skipping idle agent — never received a message"
);
continue;
}
// Crashed agents are always considered unresponsive
let unresponsive = entry_ref.state == AgentState::Crashed || inactive_secs > timeout_secs;
@@ -265,6 +295,153 @@ pub fn summarize(statuses: &[HeartbeatStatus]) -> HeartbeatSummary {
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use openfang_types::agent::*;
use std::collections::HashMap;
/// Helper: build a minimal AgentEntry for heartbeat tests.
fn make_entry(
name: &str,
state: AgentState,
created_at: chrono::DateTime<Utc>,
last_active: chrono::DateTime<Utc>,
) -> AgentEntry {
AgentEntry {
id: AgentId::new(),
name: name.to_string(),
manifest: AgentManifest {
name: name.to_string(),
version: "0.1.0".to_string(),
description: "test".to_string(),
author: "test".to_string(),
module: "test".to_string(),
schedule: ScheduleMode::default(),
model: ModelConfig::default(),
fallback_models: vec![],
resources: ResourceQuota::default(),
priority: Priority::default(),
capabilities: ManifestCapabilities::default(),
profile: None,
tools: HashMap::new(),
skills: vec![],
mcp_servers: vec![],
metadata: HashMap::new(),
tags: vec![],
routing: None,
autonomous: None,
pinned_model: None,
workspace: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
},
state,
mode: AgentMode::default(),
created_at,
last_active,
parent: None,
children: vec![],
session_id: SessionId::new(),
tags: vec![],
identity: Default::default(),
onboarding_completed: false,
onboarding_completed_at: None,
}
}
#[test]
fn test_idle_agent_skipped_by_heartbeat() {
// An agent spawned 5 minutes ago that has never processed a message
// (last_active == created_at). It should NOT appear in heartbeat
// statuses because it was never genuinely active.
let registry = crate::registry::AgentRegistry::new();
let five_min_ago = Utc::now() - Duration::seconds(300);
let idle_agent = make_entry(
"idle-agent",
AgentState::Running,
five_min_ago,
five_min_ago,
);
registry.register(idle_agent).unwrap();
let config = HeartbeatConfig::default(); // timeout = 180s
let statuses = check_agents(&registry, &config);
// The idle agent should be skipped entirely
assert!(
statuses.is_empty(),
"idle agent should be skipped by heartbeat"
);
}
#[test]
fn test_active_agent_detected_unresponsive() {
// An agent that WAS active (last_active >> created_at) but has gone
// silent for longer than the timeout — should be flagged unresponsive.
let registry = crate::registry::AgentRegistry::new();
let ten_min_ago = Utc::now() - Duration::seconds(600);
let five_min_ago = Utc::now() - Duration::seconds(300);
let active_agent = make_entry(
"active-agent",
AgentState::Running,
ten_min_ago,
five_min_ago,
);
registry.register(active_agent).unwrap();
let config = HeartbeatConfig::default(); // timeout = 180s, inactive = ~300s
let statuses = check_agents(&registry, &config);
assert_eq!(statuses.len(), 1);
assert!(
statuses[0].unresponsive,
"active agent past timeout should be unresponsive"
);
}
#[test]
fn test_active_agent_within_timeout_is_ok() {
// An agent that has been active recently (within timeout).
let registry = crate::registry::AgentRegistry::new();
let ten_min_ago = Utc::now() - Duration::seconds(600);
let just_now = Utc::now() - Duration::seconds(10);
let healthy_agent = make_entry("healthy-agent", AgentState::Running, ten_min_ago, just_now);
registry.register(healthy_agent).unwrap();
let config = HeartbeatConfig::default(); // timeout = 180s
let statuses = check_agents(&registry, &config);
assert_eq!(statuses.len(), 1);
assert!(
!statuses[0].unresponsive,
"recently active agent should not be unresponsive"
);
}
#[test]
fn test_crashed_agent_not_skipped_even_if_idle() {
// A crashed agent should still appear in statuses for recovery,
// even if it was never genuinely active.
let registry = crate::registry::AgentRegistry::new();
let five_min_ago = Utc::now() - Duration::seconds(300);
let crashed_agent = make_entry(
"crashed-idle",
AgentState::Crashed,
five_min_ago,
five_min_ago,
);
registry.register(crashed_agent).unwrap();
let config = HeartbeatConfig::default();
let statuses = check_agents(&registry, &config);
assert_eq!(statuses.len(), 1);
assert!(
statuses[0].unresponsive,
"crashed agent should be marked unresponsive"
);
}
#[test]
fn test_quiet_hours_parsing() {
@@ -332,6 +509,17 @@ mod tests {
assert_eq!(summary.unresponsive_agents[0].name, "agent-2");
}
#[test]
fn test_heartbeat_config_custom_timeout() {
let config = HeartbeatConfig {
default_timeout_secs: 600,
..HeartbeatConfig::default()
};
assert_eq!(config.default_timeout_secs, 600);
assert_eq!(config.check_interval_secs, DEFAULT_CHECK_INTERVAL_SECS);
assert_eq!(config.max_recovery_attempts, DEFAULT_MAX_RECOVERY_ATTEMPTS);
}
#[test]
fn test_recovery_tracker() {
let tracker = RecoveryTracker::new();
File diff suppressed because it is too large Load Diff
+3
View File
@@ -393,6 +393,9 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
// ── MiniMax ──────────────────────────────────────────────────
if model.contains("minimax") || model.contains("abab") {
if model.contains("m2.7") {
return (0.30, 1.20);
}
if model.contains("highspeed") {
return (0.80, 3.20);
}
+29
View File
@@ -177,6 +177,27 @@ impl AgentRegistry {
Ok(())
}
/// Update an agent's model, provider, and connection hints together.
pub fn update_model_provider_config(
&self,
id: AgentId,
new_model: String,
new_provider: String,
api_key_env: Option<String>,
base_url: Option<String>,
) -> OpenFangResult<()> {
let mut entry = self
.agents
.get_mut(&id)
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
entry.manifest.model.model = new_model;
entry.manifest.model.provider = new_provider;
entry.manifest.model.api_key_env = api_key_env;
entry.manifest.model.base_url = base_url;
entry.last_active = chrono::Utc::now();
Ok(())
}
/// Update an agent's fallback model chain.
pub fn update_fallback_models(
&self,
@@ -235,6 +256,14 @@ impl AgentRegistry {
Ok(())
}
/// Touch an agent — refresh last_active without changing any other state.
/// Used by the agent loop to prevent heartbeat false-positives during long LLM calls.
pub fn touch(&self, id: AgentId) {
if let Some(mut entry) = self.agents.get_mut(&id) {
entry.last_active = chrono::Utc::now();
}
}
/// Update an agent's system prompt (hot-swap, takes effect on next message).
pub fn update_system_prompt(&self, id: AgentId, new_prompt: String) -> OpenFangResult<()> {
let mut entry = self
@@ -269,8 +269,8 @@ mod tests {
#[test]
fn test_embedded_files_not_empty() {
assert!(!GATEWAY_INDEX_JS.is_empty());
assert!(!GATEWAY_PACKAGE_JSON.is_empty());
assert_ne!(GATEWAY_INDEX_JS, "");
assert_ne!(GATEWAY_PACKAGE_JSON, "");
assert!(GATEWAY_INDEX_JS.contains("WhatsApp"));
assert!(GATEWAY_PACKAGE_JSON.contains("@openfang/whatsapp-gateway"));
}
@@ -303,7 +303,7 @@ async fn test_wasm_agent_streaming_fallback() {
let agent_id = kernel.spawn_agent(manifest).unwrap();
let (mut rx, handle) = kernel
.send_message_streaming(agent_id, "Hi!", None, None, None)
.send_message_streaming(agent_id, "Hi!", None, None, None, None)
.expect("Streaming should start");
// Collect all stream events
+5
View File
@@ -5,6 +5,10 @@ edition.workspace = true
license.workspace = true
description = "Memory substrate for the OpenFang Agent OS"
[features]
default = ["http-memory"]
http-memory = ["reqwest"]
[dependencies]
openfang-types = { path = "../openfang-types" }
tokio = { workspace = true }
@@ -17,6 +21,7 @@ uuid = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
reqwest = { workspace = true, features = ["blocking"], optional = true }
[dev-dependencies]
tokio-test = { workspace = true }
+246
View File
@@ -0,0 +1,246 @@
//! HTTP client for the memory-api gateway.
//!
//! Provides a blocking HTTP client that routes `remember` and `recall` operations
//! to the shared memory-api service (PostgreSQL + pgvector + Jina AI embeddings).
//! Designed to be called from synchronous SemanticStore methods within
//! `spawn_blocking` contexts.
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
/// Error type for memory API operations.
#[derive(Debug, thiserror::Error)]
pub enum MemoryApiError {
#[error("HTTP error: {0}")]
Http(String),
#[error("API error (status {status}): {message}")]
Api { status: u16, message: String },
#[error("Parse error: {0}")]
Parse(String),
#[error("Missing config: {0}")]
Config(String),
}
/// HTTP client for the memory-api gateway service.
#[derive(Clone)]
pub struct MemoryApiClient {
base_url: String,
token: String,
client: reqwest::blocking::Client,
}
// -- Request/Response types matching memory-api endpoints --
#[derive(Serialize)]
struct StoreRequest<'a> {
content: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
category: Option<&'a str>,
#[serde(rename = "agentId", skip_serializing_if = "Option::is_none")]
agent_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
importance: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
tags: Option<Vec<String>>,
}
#[derive(Deserialize, Debug)]
pub struct StoreResponse {
pub id: serde_json::Value,
#[serde(default)]
pub deduplicated: bool,
}
#[derive(Serialize)]
struct SearchRequest<'a> {
query: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
limit: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
category: Option<&'a str>,
}
#[derive(Deserialize, Debug)]
pub struct SearchResponse {
pub results: Vec<SearchResult>,
pub count: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct SearchResult {
pub id: serde_json::Value,
pub content: String,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub score: f64,
#[serde(rename = "createdAt", default)]
pub created_at: Option<f64>,
}
#[derive(Deserialize, Debug)]
struct HealthResponse {
pub status: String,
}
impl MemoryApiClient {
/// Create a new memory-api HTTP client.
///
/// `base_url`: The base URL of the memory-api service (e.g., "http://127.0.0.1:5500").
/// `token_env`: The name of the environment variable holding the bearer token.
pub fn new(base_url: &str, token_env: &str) -> Result<Self, MemoryApiError> {
let token = if token_env.is_empty() {
String::new()
} else {
std::env::var(token_env).unwrap_or_else(|_| {
warn!(env = token_env, "Memory API token env var not set");
String::new()
})
};
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.user_agent("openfang-memory/0.4")
.build()
.map_err(|e| MemoryApiError::Http(e.to_string()))?;
let base_url = base_url.trim_end_matches('/').to_string();
Ok(Self {
base_url,
token,
client,
})
}
/// Check if memory-api is reachable.
pub fn health_check(&self) -> Result<(), MemoryApiError> {
let url = format!("{}/health", self.base_url);
let resp = self
.client
.get(&url)
.send()
.map_err(|e| MemoryApiError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(MemoryApiError::Api {
status: resp.status().as_u16(),
message: resp.text().unwrap_or_default(),
});
}
let body: HealthResponse = resp
.json()
.map_err(|e| MemoryApiError::Parse(e.to_string()))?;
if body.status != "ok" {
return Err(MemoryApiError::Api {
status: 503,
message: format!("memory-api status: {}", body.status),
});
}
debug!("memory-api health check passed");
Ok(())
}
/// Store a memory via POST /memory/store.
///
/// The memory-api handles embedding generation (Jina AI) and deduplication.
pub fn store(
&self,
content: &str,
category: Option<&str>,
agent_id: Option<&str>,
source: Option<&str>,
importance: Option<u8>,
tags: Option<Vec<String>>,
) -> Result<StoreResponse, MemoryApiError> {
let url = format!("{}/memory/store", self.base_url);
let body = StoreRequest {
content,
category,
agent_id,
source,
importance,
tags,
};
let mut req = self.client.post(&url).json(&body);
if !self.token.is_empty() {
req = req.header("Authorization", format!("Bearer {}", self.token));
}
let resp = req
.send()
.map_err(|e| MemoryApiError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if status != 200 && status != 201 {
let body_text = resp.text().unwrap_or_default();
return Err(MemoryApiError::Api {
status,
message: body_text,
});
}
let result: StoreResponse = resp
.json()
.map_err(|e| MemoryApiError::Parse(e.to_string()))?;
debug!(
id = %result.id,
deduplicated = result.deduplicated,
"Stored memory via HTTP"
);
Ok(result)
}
/// Search memories via POST /memory/search.
///
/// The memory-api handles embedding the query (Jina AI) and hybrid vector+BM25 search.
pub fn search(
&self,
query: &str,
limit: usize,
category: Option<&str>,
) -> Result<Vec<SearchResult>, MemoryApiError> {
let url = format!("{}/memory/search", self.base_url);
let body = SearchRequest {
query,
limit: Some(limit),
category,
};
let mut req = self.client.post(&url).json(&body);
if !self.token.is_empty() {
req = req.header("Authorization", format!("Bearer {}", self.token));
}
let resp = req
.send()
.map_err(|e| MemoryApiError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if status != 200 {
let body_text = resp.text().unwrap_or_default();
return Err(MemoryApiError::Api {
status,
message: body_text,
});
}
let result: SearchResponse = resp
.json()
.map_err(|e| MemoryApiError::Parse(e.to_string()))?;
debug!(count = result.count, "Searched memories via HTTP");
Ok(result.results)
}
}
+2 -10
View File
@@ -100,11 +100,7 @@ impl KnowledgeStore {
let mut idx = 1;
if let Some(ref source) = pattern.source {
sql.push_str(&format!(
" AND (s.id = ?{} OR s.name = ?{})",
idx,
idx + 1
));
sql.push_str(&format!(" AND (s.id = ?{} OR s.name = ?{})", idx, idx + 1));
params.push(Box::new(source.clone()));
params.push(Box::new(source.clone()));
idx += 2;
@@ -117,11 +113,7 @@ impl KnowledgeStore {
idx += 1;
}
if let Some(ref target) = pattern.target {
sql.push_str(&format!(
" AND (t.id = ?{} OR t.name = ?{})",
idx,
idx + 1
));
sql.push_str(&format!(" AND (t.id = ?{} OR t.name = ?{})", idx, idx + 1));
params.push(Box::new(target.clone()));
params.push(Box::new(target.clone()));
idx += 2;
+2
View File
@@ -8,6 +8,8 @@
//! Agents interact with a single `Memory` trait that abstracts over all three stores.
pub mod consolidation;
#[cfg(feature = "http-memory")]
pub mod http_client;
pub mod knowledge;
pub mod migration;
pub mod semantic;
+164 -3
View File
@@ -14,18 +14,43 @@ use openfang_types::memory::{MemoryFilter, MemoryFragment, MemoryId, MemorySourc
use rusqlite::Connection;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tracing::debug;
use tracing::{debug, warn};
#[cfg(feature = "http-memory")]
use crate::http_client::MemoryApiClient;
/// Semantic store backed by SQLite with optional vector search.
///
/// Supports two backends:
/// - **SQLite** (default): Local LIKE matching / cosine similarity.
/// - **HTTP**: Routes `remember`/`recall` to the memory-api gateway
/// (PostgreSQL + pgvector + Jina AI embeddings).
#[derive(Clone)]
pub struct SemanticStore {
conn: Arc<Mutex<Connection>>,
#[cfg(feature = "http-memory")]
http_client: Option<MemoryApiClient>,
}
impl SemanticStore {
/// Create a new semantic store wrapping the given connection.
/// Create a new semantic store wrapping the given connection (SQLite backend).
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
Self { conn }
Self {
conn,
#[cfg(feature = "http-memory")]
http_client: None,
}
}
/// Create a semantic store with an HTTP backend for the memory-api gateway.
///
/// The SQLite connection is still required for local fallback and other stores.
#[cfg(feature = "http-memory")]
pub fn new_with_http(conn: Arc<Mutex<Connection>>, client: MemoryApiClient) -> Self {
Self {
conn,
http_client: Some(client),
}
}
/// Store a new memory fragment (without embedding).
@@ -41,6 +66,9 @@ impl SemanticStore {
}
/// Store a new memory fragment with an optional embedding vector.
///
/// When HTTP backend is configured, stores via memory-api (which handles
/// embedding generation and deduplication). Falls back to local SQLite.
pub fn remember_with_embedding(
&self,
agent_id: AgentId,
@@ -49,6 +77,26 @@ impl SemanticStore {
scope: &str,
metadata: HashMap<String, serde_json::Value>,
embedding: Option<&[f32]>,
) -> OpenFangResult<MemoryId> {
// HTTP backend: route to memory-api
#[cfg(feature = "http-memory")]
if let Some(ref client) = self.http_client {
return self.remember_via_http(client, agent_id, content, source, scope, &metadata);
}
// SQLite backend (default)
self.remember_sqlite(agent_id, content, source, scope, metadata, embedding)
}
/// SQLite implementation of remember_with_embedding.
fn remember_sqlite(
&self,
agent_id: AgentId,
content: &str,
source: MemorySource,
scope: &str,
metadata: HashMap<String, serde_json::Value>,
embedding: Option<&[f32]>,
) -> OpenFangResult<MemoryId> {
let conn = self
.conn
@@ -80,6 +128,46 @@ impl SemanticStore {
Ok(id)
}
/// HTTP implementation of remember — routes to memory-api POST /memory/store.
#[cfg(feature = "http-memory")]
fn remember_via_http(
&self,
client: &MemoryApiClient,
agent_id: AgentId,
content: &str,
source: MemorySource,
scope: &str,
metadata: &HashMap<String, serde_json::Value>,
) -> OpenFangResult<MemoryId> {
let source_str = format!("{:?}", source).to_lowercase();
let importance = metadata
.get("importance")
.and_then(|v| v.as_u64())
.map(|v| v.min(10) as u8)
.unwrap_or(5);
let tags: Option<Vec<String>> = metadata
.get("tags")
.and_then(|v| serde_json::from_value(v.clone()).ok());
match client.store(
content,
Some(scope),
Some(&agent_id.0.to_string()),
Some(&source_str),
Some(importance),
tags,
) {
Ok(resp) => {
debug!(id = %resp.id, "Stored memory via HTTP backend");
Ok(MemoryId::new())
}
Err(e) => {
warn!(error = %e, "HTTP memory store failed, falling back to SQLite");
self.remember_sqlite(agent_id, content, source, scope, metadata.clone(), None)
}
}
}
/// Search for memories using text matching (fallback, no embeddings).
pub fn recall(
&self,
@@ -92,6 +180,9 @@ impl SemanticStore {
/// Search for memories using vector similarity when a query embedding is provided,
/// falling back to LIKE matching otherwise.
///
/// When HTTP backend is configured, searches via memory-api (hybrid vector+BM25).
/// Falls back to local SQLite on HTTP errors.
pub fn recall_with_embedding(
&self,
query: &str,
@@ -99,6 +190,17 @@ impl SemanticStore {
filter: Option<MemoryFilter>,
query_embedding: Option<&[f32]>,
) -> OpenFangResult<Vec<MemoryFragment>> {
// HTTP backend: route to memory-api
#[cfg(feature = "http-memory")]
if let Some(ref client) = self.http_client {
match self.recall_via_http(client, query, limit, &filter) {
Ok(results) => return Ok(results),
Err(e) => {
warn!(error = %e, "HTTP memory search failed, falling back to SQLite");
}
}
}
let conn = self
.conn
.lock()
@@ -277,7 +379,15 @@ impl SemanticStore {
}
/// Soft-delete a memory fragment.
///
/// In HTTP mode, logs a warning (memory-api doesn't support delete yet)
/// and performs the soft-delete locally only.
pub fn forget(&self, id: MemoryId) -> OpenFangResult<()> {
#[cfg(feature = "http-memory")]
if self.http_client.is_some() {
warn!(id = %id.0, "forget() not supported via HTTP backend, local-only soft-delete");
}
let conn = self
.conn
.lock()
@@ -304,6 +414,57 @@ impl SemanticStore {
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
Ok(())
}
/// HTTP implementation of recall — routes to memory-api POST /memory/search.
///
/// Maps memory-api search results to `MemoryFragment` structs. Fields not
/// available from the HTTP API (agent_id, embedding, access_count) use defaults.
#[cfg(feature = "http-memory")]
fn recall_via_http(
&self,
client: &MemoryApiClient,
query: &str,
limit: usize,
filter: &Option<MemoryFilter>,
) -> OpenFangResult<Vec<MemoryFragment>> {
let category = filter.as_ref().and_then(|f| f.scope.as_deref());
let results = client
.search(query, limit, category)
.map_err(|e| OpenFangError::Memory(format!("HTTP search failed: {e}")))?;
let fragments: Vec<MemoryFragment> = results
.into_iter()
.map(|r| {
let created_at = r
.created_at
.map(|ms| {
chrono::DateTime::from_timestamp_millis(ms as i64).unwrap_or_else(Utc::now)
})
.unwrap_or_else(Utc::now);
MemoryFragment {
id: MemoryId::new(),
agent_id: filter.as_ref().and_then(|f| f.agent_id).unwrap_or_default(),
content: r.content,
embedding: None,
metadata: HashMap::new(),
source: MemorySource::System,
confidence: r.score as f32,
created_at,
accessed_at: Utc::now(),
access_count: 0,
scope: r.category.unwrap_or_else(|| "general".to_string()),
}
})
.collect();
debug!(
count = fragments.len(),
"Recalled memories via HTTP backend"
);
Ok(fragments)
}
}
/// Compute cosine similarity between two vectors.
+51 -3
View File
@@ -13,6 +13,7 @@ use crate::usage::UsageStore;
use async_trait::async_trait;
use openfang_types::agent::{AgentEntry, AgentId, SessionId};
use openfang_types::config::MemoryConfig;
use openfang_types::error::{OpenFangError, OpenFangResult};
use openfang_types::memory::{
ConsolidationReport, Entity, ExportFormat, GraphMatch, GraphPattern, ImportReport, Memory,
@@ -22,6 +23,7 @@ use rusqlite::Connection;
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tracing::{info, warn};
/// The unified memory substrate. Implements the `Memory` trait by delegating
/// to specialized stores backed by a shared SQLite connection.
@@ -37,17 +39,27 @@ pub struct MemorySubstrate {
impl MemorySubstrate {
/// Open or create a memory substrate at the given database path.
pub fn open(db_path: &Path, decay_rate: f32) -> OpenFangResult<Self> {
///
/// When `memory_config.backend == "http"` and `http_url`/`http_token_env` are set,
/// the semantic store routes `remember`/`recall` to the memory-api gateway.
/// All other stores (KV, knowledge graph, sessions) remain local SQLite.
pub fn open(
db_path: &Path,
decay_rate: f32,
memory_config: &MemoryConfig,
) -> OpenFangResult<Self> {
let conn = Connection::open(db_path).map_err(|e| OpenFangError::Memory(e.to_string()))?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
run_migrations(&conn).map_err(|e| OpenFangError::Memory(e.to_string()))?;
let shared = Arc::new(Mutex::new(conn));
let semantic = Self::create_semantic_store(Arc::clone(&shared), memory_config);
Ok(Self {
conn: Arc::clone(&shared),
structured: StructuredStore::new(Arc::clone(&shared)),
semantic: SemanticStore::new(Arc::clone(&shared)),
semantic,
knowledge: KnowledgeStore::new(Arc::clone(&shared)),
sessions: SessionStore::new(Arc::clone(&shared)),
usage: UsageStore::new(Arc::clone(&shared)),
@@ -55,7 +67,43 @@ impl MemorySubstrate {
})
}
/// Create an in-memory substrate (for testing).
/// Create the semantic store, optionally with HTTP backend.
fn create_semantic_store(
conn: Arc<Mutex<Connection>>,
memory_config: &MemoryConfig,
) -> SemanticStore {
#[cfg(feature = "http-memory")]
if memory_config.backend == "http" {
if let (Some(url), Some(token_env)) =
(&memory_config.http_url, &memory_config.http_token_env)
{
match crate::http_client::MemoryApiClient::new(url, token_env) {
Ok(client) => {
// Best-effort health check on startup
match client.health_check() {
Ok(()) => info!(url = %url, "HTTP memory backend connected"),
Err(e) => {
warn!(url = %url, error = %e, "HTTP memory backend health check failed, will retry on use")
}
}
return SemanticStore::new_with_http(conn, client);
}
Err(e) => {
warn!(error = %e, "Failed to create HTTP memory client, falling back to SQLite");
}
}
} else {
warn!("backend=http but http_url/http_token_env not set, falling back to SQLite");
}
}
#[cfg(not(feature = "http-memory"))]
let _ = memory_config;
SemanticStore::new(conn)
}
/// Create an in-memory substrate (for testing). Always uses SQLite backend.
pub fn open_in_memory(decay_rate: f32) -> OpenFangResult<Self> {
let conn =
Connection::open_in_memory().map_err(|e| OpenFangError::Memory(e.to_string()))?;
+2
View File
@@ -30,6 +30,8 @@ zeroize = { workspace = true }
dashmap = { workspace = true }
regex-lite = { workspace = true }
rusqlite = { workspace = true }
rmcp = { workspace = true }
http = "1"
tokio-tungstenite = "0.24"
shlex = "1"
+240 -18
View File
@@ -8,7 +8,7 @@ use crate::context_budget::{apply_context_guard, truncate_tool_result_dynamic, C
use crate::context_overflow::{recover_from_overflow, RecoveryStage};
use crate::embedding::EmbeddingDriver;
use crate::kernel_handle::KernelHandle;
use crate::llm_driver::{CompletionRequest, LlmDriver, LlmError, StreamEvent};
use crate::llm_driver::{CompletionRequest, DriverConfig, LlmDriver, LlmError, StreamEvent};
use crate::llm_errors;
use crate::loop_guard::{LoopGuard, LoopGuardConfig, LoopGuardVerdict};
use crate::mcp::McpConnection;
@@ -17,7 +17,7 @@ use crate::web_search::WebToolsContext;
use openfang_memory::session::Session;
use openfang_memory::MemorySubstrate;
use openfang_skills::registry::SkillRegistry;
use openfang_types::agent::AgentManifest;
use openfang_types::agent::{AgentManifest, FallbackModel};
use openfang_types::error::{OpenFangError, OpenFangResult};
use openfang_types::memory::{Memory, MemoryFilter, MemorySource};
use openfang_types::message::{
@@ -44,6 +44,20 @@ const BASE_RETRY_DELAY_MS: u64 = 1000;
/// Raised from 60s to 120s for browser automation and long-running builds.
const TOOL_TIMEOUT_SECS: u64 = 120;
/// Timeout for inter-agent tool calls (seconds).
/// Agent delegation (agent_send, agent_spawn) can involve a full agent loop on the
/// target, so these need a significantly longer timeout than regular tools.
const AGENT_TOOL_TIMEOUT_SECS: u64 = 600;
/// Returns the appropriate timeout duration for a given tool name.
/// Inter-agent calls get a longer timeout since they may trigger full agent loops.
fn tool_timeout_for(tool_name: &str) -> Duration {
match tool_name {
"agent_send" | "agent_spawn" => Duration::from_secs(AGENT_TOOL_TIMEOUT_SECS),
_ => Duration::from_secs(TOOL_TIMEOUT_SECS),
}
}
/// Maximum consecutive MaxTokens continuations before returning partial response.
/// Raised from 3 to 5 to allow longer-form generation.
const MAX_CONTINUATIONS: u32 = 5;
@@ -57,8 +71,15 @@ fn phantom_action_detected(text: &str) -> bool {
let lower = text.to_lowercase();
let action_verbs = ["sent ", "posted ", "emailed ", "delivered ", "forwarded "];
let channel_refs = [
"telegram", "whatsapp", "slack", "discord", "email", "channel",
"message sent", "successfully sent", "has been sent",
"telegram",
"whatsapp",
"slack",
"discord",
"email",
"channel",
"message sent",
"successfully sent",
"has been sent",
];
let has_action = action_verbs.iter().any(|v| lower.contains(v));
let has_channel = channel_refs.iter().any(|c| lower.contains(c));
@@ -272,7 +293,9 @@ pub async fn run_agent_loop(
// The LLM already received them via llm_messages above.
for msg in session.messages.iter_mut() {
if let MessageContent::Blocks(blocks) = &mut msg.content {
let had_images = blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. }));
let had_images = blocks
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. }));
if had_images {
blocks.retain(|b| !matches!(b, ContentBlock::Image { .. }));
if blocks.is_empty() {
@@ -381,9 +404,22 @@ pub async fn run_agent_loop(
cb(LoopPhase::Thinking);
}
// Stamp last_active before the (potentially long) LLM call so the
// heartbeat monitor doesn't flag us as unresponsive mid-iteration.
if let Some(k) = &kernel {
k.touch_agent(&agent_id_str);
}
// Call LLM with retry, error classification, and circuit breaker
let provider_name = manifest.model.provider.as_str();
let mut response = call_with_retry(&*driver, request, Some(provider_name), None).await?;
let mut response = call_with_retry(
&*driver,
request,
Some(provider_name),
None,
&manifest.fallback_models,
)
.await?;
total_usage.input_tokens += response.usage.input_tokens;
total_usage.output_tokens += response.usage.output_tokens;
@@ -454,7 +490,10 @@ pub async fn run_agent_loop(
// One-shot retry: if the LLM returns empty text with no tool use,
// try once more before accepting the empty result.
// Triggers on first call OR when input_tokens=0 (silently failed request).
if text.trim().is_empty() && response.tool_calls.is_empty() && !response.has_any_content() {
if text.trim().is_empty()
&& response.tool_calls.is_empty()
&& !response.has_any_content()
{
let is_silent_failure =
response.usage.input_tokens == 0 && response.usage.output_tokens == 0;
if iteration == 0 || is_silent_failure {
@@ -499,7 +538,10 @@ pub async fn run_agent_loop(
// channel action (send, post, email, etc.) but never actually
// called the corresponding tool, re-prompt once to force real
// tool usage instead of hallucinated completion.
let text = if !any_tools_executed && iteration == 0 && phantom_action_detected(&text) {
let text = if !any_tools_executed
&& iteration == 0
&& phantom_action_detected(&text)
{
warn!(agent = %manifest.name, "Phantom action detected — re-prompting for real tool use");
messages.push(Message::assistant(text));
messages.push(Message::user(
@@ -628,7 +670,7 @@ pub async fn run_agent_loop(
// Execute each tool call with loop guard, timeout, and truncation
let mut tool_result_blocks = Vec::new();
for tool_call in &response.tool_calls {
for tool_call in deduplicate_tool_calls(&response) {
// Loop guard check
let verdict = loop_guard.check(&tool_call.name, &tool_call.input);
match &verdict {
@@ -710,8 +752,10 @@ pub async fn run_agent_loop(
let effective_exec_policy = manifest.exec_policy.as_ref();
// Timeout-wrapped execution
let timeout = tool_timeout_for(&tool_call.name);
let timeout_secs = timeout.as_secs();
let result = match tokio::time::timeout(
Duration::from_secs(TOOL_TIMEOUT_SECS),
timeout,
tool_runner::execute_tool(
&tool_call.id,
&tool_call.name,
@@ -740,12 +784,12 @@ pub async fn run_agent_loop(
{
Ok(result) => result,
Err(_) => {
warn!(tool = %tool_call.name, "Tool execution timed out after {}s", TOOL_TIMEOUT_SECS);
warn!(tool = %tool_call.name, "Tool execution timed out after {}s", timeout_secs);
openfang_types::tool::ToolResult {
tool_use_id: tool_call.id.clone(),
content: format!(
"Tool '{}' timed out after {}s.",
tool_call.name, TOOL_TIMEOUT_SECS
tool_call.name, timeout_secs
),
is_error: true,
}
@@ -920,11 +964,15 @@ pub async fn run_agent_loop(
///
/// Uses the `llm_errors` classifier for smart error handling and the
/// `ProviderCooldown` circuit breaker to prevent request storms.
///
/// When the primary model returns a `ModelNotFound` error and `fallback_models`
/// is non-empty, each fallback is tried in order before propagating the error.
async fn call_with_retry(
driver: &dyn LlmDriver,
request: CompletionRequest,
provider: Option<&str>,
cooldown: Option<&ProviderCooldown>,
fallback_models: &[FallbackModel],
) -> OpenFangResult<crate::llm_driver::CompletionResponse> {
// Check circuit breaker before calling
if let (Some(provider), Some(cooldown)) = (provider, cooldown) {
@@ -1013,6 +1061,73 @@ async fn call_with_retry(
cooldown.record_failure(provider, classified.is_billing);
}
// --- ModelNotFound fallback chain (issue #845) ---
// If the primary model was not found and fallback models are
// configured, try each fallback before giving up.
if classified.category == llm_errors::LlmErrorCategory::ModelNotFound
&& !fallback_models.is_empty()
{
warn!(
"Primary model not found, trying {} fallback model(s)",
fallback_models.len()
);
for (fb_idx, fb) in fallback_models.iter().enumerate() {
let api_key = fb
.api_key_env
.as_deref()
.and_then(|env_name| std::env::var(env_name).ok());
let fb_config = DriverConfig {
provider: fb.provider.clone(),
api_key,
base_url: fb.base_url.clone(),
skip_permissions: true,
};
let fb_driver = match crate::drivers::create_driver(&fb_config) {
Ok(d) => d,
Err(driver_err) => {
warn!(
fallback_index = fb_idx,
provider = %fb.provider,
model = %fb.model,
error = %driver_err,
"Failed to create fallback driver, skipping"
);
continue;
}
};
let mut fb_request = request.clone();
fb_request.model = fb.model.clone();
warn!(
fallback_index = fb_idx,
provider = %fb.provider,
model = %fb.model,
"Trying fallback model"
);
match fb_driver.complete(fb_request).await {
Ok(response) => {
info!(
fallback_index = fb_idx,
provider = %fb.provider,
model = %fb.model,
"Fallback model succeeded"
);
return Ok(response);
}
Err(fb_err) => {
warn!(
fallback_index = fb_idx,
provider = %fb.provider,
model = %fb.model,
error = %fb_err,
"Fallback model failed"
);
}
}
}
// All fallbacks exhausted — fall through to return the
// original ModelNotFound error below.
}
// Include raw error detail so dashboard users can debug
let user_msg = if classified.category == llm_errors::LlmErrorCategory::Format {
format!("{} — raw: {}", classified.sanitized_message, raw_error)
@@ -1032,12 +1147,16 @@ async fn call_with_retry(
/// Call an LLM driver in streaming mode with automatic retry on rate-limit and overload errors.
///
/// Uses the `llm_errors` classifier and `ProviderCooldown` circuit breaker.
///
/// When the primary model returns a `ModelNotFound` error and `fallback_models`
/// is non-empty, each fallback is tried in order before propagating the error.
async fn stream_with_retry(
driver: &dyn LlmDriver,
request: CompletionRequest,
tx: mpsc::Sender<StreamEvent>,
provider: Option<&str>,
cooldown: Option<&ProviderCooldown>,
fallback_models: &[FallbackModel],
) -> OpenFangResult<crate::llm_driver::CompletionResponse> {
// Check circuit breaker before calling
if let (Some(provider), Some(cooldown)) = (provider, cooldown) {
@@ -1127,6 +1246,69 @@ async fn stream_with_retry(
cooldown.record_failure(provider, classified.is_billing);
}
// --- ModelNotFound fallback chain (issue #845) ---
if classified.category == llm_errors::LlmErrorCategory::ModelNotFound
&& !fallback_models.is_empty()
{
warn!(
"Primary model not found (stream), trying {} fallback model(s)",
fallback_models.len()
);
for (fb_idx, fb) in fallback_models.iter().enumerate() {
let api_key = fb
.api_key_env
.as_deref()
.and_then(|env_name| std::env::var(env_name).ok());
let fb_config = DriverConfig {
provider: fb.provider.clone(),
api_key,
base_url: fb.base_url.clone(),
skip_permissions: true,
};
let fb_driver = match crate::drivers::create_driver(&fb_config) {
Ok(d) => d,
Err(driver_err) => {
warn!(
fallback_index = fb_idx,
provider = %fb.provider,
model = %fb.model,
error = %driver_err,
"Failed to create fallback stream driver, skipping"
);
continue;
}
};
let mut fb_request = request.clone();
fb_request.model = fb.model.clone();
warn!(
fallback_index = fb_idx,
provider = %fb.provider,
model = %fb.model,
"Trying fallback model (stream)"
);
match fb_driver.stream(fb_request, tx.clone()).await {
Ok(response) => {
info!(
fallback_index = fb_idx,
provider = %fb.provider,
model = %fb.model,
"Fallback model succeeded (stream)"
);
return Ok(response);
}
Err(fb_err) => {
warn!(
fallback_index = fb_idx,
provider = %fb.provider,
model = %fb.model,
error = %fb_err,
"Fallback model failed (stream)"
);
}
}
}
}
let user_msg = if classified.category == llm_errors::LlmErrorCategory::Format {
format!("{} — raw: {}", classified.sanitized_message, raw_error)
} else {
@@ -1275,7 +1457,9 @@ pub async fn run_agent_loop_streaming(
// The LLM already received them via llm_messages above.
for msg in session.messages.iter_mut() {
if let MessageContent::Blocks(blocks) = &mut msg.content {
let had_images = blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. }));
let had_images = blocks
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. }));
if had_images {
blocks.retain(|b| !matches!(b, ContentBlock::Image { .. }));
if blocks.is_empty() {
@@ -1371,6 +1555,14 @@ pub async fn run_agent_loop_streaming(
}
}
// Re-validate tool_call/tool_result pairing after overflow drains
// which may have broken assistant→tool ordering invariants.
// (Matches the non-streaming loop; fixes Qwen3.5-plus "tool_calls must
// be followed by tool messages" errors after context overflow recovery.)
if recovery != RecoveryStage::None {
messages = crate::session_repair::validate_and_repair(&messages);
}
// Context guard: compact oversized tool results before LLM call
apply_context_guard(&mut messages, &context_budget, available_tools);
@@ -1406,6 +1598,7 @@ pub async fn run_agent_loop_streaming(
stream_tx.clone(),
Some(provider_name),
None,
&manifest.fallback_models,
)
.await?;
@@ -1475,7 +1668,10 @@ pub async fn run_agent_loop_streaming(
// One-shot retry: if the LLM returns empty text with no tool use,
// try once more before accepting the empty result.
// Triggers on first call OR when input_tokens=0 (silently failed request).
if text.trim().is_empty() && response.tool_calls.is_empty() && !response.has_any_content() {
if text.trim().is_empty()
&& response.tool_calls.is_empty()
&& !response.has_any_content()
{
let is_silent_failure =
response.usage.input_tokens == 0 && response.usage.output_tokens == 0;
if iteration == 0 || is_silent_failure {
@@ -1628,7 +1824,7 @@ pub async fn run_agent_loop_streaming(
// Execute each tool call with loop guard, timeout, and truncation
let mut tool_result_blocks = Vec::new();
for tool_call in &response.tool_calls {
for tool_call in deduplicate_tool_calls(&response) {
// Loop guard check
let verdict = loop_guard.check(&tool_call.name, &tool_call.input);
match &verdict {
@@ -1709,8 +1905,10 @@ pub async fn run_agent_loop_streaming(
let effective_exec_policy = manifest.exec_policy.as_ref();
// Timeout-wrapped execution
let timeout = tool_timeout_for(&tool_call.name);
let timeout_secs = timeout.as_secs();
let result = match tokio::time::timeout(
Duration::from_secs(TOOL_TIMEOUT_SECS),
timeout,
tool_runner::execute_tool(
&tool_call.id,
&tool_call.name,
@@ -1739,12 +1937,12 @@ pub async fn run_agent_loop_streaming(
{
Ok(result) => result,
Err(_) => {
warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", TOOL_TIMEOUT_SECS);
warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", timeout_secs);
openfang_types::tool::ToolResult {
tool_use_id: tool_call.id.clone(),
content: format!(
"Tool '{}' timed out after {}s.",
tool_call.name, TOOL_TIMEOUT_SECS
tool_call.name, timeout_secs
),
is_error: true,
}
@@ -1780,6 +1978,7 @@ pub async fn run_agent_loop_streaming(
let preview: String = final_content.chars().take(300).collect();
if stream_tx
.send(StreamEvent::ToolExecutionResult {
id: tool_call.id.clone(),
name: tool_call.name.clone(),
result_preview: preview,
is_error: result.is_error,
@@ -2732,6 +2931,20 @@ fn try_parse_bare_json_tool_call(
parse_json_tool_call_object(&text[..end], tool_names)
}
/// Deduplicate tool calls from the response.
/// Returns a reference to the deduplicated tool calls.
pub fn deduplicate_tool_calls(response: &crate::llm_driver::CompletionResponse) -> Vec<&ToolCall> {
let mut hash_set = std::collections::HashSet::new();
let mut deduplicated = Vec::new();
for tool_call in &response.tool_calls {
let hash = LoopGuard::compute_hash(&tool_call.name, &tool_call.input);
if hash_set.insert(hash) {
deduplicated.push(tool_call);
}
}
deduplicated
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2793,6 +3006,15 @@ mod tests {
#[test]
fn test_tool_timeout_constant() {
assert_eq!(TOOL_TIMEOUT_SECS, 120);
assert_eq!(AGENT_TOOL_TIMEOUT_SECS, 600);
}
#[test]
fn test_tool_timeout_for_agent_tools() {
assert_eq!(tool_timeout_for("agent_send"), Duration::from_secs(600));
assert_eq!(tool_timeout_for("agent_spawn"), Duration::from_secs(600));
assert_eq!(tool_timeout_for("file_read"), Duration::from_secs(120));
assert_eq!(tool_timeout_for("shell_exec"), Duration::from_secs(120));
}
#[test]
+1 -1
View File
@@ -877,7 +877,7 @@ pub async fn tool_browser_navigate(
agent_id: &str,
) -> Result<String, String> {
let url = input["url"].as_str().ok_or("Missing 'url' parameter")?;
crate::web_fetch::check_ssrf(url)?;
crate::web_fetch::check_ssrf(url, &[])?;
let resp = mgr
.send_command(
+97 -1
View File
@@ -606,6 +606,49 @@ async fn summarize_in_chunks(
}
}
/// Adjust a split index so it does not land between an assistant ToolUse message
/// and the immediately following user ToolResult message.
///
/// If `split` points right after an assistant message that contains ToolUse blocks,
/// and the message at `split` is a user message with matching ToolResult blocks,
/// the split is pulled back by 1 so the pair stays in the "kept" portion.
fn adjust_split_for_tool_pairs(messages: &[Message], split: usize) -> usize {
use openfang_types::message::{ContentBlock, Role};
if split == 0 || split >= messages.len() {
return split;
}
// Check if split - 1 is an assistant with ToolUse and split is a user with ToolResult
let prev = &messages[split - 1];
let curr = &messages[split];
if prev.role != Role::Assistant || curr.role != Role::User {
return split;
}
let prev_has_tool_use = match &prev.content {
MessageContent::Blocks(blocks) => blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. })),
_ => false,
};
let curr_has_tool_result = match &curr.content {
MessageContent::Blocks(blocks) => blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolResult { .. })),
_ => false,
};
if prev_has_tool_use && curr_has_tool_result {
// Pull back so both stay in "kept"
split - 1
} else {
split
}
}
/// Compact a session by summarizing older messages with an LLM.
///
/// Takes all messages except the most recent `keep_recent` and uses a
@@ -634,7 +677,11 @@ pub async fn compact_session(
});
}
let split_at = msg_count.saturating_sub(config.keep_recent);
let raw_split = msg_count.saturating_sub(config.keep_recent);
// Adjust split point to avoid cutting between a ToolUse assistant message
// and its ToolResult user message. If the split lands right between them,
// pull back by 1 so the pair stays together in `kept`.
let split_at = adjust_split_for_tool_pairs(&session.messages, raw_split);
let to_compact = &session.messages[..split_at];
let kept = &session.messages[split_at..];
@@ -1403,4 +1450,53 @@ mod tests {
let text = build_conversation_text(&messages, &config);
assert!(text.contains(short_result));
}
#[test]
fn test_adjust_split_pulls_back_for_tool_pair() {
// Messages: [user, assistant(ToolUse), user(ToolResult), assistant("done")]
// Split at 2 would separate the ToolUse from its ToolResult.
let messages = vec![
Message::user("hello"),
Message {
role: Role::Assistant,
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "t1".to_string(),
name: "read".to_string(),
input: serde_json::json!({}),
provider_metadata: None,
}]),
},
Message {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t1".to_string(),
tool_name: "read".to_string(),
content: "file contents".to_string(),
is_error: false,
}]),
},
Message::assistant("Done reading."),
];
let adjusted = adjust_split_for_tool_pairs(&messages, 2);
assert_eq!(adjusted, 1, "Should pull back split to keep ToolUse + ToolResult together");
}
#[test]
fn test_adjust_split_no_change_for_text() {
let messages = vec![
Message::user("a"),
Message::assistant("b"),
Message::user("c"),
];
let adjusted = adjust_split_for_tool_pairs(&messages, 1);
assert_eq!(adjusted, 1, "Should not change split for plain text messages");
}
#[test]
fn test_adjust_split_edge_cases() {
let messages = vec![Message::user("a")];
assert_eq!(adjust_split_for_tool_pairs(&messages, 0), 0);
assert_eq!(adjust_split_for_tool_pairs(&messages, 1), 1);
assert_eq!(adjust_split_for_tool_pairs(&messages, 5), 5);
}
}
+140 -5
View File
@@ -8,10 +8,85 @@
//! 3. Truncate historical tool results to 2K chars each
//! 4. Return error suggesting /reset or /compact
use openfang_types::message::{ContentBlock, Message, MessageContent};
use openfang_types::message::{ContentBlock, Message, MessageContent, Role};
use openfang_types::tool::ToolDefinition;
use tracing::{debug, warn};
/// Adjust a drain boundary so it does not split a ToolUse/ToolResult pair.
///
/// If the message at `boundary` is a user message containing ToolResult blocks
/// whose matching ToolUse lives in the message at `boundary - 1`, we pull the
/// boundary back by one so both the assistant (ToolUse) and user (ToolResult)
/// are kept together. Conversely, if the last drained message is an assistant
/// with ToolUse blocks whose results sit at `boundary`, we push the boundary
/// forward by one so the orphaned assistant is also drained.
///
/// This is best-effort — `session_repair::validate_and_repair` is still called
/// afterwards as the authoritative fixup.
fn safe_drain_boundary(messages: &[Message], mut boundary: usize) -> usize {
if boundary == 0 || boundary >= messages.len() {
return boundary;
}
// Case 1: first kept message is a user msg with ToolResults whose ToolUse
// is in the last drained message (boundary - 1). Pull boundary back by 1.
if messages[boundary].role == Role::User {
if let MessageContent::Blocks(blocks) = &messages[boundary].content {
let has_tool_result = blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. }));
if has_tool_result && boundary > 0 && messages[boundary - 1].role == Role::Assistant {
if let MessageContent::Blocks(asst_blocks) = &messages[boundary - 1].content {
let has_tool_use = asst_blocks.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. }));
if has_tool_use {
boundary -= 1;
debug!(
new_boundary = boundary,
"Adjusted drain boundary back to keep ToolUse/ToolResult pair"
);
}
}
}
}
}
// Case 2: last drained message (boundary - 1) is an assistant with ToolUse
// but the ToolResults are at `boundary` (already handled above by pulling
// back). If the first kept message is NOT the matching result, push forward
// to drain the orphaned assistant too.
if boundary > 0 && boundary < messages.len() && messages[boundary - 1].role == Role::Assistant {
if let MessageContent::Blocks(asst_blocks) = &messages[boundary - 1].content {
let tool_use_ids: Vec<&str> = asst_blocks
.iter()
.filter_map(|b| match b {
ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
_ => None,
})
.collect();
if !tool_use_ids.is_empty() {
// Check if the first kept message has the matching results
let first_kept_has_results = match &messages[boundary].content {
MessageContent::Blocks(blocks) => blocks.iter().any(|b| match b {
ContentBlock::ToolResult { tool_use_id, .. } => {
tool_use_ids.contains(&tool_use_id.as_str())
}
_ => false,
}),
_ => false,
};
if !first_kept_has_results {
// The assistant's ToolResults were already drained; drain the
// orphaned assistant as well to avoid needing synthetic results.
boundary = boundary.min(messages.len());
// Note: we don't push forward here because that would drain
// more messages than intended. The validate_and_repair call
// will insert synthetic results for this orphan instead.
}
}
}
}
boundary
}
/// Recovery stage that was applied.
#[derive(Debug, Clone, PartialEq)]
pub enum RecoveryStage {
@@ -53,12 +128,14 @@ pub fn recover_from_overflow(
// Stage 1: Moderate trim — keep last 10 messages
if estimated <= threshold_90 {
let keep = 10.min(messages.len());
let remove = messages.len() - keep;
let raw_remove = messages.len() - keep;
// Adjust boundary to avoid splitting ToolUse/ToolResult pairs
let remove = safe_drain_boundary(messages, raw_remove);
if remove > 0 {
debug!(
estimated_tokens = estimated,
removing = remove,
"Stage 1: moderate trim to last {keep} messages"
"Stage 1: moderate trim to last {} messages", messages.len() - remove
);
messages.drain(..remove);
// Re-check after trim
@@ -72,12 +149,14 @@ pub fn recover_from_overflow(
// Stage 2: Aggressive trim — keep last 4 messages + summary marker
{
let keep = 4.min(messages.len());
let remove = messages.len() - keep;
let raw_remove = messages.len() - keep;
// Adjust boundary to avoid splitting ToolUse/ToolResult pairs
let remove = safe_drain_boundary(messages, raw_remove);
if remove > 0 {
warn!(
estimated_tokens = estimate_tokens(messages, system_prompt, tools),
removing = remove,
"Stage 2: aggressive overflow compaction to last {keep} messages"
"Stage 2: aggressive overflow compaction to last {} messages", messages.len() - remove
);
let summary = Message::user(format!(
"[System: {} earlier messages were removed due to context overflow. \
@@ -264,4 +343,60 @@ mod tests {
// Must not panic — the truncation at byte boundaries could split a 3-byte char
assert_ne!(stage, RecoveryStage::None);
}
#[test]
fn test_safe_drain_boundary_pulls_back_for_tool_pair() {
// Messages: [user, assistant(ToolUse), user(ToolResult), user]
// If boundary = 2 (keep last 2), it splits between assistant(ToolUse) and
// user(ToolResult). safe_drain_boundary should pull back to 1.
let msgs = vec![
Message::user("hello"),
Message {
role: Role::Assistant,
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "t1".to_string(),
name: "read".to_string(),
input: serde_json::json!({}),
provider_metadata: None,
}]),
},
Message {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t1".to_string(),
tool_name: "read".to_string(),
content: "file contents".to_string(),
is_error: false,
}]),
},
Message::user("thanks"),
];
// Boundary 2 would cut between the assistant(ToolUse) at [1] and user(ToolResult) at [2].
let adjusted = safe_drain_boundary(&msgs, 2);
assert_eq!(adjusted, 1, "Should pull boundary back to keep the ToolUse/ToolResult pair together");
}
#[test]
fn test_safe_drain_boundary_no_change_for_text_messages() {
let msgs = vec![
Message::user("a"),
Message::assistant("b"),
Message::user("c"),
Message::assistant("d"),
];
let adjusted = safe_drain_boundary(&msgs, 2);
assert_eq!(adjusted, 2, "Should not change boundary for plain text messages");
}
#[test]
fn test_safe_drain_boundary_edge_zero() {
let msgs = vec![Message::user("a")];
assert_eq!(safe_drain_boundary(&msgs, 0), 0);
}
#[test]
fn test_safe_drain_boundary_edge_end() {
let msgs = vec![Message::user("a"), Message::assistant("b")];
assert_eq!(safe_drain_boundary(&msgs, 2), 2);
}
}
+1 -1
View File
@@ -144,6 +144,6 @@ mod tests {
fn test_constants() {
assert!(GITHUB_DEVICE_CODE_URL.starts_with("https://"));
assert!(GITHUB_TOKEN_URL.starts_with("https://"));
assert!(!COPILOT_CLIENT_ID.is_empty());
assert_ne!(COPILOT_CLIENT_ID, "");
}
}
@@ -471,9 +471,8 @@ impl LlmDriver for AnthropicDriver {
input_json,
}) = blocks.get(block_idx)
{
let input: serde_json::Value =
serde_json::from_str(input_json)
.unwrap_or_else(|_| serde_json::json!({}));
let input: serde_json::Value = serde_json::from_str(input_json)
.unwrap_or_else(|_| serde_json::json!({}));
let _ = tx
.send(StreamEvent::ToolUseEnd {
id: id.clone(),
@@ -521,8 +520,8 @@ impl LlmDriver for AnthropicDriver {
name,
input_json,
} => {
let input: serde_json::Value =
serde_json::from_str(&input_json).unwrap_or_default();
let input: serde_json::Value = serde_json::from_str(&input_json)
.unwrap_or_else(|_| serde_json::json!({}));
content.push(ContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
@@ -553,6 +552,28 @@ impl LlmDriver for AnthropicDriver {
}
}
/// Ensure a `serde_json::Value` is a JSON object (dictionary).
///
/// The Anthropic API requires `tool_use.input` to be a JSON object, never a
/// string, null, or other scalar. This helper handles:
/// - `Value::Object` → returned as-is
/// - `Value::String` → attempt to parse as JSON; if the result is an object, use it
/// - anything else (Null, Number, Bool, Array) → empty object `{}`
fn ensure_object(v: &serde_json::Value) -> serde_json::Value {
match v {
serde_json::Value::Object(_) => v.clone(),
serde_json::Value::String(s) => {
// The input may have been double-serialized (stored as a JSON string).
// Try to parse it back into a Value.
match serde_json::from_str::<serde_json::Value>(s) {
Ok(parsed) if parsed.is_object() => parsed,
_ => serde_json::json!({}),
}
}
_ => serde_json::json!({}),
}
}
/// Convert an OpenFang Message to an Anthropic API message.
fn convert_message(msg: &Message) -> ApiMessage {
let role = match msg.role {
@@ -582,7 +603,7 @@ fn convert_message(msg: &Message) -> ApiMessage {
} => Some(ApiContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.clone(),
input: ensure_object(input),
}),
ContentBlock::ToolResult {
tool_use_id,
@@ -692,4 +713,63 @@ mod tests {
assert_eq!(response.tool_calls[0].name, "web_search");
assert_eq!(response.usage.total(), 150);
}
#[test]
fn test_ensure_object_from_object() {
let obj = serde_json::json!({"key": "value"});
assert_eq!(ensure_object(&obj), obj);
}
#[test]
fn test_ensure_object_from_string() {
// Simulates double-serialized input (stored as JSON string)
let stringified = serde_json::Value::String(r#"{"query": "rust"}"#.to_string());
let result = ensure_object(&stringified);
assert_eq!(result, serde_json::json!({"query": "rust"}));
}
#[test]
fn test_ensure_object_from_null() {
let null = serde_json::Value::Null;
assert_eq!(ensure_object(&null), serde_json::json!({}));
}
#[test]
fn test_ensure_object_from_non_object_string() {
// A string that parses to a non-object JSON value
let s = serde_json::Value::String("42".to_string());
assert_eq!(ensure_object(&s), serde_json::json!({}));
}
#[test]
fn test_ensure_object_from_invalid_json_string() {
let s = serde_json::Value::String("not json at all".to_string());
assert_eq!(ensure_object(&s), serde_json::json!({}));
}
#[test]
fn test_convert_message_normalizes_tool_input() {
// Simulate a ToolUse block with a stringified JSON input (legacy session data)
let msg = Message {
role: Role::Assistant,
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "tu-1".to_string(),
name: "web_search".to_string(),
input: serde_json::Value::String(r#"{"query": "test"}"#.to_string()),
provider_metadata: None,
}]),
};
let api_msg = convert_message(&msg);
if let ApiContent::Blocks(blocks) = api_msg.content {
match &blocks[0] {
ApiContentBlock::ToolUse { input, .. } => {
assert!(input.is_object(), "input should be an object, got: {input}");
assert_eq!(input["query"], "test");
}
_ => panic!("Expected ToolUse block"),
}
} else {
panic!("Expected Blocks content");
}
}
}
@@ -133,10 +133,6 @@ impl ClaudeCodeDriver {
fn build_prompt(request: &CompletionRequest) -> String {
let mut parts = Vec::new();
if let Some(ref sys) = request.system {
parts.push(format!("[System]\n{sys}"));
}
for msg in &request.messages {
let role_label = match msg.role {
Role::User => "User",
@@ -192,8 +188,13 @@ impl ClaudeCodeDriver {
///
/// The CLI may return the response text in different fields depending on
/// version: `result`, `content`, or `text`. We try all three.
/// All fields use `#[serde(default)]` so deserialization never fails on
/// missing keys — older and newer CLI versions differ in which fields are emitted.
#[derive(Debug, Deserialize)]
struct ClaudeJsonOutput {
// Fix: `result` now has #[serde(default)] so deserialization succeeds
// even when the CLI emits the response in `content` or `text` instead.
#[serde(default)]
result: Option<String>,
#[serde(default)]
content: Option<String>,
@@ -215,15 +216,42 @@ struct ClaudeUsage {
output_tokens: u64,
}
/// Stream JSON event from `claude -p --output-format stream-json`.
/// A single content block inside an `assistant` stream-json event.
/// The CLI emits `{"type":"text","text":"..."}` blocks inside `message.content`.
#[derive(Debug, Deserialize, Default)]
struct ClaudeMessageBlock {
#[serde(default, rename = "type")]
block_type: String,
#[serde(default)]
text: String,
}
/// Nested `message` object carried by `type=assistant` stream-json events.
#[derive(Debug, Deserialize, Default)]
struct ClaudeAssistantMessage {
#[serde(default)]
content: Vec<ClaudeMessageBlock>,
}
/// Stream JSON event from `claude -p --output-format stream-json --verbose`.
///
/// Newer CLI versions (≥2.x) carry the response text inside the nested
/// `message.content[].text` of `type=assistant` events rather than a
/// flat `content` string. Both layouts are handled here so that real-time
/// token streaming works across CLI versions.
#[derive(Debug, Deserialize)]
struct ClaudeStreamEvent {
#[serde(default)]
r#type: String,
/// Flat content string — used by older CLI versions and some event types.
#[serde(default)]
content: Option<String>,
/// Final result text carried by `type=result` events.
#[serde(default)]
result: Option<String>,
/// Nested assistant message — used by newer CLI `type=assistant` events.
#[serde(default)]
message: Option<ClaudeAssistantMessage>,
#[serde(default)]
usage: Option<ClaudeUsage>,
}
@@ -240,6 +268,10 @@ impl LlmDriver for ClaudeCodeDriver {
.arg("--output-format")
.arg("json");
if let Some(ref sys) = request.system {
cmd.arg("--system-prompt").arg(sys);
}
if self.skip_permissions {
cmd.arg("--dangerously-skip-permissions");
}
@@ -250,6 +282,13 @@ impl LlmDriver for ClaudeCodeDriver {
Self::apply_env_filter(&mut cmd);
// Inject HOME so the CLI can find its credentials (~/.claude/) when
// OpenFang runs as a service without a login shell.
if let Some(home) = home_dir() {
cmd.env("HOME", &home);
}
// Detach stdin so the CLI does not block waiting for interactive input.
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
@@ -271,14 +310,36 @@ impl LlmDriver for ClaudeCodeDriver {
debug!(pid = pid, model = %pid_label, "Claude Code CLI subprocess started");
}
// Read stdout/stderr before waiting (take ownership of pipes)
// Drain stdout and stderr concurrently while waiting for the process.
// Sequential drain (wait → read) deadlocks when the subprocess writes
// more than the OS pipe buffer (~64 KB): the child blocks on write,
// child.wait() never returns, the timeout fires, and output is lost.
let child_stdout = child.stdout.take();
let child_stderr = child.stderr.take();
let stdout_task = tokio::spawn(async move {
let mut buf = Vec::new();
if let Some(mut out) = child_stdout {
let _ = out.read_to_end(&mut buf).await;
}
buf
});
let stderr_task = tokio::spawn(async move {
let mut buf = Vec::new();
if let Some(mut err) = child_stderr {
let _ = err.read_to_end(&mut buf).await;
}
buf
});
// Wait with timeout
let timeout_duration = std::time::Duration::from_secs(self.message_timeout_secs);
let wait_result = tokio::time::timeout(timeout_duration, child.wait()).await;
// Collect pipe output — tasks complete once the process closes its end
let stdout_bytes = stdout_task.await.unwrap_or_default();
let stderr_bytes = stderr_task.await.unwrap_or_default();
// Clear PID tracking regardless of outcome
self.active_pids.remove(&pid_label);
@@ -305,16 +366,6 @@ impl LlmDriver for ClaudeCodeDriver {
}
};
// Read captured output from pipes
let mut stdout_bytes = Vec::new();
let mut stderr_bytes = Vec::new();
if let Some(mut out) = child_stdout {
let _ = out.read_to_end(&mut stdout_bytes).await;
}
if let Some(mut err) = child_stderr {
let _ = err.read_to_end(&mut stderr_bytes).await;
};
if !status.success() {
let stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
let stdout_str = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
@@ -413,6 +464,10 @@ impl LlmDriver for ClaudeCodeDriver {
.arg("stream-json")
.arg("--verbose");
if let Some(ref sys) = request.system {
cmd.arg("--system-prompt").arg(sys);
}
if self.skip_permissions {
cmd.arg("--dangerously-skip-permissions");
}
@@ -423,6 +478,11 @@ impl LlmDriver for ClaudeCodeDriver {
Self::apply_env_filter(&mut cmd);
// Same HOME and stdin hygiene as the non-streaming path.
if let Some(home) = home_dir() {
cmd.env("HOME", &home);
}
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
@@ -468,13 +528,28 @@ impl LlmDriver for ClaudeCodeDriver {
Ok(event) => {
match event.r#type.as_str() {
"content" | "text" | "assistant" | "content_block_delta" => {
if let Some(ref content) = event.content {
full_text.push_str(content);
let _ = tx
.send(StreamEvent::TextDelta {
text: content.clone(),
})
.await;
// Older CLI: flat `content` string.
// CLI ≥2.x (type=assistant): text is nested in
// `message.content[].text`; the flat `content`
// field is absent or null.
let chunk = event.content.clone().unwrap_or_default();
let nested: String = event
.message
.as_ref()
.map(|msg| {
msg.content
.iter()
.filter(|b| b.block_type == "text")
.map(|b| b.text.as_str())
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
let text_chunk = if !chunk.is_empty() { chunk } else { nested };
if !text_chunk.is_empty() {
full_text.push_str(&text_chunk);
let _ =
tx.send(StreamEvent::TextDelta { text: text_chunk }).await;
}
}
"result" | "done" | "complete" => {
@@ -645,8 +720,8 @@ mod tests {
};
let prompt = ClaudeCodeDriver::build_prompt(&request);
assert!(prompt.contains("[System]"));
assert!(prompt.contains("You are helpful."));
assert!(!prompt.contains("[System]"));
assert!(!prompt.contains("You are helpful."));
assert!(prompt.contains("[User]"));
assert!(prompt.contains("Hello"));
}
+428 -11
View File
@@ -60,6 +60,7 @@ struct GeminiRequest {
struct GeminiContent {
#[serde(skip_serializing_if = "Option::is_none")]
role: Option<String>,
#[serde(default)]
parts: Vec<GeminiPart>,
}
@@ -336,9 +337,119 @@ fn convert_messages(
}
}
// Sanitize for Gemini's strict turn-ordering rules:
// - A model turn with functionCall MUST be followed by a user turn with functionResponse
// - No consecutive same-role turns
let contents = sanitize_gemini_turns(contents);
(contents, system_instruction)
}
/// Enforce Gemini's strict turn-ordering constraints.
///
/// Gemini requires: functionCall (model) → functionResponse (user) → model.
/// After message trimming, this ordering can break. This function:
/// 1. Merges consecutive same-role turns
/// 2. Drops orphaned functionCall parts (no following functionResponse)
/// 3. Drops orphaned functionResponse parts (no preceding functionCall)
fn sanitize_gemini_turns(contents: Vec<GeminiContent>) -> Vec<GeminiContent> {
if contents.is_empty() {
return contents;
}
// Step 1: Merge consecutive same-role turns
let mut merged: Vec<GeminiContent> = Vec::with_capacity(contents.len());
for entry in contents {
if let Some(last) = merged.last_mut() {
if last.role == entry.role {
last.parts.extend(entry.parts);
continue;
}
}
merged.push(entry);
}
// Step 2: Drop orphaned functionCall parts from model turns.
// A model turn with functionCall must be followed by a user turn with functionResponse.
let len = merged.len();
for i in 0..len {
let is_model = merged[i].role.as_deref() == Some("model");
if !is_model {
continue;
}
let has_function_call = merged[i]
.parts
.iter()
.any(|p| matches!(p, GeminiPart::FunctionCall { .. }));
if !has_function_call {
continue;
}
// Check if next turn is a user turn with functionResponse
let next_has_response = i + 1 < len
&& merged[i + 1].role.as_deref() == Some("user")
&& merged[i + 1]
.parts
.iter()
.any(|p| matches!(p, GeminiPart::FunctionResponse { .. }));
if !next_has_response {
// Drop the functionCall parts from this model turn (keep text parts)
merged[i]
.parts
.retain(|p| !matches!(p, GeminiPart::FunctionCall { .. }));
}
}
// Step 3: Drop orphaned functionResponse parts from user turns.
// A user turn with functionResponse must be preceded by a model turn with functionCall.
for i in 0..merged.len() {
let is_user = merged[i].role.as_deref() == Some("user");
if !is_user {
continue;
}
let has_function_response = merged[i]
.parts
.iter()
.any(|p| matches!(p, GeminiPart::FunctionResponse { .. }));
if !has_function_response {
continue;
}
let prev_has_call = i > 0
&& merged[i - 1].role.as_deref() == Some("model")
&& merged[i - 1]
.parts
.iter()
.any(|p| matches!(p, GeminiPart::FunctionCall { .. }));
if !prev_has_call {
merged[i]
.parts
.retain(|p| !matches!(p, GeminiPart::FunctionResponse { .. }));
}
}
// Step 4: Remove turns that ended up empty after filtering
merged.retain(|c| !c.parts.is_empty());
// Step 5: Final merge pass (removing parts may have created new consecutive same-role)
let mut final_merged: Vec<GeminiContent> = Vec::with_capacity(merged.len());
for entry in merged {
if let Some(last) = final_merged.last_mut() {
if last.role == entry.role {
last.parts.extend(entry.parts);
continue;
}
}
final_merged.push(entry);
}
final_merged
}
/// Extract system prompt from messages or the explicit system field.
fn extract_system(messages: &[Message], system: &Option<String>) -> Option<GeminiContent> {
let text = system.clone().or_else(|| {
@@ -653,7 +764,20 @@ impl LlmDriver for GeminiDriver {
return Err(LlmError::Api { status, message });
}
// Parse SSE stream
// Parse SSE stream — process line-by-line like the OpenAI driver.
//
// Gemini's `streamGenerateContent?alt=sse` endpoint sends
// standard SSE: each event is a `data: {...}` line followed by
// a blank line. The previous implementation looked for `\n\n`
// as the event delimiter, but many HTTP responses use `\r\n`
// line endings, so the actual delimiter is `\r\n\r\n` which
// `find("\n\n")` never matches — causing the entire stream to
// be silently buffered without extracting any events (zero
// TextDelta emissions → empty response → infinite retry loop).
//
// Fix: process the buffer one line at a time (splitting on
// `\n` and stripping trailing `\r`). A `data:` line is
// parsed immediately. Empty/blank lines are simply skipped.
let mut buffer = String::new();
let mut text_content = String::new();
// Thought signature for accumulated text content (last one wins)
@@ -662,22 +786,32 @@ impl LlmDriver for GeminiDriver {
let mut fn_calls: Vec<(String, serde_json::Value, Option<String>)> = Vec::new();
let mut finish_reason: Option<String> = None;
let mut usage = TokenUsage::default();
let mut chunk_count: u32 = 0;
let mut sse_line_count: u32 = 0;
let mut byte_stream = resp.bytes_stream();
while let Some(chunk_result) = byte_stream.next().await {
let chunk = chunk_result.map_err(|e| LlmError::Http(e.to_string()))?;
chunk_count += 1;
buffer.push_str(&String::from_utf8_lossy(&chunk));
// Process complete SSE events (delimited by \n\n or \r\n\r\n)
while let Some(pos) = buffer.find("\n\n") {
let event_text = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
// Process complete lines (handle both \r\n and \n endings)
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim_end().to_string();
buffer = buffer[pos + 1..].to_string();
// Extract the data line (handle both "data: " and "data:" formats)
let data = event_text
.lines()
.find_map(|line| line.strip_prefix("data:").map(|d| d.trim_start()))
.unwrap_or("");
// Skip empty lines and SSE comments
if line.is_empty() || line.starts_with(':') {
continue;
}
sse_line_count += 1;
// Extract the data payload (handle both "data: " and "data:" formats)
let data = match line.strip_prefix("data:") {
Some(d) => d.trim_start(),
None => continue,
};
if data.is_empty() {
continue;
@@ -685,7 +819,14 @@ impl LlmDriver for GeminiDriver {
let json: GeminiResponse = match serde_json::from_str(data) {
Ok(v) => v,
Err(_) => continue,
Err(e) => {
debug!(
error = %e,
data_preview = &data[..data.len().min(200)],
"Failed to parse Gemini SSE data line"
);
continue;
}
};
// Extract usage from each chunk (last one wins)
@@ -768,6 +909,121 @@ impl LlmDriver for GeminiDriver {
}
}
// Process any remaining data left in the buffer after the stream
// ends (e.g. final chunk not terminated by a newline).
let remaining = buffer.trim();
if !remaining.is_empty() {
if let Some(data) = remaining.strip_prefix("data:") {
let data = data.trim();
if !data.is_empty() {
if let Ok(json) = serde_json::from_str::<GeminiResponse>(data) {
if let Some(ref u) = json.usage_metadata {
usage.input_tokens = u.prompt_token_count;
usage.output_tokens = u.candidates_token_count;
}
for candidate in &json.candidates {
if let Some(fr) = &candidate.finish_reason {
finish_reason = Some(fr.clone());
}
if let Some(ref content) = candidate.content {
for part in &content.parts {
match part {
GeminiPart::Text {
text,
thought_signature,
} => {
if !text.is_empty() {
text_content.push_str(text);
let _ = tx
.send(StreamEvent::TextDelta {
text: text.clone(),
})
.await;
}
if thought_signature.is_some() {
text_thought_sig = thought_signature.clone();
}
}
GeminiPart::FunctionCall {
function_call,
thought_signature,
} => {
let id = format!(
"call_{}",
uuid::Uuid::new_v4().simple()
);
let _ = tx
.send(StreamEvent::ToolUseStart {
id: id.clone(),
name: function_call.name.clone(),
})
.await;
let args_str =
serde_json::to_string(&function_call.args)
.unwrap_or_default();
let _ = tx
.send(StreamEvent::ToolInputDelta {
text: args_str,
})
.await;
let _ = tx
.send(StreamEvent::ToolUseEnd {
id,
name: function_call.name.clone(),
input: function_call.args.clone(),
})
.await;
fn_calls.push((
function_call.name.clone(),
function_call.args.clone(),
thought_signature.clone(),
));
}
GeminiPart::Thought { ref text, .. } => {
if !text.is_empty() {
let _ = tx
.send(StreamEvent::ThinkingDelta {
text: text.clone(),
})
.await;
}
}
_ => {}
}
}
}
}
}
}
}
}
// Log stream summary for diagnostics (mirrors OpenAI driver)
let is_empty_stream = text_content.is_empty()
&& fn_calls.is_empty()
&& usage.input_tokens == 0
&& usage.output_tokens == 0;
if is_empty_stream {
warn!(
chunks = chunk_count,
sse_lines = sse_line_count,
finish = ?finish_reason,
buffer_remaining = buffer.len(),
"Gemini SSE stream returned empty: 0 content, 0 tokens — likely a silently failed request"
);
} else {
debug!(
chunks = chunk_count,
sse_lines = sse_line_count,
text_len = text_content.len(),
tool_count = fn_calls.len(),
finish = ?finish_reason,
input_tokens = usage.input_tokens,
output_tokens = usage.output_tokens,
"Gemini SSE stream completed"
);
}
// Build final response
let mut content = Vec::new();
let mut tool_calls = Vec::new();
@@ -1351,6 +1607,15 @@ mod tests {
provider_metadata: None,
}]),
},
Message {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "call_456".to_string(),
tool_name: "read_file".to_string(),
content: "file contents".to_string(),
is_error: false,
}]),
},
];
let (contents, _) = convert_messages(&messages, &None);
@@ -1581,6 +1846,12 @@ mod tests {
_ => panic!("Expected ToolUse"),
}
// Extract the generated tool_use_id for the ToolResult
let tool_use_id = match &completion.content[1] {
ContentBlock::ToolUse { id, .. } => id.clone(),
_ => panic!("Expected ToolUse"),
};
// Now convert back to Gemini format and verify signatures are echoed
let messages = vec![
Message::user("Search for rust"),
@@ -1588,6 +1859,15 @@ mod tests {
role: Role::Assistant,
content: MessageContent::Blocks(completion.content),
},
Message {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id,
tool_name: "web_search".to_string(),
content: "search results".to_string(),
is_error: false,
}]),
},
];
let (contents, _) = convert_messages(&messages, &None);
let model_turn = &contents[1];
@@ -1720,4 +2000,141 @@ mod tests {
_ => panic!("Expected Text block"),
}
}
// ── sanitize_gemini_turns tests ─────────────────────────────────────
#[test]
fn test_sanitize_drops_orphaned_function_call() {
// A model turn with functionCall but no following functionResponse
// should have the functionCall stripped.
let contents = vec![
GeminiContent {
role: Some("user".to_string()),
parts: vec![GeminiPart::Text {
text: "Hello".to_string(),
thought_signature: None,
}],
},
GeminiContent {
role: Some("model".to_string()),
parts: vec![
GeminiPart::Text {
text: "I'll search.".to_string(),
thought_signature: None,
},
GeminiPart::FunctionCall {
function_call: GeminiFunctionCallData {
name: "web_search".to_string(),
args: serde_json::json!({"q": "rust"}),
},
thought_signature: None,
},
],
},
];
let sanitized = sanitize_gemini_turns(contents);
assert_eq!(sanitized.len(), 2);
// functionCall should be stripped, text kept
assert_eq!(sanitized[1].parts.len(), 1);
assert!(matches!(&sanitized[1].parts[0], GeminiPart::Text { .. }));
}
#[test]
fn test_sanitize_keeps_valid_function_call_response_pair() {
let contents = vec![
GeminiContent {
role: Some("user".to_string()),
parts: vec![GeminiPart::Text {
text: "Search".to_string(),
thought_signature: None,
}],
},
GeminiContent {
role: Some("model".to_string()),
parts: vec![GeminiPart::FunctionCall {
function_call: GeminiFunctionCallData {
name: "web_search".to_string(),
args: serde_json::json!({"q": "rust"}),
},
thought_signature: None,
}],
},
GeminiContent {
role: Some("user".to_string()),
parts: vec![GeminiPart::FunctionResponse {
function_response: GeminiFunctionResponseData {
name: "web_search".to_string(),
response: serde_json::json!({"result": "Rust is great"}),
},
}],
},
];
let sanitized = sanitize_gemini_turns(contents);
assert_eq!(sanitized.len(), 3);
// functionCall should be preserved
assert!(matches!(
&sanitized[1].parts[0],
GeminiPart::FunctionCall { .. }
));
}
#[test]
fn test_sanitize_drops_orphaned_function_response() {
// A user turn with functionResponse but no preceding functionCall
let contents = vec![
GeminiContent {
role: Some("user".to_string()),
parts: vec![GeminiPart::FunctionResponse {
function_response: GeminiFunctionResponseData {
name: "web_search".to_string(),
response: serde_json::json!({"result": "data"}),
},
}],
},
GeminiContent {
role: Some("model".to_string()),
parts: vec![GeminiPart::Text {
text: "Done.".to_string(),
thought_signature: None,
}],
},
];
let sanitized = sanitize_gemini_turns(contents);
// Orphaned functionResponse removed, empty user turn removed
assert_eq!(sanitized.len(), 1);
assert_eq!(sanitized[0].role.as_deref(), Some("model"));
}
#[test]
fn test_sanitize_merges_consecutive_same_role() {
let contents = vec![
GeminiContent {
role: Some("model".to_string()),
parts: vec![GeminiPart::Text {
text: "First.".to_string(),
thought_signature: None,
}],
},
GeminiContent {
role: Some("model".to_string()),
parts: vec![GeminiPart::Text {
text: "Second.".to_string(),
thought_signature: None,
}],
},
];
let sanitized = sanitize_gemini_turns(contents);
assert_eq!(sanitized.len(), 1);
assert_eq!(sanitized[0].parts.len(), 2);
}
#[test]
fn test_sanitize_empty_input() {
let sanitized = sanitize_gemini_turns(vec![]);
assert!(sanitized.is_empty());
}
}
+43 -9
View File
@@ -11,6 +11,7 @@ pub mod fallback;
pub mod gemini;
pub mod openai;
pub mod qwen_code;
pub mod vertex;
use crate::llm_driver::{DriverConfig, LlmDriver, LlmError};
use openfang_types::model_catalog::{
@@ -226,6 +227,12 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
api_key_env: "AZURE_OPENAI_API_KEY",
key_required: true,
}),
"vertex-ai" | "vertex" | "google-vertex" => Some(ProviderDefaults {
// Vertex AI uses OAuth, not API keys - base_url is per-project
base_url: "https://us-central1-aiplatform.googleapis.com",
api_key_env: "GOOGLE_APPLICATION_CREDENTIALS",
key_required: false, // Uses OAuth service account, not API key
}),
_ => None,
}
}
@@ -370,6 +377,39 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
return Ok(Arc::new(openai::OpenAIDriver::new_azure(api_key, base_url)));
}
// Vertex AI — uses Google Cloud OAuth with service account credentials.
// Requires GOOGLE_APPLICATION_CREDENTIALS env var pointing to service account JSON,
// and the service account must be activated via gcloud CLI.
if provider == "vertex-ai" || provider == "vertex" || provider == "google-vertex" {
// Get project_id from environment or service account JSON
let project_id = std::env::var("GOOGLE_CLOUD_PROJECT")
.or_else(|_| std::env::var("GCLOUD_PROJECT"))
.or_else(|_| std::env::var("GCP_PROJECT"))
.or_else(|_| {
// Try to read from service account JSON
if let Ok(creds_path) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") {
if let Ok(contents) = std::fs::read_to_string(&creds_path) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) {
if let Some(proj) = json.get("project_id").and_then(|v| v.as_str()) {
return Ok(proj.to_string());
}
}
}
}
Err(std::env::VarError::NotPresent)
})
.map_err(|_| {
LlmError::MissingApiKey(
"Set GOOGLE_APPLICATION_CREDENTIALS or GOOGLE_CLOUD_PROJECT for Vertex AI"
.to_string(),
)
})?;
let region = std::env::var("GOOGLE_CLOUD_REGION")
.or_else(|_| std::env::var("VERTEX_AI_REGION"))
.unwrap_or_else(|_| "us-central1".to_string());
return Ok(Arc::new(vertex::VertexAIDriver::new(project_id, region)));
}
// Kimi for Code — Anthropic-compatible endpoint
if provider == "kimi_coding" {
let api_key = config
@@ -791,9 +831,7 @@ mod tests {
let config = DriverConfig {
provider: "azure".to_string(),
api_key: Some("test-azure-key".to_string()),
base_url: Some(
"https://myresource.openai.azure.com/openai/deployments".to_string(),
),
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
skip_permissions: true,
};
let driver = create_driver(&config);
@@ -805,9 +843,7 @@ mod tests {
let config = DriverConfig {
provider: "azure".to_string(),
api_key: None,
base_url: Some(
"https://myresource.openai.azure.com/openai/deployments".to_string(),
),
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
skip_permissions: true,
};
let result = create_driver(&config);
@@ -843,9 +879,7 @@ mod tests {
let config = DriverConfig {
provider: "azure-openai".to_string(),
api_key: Some("test-azure-key".to_string()),
base_url: Some(
"https://myresource.openai.azure.com/openai/deployments".to_string(),
),
base_url: Some("https://myresource.openai.azure.com/openai/deployments".to_string()),
skip_permissions: true,
};
let driver = create_driver(&config);
+15 -9
View File
@@ -99,8 +99,7 @@ impl OpenAIDriver {
if self.azure_mode {
builder = builder.header("api-key", self.api_key.as_str());
} else {
builder =
builder.header("authorization", format!("Bearer {}", self.api_key.as_str()));
builder = builder.header("authorization", format!("Bearer {}", self.api_key.as_str()));
}
builder
}
@@ -682,8 +681,8 @@ impl LlmDriver for OpenAIDriver {
if let Some(calls) = choice.message.tool_calls {
for call in calls {
let input: serde_json::Value =
serde_json::from_str(&call.function.arguments).unwrap_or_default();
let input: serde_json::Value = serde_json::from_str(&call.function.arguments)
.unwrap_or_else(|_| serde_json::json!({}));
content.push(ContentBlock::ToolUse {
id: call.id.clone(),
name: call.function.name.clone(),
@@ -1150,7 +1149,10 @@ impl LlmDriver for OpenAIDriver {
}
// Reasoning/thinking content delta (DeepSeek-R1, Qwen3 via LM Studio/Ollama)
if let Some(reasoning) = delta["reasoning_content"].as_str() {
if let Some(reasoning) = delta["reasoning_content"]
.as_str()
.or_else(|| delta["reasoning"].as_str())
{
if !reasoning.is_empty() {
reasoning_content.push_str(reasoning);
let _ = tx
@@ -1173,7 +1175,10 @@ impl LlmDriver for OpenAIDriver {
// ID (sent in first chunk for this tool)
if let Some(id) = call["id"].as_str() {
tool_accum[idx].0 = id.to_string();
// Fix: Empty string IDs are overwritten, leading to inconsistencies in certain models.
if !id.is_empty() {
tool_accum[idx].0 = id.to_string();
}
}
if let Some(func) = call.get("function") {
@@ -1312,7 +1317,8 @@ impl LlmDriver for OpenAIDriver {
}
for (id, name, arguments) in &tool_accum {
let input: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
let input: serde_json::Value =
serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
content.push(ContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
@@ -1322,14 +1328,14 @@ impl LlmDriver for OpenAIDriver {
tool_calls.push(ToolCall {
id: id.clone(),
name: name.clone(),
input,
input: input.clone(),
});
let _ = tx
.send(StreamEvent::ToolUseEnd {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(arguments).unwrap_or_default(),
input,
})
.await;
}
@@ -0,0 +1,793 @@
//! Google Vertex AI driver with OAuth authentication.
//!
//! Uses service account credentials (`GOOGLE_APPLICATION_CREDENTIALS`) to
//! authenticate with Vertex AI's Gemini models via OAuth 2.0 bearer tokens.
//! This enables enterprise deployments without requiring consumer API keys.
//!
//! # Endpoint Format
//!
//! ```text
//! https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/google/models/{model}:generateContent
//! ```
//!
//! # Authentication
//!
//! Uses OAuth 2.0 bearer tokens obtained via `gcloud auth print-access-token`.
//! Tokens are cached for 50 minutes and automatically refreshed.
//!
//! # Environment Variables
//!
//! - `GOOGLE_APPLICATION_CREDENTIALS` — Path to service account JSON
//! - `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` / `GCP_PROJECT` — Project ID (optional if in credentials)
//! - `GOOGLE_CLOUD_REGION` / `VERTEX_AI_REGION` — Region (default: `us-central1`)
//! - `VERTEX_AI_ACCESS_TOKEN` — Pre-generated token (optional, for testing)
use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent};
use async_trait::async_trait;
use futures::StreamExt;
use openfang_types::message::{
ContentBlock, Message, MessageContent, Role, StopReason, TokenUsage,
};
use openfang_types::tool::ToolCall;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use zeroize::Zeroizing;
/// Vertex AI driver with OAuth authentication.
///
/// Authenticates using GCP service account credentials and OAuth 2.0 bearer tokens.
/// Tokens are cached with automatic refresh before expiry.
pub struct VertexAIDriver {
project_id: String,
region: String,
/// Cached OAuth access token (zeroized on drop for security).
token_cache: Arc<RwLock<TokenCache>>,
client: reqwest::Client,
}
/// Cached OAuth token with expiry tracking.
///
/// SECURITY: Token is wrapped in `Zeroizing` to clear memory on drop.
struct TokenCache {
token: Option<Zeroizing<String>>,
expires_at: Option<Instant>,
}
impl TokenCache {
fn new() -> Self {
Self {
token: None,
expires_at: None,
}
}
fn is_valid(&self) -> bool {
match (&self.token, &self.expires_at) {
(Some(_), Some(expires)) => Instant::now() < *expires,
_ => false,
}
}
fn get(&self) -> Option<String> {
if self.is_valid() {
self.token.as_ref().map(|t| t.as_str().to_string())
} else {
None
}
}
}
impl VertexAIDriver {
/// Create a new Vertex AI driver.
///
/// # Arguments
/// * `project_id` - GCP project ID
/// * `region` - GCP region (e.g., `us-central1`)
pub fn new(project_id: String, region: String) -> Self {
Self {
project_id,
region,
token_cache: Arc::new(RwLock::new(TokenCache::new())),
client: reqwest::Client::new(),
}
}
/// Get a valid OAuth access token, refreshing if needed.
async fn get_access_token(&self) -> Result<String, LlmError> {
// Check cache first
{
let cache = self.token_cache.read().await;
if let Some(token) = cache.get() {
debug!("Using cached Vertex AI access token");
return Ok(token);
}
}
// Need to refresh token
info!("Refreshing Vertex AI OAuth access token");
let token = self.fetch_access_token().await?;
// Cache the token (expires in ~1 hour, we refresh at 50 min)
{
let mut cache = self.token_cache.write().await;
cache.token = Some(Zeroizing::new(token.clone()));
cache.expires_at = Some(Instant::now() + Duration::from_secs(50 * 60));
}
Ok(token)
}
/// Fetch a new access token using gcloud CLI.
///
/// This uses the service account specified in GOOGLE_APPLICATION_CREDENTIALS
/// via the gcloud CLI. For production, this should use the google-auth library.
async fn fetch_access_token(&self) -> Result<String, LlmError> {
// First, check if a pre-generated token is available in env
if let Ok(token) = std::env::var("VERTEX_AI_ACCESS_TOKEN") {
if !token.is_empty() {
debug!("Using pre-set VERTEX_AI_ACCESS_TOKEN");
return Ok(token);
}
}
// Try application-default credentials first (uses GOOGLE_APPLICATION_CREDENTIALS)
let output = tokio::process::Command::new("gcloud")
.args(["auth", "application-default", "print-access-token"])
.output()
.await;
if let Ok(output) = output {
if output.status.success() {
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !token.is_empty() {
debug!("Successfully obtained Vertex AI access token via application-default");
return Ok(token);
}
}
}
// Fall back to regular gcloud auth (requires activated service account)
let output = tokio::process::Command::new("gcloud")
.args(["auth", "print-access-token"])
.output()
.await
.map_err(|e| LlmError::MissingApiKey(format!("Failed to run gcloud: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(LlmError::MissingApiKey(format!(
"gcloud auth failed: {}. Ensure GOOGLE_APPLICATION_CREDENTIALS is set and \
run: gcloud auth activate-service-account --key-file=$GOOGLE_APPLICATION_CREDENTIALS",
stderr.trim()
)));
}
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
if token.is_empty() {
return Err(LlmError::MissingApiKey(
"Empty access token from gcloud".to_string(),
));
}
debug!("Successfully obtained Vertex AI access token");
Ok(token)
}
/// Build the Vertex AI endpoint URL for a model.
fn build_endpoint(&self, model: &str, streaming: bool) -> String {
// Strip any "gemini-" prefix duplications
let model_name = model.strip_prefix("models/").unwrap_or(model);
let method = if streaming {
"streamGenerateContent"
} else {
"generateContent"
};
format!(
"https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/publishers/google/models/{model}:{method}",
region = self.region,
project = self.project_id,
model = model_name,
method = method
)
}
}
// ── Request types (reusing Gemini format) ──────────────────────────────
/// Top-level Gemini/Vertex API request body.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VertexRequest {
contents: Vec<VertexContent>,
#[serde(skip_serializing_if = "Option::is_none")]
system_instruction: Option<VertexContent>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tools: Vec<VertexToolConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
generation_config: Option<GenerationConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct VertexContent {
#[serde(skip_serializing_if = "Option::is_none")]
role: Option<String>,
parts: Vec<VertexPart>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
enum VertexPart {
Text {
text: String,
},
InlineData {
#[serde(rename = "inlineData")]
inline_data: VertexInlineData,
},
FunctionCall {
#[serde(rename = "functionCall")]
function_call: VertexFunctionCallData,
},
FunctionResponse {
#[serde(rename = "functionResponse")]
function_response: VertexFunctionResponseData,
},
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct VertexInlineData {
#[serde(rename = "mimeType")]
mime_type: String,
data: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct VertexFunctionCallData {
name: String,
args: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct VertexFunctionResponseData {
name: String,
response: serde_json::Value,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VertexToolConfig {
function_declarations: Vec<VertexFunctionDeclaration>,
}
#[derive(Debug, Serialize)]
struct VertexFunctionDeclaration {
name: String,
description: String,
parameters: serde_json::Value,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GenerationConfig {
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
max_output_tokens: Option<u32>,
}
// ── Response types ─────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VertexResponse {
#[serde(default)]
candidates: Vec<VertexCandidate>,
#[serde(default)]
usage_metadata: Option<VertexUsageMetadata>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VertexCandidate {
content: Option<VertexContent>,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VertexUsageMetadata {
#[serde(default)]
prompt_token_count: u64,
#[serde(default)]
candidates_token_count: u64,
}
#[derive(Debug, Deserialize)]
struct VertexErrorResponse {
error: VertexErrorDetail,
}
#[derive(Debug, Deserialize)]
struct VertexErrorDetail {
message: String,
}
// ── Message conversion ─────────────────────────────────────────────────
fn convert_messages(
messages: &[Message],
system: &Option<String>,
) -> (Vec<VertexContent>, Option<VertexContent>) {
let mut contents = Vec::new();
let system_instruction = extract_system(messages, system);
for msg in messages {
if msg.role == Role::System {
continue;
}
let role = match msg.role {
Role::User => "user",
Role::Assistant => "model",
Role::System => continue,
};
let parts = match &msg.content {
MessageContent::Text(text) => vec![VertexPart::Text { text: text.clone() }],
MessageContent::Blocks(blocks) => {
let mut parts = Vec::new();
for block in blocks {
match block {
ContentBlock::Text { text, .. } => {
parts.push(VertexPart::Text { text: text.clone() });
}
ContentBlock::ToolUse { name, input, .. } => {
parts.push(VertexPart::FunctionCall {
function_call: VertexFunctionCallData {
name: name.clone(),
args: input.clone(),
},
});
}
ContentBlock::Image { media_type, data } => {
parts.push(VertexPart::InlineData {
inline_data: VertexInlineData {
mime_type: media_type.clone(),
data: data.clone(),
},
});
}
ContentBlock::ToolResult { content, .. } => {
parts.push(VertexPart::FunctionResponse {
function_response: VertexFunctionResponseData {
name: String::new(),
response: serde_json::json!({ "result": content }),
},
});
}
ContentBlock::Thinking { .. } => {}
_ => {}
}
}
parts
}
};
if !parts.is_empty() {
contents.push(VertexContent {
role: Some(role.to_string()),
parts,
});
}
}
(contents, system_instruction)
}
fn extract_system(messages: &[Message], system: &Option<String>) -> Option<VertexContent> {
let text = system.clone().or_else(|| {
messages.iter().find_map(|m| {
if m.role == Role::System {
match &m.content {
MessageContent::Text(t) => Some(t.clone()),
_ => None,
}
} else {
None
}
})
})?;
Some(VertexContent {
role: None,
parts: vec![VertexPart::Text { text }],
})
}
fn convert_tools(request: &CompletionRequest) -> Vec<VertexToolConfig> {
if request.tools.is_empty() {
return Vec::new();
}
let declarations: Vec<VertexFunctionDeclaration> = request
.tools
.iter()
.map(|t| {
let normalized =
openfang_types::tool::normalize_schema_for_provider(&t.input_schema, "gemini");
VertexFunctionDeclaration {
name: t.name.clone(),
description: t.description.clone(),
parameters: normalized,
}
})
.collect();
vec![VertexToolConfig {
function_declarations: declarations,
}]
}
fn convert_response(resp: VertexResponse) -> Result<CompletionResponse, LlmError> {
let candidate = resp
.candidates
.into_iter()
.next()
.ok_or_else(|| LlmError::Parse("No candidates in Vertex AI response".to_string()))?;
let mut content = Vec::new();
let mut tool_calls = Vec::new();
if let Some(vertex_content) = candidate.content {
for part in vertex_content.parts {
match part {
VertexPart::Text { text } => {
content.push(ContentBlock::Text {
text,
provider_metadata: None,
});
}
VertexPart::FunctionCall { function_call } => {
tool_calls.push(ToolCall {
id: format!("call_{}", &uuid::Uuid::new_v4().to_string()[..8]),
name: function_call.name,
input: function_call.args,
});
}
_ => {}
}
}
}
let stop_reason = match candidate.finish_reason.as_deref() {
Some("STOP") => StopReason::EndTurn,
Some("MAX_TOKENS") => StopReason::MaxTokens,
Some("SAFETY") | Some("RECITATION") | Some("BLOCKLIST") => StopReason::EndTurn,
_ if !tool_calls.is_empty() => StopReason::ToolUse,
_ => StopReason::EndTurn,
};
let usage = resp
.usage_metadata
.map(|u| TokenUsage {
input_tokens: u.prompt_token_count,
output_tokens: u.candidates_token_count,
})
.unwrap_or_default();
Ok(CompletionResponse {
content,
stop_reason,
tool_calls,
usage,
})
}
// ── LlmDriver implementation ──────────────────────────────────────────
#[async_trait]
impl LlmDriver for VertexAIDriver {
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let (contents, system_instruction) = convert_messages(&request.messages, &request.system);
let tools = convert_tools(&request);
let vertex_request = VertexRequest {
contents,
system_instruction,
tools,
generation_config: Some(GenerationConfig {
temperature: Some(request.temperature),
max_output_tokens: Some(request.max_tokens),
}),
};
let access_token = self.get_access_token().await?;
let max_retries = 3;
for attempt in 0..=max_retries {
let url = self.build_endpoint(&request.model, false);
debug!(url = %url, attempt, "Sending Vertex AI request");
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.json(&vertex_request)
.send()
.await
.map_err(|e| LlmError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if status == 429 || status == 503 {
if attempt < max_retries {
let retry_ms = (attempt + 1) as u64 * 2000;
warn!(status, retry_ms, "Rate limited/overloaded, retrying");
tokio::time::sleep(std::time::Duration::from_millis(retry_ms)).await;
continue;
}
return Err(if status == 429 {
LlmError::RateLimited {
retry_after_ms: 5000,
}
} else {
LlmError::Overloaded {
retry_after_ms: 5000,
}
});
}
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
let message = serde_json::from_str::<VertexErrorResponse>(&body)
.map(|e| e.error.message)
.unwrap_or(body);
return Err(LlmError::Api { status, message });
}
let body = resp
.text()
.await
.map_err(|e| LlmError::Http(e.to_string()))?;
let vertex_response: VertexResponse =
serde_json::from_str(&body).map_err(|e| LlmError::Parse(e.to_string()))?;
return convert_response(vertex_response);
}
Err(LlmError::Api {
status: 0,
message: "Max retries exceeded".to_string(),
})
}
async fn stream(
&self,
request: CompletionRequest,
tx: tokio::sync::mpsc::Sender<StreamEvent>,
) -> Result<CompletionResponse, LlmError> {
let (contents, system_instruction) = convert_messages(&request.messages, &request.system);
let tools = convert_tools(&request);
let vertex_request = VertexRequest {
contents,
system_instruction,
tools,
generation_config: Some(GenerationConfig {
temperature: Some(request.temperature),
max_output_tokens: Some(request.max_tokens),
}),
};
let access_token = self.get_access_token().await?;
let max_retries = 3;
for attempt in 0..=max_retries {
let url = format!("{}?alt=sse", self.build_endpoint(&request.model, true));
debug!(url = %url, attempt, "Sending Vertex AI streaming request");
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.json(&vertex_request)
.send()
.await
.map_err(|e| LlmError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if status == 429 || status == 503 {
if attempt < max_retries {
let retry_ms = (attempt + 1) as u64 * 2000;
warn!(
status,
retry_ms, "Rate limited/overloaded (stream), retrying"
);
tokio::time::sleep(std::time::Duration::from_millis(retry_ms)).await;
continue;
}
return Err(if status == 429 {
LlmError::RateLimited {
retry_after_ms: 5000,
}
} else {
LlmError::Overloaded {
retry_after_ms: 5000,
}
});
}
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
let message = serde_json::from_str::<VertexErrorResponse>(&body)
.map(|e| e.error.message)
.unwrap_or(body);
return Err(LlmError::Api { status, message });
}
// Process SSE stream
let mut byte_stream = resp.bytes_stream();
let mut buffer = String::new();
let mut accumulated_text = String::new();
let mut final_tool_calls = Vec::new();
let mut final_usage = None;
while let Some(chunk_result) = byte_stream.next().await {
let chunk = chunk_result.map_err(|e| LlmError::Http(e.to_string()))?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
// Process complete lines
while let Some(line_end) = buffer.find('\n') {
let line = buffer[..line_end].trim().to_string();
buffer = buffer[line_end + 1..].to_string();
if line.is_empty() || !line.starts_with("data: ") {
continue;
}
let json_str = &line[6..];
if json_str == "[DONE]" {
break;
}
if let Ok(resp) = serde_json::from_str::<VertexResponse>(json_str) {
if let Some(candidate) = resp.candidates.into_iter().next() {
if let Some(content) = candidate.content {
for part in content.parts {
match part {
VertexPart::Text { text } => {
accumulated_text.push_str(&text);
let _ = tx.send(StreamEvent::TextDelta { text }).await;
}
VertexPart::FunctionCall { function_call } => {
final_tool_calls.push(ToolCall {
id: format!(
"call_{}",
&uuid::Uuid::new_v4().to_string()[..8]
),
name: function_call.name,
input: function_call.args,
});
}
_ => {}
}
}
}
}
if let Some(usage) = resp.usage_metadata {
final_usage = Some(TokenUsage {
input_tokens: usage.prompt_token_count,
output_tokens: usage.candidates_token_count,
});
}
}
}
}
let stop_reason = if !final_tool_calls.is_empty() {
StopReason::ToolUse
} else {
StopReason::EndTurn
};
let usage = final_usage.unwrap_or_default();
let _ = tx
.send(StreamEvent::ContentComplete { stop_reason, usage })
.await;
let content = if accumulated_text.is_empty() {
Vec::new()
} else {
vec![ContentBlock::Text {
text: accumulated_text,
provider_metadata: None,
}]
};
return Ok(CompletionResponse {
content,
stop_reason,
tool_calls: final_tool_calls,
usage,
});
}
Err(LlmError::Api {
status: 0,
message: "Max retries exceeded".to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vertex_driver_creation() {
let driver = VertexAIDriver::new("test-project".to_string(), "us-central1".to_string());
assert_eq!(driver.project_id, "test-project");
assert_eq!(driver.region, "us-central1");
}
#[test]
fn test_build_endpoint_non_streaming() {
let driver = VertexAIDriver::new("my-project".to_string(), "us-central1".to_string());
let endpoint = driver.build_endpoint("gemini-2.0-flash", false);
assert_eq!(
endpoint,
"https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-2.0-flash:generateContent"
);
}
#[test]
fn test_build_endpoint_streaming() {
let driver = VertexAIDriver::new("my-project".to_string(), "europe-west4".to_string());
let endpoint = driver.build_endpoint("gemini-1.5-pro", true);
assert_eq!(
endpoint,
"https://europe-west4-aiplatform.googleapis.com/v1/projects/my-project/locations/europe-west4/publishers/google/models/gemini-1.5-pro:streamGenerateContent"
);
}
#[test]
fn test_build_endpoint_strips_model_prefix() {
let driver = VertexAIDriver::new("my-project".to_string(), "us-central1".to_string());
let endpoint = driver.build_endpoint("models/gemini-2.0-flash", false);
assert_eq!(
endpoint,
"https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-2.0-flash:generateContent"
);
}
#[test]
fn test_token_cache_initially_invalid() {
let cache = TokenCache::new();
assert!(!cache.is_valid());
assert!(cache.token.is_none());
}
#[test]
fn test_vertex_content_serialization() {
let content = VertexContent {
role: Some("user".to_string()),
parts: vec![VertexPart::Text {
text: "Hello".to_string(),
}],
};
let json = serde_json::to_string(&content).unwrap();
assert!(json.contains("\"role\":\"user\""));
assert!(json.contains("\"text\":\"Hello\""));
}
}
@@ -238,6 +238,12 @@ pub trait KernelHandle: Send + Sync {
Err("Channel file data send not available".to_string())
}
/// Refresh an agent's last_active timestamp without changing any other state.
/// Called by the agent loop before long LLM calls to prevent heartbeat false-positives.
fn touch_agent(&self, agent_id: &str) {
let _ = agent_id;
}
/// Spawn an agent with capability inheritance enforcement.
/// `parent_caps` are the parent's granted capabilities. The kernel MUST verify
/// that every capability in the child manifest is covered by `parent_caps`.
@@ -135,6 +135,7 @@ pub enum StreamEvent {
},
/// Tool execution completed with result (emitted by agent loop, not LLM driver).
ToolExecutionResult {
id: String,
name: String,
result_preview: String,
is_error: bool,
+1 -1
View File
@@ -498,7 +498,7 @@ impl LoopGuard {
}
/// Compute a SHA-256 hash of the tool name and parameters.
fn compute_hash(tool_name: &str, params: &serde_json::Value) -> String {
pub fn compute_hash(tool_name: &str, params: &serde_json::Value) -> String {
let mut hasher = Sha256::new();
hasher.update(tool_name.as_bytes());
hasher.update(b"|");
+202 -450
View File
@@ -1,16 +1,20 @@
//! MCP (Model Context Protocol) client — connect to external MCP servers.
//!
//! MCP uses JSON-RPC 2.0 over stdio or HTTP+SSE. This module lets OpenFang
//! agents use tools from any MCP server (100+ available: GitHub, filesystem,
//! databases, APIs, etc.).
//! Uses the official `rmcp` SDK for protocol handling. Supports:
//! - **stdio**: subprocess with JSON-RPC over stdin/stdout
//! - **sse**: deprecated HTTP+SSE transport (protocol version 2024-11-05)
//! - **http**: Streamable HTTP transport (protocol version 2025-03-26+)
//!
//! All MCP tools are namespaced with `mcp_{server}_{tool}` to prevent collisions.
use http::{HeaderName, HeaderValue};
use openfang_types::tool::ToolDefinition;
use rmcp::model::{CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation};
use rmcp::service::RunningService;
use rmcp::{RoleClient, ServiceExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use std::sync::Arc;
use tracing::{debug, info};
// ---------------------------------------------------------------------------
@@ -30,6 +34,12 @@ pub struct McpServerConfig {
/// Environment variables to pass through to the subprocess (sandboxed).
#[serde(default)]
pub env: Vec<String>,
/// Extra HTTP headers to send with every SSE / Streamable-HTTP request.
/// Each entry is `"Header-Name: value"`. Useful for authentication
/// (`Authorization: Bearer <token>`), API keys (`X-Api-Key: ...`),
/// or any custom headers required by a remote MCP server.
#[serde(default)]
pub headers: Vec<String>,
}
fn default_timeout() -> u64 {
@@ -46,8 +56,14 @@ pub enum McpTransport {
#[serde(default)]
args: Vec<String>,
},
/// HTTP Server-Sent Events.
/// Deprecated HTTP+SSE transport (protocol version 2024-11-05).
/// Uses POST for sending and SSE for receiving.
Sse { url: String },
/// Streamable HTTP transport (MCP 2025-03-26+).
/// Single endpoint, client MUST send Accept: application/json, text/event-stream.
/// Server responds with either JSON or SSE stream.
/// Supports Mcp-Session-Id for session management.
Http { url: String },
}
// ---------------------------------------------------------------------------
@@ -64,59 +80,9 @@ pub struct McpConnection {
/// Needed because `normalize_name` replaces hyphens with underscores,
/// but the server expects the original name (e.g. "list-connections").
original_names: HashMap<String, String>,
/// Transport handle for sending requests.
transport: McpTransportHandle,
/// Next JSON-RPC request ID.
next_id: u64,
}
/// Transport handle — abstraction over stdio subprocess or HTTP.
enum McpTransportHandle {
Stdio {
child: Box<tokio::process::Child>,
stdin: tokio::process::ChildStdin,
stdout: BufReader<tokio::process::ChildStdout>,
},
Sse {
client: reqwest::Client,
url: String,
},
}
/// JSON-RPC 2.0 request.
#[derive(Serialize)]
struct JsonRpcRequest {
jsonrpc: &'static str,
id: u64,
method: String,
#[serde(skip_serializing_if = "Option::is_none")]
params: Option<serde_json::Value>,
}
/// JSON-RPC 2.0 response.
#[derive(Deserialize)]
struct JsonRpcResponse {
#[allow(dead_code)]
jsonrpc: String,
#[allow(dead_code)]
id: Option<u64>,
result: Option<serde_json::Value>,
error: Option<JsonRpcError>,
}
/// JSON-RPC 2.0 error object.
#[derive(Debug, Deserialize)]
pub struct JsonRpcError {
pub code: i64,
pub message: String,
#[allow(dead_code)]
pub data: Option<serde_json::Value>,
}
impl std::fmt::Display for JsonRpcError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "JSON-RPC error {}: {}", self.code, self.message)
}
/// The rmcp client handle — type-erased because the concrete type
/// depends on which transport was used (stdio vs HTTP).
client: RunningService<RoleClient, ClientInfo>,
}
// ---------------------------------------------------------------------------
@@ -126,13 +92,17 @@ impl std::fmt::Display for JsonRpcError {
impl McpConnection {
/// Connect to an MCP server, perform handshake, and discover tools.
pub async fn connect(config: McpServerConfig) -> Result<Self, String> {
let transport = match &config.transport {
let client_info = ClientInfo::new(
ClientCapabilities::default(),
Implementation::new("openfang", env!("CARGO_PKG_VERSION")),
);
let client = match &config.transport {
McpTransport::Stdio { command, args } => {
Self::connect_stdio(command, args, &config.env).await?
Self::connect_stdio(command, args, &config.env, client_info).await?
}
McpTransport::Sse { url } => {
// SSRF check: reject private/localhost URLs unless explicitly configured
Self::connect_sse(url).await?
McpTransport::Sse { url } | McpTransport::Http { url } => {
Self::connect_http(url, &config.headers, client_info).await?
}
};
@@ -140,13 +110,9 @@ impl McpConnection {
config,
tools: Vec::new(),
original_names: HashMap::new(),
transport,
next_id: 1,
client,
};
// Initialize handshake
conn.initialize().await?;
// Discover tools
conn.discover_tools().await?;
@@ -159,76 +125,34 @@ impl McpConnection {
Ok(conn)
}
/// Send the MCP `initialize` handshake.
async fn initialize(&mut self) -> Result<(), String> {
let params = serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "openfang",
"version": env!("CARGO_PKG_VERSION")
}
});
let response = self.send_request("initialize", Some(params)).await?;
if let Some(result) = response {
debug!(
server = %self.config.name,
server_info = %result,
"MCP initialize response"
);
}
// Send initialized notification (no response expected)
self.send_notification("notifications/initialized", None)
.await?;
Ok(())
}
/// Discover available tools via `tools/list`.
async fn discover_tools(&mut self) -> Result<(), String> {
let response = self.send_request("tools/list", None).await?;
let tools = self
.client
.list_all_tools()
.await
.map_err(|e| format!("Failed to list MCP tools: {e}"))?;
if let Some(result) = response {
if let Some(tools_array) = result.get("tools").and_then(|t| t.as_array()) {
let server_name = &self.config.name;
for tool in tools_array {
let raw_name = tool["name"].as_str().unwrap_or("unnamed");
let description = tool["description"].as_str().unwrap_or("");
let input_schema = tool
.get("inputSchema")
.cloned()
.and_then(|v| {
// Ensure input_schema is a JSON object. MCP servers may
// return it as a string, null, or omit it entirely.
match &v {
serde_json::Value::Object(_) => Some(v),
serde_json::Value::String(s) => {
serde_json::from_str::<serde_json::Value>(s)
.ok()
.filter(|p| p.is_object())
}
_ => None,
}
})
.unwrap_or(serde_json::json!({"type": "object"}));
let server_name = &self.config.name;
for tool in &tools {
let raw_name = &tool.name;
let description = tool.description.as_deref().unwrap_or("");
// Namespace: mcp_{server}_{tool}
let namespaced = format_mcp_tool_name(server_name, raw_name);
let input_schema = serde_json::to_value(&tool.input_schema)
.unwrap_or(serde_json::json!({"type": "object"}));
// Store original name so we can send it back to the server
self.original_names
.insert(namespaced.clone(), raw_name.to_string());
// Namespace: mcp_{server}_{tool}
let namespaced = format_mcp_tool_name(server_name, raw_name);
self.tools.push(ToolDefinition {
name: namespaced,
description: format!("[MCP:{server_name}] {description}"),
input_schema,
});
}
}
// Store original name so we can send it back to the server
self.original_names
.insert(namespaced.clone(), raw_name.to_string());
self.tools.push(ToolDefinition {
name: namespaced,
description: format!("[MCP:{server_name}] {description}"),
input_schema,
});
}
Ok(())
@@ -243,40 +167,38 @@ impl McpConnection {
arguments: &serde_json::Value,
) -> Result<String, String> {
// Look up the original tool name from the server (preserves hyphens etc.)
let raw_name = self
let raw_name: String = self
.original_names
.get(name)
.map(|s| s.as_str())
.or_else(|| strip_mcp_prefix(&self.config.name, name))
.unwrap_or(name);
.cloned()
.or_else(|| strip_mcp_prefix(&self.config.name, name).map(|s| s.to_string()))
.unwrap_or_else(|| name.to_string());
let params = serde_json::json!({
"name": raw_name,
"arguments": arguments,
});
let args = arguments.as_object().cloned().unwrap_or_default();
let response = self.send_request("tools/call", Some(params)).await?;
debug!(tool = %raw_name, server = %self.config.name, "MCP tool call");
match response {
Some(result) => {
// Extract text content from the response
if let Some(content) = result.get("content").and_then(|c| c.as_array()) {
let texts: Vec<&str> = content
.iter()
.filter_map(|item| {
if item["type"].as_str() == Some("text") {
item["text"].as_str()
} else {
None
}
})
.collect();
Ok(texts.join("\n"))
} else {
Ok(result.to_string())
}
}
None => Err("No result from MCP tools/call".to_string()),
let params = CallToolRequestParams::new(raw_name).with_arguments(args);
let result = self
.client
.call_tool(params)
.await
.map_err(|e| format!("MCP tool call failed: {e}"))?;
// Extract text content from the response.
// `Content` is `Annotated<RawContent>` which Derefs to `RawContent`.
let texts: Vec<&str> = result
.content
.iter()
.filter_map(|item| item.as_text().map(|tc| tc.text.as_str()))
.collect();
if texts.is_empty() {
// Fallback: serialize the entire result
Ok(serde_json::to_string(&result).unwrap_or_default())
} else {
Ok(texts.join("\n"))
}
}
@@ -290,253 +212,124 @@ impl McpConnection {
&self.config.name
}
// --- Transport helpers ---
async fn send_request(
&mut self,
method: &str,
params: Option<serde_json::Value>,
) -> Result<Option<serde_json::Value>, String> {
let id = self.next_id;
self.next_id += 1;
let request = JsonRpcRequest {
jsonrpc: "2.0",
id,
method: method.to_string(),
params,
};
let request_json = serde_json::to_string(&request)
.map_err(|e| format!("Failed to serialize request: {e}"))?;
debug!(method, id, "MCP request");
match &mut self.transport {
McpTransportHandle::Stdio { stdin, stdout, .. } => {
// Write request + newline
stdin
.write_all(request_json.as_bytes())
.await
.map_err(|e| format!("Failed to write to MCP stdin: {e}"))?;
stdin
.write_all(b"\n")
.await
.map_err(|e| format!("Failed to write newline: {e}"))?;
stdin
.flush()
.await
.map_err(|e| format!("Failed to flush stdin: {e}"))?;
// Read response line
let mut line = String::new();
let timeout = tokio::time::Duration::from_secs(self.config.timeout_secs);
match tokio::time::timeout(timeout, stdout.read_line(&mut line)).await {
Ok(Ok(0)) => return Err("MCP server closed connection".to_string()),
Ok(Ok(_)) => {}
Ok(Err(e)) => return Err(format!("Failed to read MCP response: {e}")),
Err(_) => return Err("MCP request timed out".to_string()),
}
let response: JsonRpcResponse = serde_json::from_str(line.trim())
.map_err(|e| format!("Invalid MCP JSON-RPC response: {e}"))?;
if let Some(err) = response.error {
return Err(format!("{err}"));
}
Ok(response.result)
}
McpTransportHandle::Sse { client, url } => {
let response = client
.post(url.as_str())
.json(&request)
.timeout(std::time::Duration::from_secs(self.config.timeout_secs))
.send()
.await
.map_err(|e| format!("MCP SSE request failed: {e}"))?;
if !response.status().is_success() {
return Err(format!("MCP SSE returned {}", response.status()));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read SSE response: {e}"))?;
let rpc_response: JsonRpcResponse = serde_json::from_str(&body)
.map_err(|e| format!("Invalid MCP SSE JSON-RPC response: {e}"))?;
if let Some(err) = rpc_response.error {
return Err(format!("{err}"));
}
Ok(rpc_response.result)
}
}
}
async fn send_notification(
&mut self,
method: &str,
params: Option<serde_json::Value>,
) -> Result<(), String> {
let notification = serde_json::json!({
"jsonrpc": "2.0",
"method": method,
"params": params.unwrap_or(serde_json::json!({})),
});
let json = serde_json::to_string(&notification)
.map_err(|e| format!("Failed to serialize notification: {e}"))?;
match &mut self.transport {
McpTransportHandle::Stdio { stdin, .. } => {
stdin
.write_all(json.as_bytes())
.await
.map_err(|e| format!("Write notification: {e}"))?;
stdin
.write_all(b"\n")
.await
.map_err(|e| format!("Write newline: {e}"))?;
stdin.flush().await.map_err(|e| format!("Flush: {e}"))?;
}
McpTransportHandle::Sse { client, url } => {
let _ = client.post(url.as_str()).json(&notification).send().await;
}
}
Ok(())
}
// -- Transport constructors -----------------------------------------------
/// Connect using stdio transport (subprocess).
async fn connect_stdio(
command: &str,
args: &[String],
env_whitelist: &[String],
) -> Result<McpTransportHandle, String> {
client_info: ClientInfo,
) -> Result<RunningService<RoleClient, ClientInfo>, String> {
use rmcp::transport::{ConfigureCommandExt, TokioChildProcess};
use tokio::process::Command;
// Validate command path (no path traversal)
if command.contains("..") {
return Err("MCP command path contains '..': rejected".to_string());
}
// On Windows, npm/npx install as .cmd batch wrappers. Detect and adapt.
let resolved_command: String = if cfg!(windows) {
// If the user already specified .cmd/.bat, use as-is
if command.ends_with(".cmd") || command.ends_with(".bat") {
command.to_string()
} else {
// Check if the .cmd variant exists on PATH
let cmd_variant = format!("{command}.cmd");
let has_cmd = std::env::var("PATH")
.unwrap_or_default()
.split(';')
.any(|dir| std::path::Path::new(dir).join(&cmd_variant).exists());
if has_cmd {
cmd_variant
} else {
command.to_string()
let cmd_str = command.to_string();
let args_vec: Vec<String> = args.to_vec();
let env_list: Vec<String> = env_whitelist.to_vec();
let transport = TokioChildProcess::new(Command::new(&cmd_str).configure(move |cmd| {
for arg in &args_vec {
cmd.arg(arg);
}
// Sandbox: clear environment, only pass whitelisted vars
cmd.env_clear();
for var_name in &env_list {
if let Ok(val) = std::env::var(var_name) {
cmd.env(var_name, val);
}
}
} else {
command.to_string()
// Always pass PATH for binary resolution
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
// On Windows, npm/node need extra vars
if cfg!(windows) {
for var in &[
"APPDATA",
"LOCALAPPDATA",
"USERPROFILE",
"SystemRoot",
"TEMP",
"TMP",
"HOME",
"HOMEDRIVE",
"HOMEPATH",
] {
if let Ok(val) = std::env::var(var) {
cmd.env(var, val);
}
}
}
}))
.map_err(|e| format!("Failed to spawn MCP server '{cmd_str}': {e}"))?;
let client = client_info
.serve(transport)
.await
.map_err(|e| format!("MCP stdio handshake failed: {e}"))?;
Ok(client)
}
/// Connect using Streamable HTTP transport (or SSE fallback via the same endpoint).
///
/// The `rmcp` SDK's `StreamableHttpClientTransport` handles the full
/// Streamable HTTP protocol: Accept headers, Mcp-Session-Id tracking,
/// SSE stream parsing, and content-type negotiation.
async fn connect_http(
url: &str,
headers: &[String],
client_info: ClientInfo,
) -> Result<RunningService<RoleClient, ClientInfo>, String> {
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::transport::StreamableHttpClientTransport;
Self::check_ssrf(url)?;
// Parse custom headers (e.g., "Authorization: Bearer <token>").
let mut custom_headers: HashMap<HeaderName, HeaderValue> = HashMap::new();
for header_str in headers {
if let Some((name, value)) = header_str.split_once(':') {
let name = name.trim();
let value = value.trim();
if let (Ok(hn), Ok(hv)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(value),
) {
custom_headers.insert(hn, hv);
}
}
}
let config = StreamableHttpClientTransportConfig {
uri: Arc::from(url),
custom_headers,
..Default::default()
};
let mut cmd = tokio::process::Command::new(&resolved_command);
cmd.args(args);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let transport = StreamableHttpClientTransport::from_config(config);
// Sandbox: clear environment, only pass whitelisted vars
cmd.env_clear();
for var_name in env_whitelist {
if let Ok(val) = std::env::var(var_name) {
cmd.env(var_name, val);
}
}
// Always pass PATH for binary resolution
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
// On Windows, npm/node need APPDATA, USERPROFILE, LOCALAPPDATA, and SystemRoot
if cfg!(windows) {
for var in &[
"APPDATA",
"LOCALAPPDATA",
"USERPROFILE",
"SystemRoot",
"TEMP",
"TMP",
"HOME",
"HOMEDRIVE",
"HOMEPATH",
] {
if let Ok(val) = std::env::var(var) {
cmd.env(var, val);
}
}
}
let client = client_info
.serve(transport)
.await
.map_err(|e| format!("MCP HTTP connection failed: {e}"))?;
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn MCP server '{resolved_command}': {e}"))?;
// Log stderr in background for debugging MCP server issues
if let Some(stderr) = child.stderr.take() {
let cmd_name = resolved_command.clone();
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let reader = tokio::io::BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(mcp_server = %cmd_name, "stderr: {line}");
}
});
}
let stdin = child
.stdin
.take()
.ok_or("Failed to capture MCP server stdin")?;
let stdout = child
.stdout
.take()
.ok_or("Failed to capture MCP server stdout")?;
Ok(McpTransportHandle::Stdio {
child: Box::new(child),
stdin,
stdout: BufReader::new(stdout),
})
Ok(client)
}
async fn connect_sse(url: &str) -> Result<McpTransportHandle, String> {
// Basic SSRF check: reject obviously private URLs
/// Basic SSRF check: reject obviously private/metadata URLs.
fn check_ssrf(url: &str) -> Result<(), String> {
let lower = url.to_lowercase();
if lower.contains("169.254.169.254") || lower.contains("metadata.google") {
return Err("SSRF: MCP SSE URL targets metadata endpoint".to_string());
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
Ok(McpTransportHandle::Sse {
client,
url: url.to_string(),
})
}
}
impl Drop for McpConnection {
fn drop(&mut self) {
if let McpTransportHandle::Stdio { ref mut child, .. } = self.transport {
// Best-effort kill of the subprocess
let _ = child.start_kill();
return Err("SSRF: MCP URL targets metadata endpoint".to_string());
}
Ok(())
}
}
@@ -627,16 +420,12 @@ mod tests {
#[test]
fn test_hyphenated_tool_name_preserved() {
// Tool names with hyphens get normalized to underscores for namespacing,
// but original_names map preserves the original for call_tool dispatch.
let namespaced = format_mcp_tool_name("sqlcl", "list-connections");
assert_eq!(namespaced, "mcp_sqlcl_list_connections");
// Simulate what discover_tools does
let mut original_names = HashMap::new();
original_names.insert(namespaced.clone(), "list-connections".to_string());
// call_tool should resolve to original hyphenated name
let raw = original_names
.get(&namespaced)
.map(|s| s.as_str())
@@ -655,25 +444,21 @@ mod tests {
#[test]
fn test_extract_mcp_server_from_known_with_hyphens() {
// Server "bocha-search" normalized to "bocha_search" in tool prefix
let servers = vec!["bocha-search", "github"];
let tool = "mcp_bocha_search_bocha_web_search";
assert_eq!(
extract_mcp_server_from_known(tool, &servers),
Some("bocha-search")
);
// Simple server name still works
assert_eq!(
extract_mcp_server_from_known("mcp_github_create_issue", &servers),
Some("github")
);
// Non-MCP tool returns None
assert_eq!(extract_mcp_server_from_known("file_read", &servers), None);
}
#[test]
fn test_extract_mcp_server_from_known_longest_match() {
// "my-api" and "my-api-v2" — should match the longer one
let servers = vec!["my-api", "my-api-v2"];
assert_eq!(
extract_mcp_server_from_known("mcp_my_api_v2_get_users", &servers),
@@ -685,60 +470,6 @@ mod tests {
);
}
#[test]
fn test_mcp_jsonrpc_initialize() {
// Verify the initialize request structure
let request = JsonRpcRequest {
jsonrpc: "2.0",
id: 1,
method: "initialize".to_string(),
params: Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "openfang",
"version": "0.1.0"
}
})),
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("initialize"));
assert!(json.contains("protocolVersion"));
assert!(json.contains("openfang"));
}
#[test]
fn test_mcp_jsonrpc_tools_list() {
// Simulate a tools/list response
let response_json = r#"{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "create_issue",
"description": "Create a GitHub issue",
"inputSchema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"body": {"type": "string"}
},
"required": ["title"]
}
}
]
}
}"#;
let response: JsonRpcResponse = serde_json::from_str(response_json).unwrap();
assert!(response.error.is_none());
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0]["name"].as_str().unwrap(), "create_issue");
}
#[test]
fn test_mcp_transport_config_serde() {
let config = McpServerConfig {
@@ -752,6 +483,7 @@ mod tests {
},
timeout_secs: 30,
env: vec!["GITHUB_PERSONAL_ACCESS_TOKEN".to_string()],
headers: vec![],
};
let json = serde_json::to_string(&config).unwrap();
@@ -776,6 +508,7 @@ mod tests {
},
timeout_secs: 60,
env: vec![],
headers: vec![],
};
let json = serde_json::to_string(&sse_config).unwrap();
let back: McpServerConfig = serde_json::from_str(&json).unwrap();
@@ -783,5 +516,24 @@ mod tests {
McpTransport::Sse { url } => assert_eq!(url, "https://example.com/mcp"),
_ => panic!("Expected SSE transport"),
}
// HTTP (Streamable HTTP) variant
let http_config = McpServerConfig {
name: "atlassian".to_string(),
transport: McpTransport::Http {
url: "https://mcp.atlassian.com/v1/mcp".to_string(),
},
timeout_secs: 120,
env: vec![],
headers: vec!["Authorization: Bearer test-token-456".to_string()],
};
let json = serde_json::to_string(&http_config).unwrap();
let back: McpServerConfig = serde_json::from_str(&json).unwrap();
match back.transport {
McpTransport::Http { url } => {
assert_eq!(url, "https://mcp.atlassian.com/v1/mcp")
}
_ => panic!("Expected Http transport"),
}
}
}
+352 -61
View File
@@ -5,12 +5,11 @@
use openfang_types::model_catalog::{
AuthStatus, ModelCatalogEntry, ModelTier, ProviderInfo, AI21_BASE_URL, ANTHROPIC_BASE_URL,
AZURE_OPENAI_BASE_URL, BEDROCK_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL,
COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL,
GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL,
LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL,
MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
AZURE_OPENAI_BASE_URL, BEDROCK_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, COHERE_BASE_URL,
DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL,
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL,
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
@@ -107,20 +106,138 @@ impl ModelCatalog {
&self.models
}
/// Find a model by its canonical ID or by alias.
/// Find a model by its canonical ID, display name, or alias.
///
/// When multiple models match case-insensitively (e.g. a builtin `qwen3-30b-a3b`
/// and a custom `Qwen3-30B-A3B`), user-defined entries (Custom or Local tier)
/// take priority. This ensures models from `custom_models.json` or dynamically
/// discovered local models are not shadowed by builtins that happen to share the
/// same lowercased name (#856).
pub fn find_model(&self, id_or_alias: &str) -> Option<&ModelCatalogEntry> {
let lower = id_or_alias.to_lowercase();
// Direct ID match first
if let Some(entry) = self.models.iter().find(|m| m.id.to_lowercase() == lower) {
// Single scan: prefer user-defined models (Custom/Local tier) over builtins.
//
// Priority order:
// 1. User-defined entry with exact-case ID match
// 2. User-defined entry with case-insensitive ID match
// 3. Builtin entry with exact-case ID match
// 4. Builtin entry with case-insensitive ID match
//
// This ensures that custom models from custom_models.json and dynamically
// discovered local models are never shadowed by builtins that share the same
// lowercased name, regardless of how the caller cased the search term.
let mut user_ci: Option<&ModelCatalogEntry> = None;
let mut builtin_exact: Option<&ModelCatalogEntry> = None;
let mut builtin_ci: Option<&ModelCatalogEntry> = None;
for m in &self.models {
if m.id.to_lowercase() != lower {
continue;
}
let is_user_defined = m.tier == ModelTier::Custom || m.tier == ModelTier::Local;
let is_exact = m.id == id_or_alias;
match (is_user_defined, is_exact) {
(true, true) => return Some(m), // Best possible: user-defined + exact
(true, false) if user_ci.is_none() => user_ci = Some(m),
(false, true) if builtin_exact.is_none() => builtin_exact = Some(m),
(false, false) if builtin_ci.is_none() => builtin_ci = Some(m),
_ => {}
}
}
if let Some(entry) = user_ci {
return Some(entry);
}
// Alias resolution
if let Some(entry) = builtin_exact {
return Some(entry);
}
if let Some(entry) = builtin_ci {
return Some(entry);
}
// 3. Display-name match for dashboard/UI payloads that send labels.
if let Some(entry) = self
.models
.iter()
.find(|m| m.display_name.to_lowercase() == lower)
{
return Some(entry);
}
// 4. Alias resolution
if let Some(canonical) = self.aliases.get(&lower) {
return self.models.iter().find(|m| m.id == *canonical);
}
None
}
/// Find a model by ID/alias, preferring entries from the given provider.
///
/// When `provider` is specified, this method first looks for a matching model
/// that belongs to that provider. If no provider-scoped match is found, it
/// falls back to the normal `find_model` resolution.
///
/// This prevents issue #833 where switching to model "kimi-2.5" with provider
/// "model_studio" would incorrectly resolve to moonshot's builtin kimi-2.5
/// because `find_model` does not consider provider affinity.
pub fn find_model_for_provider(
&self,
id_or_alias: &str,
provider: &str,
) -> Option<&ModelCatalogEntry> {
let lower = id_or_alias.to_lowercase();
// First pass: look for a match scoped to the requested provider.
// Priority: exact-case ID > case-insensitive ID > display-name.
let mut provider_ci: Option<&ModelCatalogEntry> = None;
for m in &self.models {
if m.provider != provider {
continue;
}
if m.id.to_lowercase() != lower {
continue;
}
if m.id == id_or_alias {
return Some(m); // Exact-case match on the right provider — best result
}
if provider_ci.is_none() {
provider_ci = Some(m);
}
}
if let Some(entry) = provider_ci {
return Some(entry);
}
// Display-name match scoped to provider
if let Some(entry) = self
.models
.iter()
.find(|m| m.provider == provider && m.display_name.to_lowercase() == lower)
{
return Some(entry);
}
// Alias resolution scoped to provider: resolve the alias, then check if
// the canonical model belongs to the requested provider.
if let Some(canonical) = self.aliases.get(&lower) {
if let Some(entry) = self
.models
.iter()
.find(|m| m.id == *canonical && m.provider == provider)
{
return Some(entry);
}
}
// No provider-scoped match — fall back to normal resolution so callers
// still get a result when the model genuinely doesn't exist on this provider
// (e.g. user typo, or a model name that only exists elsewhere).
self.find_model(id_or_alias)
}
/// Resolve an alias to a canonical model ID, or None if not an alias.
pub fn resolve_alias(&self, alias: &str) -> Option<&str> {
self.aliases.get(&alias.to_lowercase()).map(|s| s.as_str())
@@ -255,8 +372,8 @@ impl ModelCatalog {
display_name: display,
provider: provider.to_string(),
tier: ModelTier::Local,
context_window: 32_768,
max_output_tokens: 4_096,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
@@ -283,6 +400,10 @@ impl ModelCatalog {
///
/// Returns `true` if the model was added, `false` if a model with the same
/// ID **and** provider already exists (case-insensitive).
///
/// The entry's tier is forced to [`ModelTier::Custom`] so that user-defined
/// models are always preferred over builtins with the same lowercased name
/// (see `find_model` priority logic, #856).
pub fn add_custom_model(&mut self, entry: ModelCatalogEntry) -> bool {
let lower_id = entry.id.to_lowercase();
let lower_provider = entry.provider.to_lowercase();
@@ -294,6 +415,8 @@ impl ModelCatalog {
return false;
}
let provider = entry.provider.clone();
let mut entry = entry;
entry.tier = ModelTier::Custom;
self.models.push(entry);
// Update provider model count
@@ -849,7 +972,8 @@ fn builtin_aliases() -> HashMap<String, String> {
("ernie", "ernie-4.5-8k"),
("kimi", "kimi-k2"),
("moonshot", "moonshot-v1-128k"),
("minimax", "MiniMax-M2.5"),
("minimax", "MiniMax-M2.7"),
("minimax-m2.7", "MiniMax-M2.7"),
("minimax-m2.5", "MiniMax-M2.5"),
("minimax-m2.5-highspeed", "MiniMax-M2.5-highspeed"),
("minimax-highspeed", "MiniMax-M2.5-highspeed"),
@@ -1540,34 +1664,6 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "llama-3.2-3b-preview".into(),
display_name: "Llama 3.2 3B".into(),
provider: "groq".into(),
tier: ModelTier::Fast,
context_window: 128_000,
max_output_tokens: 8_192,
input_cost_per_m: 0.06,
output_cost_per_m: 0.06,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "llama-3.2-1b-preview".into(),
display_name: "Llama 3.2 1B".into(),
provider: "groq".into(),
tier: ModelTier::Fast,
context_window: 128_000,
max_output_tokens: 8_192,
input_cost_per_m: 0.04,
output_cost_per_m: 0.04,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "mixtral-8x7b-32768".into(),
display_name: "Mixtral 8x7B".into(),
@@ -1582,20 +1678,6 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec!["mixtral".into()],
},
ModelCatalogEntry {
id: "gemma2-9b-it".into(),
display_name: "Gemma 2 9B".into(),
provider: "groq".into(),
tier: ModelTier::Fast,
context_window: 8_192,
max_output_tokens: 4_096,
input_cost_per_m: 0.02,
output_cost_per_m: 0.02,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "qwen-qwq-32b".into(),
display_name: "Qwen QWQ 32B".into(),
@@ -3010,8 +3092,22 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// MiniMax (6)
// MiniMax (7)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "MiniMax-M2.7".into(),
display_name: "MiniMax M2.7".into(),
provider: "minimax".into(),
tier: ModelTier::Frontier,
context_window: 204_800,
max_output_tokens: 131_072,
input_cost_per_m: 0.30,
output_cost_per_m: 1.20,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["minimax-m2.7".into()],
},
ModelCatalogEntry {
id: "minimax-text-01".into(),
display_name: "MiniMax Text 01".into(),
@@ -3024,7 +3120,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["minimax".into()],
aliases: vec![],
},
ModelCatalogEntry {
id: "MiniMax-M2.5".into(),
@@ -3916,7 +4012,7 @@ mod tests {
let anthropic = catalog.get_provider("anthropic").unwrap();
assert_eq!(anthropic.model_count, 7);
let groq = catalog.get_provider("groq").unwrap();
assert_eq!(groq.model_count, 10);
assert_eq!(groq.model_count, 7);
}
#[test]
@@ -3938,6 +4034,14 @@ mod tests {
assert_eq!(entry.provider, "xai");
}
#[test]
fn test_find_model_by_display_name() {
let catalog = ModelCatalog::new();
let entry = catalog.find_model("Grok 4").unwrap();
assert_eq!(entry.id, "grok-4-0709");
assert_eq!(entry.provider, "xai");
}
#[test]
fn test_new_providers_in_catalog() {
let catalog = ModelCatalog::new();
@@ -4043,14 +4147,21 @@ mod tests {
assert!(catalog.find_model("codegeex").is_some());
assert!(catalog.find_model("ernie").is_some());
assert!(catalog.find_model("minimax").is_some());
// MiniMax M2.7 — new flagship model
let m27 = catalog.find_model("MiniMax-M2.7").unwrap();
assert_eq!(m27.provider, "minimax");
assert_eq!(m27.tier, ModelTier::Frontier);
assert!(!m27.supports_vision);
assert!(m27.supports_tools);
assert!(catalog.find_model("minimax-m2.7").is_some());
// Default "minimax" alias now points to M2.7
let default = catalog.find_model("minimax").unwrap();
assert_eq!(default.id, "MiniMax-M2.7");
// MiniMax M2.5 — by exact ID, alias, and case-insensitive
let m25 = catalog.find_model("MiniMax-M2.5").unwrap();
assert_eq!(m25.provider, "minimax");
assert_eq!(m25.tier, ModelTier::Frontier);
assert!(catalog.find_model("minimax-m2.5").is_some());
// Default "minimax" alias now points to M2.5
let default = catalog.find_model("minimax").unwrap();
assert_eq!(default.id, "MiniMax-M2.5");
// MiniMax M2.5 Highspeed — by exact ID and aliases
let hs = catalog.find_model("MiniMax-M2.5-highspeed").unwrap();
assert_eq!(hs.provider, "minimax");
@@ -4232,4 +4343,184 @@ mod tests {
assert!(entry.supports_tools);
assert!(entry.supports_vision);
}
/// Regression test for #856: custom models with case-sensitive names must not be
/// shadowed by builtin models that share the same lowercased ID.
///
/// When a user deploys `Qwen3-30B-A3B` via vLLM and adds it to custom_models.json,
/// find_model should return the custom entry (provider "vllm"), not the builtin
/// entry (provider "qwen") whose id is `qwen3-30b-a3b`.
#[test]
fn test_custom_model_not_shadowed_by_builtin_856() {
let mut catalog = ModelCatalog::new();
// Verify the builtin exists first
let builtin = catalog.find_model("qwen3-30b-a3b").unwrap();
assert_eq!(builtin.provider, "qwen");
// Add a custom model with a case-sensitive name on a different provider
let added = catalog.add_custom_model(ModelCatalogEntry {
id: "Qwen3-30B-A3B".into(),
display_name: "Qwen3 30B (Local vLLM)".into(),
provider: "vllm".into(),
tier: ModelTier::Balanced, // user might not set tier explicitly
context_window: 32_768,
max_output_tokens: 4_096,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
});
assert!(added, "custom model should be added (different provider)");
// add_custom_model should force Custom tier
let custom = catalog
.list_models()
.iter()
.find(|m| m.id == "Qwen3-30B-A3B")
.unwrap();
assert_eq!(custom.tier, ModelTier::Custom);
// Exact-case lookup must find the custom entry, not the builtin
let found = catalog.find_model("Qwen3-30B-A3B").unwrap();
assert_eq!(found.id, "Qwen3-30B-A3B");
assert_eq!(found.provider, "vllm");
assert_eq!(found.tier, ModelTier::Custom);
// Lowercase lookup: builtin "qwen3-30b-a3b" (tier Fast) gets an exact-case match,
// but the custom "Qwen3-30B-A3B" (tier Custom) gets a case-insensitive match.
// With our fix, the user-defined entry (Custom tier) wins even when there's
// an exact-case builtin match, because user-defined entries always take priority.
let lower_found = catalog.find_model("qwen3-30b-a3b").unwrap();
assert_eq!(lower_found.provider, "vllm");
assert_eq!(lower_found.tier, ModelTier::Custom);
}
/// Verify that find_model's exact-case match takes priority over case-insensitive.
#[test]
fn test_find_model_exact_case_priority() {
let catalog = ModelCatalog::new();
// MiniMax-M2.5 exists as a builtin with exact case "MiniMax-M2.5"
let entry = catalog.find_model("MiniMax-M2.5").unwrap();
assert_eq!(entry.id, "MiniMax-M2.5");
assert_eq!(entry.provider, "minimax");
// Case-insensitive still works
let lower = catalog.find_model("minimax-m2.5").unwrap();
assert_eq!(lower.id, "MiniMax-M2.5");
}
/// Verify that dynamically discovered local models (Local tier) are preferred
/// over builtins in case-insensitive lookups.
#[test]
fn test_discovered_local_model_preferred() {
let mut catalog = ModelCatalog::new();
// merge_discovered_models adds models with Local tier
catalog.merge_discovered_models("ollama", &["Custom-Model-7B".to_string()]);
// Verify it was added
let found = catalog.find_model("Custom-Model-7B").unwrap();
assert_eq!(found.tier, ModelTier::Local);
assert_eq!(found.provider, "ollama");
// Case-insensitive lookup should prefer the Local-tier entry
let lower = catalog.find_model("custom-model-7b").unwrap();
assert_eq!(lower.tier, ModelTier::Local);
assert_eq!(lower.provider, "ollama");
}
/// Regression test for #833: find_model_for_provider should prefer the entry
/// from the specified provider when multiple providers share the same model name.
///
/// Scenario: a custom provider "model_studio" has a model "kimi-k2.5", and the
/// builtin "moonshot" provider also has "kimi-k2.5". When the user switches to
/// "kimi-k2.5" with provider "model_studio", we must resolve to model_studio's
/// entry, not moonshot's builtin.
#[test]
fn test_find_model_for_provider_prefers_specified_provider_833() {
let mut catalog = ModelCatalog::new();
// Verify the builtin moonshot entry exists
let builtin = catalog.find_model("kimi-k2.5").unwrap();
assert_eq!(builtin.provider, "moonshot");
// Add a custom model with the same name on a different provider
let added = catalog.add_custom_model(ModelCatalogEntry {
id: "kimi-k2.5".into(),
display_name: "Kimi K2.5 (Model Studio)".into(),
provider: "model_studio".into(),
tier: ModelTier::Balanced,
context_window: 131_072,
max_output_tokens: 8_192,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
});
assert!(added, "custom model should be added (different provider)");
// Plain find_model returns the custom entry (Custom tier wins over builtin)
let plain = catalog.find_model("kimi-k2.5").unwrap();
assert_eq!(plain.tier, ModelTier::Custom);
// find_model_for_provider with "model_studio" must return model_studio's entry
let ms = catalog
.find_model_for_provider("kimi-k2.5", "model_studio")
.unwrap();
assert_eq!(ms.provider, "model_studio");
assert_eq!(ms.display_name, "Kimi K2.5 (Model Studio)");
// find_model_for_provider with "moonshot" must return moonshot's builtin
let moonshot = catalog
.find_model_for_provider("kimi-k2.5", "moonshot")
.unwrap();
assert_eq!(moonshot.provider, "moonshot");
assert_eq!(moonshot.display_name, "Kimi K2.5");
}
/// Verify find_model_for_provider falls back to normal resolution when the
/// model doesn't exist on the requested provider.
#[test]
fn test_find_model_for_provider_fallback() {
let catalog = ModelCatalog::new();
// "claude-sonnet-4-20250514" only exists on "anthropic"
let entry = catalog
.find_model_for_provider("claude-sonnet-4-20250514", "nonexistent_provider")
.unwrap();
assert_eq!(entry.provider, "anthropic");
}
/// Verify find_model_for_provider is case-insensitive for the model name.
#[test]
fn test_find_model_for_provider_case_insensitive() {
let mut catalog = ModelCatalog::new();
catalog.add_custom_model(ModelCatalogEntry {
id: "My-Custom-LLM".into(),
display_name: "My Custom LLM".into(),
provider: "custom_provider".into(),
tier: ModelTier::Balanced,
context_window: 32_768,
max_output_tokens: 4_096,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
});
// Case-insensitive lookup with the correct provider
let found = catalog
.find_model_for_provider("my-custom-llm", "custom_provider")
.unwrap();
assert_eq!(found.provider, "custom_provider");
assert_eq!(found.id, "My-Custom-LLM");
}
}
+41 -8
View File
@@ -18,6 +18,13 @@ use tracing::{debug, warn};
/// Maximum inter-agent call depth to prevent infinite recursion (A->B->C->...).
const MAX_AGENT_CALL_DEPTH: u32 = 5;
/// Check if a tool name refers to a shell execution tool.
///
/// Used to determine whether exec_policy settings should bypass the approval gate.
fn is_shell_tool(name: &str) -> bool {
name == "shell_exec"
}
/// Check if a shell command should be blocked by taint tracking.
///
/// Layer 1: Shell metacharacter injection (backticks, `$(`, `${`, etc.)
@@ -133,9 +140,28 @@ pub async fn execute_tool(
}
}
// Approval gate: check if this tool requires human approval before execution
// Approval gate: check if this tool requires human approval before execution.
//
// When exec_policy.mode = "full" (or allowlist with allowed_commands = ["*"]),
// the user has explicitly opted into unrestricted shell access. In that case,
// shell_exec should bypass the approval gate — requiring approval for commands
// the user already whitelisted is contradictory (GitHub issue #772).
let exec_policy_bypasses_approval = is_shell_tool(tool_name)
&& exec_policy.is_some_and(|p| {
p.mode == openfang_types::config::ExecSecurityMode::Full
|| (p.mode == openfang_types::config::ExecSecurityMode::Allowlist
&& p.allowed_commands.iter().any(|c| c == "*"))
});
if exec_policy_bypasses_approval {
debug!(
tool_name,
"Approval bypassed: exec_policy grants unrestricted shell access"
);
}
if let Some(kh) = kernel {
if kh.requires_approval(tool_name) {
if !exec_policy_bypasses_approval && kh.requires_approval(tool_name) {
let agent_id_str = caller_agent_id.unwrap_or("unknown");
let input_str = input.to_string();
let summary = format!(
@@ -1549,7 +1575,7 @@ async fn tool_shell_exec(
// Truncate very long outputs to prevent memory issues
let max_output = 100_000;
let stdout_str = if stdout.len() > max_output {
let mut stdout_str = if stdout.len() > max_output {
format!(
"{}...\n[truncated, {} total bytes]",
crate::str_utils::safe_truncate_str(&stdout, max_output),
@@ -1568,6 +1594,10 @@ async fn tool_shell_exec(
stderr.to_string()
};
if exit_code == 0 && stdout_str.is_empty() {
stdout_str = "Command executed successfully".to_string();
}
Ok(format!(
"Exit code: {exit_code}\n\nSTDOUT:\n{stdout_str}\nSTDERR:\n{stderr_str}"
))
@@ -2447,7 +2477,7 @@ async fn tool_a2a_discover(input: &serde_json::Value) -> Result<String, String>
let url = input["url"].as_str().ok_or("Missing 'url' parameter")?;
// SSRF protection: block private/metadata IPs
if crate::web_fetch::check_ssrf(url).is_err() {
if crate::web_fetch::check_ssrf(url, &[]).is_err() {
return Err("SSRF blocked: URL resolves to a private or metadata address".to_string());
}
@@ -2470,7 +2500,7 @@ async fn tool_a2a_send(
// Resolve agent URL: either directly provided or looked up by name
let url = if let Some(url) = input["agent_url"].as_str() {
// SSRF protection
if crate::web_fetch::check_ssrf(url).is_err() {
if crate::web_fetch::check_ssrf(url, &[]).is_err() {
return Err("SSRF blocked: URL resolves to a private or metadata address".to_string());
}
url.to_string()
@@ -3647,10 +3677,13 @@ mod tests {
None, // process_manager
)
.await;
// Should NOT be "Permission denied" — it should normalize to file_write
// and pass the capability check. It will fail for other reasons (path validation).
// Should NOT be the capability-check denial — it should normalize to file_write
// and pass the capability check. It may fail for other reasons (path validation,
// OS-level errors), but not the agent capability gate.
assert!(
!result.content.contains("Permission denied"),
!result
.content
.contains("does not have capability to use tool"),
"fs-write should normalize to file_write and pass capability check, got: {}",
result.content
);
+171 -14
View File
@@ -53,7 +53,7 @@ impl WebFetchEngine {
let method_upper = method.to_uppercase();
// Step 1: SSRF protection — BEFORE any network I/O
check_ssrf(url)?;
check_ssrf(url, &self.config.ssrf_allowed_hosts)?;
// Step 2: Cache lookup (only for GET)
let cache_key = format!("fetch:{}:{}", method_upper, url);
@@ -185,7 +185,14 @@ fn is_html(content_type: &str, body: &str) -> bool {
/// Check if a URL targets a private/internal network resource.
/// Blocks localhost, metadata endpoints, and private IPs.
/// Must run BEFORE any network I/O.
pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
///
/// The `allowed_hosts` slice lets self-hosted deployments bypass the
/// private-IP check for specific hosts. Entries can be exact hostnames
/// (`"n8n.local"`), wildcard domains (`"*.olares.com"`), or CIDR ranges
/// (`"10.0.0.0/8"`).
///
/// **Cloud metadata endpoints are NEVER allowed regardless of the allowlist.**
pub(crate) fn check_ssrf(url: &str, allowed_hosts: &[String]) -> Result<(), String> {
// Only allow http:// and https:// schemes
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err("Only http:// and https:// URLs are allowed".to_string());
@@ -200,6 +207,7 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
};
// Hostname-based blocklist (catches metadata endpoints)
// These are UNCONDITIONALLY blocked — no allowlist can override them.
let blocked = [
"localhost",
"ip6-localhost",
@@ -217,13 +225,28 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
return Err(format!("SSRF blocked: {hostname} is a restricted hostname"));
}
// Check if the hostname is explicitly allowed before doing DNS resolution.
if is_host_allowed(hostname, allowed_hosts) {
return Ok(());
}
// Resolve DNS and check every returned IP
let port = if url.starts_with("https") { 443 } else { 80 };
let socket_addr = format!("{hostname}:{port}");
if let Ok(addrs) = socket_addr.to_socket_addrs() {
for addr in addrs {
let ip = addr.ip();
if is_metadata_ip(&ip) {
// Metadata IPs are NEVER allowed, even via allowlist.
return Err(format!(
"SSRF blocked: {hostname} resolves to metadata IP {ip}"
));
}
if ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) {
// Check if the resolved IP matches a CIDR in the allowlist.
if is_ip_allowed(&ip, allowed_hosts) {
continue;
}
return Err(format!(
"SSRF blocked: {hostname} resolves to private IP {ip}"
));
@@ -234,6 +257,97 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
Ok(())
}
/// Returns true if an IP is a cloud metadata endpoint address.
fn is_metadata_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let octets = v4.octets();
// 169.254.169.254 (AWS/GCP/Azure IMDS)
octets == [169, 254, 169, 254]
// 100.100.100.200 (Alibaba Cloud IMDS)
|| octets == [100, 100, 100, 200]
// 192.0.0.192 (Azure IMDS alternative)
|| octets == [192, 0, 0, 192]
}
_ => false,
}
}
/// Check if a hostname matches any entry in the allowlist.
/// Supports exact match and wildcard domains (`*.example.com`).
fn is_host_allowed(hostname: &str, allowed_hosts: &[String]) -> bool {
let lower = hostname.to_lowercase();
for entry in allowed_hosts {
let entry_lower = entry.to_lowercase();
// Exact match
if entry_lower == lower {
return true;
}
// Wildcard domain: *.example.com matches sub.example.com
if let Some(suffix) = entry_lower.strip_prefix("*.") {
if lower.ends_with(&format!(".{suffix}")) || lower == suffix {
return true;
}
}
}
false
}
/// Check if an IP address matches any CIDR entry in the allowlist.
fn is_ip_allowed(ip: &IpAddr, allowed_hosts: &[String]) -> bool {
for entry in allowed_hosts {
if let Some(pos) = entry.find('/') {
// Parse as CIDR: base_ip/prefix_len
let base_str = &entry[..pos];
let prefix_str = &entry[pos + 1..];
if let (Ok(base_ip), Ok(prefix_len)) =
(base_str.parse::<IpAddr>(), prefix_str.parse::<u32>())
{
if ip_in_cidr(ip, &base_ip, prefix_len) {
return true;
}
}
} else if let Ok(entry_ip) = entry.parse::<IpAddr>() {
// Exact IP match
if *ip == entry_ip {
return true;
}
}
}
false
}
/// Check if `ip` falls within the CIDR block `base/prefix_len`.
fn ip_in_cidr(ip: &IpAddr, base: &IpAddr, prefix_len: u32) -> bool {
match (ip, base) {
(IpAddr::V4(ip4), IpAddr::V4(base4)) => {
if prefix_len > 32 {
return false;
}
if prefix_len == 0 {
return true;
}
let ip_bits = u32::from_be_bytes(ip4.octets());
let base_bits = u32::from_be_bytes(base4.octets());
let mask = !0u32 << (32 - prefix_len);
(ip_bits & mask) == (base_bits & mask)
}
(IpAddr::V6(ip6), IpAddr::V6(base6)) => {
if prefix_len > 128 {
return false;
}
if prefix_len == 0 {
return true;
}
let ip_bits = u128::from_be_bytes(ip6.octets());
let base_bits = u128::from_be_bytes(base6.octets());
let mask = !0u128 << (128 - prefix_len);
(ip_bits & mask) == (base_bits & mask)
}
_ => false, // mismatched families
}
}
/// Check if an IP address is in a private range.
fn is_private_ip(ip: &IpAddr) -> bool {
match ip {
@@ -308,8 +422,8 @@ mod tests {
#[test]
fn test_ssrf_blocks_localhost() {
assert!(check_ssrf("http://localhost/admin").is_err());
assert!(check_ssrf("http://localhost:8080/api").is_err());
assert!(check_ssrf("http://localhost/admin", &[]).is_err());
assert!(check_ssrf("http://localhost:8080/api", &[]).is_err());
}
#[test]
@@ -323,8 +437,8 @@ mod tests {
#[test]
fn test_ssrf_blocks_metadata() {
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/").is_err());
assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/").is_err());
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/", &[]).is_err());
assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/", &[]).is_err());
}
#[test]
@@ -339,28 +453,28 @@ mod tests {
#[test]
fn test_ssrf_blocks_non_http() {
assert!(check_ssrf("file:///etc/passwd").is_err());
assert!(check_ssrf("ftp://internal.corp/data").is_err());
assert!(check_ssrf("gopher://evil.com").is_err());
assert!(check_ssrf("file:///etc/passwd", &[]).is_err());
assert!(check_ssrf("ftp://internal.corp/data", &[]).is_err());
assert!(check_ssrf("gopher://evil.com", &[]).is_err());
}
#[test]
fn test_ssrf_blocks_cloud_metadata() {
// Alibaba Cloud IMDS
assert!(check_ssrf("http://100.100.100.200/latest/meta-data/").is_err());
assert!(check_ssrf("http://100.100.100.200/latest/meta-data/", &[]).is_err());
// Azure IMDS alternative
assert!(check_ssrf("http://192.0.0.192/metadata/instance").is_err());
assert!(check_ssrf("http://192.0.0.192/metadata/instance", &[]).is_err());
}
#[test]
fn test_ssrf_blocks_zero_ip() {
assert!(check_ssrf("http://0.0.0.0/").is_err());
assert!(check_ssrf("http://0.0.0.0/", &[]).is_err());
}
#[test]
fn test_ssrf_blocks_ipv6_localhost() {
assert!(check_ssrf("http://[::1]/admin").is_err());
assert!(check_ssrf("http://[::1]:8080/api").is_err());
assert!(check_ssrf("http://[::1]/admin", &[]).is_err());
assert!(check_ssrf("http://[::1]:8080/api", &[]).is_err());
}
#[test]
@@ -374,4 +488,47 @@ mod tests {
let h3 = extract_host("http://[::1]/path");
assert_eq!(h3, "[::1]:80");
}
// ── SSRF allowlist tests ─────────────────────────────────────────────
#[test]
fn test_ssrf_allowlist_permits_private_ip() {
// A CIDR allowlist entry should permit an otherwise-blocked private IP.
let allow = vec!["10.0.0.0/8".to_string()];
assert!(check_ssrf("http://10.1.2.3", &allow).is_ok());
}
#[test]
fn test_ssrf_allowlist_still_blocks_metadata() {
// Even if the allowlist covers the entire link-local range,
// cloud metadata endpoints must NEVER be permitted.
let allow = vec!["169.254.0.0/16".to_string()];
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/", &allow).is_err());
// Also verify hostname-based metadata blocks
let allow2 = vec!["metadata.google.internal".to_string()];
assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/", &allow2).is_err());
}
#[test]
fn test_ssrf_allowlist_wildcard_domain() {
let allow = vec!["*.example.com".to_string()];
assert!(check_ssrf("http://api.example.com", &allow).is_ok());
// Non-matching domain should still go through normal checks
assert!(is_host_allowed("other.net", &allow) == false);
}
#[test]
fn test_ssrf_allowlist_exact_hostname() {
let allow = vec!["n8n.local".to_string()];
assert!(check_ssrf("http://n8n.local/webhook", &allow).is_ok());
}
#[test]
fn test_cidr_matching() {
let ip_in: IpAddr = "10.1.2.3".parse().unwrap();
let ip_out: IpAddr = "11.0.0.1".parse().unwrap();
let base: IpAddr = "10.0.0.0".parse().unwrap();
assert!(ip_in_cidr(&ip_in, &base, 8));
assert!(!ip_in_cidr(&ip_out, &base, 8));
}
}

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