Compare commits

..
Author SHA1 Message Date
Coffee ab806efec4 Rename WASM schema test for direct advertising behavior 2026-03-18 14:10:19 +08:00
Coffee 83675c91da fix: brave web search 2026-03-18 13:55:09 +08:00
78 changed files with 1186 additions and 7495 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ NEARAI_AUTH_URL=https://private.near.ai
# === MiniMax ===
# LLM_BACKEND=minimax
# MINIMAX_API_KEY=...
# MINIMAX_MODEL=MiniMax-M2.7
# MINIMAX_MODEL=MiniMax-M2.5
# MINIMAX_BASE_URL=https://api.minimax.io/v1 # default (global); use https://api.minimaxi.com/v1 for China
# === Anthropic Direct ===
+1 -1
View File
@@ -174,7 +174,7 @@ jobs:
- name: Run E2E tests
run: |
pytest tests/e2e/ -v --timeout=120
pytest tests/e2e/ -v -x --timeout=120
env:
RUST_LOG: ironclaw=info
RUST_BACKTRACE: "1"
-147
View File
@@ -7,153 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17
### Added
- verify telegram owner during hot activation ([#1157](https://github.com/nearai/ironclaw/pull/1157))
- *(config)* unify config resolution with Settings fallback (Phase 2, #1119) ([#1203](https://github.com/nearai/ironclaw/pull/1203))
- *(sandbox)* add retry logic for transient container failures ([#1232](https://github.com/nearai/ironclaw/pull/1232))
- *(heartbeat)* fire_at time-of-day scheduling with IANA timezone ([#1029](https://github.com/nearai/ironclaw/pull/1029))
- Reuse Codex CLI OAuth tokens for ChatGPT backend LLM calls ([#693](https://github.com/nearai/ironclaw/pull/693))
- add pre-push git hook with delta lint mode ([#833](https://github.com/nearai/ironclaw/pull/833))
- *(cli)* add `logs` command for gateway log access ([#1105](https://github.com/nearai/ironclaw/pull/1105))
- add Feishu/Lark WASM channel plugin ([#1110](https://github.com/nearai/ironclaw/pull/1110))
- add Criterion benchmarks for safety layer hot paths ([#836](https://github.com/nearai/ironclaw/pull/836))
- *(routines)* human-readable cron schedule summaries in web UI ([#1154](https://github.com/nearai/ironclaw/pull/1154))
- *(web)* add follow-up suggestion chips and ghost text ([#1156](https://github.com/nearai/ironclaw/pull/1156))
- *(ci)* include commit history in staging promotion PRs ([#952](https://github.com/nearai/ironclaw/pull/952))
- *(tools)* add reusable sensitive JSON redaction helper ([#457](https://github.com/nearai/ironclaw/pull/457))
- configurable hybrid search fusion strategy ([#234](https://github.com/nearai/ironclaw/pull/234))
- *(cli)* add cron subcommand for managing scheduled routines ([#1017](https://github.com/nearai/ironclaw/pull/1017))
- adds context-llm tool support ([#616](https://github.com/nearai/ironclaw/pull/616))
- *(web-chat)* add hover copy button for user/assistant messages ([#948](https://github.com/nearai/ironclaw/pull/948))
- add Slack approval buttons for tool execution in DMs ([#796](https://github.com/nearai/ironclaw/pull/796))
- enhance HTTP tool parameter parsing ([#911](https://github.com/nearai/ironclaw/pull/911))
- *(routines)* enable tool access in lightweight routine execution ([#257](https://github.com/nearai/ironclaw/pull/257)) ([#730](https://github.com/nearai/ironclaw/pull/730))
- add MiniMax as a built-in LLM provider ([#940](https://github.com/nearai/ironclaw/pull/940))
- *(cli)* add `ironclaw channels list` subcommand ([#933](https://github.com/nearai/ironclaw/pull/933))
- *(cli)* add `ironclaw skills list/search/info` subcommands ([#918](https://github.com/nearai/ironclaw/pull/918))
- add cargo-deny for supply chain safety ([#834](https://github.com/nearai/ironclaw/pull/834))
- *(setup)* display ASCII art banner during onboarding ([#851](https://github.com/nearai/ironclaw/pull/851))
- *(extensions)* unify auth and configure into single entrypoint ([#677](https://github.com/nearai/ironclaw/pull/677))
- *(i18n)* Add internationalization support with Chinese and English translations ([#929](https://github.com/nearai/ironclaw/pull/929))
- Import OpenClaw memory, history and settings ([#903](https://github.com/nearai/ironclaw/pull/903))
### Fixed
- jobs limit ([#1274](https://github.com/nearai/ironclaw/pull/1274))
- misleading UI message ([#1265](https://github.com/nearai/ironclaw/pull/1265))
- bump channel registry versions for promotion ([#1264](https://github.com/nearai/ironclaw/pull/1264))
- cover staging CI all-features and routine batch regressions ([#1256](https://github.com/nearai/ironclaw/pull/1256))
- resolve merge conflict fallout and missing config fields
- web/CLI routine mutations do not refresh live event trigger cache ([#1255](https://github.com/nearai/ironclaw/pull/1255))
- *(jobs)* make completed->completed transition idempotent to prevent race errors ([#1068](https://github.com/nearai/ironclaw/pull/1068))
- *(llm)* persist refreshed Anthropic OAuth token after Keychain re-read ([#1213](https://github.com/nearai/ironclaw/pull/1213))
- *(worker)* prevent orphaned tool_results and fix parallel merging ([#1069](https://github.com/nearai/ironclaw/pull/1069))
- Telegram bot token validation fails intermittently (HTTP 404) ([#1166](https://github.com/nearai/ironclaw/pull/1166))
- *(security)* prevent metadata spoofing of internal job monitor flag ([#1195](https://github.com/nearai/ironclaw/pull/1195))
- *(security)* default webhook server to loopback when tunnel is configured ([#1194](https://github.com/nearai/ironclaw/pull/1194))
- *(auth)* avoid false success and block chat during pending auth ([#1111](https://github.com/nearai/ironclaw/pull/1111))
- *(config)* unify ChannelsConfig resolution to env > settings > default ([#1124](https://github.com/nearai/ironclaw/pull/1124))
- *(web-chat)* normalize chat copy to plain text ([#1114](https://github.com/nearai/ironclaw/pull/1114))
- *(skill)* treat empty url param as absent when installing skills ([#1128](https://github.com/nearai/ironclaw/pull/1128))
- preserve AuthError type in oauth_http_client cache ([#1152](https://github.com/nearai/ironclaw/pull/1152))
- *(web)* prevent Safari IME composition Enter from sending message ([#1140](https://github.com/nearai/ironclaw/pull/1140))
- *(mcp)* handle 400 auth errors, clear auth mode after OAuth, trim tokens ([#1158](https://github.com/nearai/ironclaw/pull/1158))
- eliminate panic paths in production code ([#1184](https://github.com/nearai/ironclaw/pull/1184))
- N+1 query pattern in event trigger loop (routine_engine) ([#1163](https://github.com/nearai/ironclaw/pull/1163))
- *(llm)* add stop_sequences parity for tool completions ([#1170](https://github.com/nearai/ironclaw/pull/1170))
- *(channels)* use live owner binding during wasm hot activation ([#1171](https://github.com/nearai/ironclaw/pull/1171))
- Non-transactional multi-step context updates between metadata/to… ([#1161](https://github.com/nearai/ironclaw/pull/1161))
- *(webhook)* avoid lock-held awaits in server lifecycle paths ([#1168](https://github.com/nearai/ironclaw/pull/1168))
- Google Sheets returns 403 PERMISSION_DENIED after completing OAuth ([#1164](https://github.com/nearai/ironclaw/pull/1164))
- HTTP webhook secret transmitted in request body rather than via header, docs inconsistency and security concern ([#1162](https://github.com/nearai/ironclaw/pull/1162))
- *(ci)* exclude ironclaw_safety from release automation ([#1146](https://github.com/nearai/ironclaw/pull/1146))
- *(registry)* bump versions for github, web-search, and discord extensions ([#1106](https://github.com/nearai/ironclaw/pull/1106))
- *(mcp)* address 14 audit findings across MCP module ([#1094](https://github.com/nearai/ironclaw/pull/1094))
- *(http)* replace .expect() with match in webhook handler ([#1133](https://github.com/nearai/ironclaw/pull/1133))
- *(time)* treat empty timezone string as absent ([#1127](https://github.com/nearai/ironclaw/pull/1127))
- 5 critical/high-priority bugs (auth bypass, relay failures, unbounded recursion, context growth) ([#1083](https://github.com/nearai/ironclaw/pull/1083))
- *(ci)* checkout promotion PR head for metadata refresh ([#1097](https://github.com/nearai/ironclaw/pull/1097))
- *(ci)* add missing attachments field and crates/ dir to Dockerfiles ([#1100](https://github.com/nearai/ironclaw/pull/1100))
- *(registry)* bump telegram channel version for capabilities change ([#1064](https://github.com/nearai/ironclaw/pull/1064))
- *(ci)* repair staging promotion workflow behavior ([#1091](https://github.com/nearai/ironclaw/pull/1091))
- *(wasm)* address #1086 review followups -- description hint and coercion safety ([#1092](https://github.com/nearai/ironclaw/pull/1092))
- *(ci)* repair staging-ci workflow parsing ([#1090](https://github.com/nearai/ironclaw/pull/1090))
- *(extensions)* fix lifecycle bugs + comprehensive E2E tests ([#1070](https://github.com/nearai/ironclaw/pull/1070))
- add tool_info schema discovery for WASM tools ([#1086](https://github.com/nearai/ironclaw/pull/1086))
- resolve bug_bash UX/logging issues (#1054 #1055 #1058) ([#1072](https://github.com/nearai/ironclaw/pull/1072))
- *(http)* fail closed when webhook secret is missing at runtime ([#1075](https://github.com/nearai/ironclaw/pull/1075))
- *(service)* set CLI_ENABLED=false in macOS launchd plist ([#1079](https://github.com/nearai/ironclaw/pull/1079))
- relax approval requirements for low-risk tools ([#922](https://github.com/nearai/ironclaw/pull/922))
- *(web)* make approval requests appear without page reload ([#996](https://github.com/nearai/ironclaw/pull/996)) ([#1073](https://github.com/nearai/ironclaw/pull/1073))
- *(routines)* run cron checks immediately on ticker startup ([#1066](https://github.com/nearai/ironclaw/pull/1066))
- *(web)* recompute cron next_fire_at when re-enabling routines ([#1080](https://github.com/nearai/ironclaw/pull/1080))
- *(memory)* reject absolute filesystem paths with corrective routing ([#934](https://github.com/nearai/ironclaw/pull/934))
- remove all inline event handlers for CSP script-src compliance ([#1063](https://github.com/nearai/ironclaw/pull/1063))
- *(mcp)* include OAuth state parameter in authorization URLs ([#1049](https://github.com/nearai/ironclaw/pull/1049))
- *(mcp)* open MCP OAuth in same browser as gateway ([#951](https://github.com/nearai/ironclaw/pull/951))
- *(deploy)* harden production container and bootstrap security ([#1014](https://github.com/nearai/ironclaw/pull/1014))
- release lock guards before awaiting channel send ([#869](https://github.com/nearai/ironclaw/pull/869)) ([#1003](https://github.com/nearai/ironclaw/pull/1003))
- *(registry)* use versioned artifact URLs and checksums for all WASM manifests ([#1007](https://github.com/nearai/ironclaw/pull/1007))
- *(setup)* preserve model selection on provider re-run ([#679](https://github.com/nearai/ironclaw/pull/679)) ([#987](https://github.com/nearai/ironclaw/pull/987))
- *(mcp)* attach session manager for non-OAuth HTTP clients ([#793](https://github.com/nearai/ironclaw/pull/793)) ([#986](https://github.com/nearai/ironclaw/pull/986))
- *(security)* migrate webhook auth to HMAC-SHA256 signature header ([#970](https://github.com/nearai/ironclaw/pull/970))
- *(security)* make unsafe env::set_var calls safe with explicit invariants ([#968](https://github.com/nearai/ironclaw/pull/968))
- *(security)* require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy ([#967](https://github.com/nearai/ironclaw/pull/967))
- *(security)* add Content-Security-Policy header to web gateway ([#966](https://github.com/nearai/ironclaw/pull/966))
- *(test)* stabilize openai compat oversized-body regression ([#839](https://github.com/nearai/ironclaw/pull/839))
- *(ci)* disambiguate WASM bundle filenames to prevent tool/channel collision ([#964](https://github.com/nearai/ironclaw/pull/964))
- *(setup)* validate channel credentials during setup ([#684](https://github.com/nearai/ironclaw/pull/684))
- drain tunnel pipes to prevent zombie process ([#735](https://github.com/nearai/ironclaw/pull/735))
- *(mcp)* header safety validation and Authorization conflict bug from #704 ([#752](https://github.com/nearai/ironclaw/pull/752))
- *(agent)* block thread_id-based context pollution across users ([#760](https://github.com/nearai/ironclaw/pull/760))
- *(mcp)* stdio/unix transports skip initialize handshake ([#890](https://github.com/nearai/ironclaw/pull/890)) ([#935](https://github.com/nearai/ironclaw/pull/935))
- *(setup)* drain residual events and filter key kind in onboard prompts ([#937](https://github.com/nearai/ironclaw/pull/937)) ([#949](https://github.com/nearai/ironclaw/pull/949))
- *(security)* load WASM tool description and schema from capabilities.json ([#520](https://github.com/nearai/ironclaw/pull/520))
- *(security)* resolve DNS once and reuse for SSRF validation to prevent rebinding ([#518](https://github.com/nearai/ironclaw/pull/518))
- *(security)* replace regex HTML sanitizer with DOMPurify to prevent XSS ([#510](https://github.com/nearai/ironclaw/pull/510))
- *(ci)* improve Claude Code review reliability ([#955](https://github.com/nearai/ironclaw/pull/955))
- *(ci)* run gated test jobs during staging CI ([#956](https://github.com/nearai/ironclaw/pull/956))
- *(ci)* prevent staging-ci tag failure and chained PR auto-close ([#900](https://github.com/nearai/ironclaw/pull/900))
- *(ci)* WASM WIT compat sqlite3 duplicate symbol conflict ([#953](https://github.com/nearai/ironclaw/pull/953))
- resolve deferred review items from PRs #883, #848, #788 ([#915](https://github.com/nearai/ironclaw/pull/915))
- *(web)* improve UX readability and accessibility in chat UI ([#910](https://github.com/nearai/ironclaw/pull/910))
### Other
- Fix Telegram auto-verify flow and routing ([#1273](https://github.com/nearai/ironclaw/pull/1273))
- *(e2e)* fix approval waiting regression coverage ([#1270](https://github.com/nearai/ironclaw/pull/1270))
- isolate heavy integration tests ([#1266](https://github.com/nearai/ironclaw/pull/1266))
- Merge branch 'main' into fix/resolve-conflicts
- Refactor owner scope across channels and fix default routing fallback ([#1151](https://github.com/nearai/ironclaw/pull/1151))
- *(extensions)* document relay manager init order ([#928](https://github.com/nearai/ironclaw/pull/928))
- *(setup)* extract init logic from wizard into owning modules ([#1210](https://github.com/nearai/ironclaw/pull/1210))
- mention MiniMax as built-in provider in all READMEs ([#1209](https://github.com/nearai/ironclaw/pull/1209))
- Fix schema-guided tool parameter coercion ([#1143](https://github.com/nearai/ironclaw/pull/1143))
- Make no-panics CI check test-aware ([#1160](https://github.com/nearai/ironclaw/pull/1160))
- *(mcp)* avoid reallocating SSE buffer on each chunk ([#1153](https://github.com/nearai/ironclaw/pull/1153))
- *(routines)* avoid full message history clone each tool iteration ([#1172](https://github.com/nearai/ironclaw/pull/1172))
- *(registry)* align manifest versions with published artifacts ([#1169](https://github.com/nearai/ironclaw/pull/1169))
- remove __pycache__ from repo and add to .gitignore ([#1177](https://github.com/nearai/ironclaw/pull/1177))
- *(registry)* move MCP servers from code to JSON manifests ([#1144](https://github.com/nearai/ironclaw/pull/1144))
- improve routine schema guidance ([#1089](https://github.com/nearai/ironclaw/pull/1089))
- add event-trigger routine e2e coverage ([#1088](https://github.com/nearai/ironclaw/pull/1088))
- enforce no .unwrap(), .expect(), or assert!() in production code ([#1087](https://github.com/nearai/ironclaw/pull/1087))
- periodic sync main into staging (resolved conflicts) ([#1098](https://github.com/nearai/ironclaw/pull/1098))
- fix formatting in cli/mod.rs and mcp/auth.rs ([#1071](https://github.com/nearai/ironclaw/pull/1071))
- Expose the shared agent session manager via AppComponents ([#532](https://github.com/nearai/ironclaw/pull/532))
- *(agent)* remove unnecessary Worker re-export ([#923](https://github.com/nearai/ironclaw/pull/923))
- Fix UTF-8 unsafe truncation in WASM emit_message ([#1015](https://github.com/nearai/ironclaw/pull/1015))
- extract safety module into ironclaw_safety crate ([#1024](https://github.com/nearai/ironclaw/pull/1024))
- Add Z.AI provider support for GLM-5 ([#938](https://github.com/nearai/ironclaw/pull/938))
- *(html_to_markdown)* refresh golden files after renderer bump ([#1016](https://github.com/nearai/ironclaw/pull/1016))
- Migrate GitHub webhook normalization into github tool ([#758](https://github.com/nearai/ironclaw/pull/758))
- Fix systemctl unit ([#472](https://github.com/nearai/ironclaw/pull/472))
- add Russian localization (README.ru.md) ([#850](https://github.com/nearai/ironclaw/pull/850))
- Add generic host-verified /webhook/tools/{tool} ingress ([#757](https://github.com/nearai/ironclaw/pull/757))
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
### Other
Generated
+1 -1
View File
@@ -3436,7 +3436,7 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.19.0"
version = "0.18.0"
dependencies = [
"aes-gcm",
"aho-corasick",
+1 -1
View File
@@ -20,7 +20,7 @@ exclude = [
[package]
name = "ironclaw"
version = "0.19.0"
version = "0.18.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
-330
View File
@@ -1,330 +0,0 @@
<p align="center">
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
</p>
<h1 align="center">IronClaw</h1>
<p align="center">
<strong>あなたの味方になる、安全なパーソナルAIアシスタント</strong>
</p>
<p align="center">
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
<a href="#フィロソフィー">フィロソフィー</a> •
<a href="#機能">機能</a> •
<a href="#インストール">インストール</a> •
<a href="#設定">設定</a> •
<a href="#セキュリティ">セキュリティ</a> •
<a href="#アーキテクチャ">アーキテクチャ</a>
</p>
---
## フィロソフィー
IronClawはシンプルな原則に基づいて構築されています:**あなたのAIアシスタントは、あなたのために働くべきであり、あなたに不利益をもたらすべきではありません。**
AIシステムがデータの取り扱いについて不透明になり、企業の利益に沿って調整されることが増えている世界で、IronClawは異なるアプローチを取ります:
- **あなたのデータはあなたのもの** - すべての情報はローカルに保存・暗号化され、あなたの管理下から離れることはありません
- **設計段階からの透明性** - オープンソース、監査可能、隠れたテレメトリやデータ収集なし
- **自己拡張する能力** - ベンダーのアップデートを待たずに、新しいツールをその場で構築
- **多層防御** - 複数のセキュリティレイヤーがプロンプトインジェクションやデータ流出から保護
IronClawは、個人生活にも仕事にも本当に信頼できるAIアシスタントです。
## 機能
### セキュリティファースト
- **WASMサンドボックス** - 信頼されていないツールは、機能ベースの権限を持つ隔離されたWebAssemblyコンテナで実行
- **認証情報の保護** - シークレットはツールに公開されず、リーク検出付きでホスト境界で注入
- **プロンプトインジェクション防御** - パターン検出、コンテンツサニタイズ、ポリシー適用
- **エンドポイントの許可リスト** - HTTPリクエストは明示的に許可されたホストとパスのみに制限
### 常時利用可能
- **マルチチャネル** - REPL、HTTPウェブフック、WASMチャネル(Telegram、Slack)、Webゲートウェイ
- **Dockerサンドボックス** - ジョブごとのトークンとオーケストレーター/ワーカーパターンによる隔離されたコンテナ実行
- **Webゲートウェイ** - リアルタイムSSE/WebSocketストリーミング対応のブラウザUI
- **ルーティン** - cronスケジュール、イベントトリガー、ウェブフックハンドラーによるバックグラウンド自動化
- **ハートビートシステム** - 監視・保守タスクのためのプロアクティブなバックグラウンド実行
- **並列ジョブ** - 隔離されたコンテキストで複数のリクエストを同時に処理
- **自己修復** - スタックした操作の自動検出と復旧
### 自己拡張
- **動的ツール構築** - 必要なものを説明すると、IronClawがWASMツールとして構築
- **MCPプロトコル** - Model Context Protocolサーバーに接続して追加機能を利用
- **プラグインアーキテクチャ** - 再起動なしで新しいWASMツールやチャネルを追加
### 永続メモリ
- **ハイブリッド検索** - Reciprocal Rank Fusionを使用した全文検索+ベクトル検索
- **ワークスペースファイルシステム** - メモ、ログ、コンテキストのための柔軟なパスベースストレージ
- **アイデンティティファイル** - セッション間で一貫した人格と設定を維持
## インストール
### 前提条件
- Rust 1.85+
- PostgreSQL 15+ ([pgvector](https://github.com/pgvector/pgvector)拡張機能を含む)
- NEAR AIアカウント(セットアップウィザードで認証を処理)
## ダウンロードまたはビルド
最新のアップデートは[リリースページ](https://github.com/nearai/ironclaw/releases/)をご覧ください。
<details>
<summary>Windowsインストーラーでインストール(Windows</summary>
[Windowsインストーラー](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)をダウンロードして実行してください。
</details>
<details>
<summary>PowerShellスクリプトでインストール(Windows</summary>
```sh
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
```
</details>
<details>
<summary>シェルスクリプトでインストール(macOS、Linux、Windows/WSL</summary>
```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
```
</details>
<details>
<summary>Homebrewでインストール(macOS/Linux</summary>
```sh
brew install ironclaw
```
</details>
<details>
<summary>ソースコードからコンパイル(Windows、Linux、macOSでCargo</summary>
`cargo`でインストールします。コンピューターに[Rust](https://rustup.rs)がインストールされていることを確認してください。
```bash
# リポジトリをクローン
git clone https://github.com/nearai/ironclaw.git
cd ironclaw
# ビルド
cargo build --release
# テストを実行
cargo test
```
**フルリリース**(チャネルソースを変更した後)の場合、まず`./scripts/build-all.sh`を実行してチャネルを再ビルドしてください。
</details>
### データベースのセットアップ
```bash
# データベースを作成
createdb ironclaw
# pgvectorを有効化
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
```
## 設定
セットアップウィザードを実行してIronClawを設定します:
```bash
ironclaw onboard
```
ウィザードは、データベース接続、NEAR AI認証(ブラウザOAuth経由)、シークレットの暗号化(システムキーチェーンを使用)を処理します。設定は接続されたデータベースに永続化されます。ブートストラップ変数(例:`DATABASE_URL``LLM_BACKEND`)は、データベース接続前に利用できるよう`~/.ironclaw/.env`に書き込まれます。
### 代替LLMプロバイダー
IronClawはデフォルトでNEAR AIを使用しますが、多くのLLMプロバイダーをすぐに利用できます。組み込みプロバイダーには**Anthropic**、**OpenAI**、**Google Gemini**、**MiniMax**、**Mistral**、**Ollama**(ローカル)が含まれます。**OpenRouter**300以上のモデル)、**Together AI**、**Fireworks AI**、セルフホストサーバー(**vLLM**、**LiteLLM**)などのOpenAI互換サービスもサポートされています。
ウィザードでプロバイダーを選択するか、環境変数を直接設定してください:
```env
# 例:MiniMax(組み込み、204Kコンテキスト)
LLM_BACKEND=minimax
MINIMAX_API_KEY=...
# 例:OpenAI互換エンドポイント
LLM_BACKEND=openai_compatible
LLM_BASE_URL=https://openrouter.ai/api/v1
LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
完全なプロバイダーガイドは[docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md)をご覧ください。
## セキュリティ
IronClawは、データを保護し悪用を防ぐために多層防御を実装しています。
### WASMサンドボックス
すべての信頼されていないツールは、隔離されたWebAssemblyコンテナで実行されます:
- **機能ベースの権限** - HTTP、シークレット、ツール呼び出しの明示的なオプトイン
- **エンドポイントの許可リスト** - 許可されたホスト/パスへのHTTPリクエストのみ
- **認証情報の注入** - シークレットはホスト境界で注入され、WASMコードに公開されない
- **リーク検出** - リクエストとレスポンスのシークレット流出試行をスキャン
- **レート制限** - 悪用防止のためのツールごとのリクエスト制限
- **リソース制限** - メモリ、CPU、実行時間の制約
```
WASM ──► 許可リスト ──► リーク ──► 認証情報 ──► リクエスト ──► リーク ──► WASM
バリデーター スキャン 注入 実行 スキャン
(リクエスト) (レスポンス)
```
### プロンプトインジェクション防御
外部コンテンツは複数のセキュリティレイヤーを通過します:
- パターンベースのインジェクション試行検出
- コンテンツのサニタイズとエスケープ
- 重要度レベル付きポリシールール(ブロック/警告/レビュー/サニタイズ)
- 安全なLLMコンテキスト注入のためのツール出力ラッピング
### データ保護
- すべてのデータはローカルのPostgreSQLデータベースに保存
- AES-256-GCMでシークレットを暗号化
- テレメトリ、分析、データ共有なし
- すべてのツール実行の完全な監査ログ
## アーキテクチャ
```
┌────────────────────────────────────────────────────────────────┐
│ チャネル │
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ REPL │ │ HTTP │ │WASMチャネル │ │ Web │ │
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ ゲートウェイ│ │
│ │ │ │ │(SSE + WS) │ │
│ │ │ │ └──────┬──────┘ │
│ └─────────┴──────────────┴────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ エージェントループ │ インテントルーティング│
│ └────┬──────────┬───┘ │
│ │ │ │
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
│ │ スケジューラー │ │ ルーティン │ │
│ │ (並列ジョブ) │ │ エンジン │ │
│ └──────┬────────┘ │(cron,event,wh) │ │
│ │ └────────┬─────────┘ │
│ ┌─────────────┼────────────────────┘ │
│ │ │ │
│ ┌───▼─────┐ ┌────▼────────────────┐ │
│ │ ローカル │ │ オーケストレーター │ │
│ │ ワーカー │ │ ┌───────────────┐ │ │
│ │(プロセス │ │ │ Docker │ │ │
│ │ 内) │ │ │ サンドボックス│ │ │
│ └───┬─────┘ │ │ コンテナ │ │ │
│ │ │ │ ┌───────────┐ │ │ │
│ │ │ │ │Worker / CC│ │ │ │
│ │ │ │ └───────────┘ │ │ │
│ │ │ └───────────────┘ │ │
│ │ └─────────┬───────────┘ │
│ └──────────────────┤ │
│ │ │
│ ┌───────────▼──────────┐ │
│ │ ツールレジストリ │ │
│ │ 組み込み, MCP, WASM │ │
│ └──────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
```
### コアコンポーネント
| コンポーネント | 目的 |
|---------------|------|
| **エージェントループ** | メインのメッセージ処理とジョブの調整 |
| **ルーター** | ユーザーの意図を分類(コマンド、クエリ、タスク) |
| **スケジューラー** | 優先度付きの並列ジョブ実行を管理 |
| **ワーカー** | LLM推論とツール呼び出しでジョブを実行 |
| **オーケストレーター** | コンテナのライフサイクル、LLMプロキシ、ジョブごとの認証 |
| **Webゲートウェイ** | チャット、メモリ、ジョブ、ログ、拡張機能、ルーティンのブラウザUI |
| **ルーティンエンジン** | スケジュール(cron)とリアクティブ(イベント、ウェブフック)のバックグラウンドタスク |
| **ワークスペース** | ハイブリッド検索付き永続メモリ |
| **セーフティレイヤー** | プロンプトインジェクション防御とコンテンツサニタイズ |
## 使い方
```bash
# 初回セットアップ(データベース、認証などを設定)
ironclaw onboard
# インタラクティブREPLを起動
cargo run
# デバッグログ付き
RUST_LOG=ironclaw=debug cargo run
```
## 開発
```bash
# コードフォーマット
cargo fmt
# リント
cargo clippy --all --benches --tests --examples --all-features
# テスト実行
createdb ironclaw_test
cargo test
# 特定のテストを実行
cargo test test_name
```
- **Telegramチャネル**: セットアップとDMペアリングについては[docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md)を参照してください。
- **チャネルソースの変更**: `cargo build`の前に`./channels-src/telegram/build.sh`を実行して、更新されたWASMをバンドルしてください。
## OpenClawの系譜
IronClawは[OpenClaw](https://github.com/openclaw/openclaw)にインスパイアされたRust再実装です。完全な対応表は[FEATURE_PARITY.md](FEATURE_PARITY.md)をご覧ください。
主な違い:
- **Rust vs TypeScript** - ネイティブパフォーマンス、メモリ安全性、シングルバイナリ
- **WASMサンドボックス vs Docker** - 軽量、機能ベースのセキュリティ
- **PostgreSQL vs SQLite** - 本番環境対応の永続化
- **セキュリティファースト設計** - 複数の防御レイヤー、認証情報の保護
## ライセンス
以下のいずれかのライセンスの下で提供されています:
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
- MIT License ([LICENSE-MIT](LICENSE-MIT))
お好みに応じて選択してください。
+1 -2
View File
@@ -17,8 +17,7 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
<a href="README.ru.md">Русский</a>
</p>
<p align="center">
+1 -2
View File
@@ -17,8 +17,7 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
<a href="README.ru.md">Русский</a>
</p>
<p align="center">
+1 -2
View File
@@ -17,8 +17,7 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
<a href="README.ru.md">Русский</a>
</p>
<p align="center">
+19 -222
View File
@@ -360,8 +360,6 @@ enum TelegramStatusAction {
}
const TELEGRAM_STATUS_MAX_CHARS: usize = 600;
/// Telegram's hard limit for message text length.
const TELEGRAM_MAX_MESSAGE_LEN: usize = 4096;
fn truncate_status_message(input: &str, max_chars: usize) -> String {
let mut iter = input.chars();
@@ -373,73 +371,6 @@ fn truncate_status_message(input: &str, max_chars: usize) -> String {
}
}
/// Split a long message into chunks that fit within Telegram's 4096-char limit.
///
/// Tries to split at the most natural boundary available (in priority order):
/// 1. Double newline (paragraph break)
/// 2. Single newline
/// 3. Sentence end (`. `, `! `, `? `)
/// 4. Word boundary (space)
/// 5. Hard cut at the limit (last resort for pathological input)
fn split_message(text: &str) -> Vec<String> {
if text.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN {
return vec![text.to_string()];
}
let mut chunks: Vec<String> = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
// Count chars to find the byte offset for our window.
let window_bytes = remaining
.char_indices()
.take(TELEGRAM_MAX_MESSAGE_LEN)
.last()
.map(|(byte_idx, ch)| byte_idx + ch.len_utf8())
.unwrap_or(remaining.len());
if window_bytes >= remaining.len() {
// Remainder fits entirely.
chunks.push(remaining.to_string());
break;
}
let window = &remaining[..window_bytes];
// 1. Double newline — best paragraph boundary
let split_at = window.rfind("\n\n")
// 2. Single newline
.or_else(|| window.rfind('\n'))
// 3. Sentence-ending punctuation followed by space.
// Note: this only detects ASCII punctuation (. ! ?), not CJK
// sentence-ending marks (。!?). CJK text falls through to
// word-boundary or hard-cut splitting.
.or_else(|| {
let bytes = window.as_bytes();
// Search backwards for '. ', '! ', '? '
(1..bytes.len()).rev().find(|&i| {
matches!(bytes[i - 1], b'.' | b'!' | b'?') && bytes[i] == b' '
})
})
// 4. Word boundary (last space)
.or_else(|| window.rfind(' '))
// 5. Hard cut
.unwrap_or(window_bytes);
// Avoid empty chunks (e.g. text starting with \n\n).
let split_at = if split_at == 0 { window_bytes } else { split_at };
// Trim whitespace at chunk boundaries for clean Telegram display.
// Note: this drops leading/trailing spaces at split points, which is
// acceptable for chat messages but means the concatenation of chunks
// may not exactly equal the original text when split at spaces.
chunks.push(remaining[..split_at].trim_end().to_string());
remaining = remaining[split_at..].trim_start();
}
chunks
}
fn status_message_for_user(update: &StatusUpdate) -> Option<String> {
let message = update.message.trim();
if message.is_empty() {
@@ -1311,64 +1242,26 @@ fn send_response(
return Ok(());
}
// Split large messages into chunks that fit Telegram's limit.
let chunks = split_message(&response.content);
let total = chunks.len();
// The first chunk replies to the original message; subsequent chunks
// reply to the previously sent chunk so they form a visual thread.
let mut reply_to = reply_to_message_id;
for (i, chunk) in chunks.into_iter().enumerate() {
// Try Markdown, fall back to plain text on parse errors
let result = send_message(chat_id, &chunk, reply_to, Some("Markdown"), message_thread_id);
let msg_id = match result {
Ok(id) => {
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Sent message chunk {}/{} to chat {}: message_id={}",
i + 1,
total,
chat_id,
id,
),
);
id
}
Err(SendError::ParseEntities(detail)) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Markdown parse failed on chunk {}/{} ({}), retrying as plain text",
i + 1,
total,
detail
),
);
let id = send_message(chat_id, &chunk, reply_to, None, message_thread_id)
.map_err(|e| format!("Plain-text retry also failed: {}", e))?;
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Sent plain-text chunk {}/{} to chat {}: message_id={}",
i + 1,
total,
chat_id,
id,
),
);
id
}
Err(e) => return Err(e.to_string()),
};
// Each subsequent chunk threads off the previous sent message.
reply_to = Some(msg_id);
// Try Markdown, fall back to plain text on parse errors
match send_message(
chat_id,
&response.content,
reply_to_message_id,
Some("Markdown"),
message_thread_id,
) {
Ok(_) => Ok(()),
Err(SendError::ParseEntities(_)) => send_message(
chat_id,
&response.content,
reply_to_message_id,
None,
message_thread_id,
)
.map(|_| ())
.map_err(|e| format!("Plain-text retry also failed: {}", e)),
Err(e) => Err(e.to_string()),
}
Ok(())
}
/// Send a single attachment, choosing sendPhoto or sendDocument based on MIME type.
@@ -2150,102 +2043,6 @@ export!(TelegramChannel);
mod tests {
use super::*;
#[test]
fn test_split_message_short() {
let text = "Hello, world!";
let chunks = split_message(text);
assert_eq!(chunks, vec![text]);
}
#[test]
fn test_split_message_paragraph_boundary() {
let para_a = "A".repeat(3000);
let para_b = "B".repeat(3000);
let text = format!("{}\n\n{}", para_a, para_b);
let chunks = split_message(&text);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0], para_a);
assert_eq!(chunks[1], para_b);
}
#[test]
fn test_split_message_word_boundary() {
// Build a string well over the limit with no newlines.
let words: Vec<String> = (0..1000).map(|i| format!("word{:04}", i)).collect();
let text = words.join(" ");
assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
assert!(chunks.len() > 1, "expected multiple chunks");
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
}
// Rejoined chunks must equal the original text exactly.
let rejoined = chunks.join(" ");
assert_eq!(rejoined, text);
}
#[test]
fn test_split_message_each_chunk_fits() {
// Stress-test: 20 000 chars of mixed text.
let text: String = (0..500)
.map(|i| format!("Sentence number {}. ", i))
.collect();
assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
}
}
#[test]
fn test_split_message_sentence_boundary() {
// Build text that exceeds the limit, with sentence boundaries inside.
let sentence = "This is a test sentence. ";
let repeat_count = TELEGRAM_MAX_MESSAGE_LEN / sentence.len() + 5;
let text: String = sentence.repeat(repeat_count);
assert!(text.chars().count() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
assert!(chunks.len() > 1);
// First chunk should end at a sentence boundary (trimmed)
let first = &chunks[0];
assert!(
first.ends_with('.'),
"First chunk should end at a sentence boundary, got: ...{}",
&first[first.len().saturating_sub(20)..]
);
}
#[test]
fn test_split_message_hard_cut_no_spaces() {
// Pathological input: a single huge "word" with no spaces or newlines.
let text = "x".repeat(TELEGRAM_MAX_MESSAGE_LEN * 2 + 100);
let chunks = split_message(&text);
assert!(chunks.len() >= 2);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
}
// Rejoined must preserve all characters
let rejoined: String = chunks.concat();
assert_eq!(rejoined, text);
}
#[test]
fn test_split_message_multibyte_chars() {
// Emoji are 4 bytes each. Ensure we don't panic or split mid-character.
let emoji = "\u{1F600}"; // 😀
let text: String = emoji.repeat(TELEGRAM_MAX_MESSAGE_LEN + 100);
assert!(text.chars().count() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
assert!(chunks.len() >= 2);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
// Every char should be a complete emoji
assert!(chunk.chars().all(|c| c == '\u{1F600}'));
}
}
#[test]
fn test_clean_message_text() {
// Without bot_username: strips any leading @mention
+1 -1
View File
@@ -269,7 +269,7 @@ dependencies = [
[[package]]
name = "whatsapp-channel"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"serde",
"serde_json",
+4 -8
View File
@@ -2,13 +2,9 @@ coverage:
status:
project:
default:
target: 80%
threshold: 2%
target: auto
threshold: 1%
patch:
default:
target: 90%
comment:
layout: "reach,diff,flags"
behavior: default
require_changes: true
target: 80%
threshold: 5%
+2 -2
View File
@@ -15,7 +15,7 @@ configurations.
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 models |
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.5 models |
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
| Ollama | `ollama` | No | Local inference |
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
@@ -84,7 +84,7 @@ LLM_BACKEND=minimax
MINIMAX_API_KEY=...
```
Available models: `MiniMax-M2.7` (default), `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`
Available models: `MiniMax-M2.5` (default), `MiniMax-M2.5-highspeed`
To use the China mainland endpoint, set:
+2 -2
View File
@@ -393,8 +393,8 @@
"api_key_required": true,
"base_url_env": "MINIMAX_BASE_URL",
"model_env": "MINIMAX_MODEL",
"default_model": "MiniMax-M2.7",
"description": "MiniMax API (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
"default_model": "MiniMax-M2.5",
"description": "MiniMax API (MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
"setup": {
"kind": "api_key",
"secret_name": "llm_minimax_api_key",
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-discord-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "6159cb54aa44a9d8219e29bf0aea9404213b20ff567506fe75f23d4698d6ec18"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/discord-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69"
}
},
"auth_summary": {
+1 -6
View File
@@ -17,12 +17,7 @@
"capabilities": "feishu.capabilities.json",
"crate_name": "feishu-channel"
},
"artifacts": {
"wasm32-wasip2": {
"sha256": "5fca74022264d1c8e78a0853766276f7ffa3cf0d8065b2f51ca10985acad4714",
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-feishu-0.1.1-wasm32-wasip2.tar.gz"
}
},
"artifacts": {},
"auth_summary": {
"method": "manual",
"provider": "Feishu / Lark",
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-telegram-0.2.4-wasm32-wasip2.tar.gz",
"sha256": "a7cb300ec1c946831cfceaa95c1dc8f30d0f42a3924f3cb5de8098821573f4b8"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.3-wasm32-wasip2.tar.gz",
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
}
},
"auth_summary": {
+2 -2
View File
@@ -19,8 +19,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/github-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b"
}
},
"auth_summary": {
+2 -2
View File
@@ -21,8 +21,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz",
"sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e"
"url": "https://github.com/nearai/ironclaw/releases/latest/download/llm-context-wasm32-wasip2.tar.gz",
"sha256": "581cc5867ef3b75116b7ddc8161e63dd92befe2b53e6ad8213c007639aa243c3"
}
},
"auth_summary": {
+2 -2
View File
@@ -17,8 +17,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48"
}
},
"auth_summary": {
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.2-wasm32-wasip2.tar.gz",
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
}
},
"auth_summary": {
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/web-search-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc"
}
},
"auth_summary": {
-1
View File
@@ -3,5 +3,4 @@ git_release_enable = false
[[package]]
name = "ironclaw_safety"
publish = false
release = false
@@ -8,21 +8,15 @@ Replace `{{...}}` placeholders before use.
{
"name": "wf-issue-plan",
"description": "Create implementation plan when a new issue arrives",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "issue.opened",
"event_filters": {
"repository_name": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.",
"request": {
"kind": "system_event",
"source": "github",
"event_type": "issue.opened",
"filters": {
"repository_name": "{{repository}}"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 30
}
"cooldown_secs": 30
}
```
@@ -34,22 +28,16 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-maintainer-comment-gate-{{maintainer}}",
"description": "React to maintainer guidance comments on issues/PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.comment.created",
"event_filters": {
"repository_name": "{{repository}}",
"comment_author": "{{maintainer}}"
},
"action_type": "full_job",
"prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.",
"request": {
"kind": "system_event",
"source": "github",
"event_type": "pr.comment.created",
"filters": {
"repository_name": "{{repository}}",
"comment_author": "{{maintainer}}"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 20
}
"cooldown_secs": 20
}
```
@@ -59,21 +47,15 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-pr-monitor-loop",
"description": "Keep PR healthy: address review comments and refresh branch",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.synchronize",
"event_filters": {
"repository_name": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.",
"request": {
"kind": "system_event",
"source": "github",
"event_type": "pr.synchronize",
"filters": {
"repository_name": "{{repository}}"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 20
}
"cooldown_secs": 20
}
```
@@ -83,22 +65,16 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-ci-fix-loop",
"description": "Fix failing CI checks on active PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "ci.check_run.completed",
"event_filters": {
"repository_name": "{{repository}}",
"ci_conclusion": "failure"
},
"action_type": "full_job",
"prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.",
"request": {
"kind": "system_event",
"source": "github",
"event_type": "ci.check_run.completed",
"filters": {
"repository_name": "{{repository}}",
"ci_conclusion": "failure"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 20
}
"cooldown_secs": 20
}
```
@@ -108,17 +84,11 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-staging-batch-review",
"description": "Batch correctness review through staging, then merge to main",
"trigger_type": "cron",
"schedule": "0 0 */{{batch_interval_hours}} * * *",
"action_type": "full_job",
"prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.",
"request": {
"kind": "cron",
"schedule": "0 0 */{{batch_interval_hours}} * * *"
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 120
}
"cooldown_secs": 120
}
```
@@ -128,22 +98,16 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-learning-memory",
"description": "Capture merge learnings into shared memory",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.closed",
"event_filters": {
"repository_name": "{{repository}}",
"pr_merged": "true"
},
"action_type": "full_job",
"prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.",
"request": {
"kind": "system_event",
"source": "github",
"event_type": "pr.closed",
"filters": {
"repository_name": "{{repository}}",
"pr_merged": "true"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 30
}
"cooldown_secs": 30
}
```
@@ -151,7 +115,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
```json
{
"event_source": "github",
"source": "github",
"event_type": "issue.opened",
"payload": {
"repository_name": "{{repository}}",
+36 -41
View File
@@ -146,8 +146,6 @@ pub struct AgentDeps {
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
/// Software builder for self-repair tool rebuilding.
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
}
/// The main agent that coordinates all components.
@@ -163,10 +161,9 @@ pub struct Agent {
pub(super) heartbeat_config: Option<HeartbeatConfig>,
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
pub(super) routine_config: Option<RoutineConfig>,
/// Shared routine-engine slot used for internal event matching and for exposing
/// the engine to gateway/manual trigger entry points.
/// Optional slot to expose the routine engine to the gateway for manual triggering.
pub(super) routine_engine_slot:
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
Option<Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>>,
}
impl Agent {
@@ -231,21 +228,16 @@ impl Agent {
heartbeat_config,
hygiene_config,
routine_config,
routine_engine_slot: Arc::new(tokio::sync::RwLock::new(None)),
routine_engine_slot: None,
}
}
/// Replace the routine-engine slot with a shared one so the gateway and
/// agent reference the same engine.
/// Set the routine engine slot for exposing the engine to the gateway.
pub fn set_routine_engine_slot(
&mut self,
slot: Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
) {
self.routine_engine_slot = slot;
}
async fn routine_engine(&self) -> Option<Arc<crate::agent::routine_engine::RoutineEngine>> {
self.routine_engine_slot.read().await.clone()
self.routine_engine_slot = Some(slot);
}
// Convenience accessors
@@ -342,18 +334,11 @@ impl Agent {
let mut message_stream = self.channels.start_all().await?;
// Start self-repair task with notification forwarding
let mut self_repair = DefaultSelfRepair::new(
let repair = Arc::new(DefaultSelfRepair::new(
self.context_manager.clone(),
self.config.stuck_threshold,
self.config.max_repair_attempts,
);
if let Some(ref store) = self.deps.store {
self_repair = self_repair.with_store(Arc::clone(store));
}
if let Some(ref builder) = self.deps.builder {
self_repair = self_repair.with_builder(Arc::clone(builder), Arc::clone(self.tools()));
}
let repair = Arc::new(self_repair);
));
let repair_interval = self.config.repair_check_interval;
let repair_channels = self.channels.clone();
let repair_owner_id = self.owner_id().to_string();
@@ -648,7 +633,9 @@ impl Agent {
// via a local to use in the message loop below.
// Expose engine to gateway for manual triggering
*self.routine_engine_slot.write().await = Some(Arc::clone(&engine));
if let Some(ref slot) = self.routine_engine_slot {
*slot.write().await = Some(Arc::clone(&engine));
}
tracing::debug!(
"Routines enabled: cron ticker every {}s, max {} concurrent",
@@ -668,6 +655,9 @@ impl Agent {
None
};
// Extract engine ref for use in message loop
let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
// Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name);
@@ -703,6 +693,29 @@ impl Agent {
// Store successfully extracted document text in workspace for indexing
self.store_extracted_documents(&message).await;
// Event-triggered routines consume plain user input before it enters
// the normal chat/tool pipeline. This avoids a duplicate turn where
// the main agent responds and the routine also fires on the same
// inbound message.
if !message.is_internal
&& matches!(
SubmissionParser::parse(&message.content),
Submission::UserInput { .. }
)
&& let Some(ref engine) = routine_engine_for_loop
{
let fired = engine.check_event_triggers(&message).await;
if fired > 0 {
tracing::debug!(
channel = %message.channel,
user = %message.user_id,
fired,
"Consumed inbound user message with matching event-triggered routine(s)"
);
continue;
}
}
match self.handle_message(&message).await {
Ok(Some(response)) if !response.is_empty() => {
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
@@ -1019,24 +1032,6 @@ impl Agent {
message.content.len()
);
if !message.is_internal
&& let Submission::UserInput { ref content } = submission
&& let Some(engine) = self.routine_engine().await
{
let fired = engine
.check_event_triggers(&message.user_id, &message.channel, content)
.await;
if fired > 0 {
tracing::debug!(
channel = %message.channel,
user = %message.user_id,
fired,
"Consumed inbound user message with matching event-triggered routine(s)"
);
return Ok(Some(String::new()));
}
}
// Process based on submission type
let result = match submission {
Submission::UserInput { content } => {
-3
View File
@@ -1197,7 +1197,6 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
Agent::new(
@@ -2038,7 +2037,6 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
Agent::new(
@@ -2157,7 +2155,6 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
Agent::new(
+74 -615
View File
@@ -10,7 +10,6 @@
//! Lightweight routines execute inline (single LLM call, no scheduler slot).
//! Full-job routines are delegated to the existing `Scheduler`.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
@@ -24,19 +23,19 @@ use crate::agent::Scheduler;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
};
use crate::channels::OutgoingResponse;
use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::config::RoutineConfig;
use crate::context::{JobContext, JobState};
use crate::context::JobContext;
use crate::db::Database;
use crate::error::RoutineError;
use crate::llm::{
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
};
use crate::safety::SafetyLayer;
use crate::tools::{
ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
};
use crate::workspace::Workspace;
use ironclaw_safety::SafetyLayer;
enum EventMatcher {
Message { routine: Routine, regex: Regex },
@@ -61,10 +60,6 @@ pub struct RoutineEngine {
tools: Arc<ToolRegistry>,
/// Safety layer for tool output sanitization.
safety: Arc<SafetyLayer>,
/// Timestamp when this engine instance was created. Used by
/// `sync_dispatched_runs` to distinguish orphaned runs (from a previous
/// process) from actively-watched runs (from this process).
boot_time: chrono::DateTime<Utc>,
}
impl RoutineEngine {
@@ -90,16 +85,9 @@ impl RoutineEngine {
scheduler,
tools,
safety,
boot_time: Utc::now(),
}
}
/// Expose the running count for integration tests.
#[doc(hidden)]
pub fn running_count_for_test(&self) -> &Arc<AtomicUsize> {
&self.running_count
}
/// Refresh the in-memory event trigger cache from DB.
pub async fn refresh_event_cache(&self) {
match self.store.list_event_routines().await {
@@ -147,19 +135,10 @@ impl RoutineEngine {
/// Check incoming message against event triggers. Returns number of routines fired.
///
/// Accepts only the three fields needed for matching (user scope, channel,
/// message content) so callers never need to clone a full `IncomingMessage`.
pub async fn check_event_triggers(&self, user_id: &str, channel: &str, content: &str) -> usize {
/// Called synchronously from the main loop after handle_message(). The actual
/// execution is spawned async so this returns quickly.
pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize {
let cache = self.event_cache.read().await;
// Early return if there are no message matchers at all.
if !cache
.iter()
.any(|m| matches!(m, EventMatcher::Message { .. }))
{
return 0;
}
let mut fired = 0;
// Collect routine IDs for batch query
@@ -176,9 +155,16 @@ impl RoutineEngine {
}
// Single batch query instead of N queries
let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
Some(counts) => counts,
None => return 0,
let concurrent_counts = match self
.store
.count_running_routine_runs_batch(&routine_ids)
.await
{
Ok(counts) => counts,
Err(e) => {
tracing::error!("Failed to batch-load concurrent counts: {}", e);
return 0;
}
};
for matcher in cache.iter() {
@@ -187,7 +173,7 @@ impl RoutineEngine {
EventMatcher::System { .. } => continue,
};
if routine.user_id != user_id {
if routine.user_id != message.user_id {
continue;
}
@@ -195,13 +181,13 @@ impl RoutineEngine {
if let Trigger::Event {
channel: Some(ch), ..
} = &routine.trigger
&& ch != channel
&& ch != &message.channel
{
continue;
}
// Regex match
if !re.is_match(content) {
if !re.is_match(&message.content) {
continue;
}
@@ -224,7 +210,7 @@ impl RoutineEngine {
continue;
}
let detail = truncate(content, 200);
let detail = truncate(&message.content, 200);
self.spawn_fire(routine.clone(), "event", Some(detail));
fired += 1;
}
@@ -243,15 +229,6 @@ impl RoutineEngine {
user_id: Option<&str>,
) -> usize {
let cache = self.event_cache.read().await;
// Early return if there are no system-event matchers at all.
if !cache
.iter()
.any(|m| matches!(m, EventMatcher::System { .. }))
{
return 0;
}
let mut fired = 0;
// Collect routine IDs for batch query
@@ -268,9 +245,19 @@ impl RoutineEngine {
}
// Single batch query instead of N queries
let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
Some(counts) => counts,
None => return 0,
let concurrent_counts = match self
.store
.count_running_routine_runs_batch(&routine_ids)
.await
{
Ok(counts) => counts,
Err(e) => {
tracing::error!(
"Failed to batch-load concurrent counts for system events: {}",
e
);
return 0;
}
};
for matcher in cache.iter() {
@@ -344,23 +331,6 @@ impl RoutineEngine {
fired
}
/// Batch-load concurrent run counts for a set of routine IDs.
///
/// Returns `None` on database error (already logged).
async fn batch_concurrent_counts(&self, routine_ids: &[Uuid]) -> Option<HashMap<Uuid, i64>> {
match self
.store
.count_running_routine_runs_batch(routine_ids)
.await
{
Ok(counts) => Some(counts),
Err(e) => {
tracing::error!("Failed to batch-load concurrent counts: {}", e);
None
}
}
}
/// Check all due cron routines and fire them. Called by the cron ticker.
pub async fn check_cron_triggers(&self) {
let routines = match self.store.list_due_cron_routines().await {
@@ -395,230 +365,6 @@ impl RoutineEngine {
}
}
/// Reconcile orphaned full_job routine runs with their linked job outcomes.
///
/// Called on each cron tick. Finds routine runs that are still `running`
/// with a linked `job_id`, checks the job state, and finalizes the run
/// when the job reaches a completed or terminal state.
///
/// Only processes runs started **before** this engine's boot time, so it
/// never races with `FullJobWatcher` instances from the current process.
/// This makes it safe to call on every tick as a crash-recovery mechanism.
pub async fn sync_dispatched_runs(&self) {
let runs = match self.store.list_dispatched_routine_runs().await {
Ok(r) => r,
Err(e) => {
tracing::error!("Failed to list dispatched routine runs: {}", e);
return;
}
};
// Only process runs from a previous process instance. Runs started
// after boot_time are actively watched by a FullJobWatcher in this
// process and should not be finalized here.
let orphaned: Vec<_> = runs
.into_iter()
.filter(|r| r.started_at < self.boot_time)
.collect();
if orphaned.is_empty() {
return;
}
tracing::info!(
"Recovering {} orphaned dispatched routine runs",
orphaned.len()
);
for run in orphaned {
let job_id = match run.job_id {
Some(id) => id,
None => continue, // Should not happen (query filters), but guard anyway
};
// Fetch the linked job
let job = match self.store.get_job(job_id).await {
Ok(Some(j)) => j,
Ok(None) => {
// Orphaned: job record was deleted or never persisted
tracing::warn!(
run_id = %run.id,
job_id = %job_id,
"Linked job not found, marking routine run as failed"
);
self.complete_dispatched_run(
&run,
RunStatus::Failed,
&format!("Linked job {job_id} not found (orphaned)"),
)
.await;
continue;
}
Err(e) => {
tracing::error!(
run_id = %run.id,
job_id = %job_id,
"Failed to fetch linked job: {}", e
);
continue;
}
};
// Map job state to final run status
let final_status = match job.state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
// Pending, InProgress, Stuck — still running
_ => None,
};
let status = match final_status {
Some(s) => s,
None => continue, // Job still active, check again next tick
};
// Build summary
let summary = if status == RunStatus::Failed {
match self.store.get_agent_job_failure_reason(job_id).await {
Ok(Some(reason)) => format!("Job {job_id} failed: {reason}"),
_ => format!("Job {job_id} {}", job.state),
}
} else {
format!("Job {job_id} completed successfully")
};
self.complete_dispatched_run(&run, status, &summary).await;
}
}
/// Finalize a dispatched routine run: update DB, update routine runtime,
/// persist to conversation thread, and send notification.
async fn complete_dispatched_run(&self, run: &RoutineRun, status: RunStatus, summary: &str) {
// Complete the run record in DB
if let Err(e) = self
.store
.complete_routine_run(run.id, status, Some(summary), None)
.await
{
tracing::error!(
run_id = %run.id,
"Failed to complete dispatched routine run: {}", e
);
return;
}
tracing::info!(
run_id = %run.id,
status = %status,
"Finalized dispatched routine run"
);
// Load the routine to update consecutive_failures and send notification
let routine = match self.store.get_routine(run.routine_id).await {
Ok(Some(r)) => r,
Ok(None) => {
tracing::warn!(
run_id = %run.id,
routine_id = %run.routine_id,
"Routine not found for dispatched run finalization"
);
return;
}
Err(e) => {
tracing::error!(
run_id = %run.id,
"Failed to load routine for dispatched run: {}", e
);
return;
}
};
// Update runtime fields. In crash recovery, execute_routine() never
// reached its normal runtime update, so we must advance all fields here.
let new_failures = if status == RunStatus::Failed {
routine.consecutive_failures + 1
} else {
0
};
let now = Utc::now();
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
} = routine.trigger
{
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
} else {
None
};
if let Err(e) = self
.store
.update_routine_runtime(
routine.id,
now,
next_fire,
routine.run_count + 1,
new_failures,
&routine.state,
)
.await
{
tracing::error!(
routine = %routine.name,
"Failed to update routine runtime after dispatched run: {}", e
);
}
// Persist result to the routine's conversation thread
let thread_id = match self
.store
.get_or_create_routine_conversation(routine.id, &routine.name, &routine.user_id)
.await
{
Ok(conv_id) => {
let msg = format!("[dispatched] {}: {}", status, summary);
if let Err(e) = self
.store
.add_conversation_message(conv_id, "assistant", &msg)
.await
{
tracing::error!(
routine = %routine.name,
"Failed to persist dispatched run message: {}", e
);
}
Some(conv_id.to_string())
}
Err(e) => {
tracing::error!(
routine = %routine.name,
"Failed to get routine conversation: {}", e
);
None
}
};
// Send notification
send_notification(
&self.notify_tx,
&routine.notify,
&routine.user_id,
&routine.name,
status,
Some(summary),
thread_id.as_deref(),
)
.await;
// Note: we do NOT decrement running_count here. In normal flow,
// execute_routine() handles that after FullJobWatcher returns.
// This sync path only runs for crash recovery (process restarted),
// where running_count was already reset to 0.
}
/// Fire a routine manually (from tool call or CLI).
///
/// Bypasses cooldown checks (those only apply to cron/event triggers).
@@ -762,92 +508,6 @@ impl RoutineEngine {
}
}
/// Watches a dispatched full_job until the linked scheduler job completes.
///
/// Polls `store.get_job(job_id)` at a fixed interval until the job leaves
/// an active state (Pending/InProgress/Stuck). Maps the final `JobState` to
/// a `RunStatus` for the routine run.
struct FullJobWatcher {
store: Arc<dyn Database>,
job_id: Uuid,
routine_name: String,
}
impl FullJobWatcher {
/// Poll interval between DB checks.
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// Safety ceiling: 24 hours, derived from POLL_INTERVAL.
const MAX_POLLS: u32 = (24 * 60 * 60) / Self::POLL_INTERVAL.as_secs() as u32;
fn new(store: Arc<dyn Database>, job_id: Uuid, routine_name: String) -> Self {
Self {
store,
job_id,
routine_name,
}
}
/// Block until the linked job finishes and return the mapped status + summary.
async fn wait_for_completion(&self) -> (RunStatus, Option<String>) {
let mut polls = 0u32;
let final_status = loop {
// Check job state before sleeping so we finalize promptly
// if the job is already done (e.g. fast-failing jobs).
match self.store.get_job(self.job_id).await {
Ok(Some(job_ctx)) => {
// Use is_parallel_blocking (Pending/InProgress/Stuck) instead
// of is_active (!is_terminal) because routine jobs typically
// stop at Completed — which is NOT terminal but IS finished
// from an execution standpoint.
if !job_ctx.state.is_parallel_blocking() {
break Self::map_job_state(&job_ctx.state);
}
}
Ok(None) => {
tracing::warn!(
routine = %self.routine_name,
job_id = %self.job_id,
"full_job disappeared from DB while polling"
);
break RunStatus::Failed;
}
Err(e) => {
tracing::error!(
routine = %self.routine_name,
job_id = %self.job_id,
"Error polling full_job state: {}", e
);
break RunStatus::Failed;
}
}
polls += 1;
if polls >= Self::MAX_POLLS {
tracing::error!(
routine = %self.routine_name,
job_id = %self.job_id,
"full_job timed out after 24 hours, treating as failed"
);
break RunStatus::Failed;
}
tokio::time::sleep(Self::POLL_INTERVAL).await;
};
let summary = format!("Job {} finished ({})", self.job_id, final_status);
(final_status, Some(summary))
}
fn map_job_state(state: &crate::context::JobState) -> RunStatus {
use crate::context::JobState;
match state {
JobState::Failed | JobState::Cancelled => RunStatus::Failed,
_ => RunStatus::Ok, // Completed / Submitted / Accepted
}
}
}
/// Shared context passed to the execution function.
struct EngineContext {
config: RoutineConfig,
@@ -1022,10 +682,8 @@ fn sanitize_routine_name(name: &str) -> String {
///
/// Fire-and-forget: creates a job via `Scheduler::dispatch_job` (which handles
/// creation, metadata, persistence, and scheduling), links the routine run to
/// the job, then watches it via `FullJobWatcher` until it reaches a
/// non-active state (not Pending/InProgress/Stuck). Returns the final
/// `RunStatus` mapped from the job outcome. This keeps the routine run
/// active for the full job lifetime so concurrency guardrails apply.
/// the job, and returns immediately. The job runs independently via the
/// existing Worker/Scheduler with full tool access.
async fn execute_full_job(
ctx: &EngineContext,
routine: &Routine,
@@ -1068,30 +726,25 @@ async fn execute_full_job(
reason: format!("failed to dispatch job: {e}"),
})?;
// Link the routine run to the dispatched job.
// This MUST succeed — if it fails, sync_dispatched_runs() will never find
// this run (it filters on job_id IS NOT NULL), leaving it stuck as 'running'
// with running_count permanently elevated.
ctx.store
.link_routine_run_to_job(run.id, job_id)
.await
.map_err(|e| RoutineError::Database {
reason: format!("failed to link run to job: {e}"),
})?;
// Link the routine run to the dispatched job
if let Err(e) = ctx.store.link_routine_run_to_job(run.id, job_id).await {
tracing::error!(
routine = %routine.name,
"Failed to link run to job: {}", e
);
}
tracing::info!(
routine = %routine.name,
job_id = %job_id,
max_iterations = max_iterations,
"Dispatched full job for routine, watching for completion"
"Dispatched full job for routine"
);
// Watch the job until it finishes — keeps the routine run active
// so concurrency guardrails (running_count, routine_runs status)
// remain enforced for the full job lifetime.
let watcher = FullJobWatcher::new(ctx.store.clone(), job_id, routine.name.clone());
let (status, summary) = watcher.wait_for_completion().await;
Ok((status, summary, None))
let summary = format!(
"Dispatched job {job_id} for full execution with tool access (max_iterations: {max_iterations})"
);
Ok((RunStatus::Ok, Some(summary), None))
}
/// Execute a lightweight routine with optional tool support.
@@ -1131,12 +784,23 @@ async fn execute_lightweight(
Err(_) => None,
};
let full_prompt = build_lightweight_prompt(
prompt,
&context_parts,
state_content.as_deref(),
&routine.notify,
use_tools,
// Build the user-facing prompt
let mut full_prompt = String::new();
full_prompt.push_str(prompt);
if !context_parts.is_empty() {
full_prompt.push_str("\n\n---\n\n# Context\n\n");
full_prompt.push_str(&context_parts.join("\n\n"));
}
if let Some(state) = &state_content {
full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
full_prompt.push_str(state);
}
full_prompt.push_str(
"\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
If something needs attention, provide a concise summary.",
);
// Get system prompt
@@ -1180,65 +844,6 @@ async fn execute_lightweight(
}
}
fn build_lightweight_prompt(
prompt: &str,
context_parts: &[String],
state_content: Option<&str>,
notify: &NotifyConfig,
use_tools: bool,
) -> String {
let mut full_prompt = String::new();
full_prompt.push_str(prompt);
if notify.on_attention {
full_prompt.push_str("\n\n---\n\n# Delivery\n\n");
full_prompt.push_str(
"If you reply with anything other than ROUTINE_OK, the host will deliver your \
reply as the routine notification. Return the message exactly as it should be sent.\n",
);
if let Some(channel) = notify.channel.as_deref() {
full_prompt.push_str(&format!(
"The configured delivery channel for this routine is `{channel}`.\n"
));
}
if let Some(user) = notify.user.as_deref() {
full_prompt.push_str(&format!(
"The configured delivery target for this routine is `{user}`.\n"
));
}
full_prompt.push_str(
"Do not claim you lack messaging integrations or ask the user to set one up when \
a plain reply is sufficient.\n",
);
}
if !use_tools {
full_prompt.push_str(
"\nTools are disabled for this routine run. Do not ask to call tools or describe tool limitations unless they prevent a necessary external action.\n",
);
}
if !context_parts.is_empty() {
full_prompt.push_str("\n\n---\n\n# Context\n\n");
full_prompt.push_str(&context_parts.join("\n\n"));
}
if let Some(state) = state_content {
full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
full_prompt.push_str(state);
}
full_prompt.push_str(
"\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
If something needs attention, provide a concise summary.",
);
full_prompt
}
/// Execute a lightweight routine without tool support (original single-call behavior).
async fn execute_lightweight_no_tools(
ctx: &EngineContext,
@@ -1296,8 +901,8 @@ fn handle_text_response(
};
}
// Check for the "nothing to do" sentinel (exact match on trimmed content).
if content == "ROUTINE_OK" {
// Check for the "nothing to do" sentinel
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
return Ok((RunStatus::Ok, None, total_tokens));
}
@@ -1663,22 +1268,14 @@ pub fn spawn_cron_ticker(
interval: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
// Recover orphaned runs from a previous process crash before
// dispatching any new work, so we don't confuse fresh dispatches
// with crash orphans.
engine.sync_dispatched_runs().await;
// Run one cron check immediately so routines due at startup don't
// wait an extra full polling interval.
// Run one check immediately so routines due at startup don't wait
// an extra full polling interval.
engine.check_cron_triggers().await;
let mut ticker = tokio::time::interval(interval);
loop {
ticker.tick().await;
// Sync first: only processes runs from before boot_time, so it
// never races with FullJobWatcher instances from this process.
engine.sync_dispatched_runs().await;
engine.check_cron_triggers().await;
}
})
@@ -1788,78 +1385,22 @@ mod tests {
}
}
#[test]
fn test_build_lightweight_prompt_explains_delivery_and_disabled_tools() {
let notify = NotifyConfig {
channel: Some("telegram".to_string()),
user: Some("default".to_string()),
on_attention: true,
on_failure: true,
on_success: false,
};
let prompt = super::build_lightweight_prompt(
"Send a Telegram reminder message to the user.",
&[],
None,
&notify,
false,
);
assert!(
prompt.contains("the host will deliver your reply as the routine notification"),
"delivery guidance should explain host delivery: {prompt}",
);
assert!(
prompt.contains("configured delivery channel for this routine is `telegram`"),
"delivery guidance should mention telegram channel: {prompt}",
);
assert!(
prompt.contains("Do not claim you lack messaging integrations"),
"delivery guidance should suppress fake setup chatter: {prompt}",
);
assert!(
prompt.contains("Tools are disabled for this routine run"),
"prompt should explain that tools are disabled: {prompt}",
);
}
#[test]
fn test_build_lightweight_prompt_skips_delivery_block_when_attention_notifications_disabled() {
let notify = NotifyConfig {
on_attention: false,
..NotifyConfig::default()
};
let prompt = super::build_lightweight_prompt("Check inbox.", &[], None, &notify, true);
assert!(
!prompt.contains("# Delivery"),
"prompt should not include delivery guidance when attention notifications are off: {prompt}",
);
assert!(
!prompt.contains("Tools are disabled for this routine run"),
"prompt should not claim tools are disabled when they are enabled: {prompt}",
);
}
#[test]
fn test_routine_sentinel_detection_exact_match() {
// Sentinel detection uses exact match on trimmed content to avoid
// false positives from substrings like "NOT_ROUTINE_OK".
// The execute_lightweight_no_tools checks: content == "ROUTINE_OK" || content.contains("ROUTINE_OK")
// After trim(), whitespace is removed
let test_cases = vec![
("ROUTINE_OK", true),
(" ROUTINE_OK ", true), // After trim, whitespace is removed so matches
("something ROUTINE_OK something", false), // substring no longer matches
("ROUTINE_OK is done", false), // substring no longer matches
("done ROUTINE_OK", false), // substring no longer matches
("NOT_ROUTINE_OK", false), // exact match prevents this
("something ROUTINE_OK something", true),
("ROUTINE_OK is done", true),
("done ROUTINE_OK", true),
("no sentinel here", false),
];
for (content, should_match) in test_cases {
let trimmed = content.trim();
let matches = trimmed == "ROUTINE_OK";
let matches = trimmed == "ROUTINE_OK" || trimmed.contains("ROUTINE_OK");
assert_eq!(
matches, should_match,
"Content '{}' sentinel detection should be {}, got {}",
@@ -1973,86 +1514,4 @@ mod tests {
assert_eq!(snapshot[1].content, "a"); // safety: test-only no-panics CI false positive
assert_eq!(snapshot[2].content, "b"); // safety: test-only no-panics CI false positive
}
/// Regression test for #1317: FullJobWatcher maps terminal job states correctly.
#[test]
fn test_full_job_watcher_state_mapping() {
use crate::context::JobState;
// Failed/Cancelled → RunStatus::Failed
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Failed),
RunStatus::Failed
);
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Cancelled),
RunStatus::Failed
);
// All other non-active states → RunStatus::Ok
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Completed),
RunStatus::Ok
);
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Accepted),
RunStatus::Ok
);
}
/// Verify that job state to run status mapping covers all expected cases.
#[test]
fn test_job_state_to_run_status_mapping() {
use crate::context::JobState;
// Success states
for state in [JobState::Completed, JobState::Submitted, JobState::Accepted] {
let status = match state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
_ => None,
};
assert_eq!(
status,
Some(RunStatus::Ok),
"{:?} should map to RunStatus::Ok",
state
);
}
// Failure states
for state in [JobState::Failed, JobState::Cancelled] {
let status = match state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
_ => None,
};
assert_eq!(
status,
Some(RunStatus::Failed),
"{:?} should map to RunStatus::Failed",
state
);
}
// Active states (should not finalize)
for state in [JobState::Pending, JobState::InProgress, JobState::Stuck] {
let status = match state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
_ => None,
};
assert_eq!(
status, None,
"{:?} should not finalize the routine run",
state
);
}
}
}
+17 -259
View File
@@ -66,10 +66,14 @@ pub trait SelfRepair: Send + Sync {
/// Default self-repair implementation.
pub struct DefaultSelfRepair {
context_manager: Arc<ContextManager>,
// TODO: use for time-based stuck detection (currently only max_repair_attempts is checked)
#[allow(dead_code)]
stuck_threshold: Duration,
max_repair_attempts: u32,
store: Option<Arc<dyn Database>>,
builder: Option<Arc<dyn SoftwareBuilder>>,
// TODO: use for tool hot-reload after repair
#[allow(dead_code)]
tools: Option<Arc<ToolRegistry>>,
}
@@ -91,13 +95,15 @@ impl DefaultSelfRepair {
}
/// Add a Store for tool failure tracking.
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
#[allow(dead_code)] // TODO: wire up in main.rs when persistence is needed
pub(crate) fn with_store(mut self, store: Arc<dyn Database>) -> Self {
self.store = Some(store);
self
}
/// Add a Builder and ToolRegistry for automatic tool repair.
pub fn with_builder(
#[allow(dead_code)] // TODO: wire up in main.rs when auto-repair is needed
pub(crate) fn with_builder(
mut self,
builder: Arc<dyn SoftwareBuilder>,
tools: Arc<ToolRegistry>,
@@ -118,30 +124,18 @@ impl SelfRepair for DefaultSelfRepair {
if let Ok(ctx) = self.context_manager.get_context(job_id).await
&& ctx.state == JobState::Stuck
{
// Measure stuck_duration from the most recent Stuck transition,
// not from started_at (which reflects when the job first ran).
let stuck_since = ctx
.transitions
.iter()
.rev()
.find(|t| t.to == JobState::Stuck)
.map(|t| t.timestamp);
let stuck_duration = stuck_since
.map(|ts| {
let duration = Utc::now().signed_duration_since(ts);
let stuck_duration = ctx
.started_at
.map(|start| {
let now = Utc::now();
let duration = now.signed_duration_since(start);
Duration::from_secs(duration.num_seconds().max(0) as u64)
})
.unwrap_or_default();
// Only report jobs that have been stuck long enough
if stuck_duration < self.stuck_threshold {
continue;
}
stuck_jobs.push(StuckJob {
job_id,
last_activity: stuck_since.unwrap_or(ctx.created_at),
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
stuck_duration,
last_error: None,
repair_attempts: ctx.repair_attempts,
@@ -279,8 +273,9 @@ impl SelfRepair for DefaultSelfRepair {
tracing::warn!("Failed to mark tool as repaired: {}", e);
}
// Log if the tool was auto-registered
if result.registered {
tracing::info!("Repaired tool '{}' auto-registered by builder", tool.name);
tracing::info!("Repaired tool '{}' auto-registered", tool.name);
}
Ok(RepairResult::Success {
@@ -422,8 +417,7 @@ mod tests {
.unwrap()
.unwrap();
// Use zero threshold so the just-stuck job is detected immediately.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(0), 3);
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
let stuck = repair.detect_stuck_jobs().await;
assert_eq!(stuck.len(), 1);
assert_eq!(stuck[0].job_id, job_id);
@@ -489,98 +483,6 @@ mod tests {
);
}
#[tokio::test]
async fn detect_stuck_jobs_filters_by_threshold() {
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("Stuck job", "desc").await.unwrap();
// Transition to InProgress, then to Stuck.
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("timed out".to_string()))
})
.await
.unwrap()
.unwrap();
// Use a very large threshold (1 hour). Job just became stuck, so
// stuck_duration < threshold. It should be filtered out.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(3600), 3);
let stuck = repair.detect_stuck_jobs().await;
assert!(
stuck.is_empty(),
"Job stuck for <1s should be filtered by 1h threshold"
);
}
#[tokio::test]
async fn detect_stuck_jobs_includes_when_over_threshold() {
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("Stuck job", "desc").await.unwrap();
// Transition to InProgress, then to Stuck.
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("timed out".to_string()))
})
.await
.unwrap()
.unwrap();
// Use a zero threshold -- any stuck duration should be included.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(0), 3);
let stuck = repair.detect_stuck_jobs().await;
assert_eq!(stuck.len(), 1, "Job should be detected with zero threshold");
assert_eq!(stuck[0].job_id, job_id);
}
/// Regression: stuck_duration must be measured from the Stuck transition,
/// not from started_at. A job that ran for 2 hours before becoming stuck
/// should NOT immediately exceed a 5-minute threshold.
#[tokio::test]
async fn stuck_duration_measured_from_stuck_transition_not_started_at() {
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("Long runner", "desc").await.unwrap();
// Transition to InProgress (sets started_at to now).
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
// Backdate started_at to 2 hours ago to simulate a long-running job.
cm.update_context(job_id, |ctx| {
ctx.started_at = Some(Utc::now() - chrono::Duration::hours(2));
Ok::<(), crate::error::Error>(())
})
.await
.unwrap()
.unwrap();
// Now transition to Stuck (stuck transition timestamp is ~now).
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("wedged".into()))
})
.await
.unwrap()
.unwrap();
// With a 5-minute threshold, the job JUST became stuck — should NOT be detected.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(300), 3);
let stuck = repair.detect_stuck_jobs().await;
assert!(
stuck.is_empty(),
"Job stuck for <1s should not exceed 5min threshold, \
but stuck_duration was computed from started_at (2h ago)"
);
}
#[tokio::test]
async fn detect_broken_tools_returns_empty_without_store() {
let cm = Arc::new(ContextManager::new(10));
@@ -613,148 +515,4 @@ mod tests {
result
);
}
/// Mock SoftwareBuilder that returns a successful build result.
struct MockBuilder {
build_count: std::sync::atomic::AtomicU32,
}
impl MockBuilder {
fn new() -> Self {
Self {
build_count: std::sync::atomic::AtomicU32::new(0),
}
}
fn builds(&self) -> u32 {
self.build_count.load(std::sync::atomic::Ordering::Relaxed)
}
}
#[async_trait]
impl crate::tools::SoftwareBuilder for MockBuilder {
async fn analyze(
&self,
_description: &str,
) -> Result<crate::tools::BuildRequirement, crate::error::ToolError> {
Ok(crate::tools::BuildRequirement {
name: "mock-tool".to_string(),
description: "mock".to_string(),
software_type: crate::tools::SoftwareType::WasmTool,
language: crate::tools::Language::Rust,
input_spec: None,
output_spec: None,
dependencies: vec![],
capabilities: vec![],
})
}
async fn build(
&self,
requirement: &crate::tools::BuildRequirement,
) -> Result<crate::tools::BuildResult, crate::error::ToolError> {
self.build_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(crate::tools::BuildResult {
build_id: Uuid::new_v4(),
requirement: requirement.clone(),
artifact_path: std::path::PathBuf::from("/tmp/mock.wasm"),
logs: vec![],
success: true,
error: None,
started_at: Utc::now(),
completed_at: Utc::now(),
iterations: 1,
validation_warnings: vec![],
tests_passed: 1,
tests_failed: 0,
registered: true,
})
}
async fn repair(
&self,
_result: &crate::tools::BuildResult,
_error: &str,
) -> Result<crate::tools::BuildResult, crate::error::ToolError> {
unimplemented!("not needed for this test")
}
}
/// E2E test: stuck job detected -> repaired -> transitions back to InProgress,
/// and broken tool detected -> builder invoked -> tool marked repaired.
#[cfg(feature = "libsql")]
#[tokio::test]
async fn e2e_stuck_job_repair_and_tool_rebuild() {
// --- Setup ---
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("E2E stuck job", "desc").await.unwrap();
// Transition job: Pending -> InProgress -> Stuck
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("deadlocked".to_string()))
})
.await
.unwrap()
.unwrap();
// Create a mock builder and a real test database (for store)
let builder = Arc::new(MockBuilder::new());
let tools = Arc::new(ToolRegistry::new());
let (db, _tmp_dir) = crate::testing::test_db().await;
// Create self-repair with zero threshold (detect immediately),
// wired with store, builder, and tools.
let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(0), 3)
.with_store(Arc::clone(&db))
.with_builder(
Arc::clone(&builder) as Arc<dyn crate::tools::SoftwareBuilder>,
tools,
);
// --- Phase 1: Detect and repair stuck job ---
let stuck_jobs = repair.detect_stuck_jobs().await;
assert_eq!(stuck_jobs.len(), 1, "Should detect the stuck job");
assert_eq!(stuck_jobs[0].job_id, job_id);
let result = repair.repair_stuck_job(&stuck_jobs[0]).await.unwrap();
assert!(
matches!(result, RepairResult::Success { .. }),
"Job repair should succeed: {:?}",
result
);
// Verify job transitioned back to InProgress
let ctx = cm.get_context(job_id).await.unwrap();
assert_eq!(
ctx.state,
JobState::InProgress,
"Job should be back to InProgress after repair"
);
// --- Phase 2: Repair a broken tool via builder ---
let broken = BrokenTool {
name: "broken-wasm-tool".to_string(),
failure_count: 10,
last_error: Some("panic in tool execution".to_string()),
first_failure: Utc::now() - chrono::Duration::hours(1),
last_failure: Utc::now(),
last_build_result: None,
repair_attempts: 0,
};
let tool_result = repair.repair_broken_tool(&broken).await.unwrap();
assert!(
matches!(tool_result, RepairResult::Success { .. }),
"Tool repair should succeed with mock builder: {:?}",
tool_result
);
// Verify builder was actually invoked
assert_eq!(builder.builds(), 1, "Builder should have been called once");
}
}
+6 -12
View File
@@ -56,7 +56,6 @@ pub struct AppComponents {
pub session: Arc<SessionManager>,
pub catalog_entries: Vec<crate::extensions::RegistryEntry>,
pub dev_loaded_tool_names: Vec<String>,
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
}
/// Options that control optional init phases.
@@ -281,7 +280,6 @@ impl AppBuilder {
Arc<ToolRegistry>,
Option<Arc<dyn EmbeddingProvider>>,
Option<Arc<Workspace>>,
Option<Arc<dyn crate::tools::SoftwareBuilder>>,
),
anyhow::Error,
> {
@@ -369,19 +367,16 @@ impl AppBuilder {
}
// Register builder tool if enabled
let builder = if self.config.builder.enabled
if self.config.builder.enabled
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled)
{
let b = tools
tools
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.await;
tracing::info!("Builder mode enabled");
Some(b)
} else {
None
};
tracing::debug!("Builder mode enabled");
}
Ok((safety, tools, embeddings, workspace, builder))
Ok((safety, tools, embeddings, workspace))
}
/// Phase 5: Load WASM tools, MCP servers, and create extension manager.
@@ -704,7 +699,7 @@ impl AppBuilder {
} else {
self.init_llm().await?
};
let (safety, tools, embeddings, workspace, builder) = self.init_tools(&llm).await?;
let (safety, tools, embeddings, workspace) = self.init_tools(&llm).await?;
// Create hook registry early so runtime extension activation can register hooks.
let hooks = Arc::new(HookRegistry::new());
@@ -824,7 +819,6 @@ impl AppBuilder {
session: self.session,
catalog_entries,
dev_loaded_tool_names,
builder,
})
}
}
+2 -39
View File
@@ -492,16 +492,8 @@ impl near::agent::channel_host::Host for ChannelStoreData {
tracing::debug!(body = %truncated, "Response body");
}
// Leak detection on response body (best-effort).
//
// Telegram `getUpdates` is special: it is inbound polling data, so
// user-pasted secrets can legitimately appear in the response body.
// Those messages are still checked later by the inbound message
// safety layer before they reach the LLM, so we allow the polling
// response to continue here to avoid poisoning the offset state.
if let Ok(body_str) = std::str::from_utf8(&body)
&& !should_skip_response_leak_scan(&url)
{
// Leak detection on response body (best-effort)
if let Ok(body_str) = std::str::from_utf8(&body) {
leak_detector
.scan_and_clean(body_str)
.map_err(|e| format!("Potential secret leak in response: {}", e))?;
@@ -3130,19 +3122,6 @@ fn extract_host_from_url(url: &str) -> Option<String> {
})
}
fn should_skip_response_leak_scan(url: &str) -> bool {
url::Url::parse(url).is_ok_and(|parsed| {
matches!(parsed.scheme(), "http" | "https")
&& parsed
.host_str()
.is_some_and(|host| host.eq_ignore_ascii_case("api.telegram.org"))
&& parsed
.path_segments()
.and_then(|segments| segments.rev().find(|segment| !segment.is_empty()))
.is_some_and(|segment| segment == "getUpdates")
})
}
/// Pre-resolve host credentials for all HTTP capability mappings.
///
/// Called once per callback (in async context, before spawn_blocking) so the
@@ -4407,22 +4386,6 @@ mod tests {
assert_eq!(store.redact_credentials(input), input);
}
#[test]
fn test_should_skip_response_leak_scan_only_for_telegram_getupdates() {
use super::should_skip_response_leak_scan;
assert!(should_skip_response_leak_scan(
"https://api.telegram.org/bot123/getUpdates?offset=1"
));
assert!(!should_skip_response_leak_scan(
"https://api.telegram.org/bot123/sendMessage"
));
assert!(!should_skip_response_leak_scan(
"https://api.example.com/getUpdates"
));
assert!(!should_skip_response_leak_scan("not a url"));
}
/// Verify that WASM HTTP host functions work using a dedicated
/// current-thread runtime inside spawn_blocking.
#[tokio::test]
-8
View File
@@ -102,7 +102,6 @@ impl GatewayChannel {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: server::ActiveConfigSnapshot::default(),
});
Self {
@@ -140,7 +139,6 @@ impl GatewayChannel {
cost_guard: self.state.cost_guard.clone(),
routine_engine: Arc::clone(&self.state.routine_engine),
startup_time: self.state.startup_time,
active_config: self.state.active_config.clone(),
};
mutate(&mut new_state);
self.state = Arc::new(new_state);
@@ -252,12 +250,6 @@ impl GatewayChannel {
self
}
/// Inject the active (resolved) configuration snapshot for the status endpoint.
pub fn with_active_config(mut self, config: server::ActiveConfigSnapshot) -> Self {
self.rebuild_state(|s| s.active_config = config);
self
}
/// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
&self.auth_token
-17
View File
@@ -126,14 +126,6 @@ impl RateLimiter {
}
}
/// Snapshot of the active (resolved) configuration exposed to the frontend.
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct ActiveConfigSnapshot {
pub llm_backend: String,
pub llm_model: String,
pub enabled_channels: Vec<String>,
}
/// Shared state for all gateway handlers.
pub struct GatewayState {
/// Channel to send messages to the agent loop.
@@ -185,8 +177,6 @@ pub struct GatewayState {
pub routine_engine: RoutineEngineSlot,
/// Server startup time for uptime calculation.
pub startup_time: std::time::Instant,
/// Snapshot of active (resolved) configuration for the frontend.
pub active_config: ActiveConfigSnapshot,
}
/// Start the gateway HTTP server.
@@ -2679,9 +2669,6 @@ async fn gateway_status_handler(
daily_cost,
actions_this_hour,
model_usage,
llm_backend: state.active_config.llm_backend.clone(),
llm_model: state.active_config.llm_model.clone(),
enabled_channels: state.active_config.enabled_channels.clone(),
})
}
@@ -2707,9 +2694,6 @@ struct GatewayStatusResponse {
actions_this_hour: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
model_usage: Option<Vec<ModelUsageEntry>>,
llm_backend: String,
llm_model: String,
enabled_channels: Vec<String>,
}
#[cfg(test)]
@@ -2906,7 +2890,6 @@ mod tests {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ActiveConfigSnapshot::default(),
})
}
File diff suppressed because it is too large Load Diff
+10 -170
View File
@@ -29,15 +29,9 @@ I18n.register('en', {
'tab.memory': 'Memory',
'tab.jobs': 'Jobs',
'tab.routines': 'Routines',
'tab.settings': 'Settings',
'tab.extensions': 'Extensions',
'tab.skills': 'Skills',
'tab.logs': 'Logs',
'settings.inference': 'Inference',
'settings.agent': 'Agent',
'settings.channels': 'Channels',
'settings.networking': 'Networking',
'settings.mcp': 'MCP',
// Status
'status.connected': 'Connected',
@@ -137,10 +131,10 @@ I18n.register('en', {
// Extensions Tab
'extensions.installed': 'Installed Extensions',
'extensions.available': 'Available Extensions',
'extensions.installWasm': 'Install Extension',
'extensions.available': 'Available WASM Extensions',
'extensions.installWasm': 'Install WASM Extension',
'extensions.noInstalled': 'No extensions installed',
'extensions.noAvailable': 'No additional extensions available',
'extensions.noAvailable': 'No additional WASM extensions available',
'extensions.loading': 'Loading...',
'extensions.install': 'Install',
'extensions.installing': 'Installing...',
@@ -162,8 +156,13 @@ I18n.register('en', {
'mcp.addCustom': 'Add Custom MCP Server',
'mcp.add': 'Add',
'mcp.addedSuccess': 'Added MCP server {name}',
// Registered Tools
'tools.registered': 'Registered Tools',
'tools.name': 'Name',
'tools.description': 'Description',
'tools.empty': 'No tools registered',
// Skills Tab
'skills.installed': 'Installed Skills',
'skills.noInstalled': 'No skills installed',
@@ -303,7 +302,6 @@ I18n.register('en', {
// Common
'common.loading': 'Loading...',
'common.loadFailed': 'Failed to load',
'common.noData': 'No data',
'common.search': 'Search',
'common.add': 'Add',
@@ -330,8 +328,6 @@ I18n.register('en', {
// Extensions
'ext.active': 'Active',
'ext.inactive': 'Inactive',
'ext.builtin': 'Built-in',
'ext.remove': 'Remove',
'ext.install': 'Install',
'ext.installing': 'Installing...',
@@ -359,160 +355,4 @@ I18n.register('en', {
'config.autoGenerate': 'Auto-generated if empty',
'config.save': 'Save',
'config.cancel': 'Cancel',
// Settings toolbar
'settings.export': 'Export',
'settings.import': 'Import',
'settings.searchPlaceholder': 'Search settings...',
'settings.exportSuccess': 'Settings exported',
'settings.exportFailed': 'Export failed: {message}',
'settings.importSuccess': 'Settings imported successfully',
'settings.importFailed': 'Import failed: {message}',
'settings.restartRequired': 'Restart required for changes to take effect.',
'settings.restartNow': 'Restart Now',
'settings.noMatchingSettings': 'No settings matching "{query}"',
'settings.noSettings': 'No settings found',
'settings.saved': 'Saved',
'settings.on': 'On',
'settings.off': 'Off',
'settings.envValue': 'env: {value}',
'settings.envDefault': 'env default',
'settings.useEnvDefault': 'use env default',
// Settings groups
'cfg.group.llm': 'LLM Provider',
'cfg.group.embeddings': 'Embeddings',
'cfg.group.agent': 'Agent',
'cfg.group.heartbeat': 'Heartbeat',
'cfg.group.sandbox': 'Sandbox',
'cfg.group.routines': 'Routines',
'cfg.group.safety': 'Safety',
'cfg.group.skills': 'Skills',
'cfg.group.search': 'Search',
'cfg.group.tunnel': 'Tunnel',
'cfg.group.gateway': 'Gateway',
// Inference settings
'cfg.llm_backend.label': 'Backend',
'cfg.llm_backend.desc': 'LLM inference provider',
'cfg.selected_model.label': 'Model',
'cfg.selected_model.desc': 'Model name or ID for the selected backend',
'cfg.ollama_base_url.label': 'Ollama URL',
'cfg.ollama_base_url.desc': 'Base URL for Ollama API',
'cfg.openai_compatible_base_url.label': 'OpenAI-compatible URL',
'cfg.openai_compatible_base_url.desc': 'Base URL for OpenAI-compatible API',
'cfg.bedrock_region.label': 'Bedrock Region',
'cfg.bedrock_region.desc': 'AWS region for Bedrock',
'cfg.bedrock_cross_region.label': 'Cross-Region',
'cfg.bedrock_cross_region.desc': 'Enable cross-region inference',
'cfg.bedrock_profile.label': 'AWS Profile',
'cfg.bedrock_profile.desc': 'AWS profile for Bedrock auth',
'cfg.embeddings_enabled.label': 'Enabled',
'cfg.embeddings_enabled.desc': 'Enable vector embeddings for memory search',
'cfg.embeddings_provider.label': 'Provider',
'cfg.embeddings_provider.desc': 'Embeddings API provider',
'cfg.embeddings_model.label': 'Model',
'cfg.embeddings_model.desc': 'Embedding model name',
// Agent settings
'cfg.agent_name.label': 'Name',
'cfg.agent_name.desc': 'Agent display name',
'cfg.agent_max_parallel_jobs.label': 'Max Parallel Jobs',
'cfg.agent_max_parallel_jobs.desc': 'Maximum concurrent background jobs',
'cfg.agent_job_timeout.label': 'Job Timeout',
'cfg.agent_job_timeout.desc': 'Max duration per job in seconds',
'cfg.agent_max_tool_iterations.label': 'Max Tool Iterations',
'cfg.agent_max_tool_iterations.desc': 'Max tool calls per turn',
'cfg.agent_use_planning.label': 'Planning',
'cfg.agent_use_planning.desc': 'Enable multi-step planning before execution',
'cfg.agent_auto_approve.label': 'Auto-approve Tools',
'cfg.agent_auto_approve.desc': 'Skip manual approval for tool calls',
'cfg.agent_timezone.label': 'Timezone',
'cfg.agent_timezone.desc': 'Default timezone (IANA)',
'cfg.agent_session_idle.label': 'Session Idle Timeout',
'cfg.agent_session_idle.desc': 'Seconds before idle session expires',
'cfg.agent_stuck_threshold.label': 'Stuck Threshold',
'cfg.agent_stuck_threshold.desc': 'Seconds before a job is considered stuck',
'cfg.agent_max_repair.label': 'Max Repair Attempts',
'cfg.agent_max_repair.desc': 'Auto-recovery attempts for stuck jobs',
'cfg.agent_max_cost.label': 'Max Daily Cost',
'cfg.agent_max_cost.desc': 'Daily LLM spend cap in cents (0 = unlimited)',
'cfg.agent_max_actions.label': 'Max Actions/Hour',
'cfg.agent_max_actions.desc': 'Hourly tool call rate limit (0 = unlimited)',
'cfg.agent_allow_local.label': 'Allow Local Tools',
'cfg.agent_allow_local.desc': 'Enable local filesystem tool execution',
// Heartbeat settings
'cfg.heartbeat_enabled.label': 'Enabled',
'cfg.heartbeat_enabled.desc': 'Run periodic background checks',
'cfg.heartbeat_interval.label': 'Interval',
'cfg.heartbeat_interval.desc': 'Seconds between heartbeats (default: 1800)',
'cfg.heartbeat_notify_channel.label': 'Notify Channel',
'cfg.heartbeat_notify_channel.desc': 'Channel to send heartbeat findings to',
'cfg.heartbeat_notify_user.label': 'Notify User',
'cfg.heartbeat_notify_user.desc': 'User ID to notify',
'cfg.heartbeat_quiet_start.label': 'Quiet Hours Start',
'cfg.heartbeat_quiet_start.desc': 'Hour (0-23) to stop heartbeats',
'cfg.heartbeat_quiet_end.label': 'Quiet Hours End',
'cfg.heartbeat_quiet_end.desc': 'Hour (0-23) to resume heartbeats',
'cfg.heartbeat_timezone.label': 'Timezone',
'cfg.heartbeat_timezone.desc': 'Timezone for quiet hours (IANA)',
// Sandbox settings
'cfg.sandbox_enabled.label': 'Enabled',
'cfg.sandbox_enabled.desc': 'Enable Docker sandbox for background jobs',
'cfg.sandbox_policy.label': 'Policy',
'cfg.sandbox_policy.desc': 'Sandbox security policy',
'cfg.sandbox_timeout.label': 'Timeout',
'cfg.sandbox_timeout.desc': 'Max job duration in seconds',
'cfg.sandbox_memory.label': 'Memory Limit',
'cfg.sandbox_memory.desc': 'Container memory limit (MB)',
'cfg.sandbox_image.label': 'Docker Image',
'cfg.sandbox_image.desc': 'Container image for sandbox jobs',
// Routines settings
'cfg.routines_max_concurrent.label': 'Max Concurrent',
'cfg.routines_max_concurrent.desc': 'Maximum routines running simultaneously',
'cfg.routines_cooldown.label': 'Default Cooldown',
'cfg.routines_cooldown.desc': 'Minimum seconds between routine fires',
// Safety settings
'cfg.safety_max_output.label': 'Max Output Length',
'cfg.safety_max_output.desc': 'Maximum output tokens per response',
'cfg.safety_injection_check.label': 'Injection Check',
'cfg.safety_injection_check.desc': 'Enable prompt injection detection',
// Skills settings
'cfg.skills_max_active.label': 'Max Active Skills',
'cfg.skills_max_active.desc': 'Maximum skills active simultaneously',
'cfg.skills_max_tokens.label': 'Max Context Tokens',
'cfg.skills_max_tokens.desc': 'Token budget for skill prompts',
// Search settings
'cfg.search_fusion.label': 'Fusion Strategy',
'cfg.search_fusion.desc': 'Hybrid search ranking method',
// Networking settings
'cfg.tunnel_provider.label': 'Provider',
'cfg.tunnel_provider.desc': 'Public URL tunnel provider',
'cfg.tunnel_public_url.label': 'Public URL',
'cfg.tunnel_public_url.desc': 'Static public URL (if not using tunnel provider)',
'cfg.gateway_rate_limit.label': 'Rate Limit',
'cfg.gateway_rate_limit.desc': 'Max chat messages per minute',
'cfg.gateway_max_connections.label': 'Max Connections',
'cfg.gateway_max_connections.desc': 'Max simultaneous SSE/WS connections',
// Channels subtab
'channels.builtin': 'Built-in Channels',
'channels.messaging': 'Messaging Channels',
'channels.webGateway': 'Web Gateway',
'channels.webGatewayDesc': 'Browser-based chat interface',
'channels.httpWebhook': 'HTTP Webhook',
'channels.httpWebhookDesc': 'Incoming webhook endpoint for external integrations',
'channels.cli': 'CLI',
'channels.cliDesc': 'Terminal UI with Ratatui',
'channels.repl': 'REPL',
'channels.replDesc': 'Simple read-eval-print loop for testing',
'channels.configureVia': 'Configure via {env}',
'channels.runWith': 'Run with: {cmd}',
});
+10 -170
View File
@@ -29,15 +29,9 @@ I18n.register('zh-CN', {
'tab.memory': '记忆',
'tab.jobs': '任务',
'tab.routines': '定时任务',
'tab.settings': '设置',
'tab.extensions': '扩展',
'tab.skills': '技能',
'tab.logs': '日志',
'settings.inference': '推理',
'settings.agent': '代理',
'settings.channels': '频道',
'settings.networking': '网络',
'settings.mcp': 'MCP',
// 状态
'status.connected': '已连接',
@@ -137,10 +131,10 @@ I18n.register('zh-CN', {
// 扩展标签页
'extensions.installed': '已安装扩展',
'extensions.available': '可用扩展',
'extensions.installWasm': '安装扩展',
'extensions.available': '可用 WASM 扩展',
'extensions.installWasm': '安装 WASM 扩展',
'extensions.noInstalled': '没有安装扩展',
'extensions.noAvailable': '没有其他可用扩展',
'extensions.noAvailable': '没有其他可用的 WASM 扩展',
'extensions.loading': '加载中...',
'extensions.install': '安装',
'extensions.installing': '安装中...',
@@ -162,8 +156,13 @@ I18n.register('zh-CN', {
'mcp.addCustom': '添加自定义 MCP 服务器',
'mcp.add': '添加',
'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
// 注册工具
'tools.registered': '注册工具',
'tools.name': '名称',
'tools.description': '描述',
'tools.empty': '没有注册工具',
// 技能标签页
'skills.installed': '已安装技能',
'skills.noInstalled': '没有安装技能',
@@ -303,7 +302,6 @@ I18n.register('zh-CN', {
// 通用
'common.loading': '加载中...',
'common.loadFailed': '加载失败',
'common.noData': '暂无数据',
'common.search': '搜索',
'common.add': '添加',
@@ -330,8 +328,6 @@ I18n.register('zh-CN', {
// 扩展
'ext.active': '已激活',
'ext.inactive': '未激活',
'ext.builtin': '内置',
'ext.remove': '移除',
'ext.install': '安装',
'ext.installing': '安装中...',
@@ -358,160 +354,4 @@ I18n.register('zh-CN', {
'config.autoGenerate': '如果为空则自动生成',
'config.save': '保存',
'config.cancel': '取消',
// 设置工具栏
'settings.export': '导出',
'settings.import': '导入',
'settings.searchPlaceholder': '搜索设置...',
'settings.exportSuccess': '设置已导出',
'settings.exportFailed': '导出失败: {message}',
'settings.importSuccess': '设置导入成功',
'settings.importFailed': '导入失败: {message}',
'settings.restartRequired': '需要重启才能使更改生效。',
'settings.restartNow': '立即重启',
'settings.noMatchingSettings': '没有匹配 "{query}" 的设置',
'settings.noSettings': '未找到设置',
'settings.saved': '已保存',
'settings.on': '开启',
'settings.off': '关闭',
'settings.envValue': '环境变量: {value}',
'settings.envDefault': '使用环境变量默认值',
'settings.useEnvDefault': '使用环境变量默认值',
// 设置分组
'cfg.group.llm': 'LLM 提供商',
'cfg.group.embeddings': '嵌入向量',
'cfg.group.agent': '代理',
'cfg.group.heartbeat': '心跳',
'cfg.group.sandbox': '沙箱',
'cfg.group.routines': '定时任务',
'cfg.group.safety': '安全',
'cfg.group.skills': '技能',
'cfg.group.search': '搜索',
'cfg.group.tunnel': '隧道',
'cfg.group.gateway': '网关',
// 推理设置
'cfg.llm_backend.label': '后端',
'cfg.llm_backend.desc': 'LLM 推理提供商',
'cfg.selected_model.label': '模型',
'cfg.selected_model.desc': '所选后端的模型名称或 ID',
'cfg.ollama_base_url.label': 'Ollama URL',
'cfg.ollama_base_url.desc': 'Ollama API 基础 URL',
'cfg.openai_compatible_base_url.label': 'OpenAI 兼容 URL',
'cfg.openai_compatible_base_url.desc': 'OpenAI 兼容 API 基础 URL',
'cfg.bedrock_region.label': 'Bedrock 区域',
'cfg.bedrock_region.desc': 'Bedrock 的 AWS 区域',
'cfg.bedrock_cross_region.label': '跨区域',
'cfg.bedrock_cross_region.desc': '启用跨区域推理',
'cfg.bedrock_profile.label': 'AWS 配置文件',
'cfg.bedrock_profile.desc': 'Bedrock 认证的 AWS 配置文件',
'cfg.embeddings_enabled.label': '启用',
'cfg.embeddings_enabled.desc': '启用向量嵌入以支持记忆搜索',
'cfg.embeddings_provider.label': '提供商',
'cfg.embeddings_provider.desc': '嵌入向量 API 提供商',
'cfg.embeddings_model.label': '模型',
'cfg.embeddings_model.desc': '嵌入向量模型名称',
// 代理设置
'cfg.agent_name.label': '名称',
'cfg.agent_name.desc': '代理显示名称',
'cfg.agent_max_parallel_jobs.label': '最大并行任务数',
'cfg.agent_max_parallel_jobs.desc': '最大并发后台任务数',
'cfg.agent_job_timeout.label': '任务超时',
'cfg.agent_job_timeout.desc': '每个任务的最大持续时间(秒)',
'cfg.agent_max_tool_iterations.label': '最大工具迭代次数',
'cfg.agent_max_tool_iterations.desc': '每轮最大工具调用次数',
'cfg.agent_use_planning.label': '规划',
'cfg.agent_use_planning.desc': '执行前启用多步规划',
'cfg.agent_auto_approve.label': '自动批准工具',
'cfg.agent_auto_approve.desc': '跳过工具调用的手动审批',
'cfg.agent_timezone.label': '时区',
'cfg.agent_timezone.desc': '默认时区(IANA',
'cfg.agent_session_idle.label': '会话空闲超时',
'cfg.agent_session_idle.desc': '空闲会话过期前的秒数',
'cfg.agent_stuck_threshold.label': '卡住阈值',
'cfg.agent_stuck_threshold.desc': '任务被认为卡住前的秒数',
'cfg.agent_max_repair.label': '最大修复尝试次数',
'cfg.agent_max_repair.desc': '卡住任务的自动恢复尝试次数',
'cfg.agent_max_cost.label': '每日最大费用',
'cfg.agent_max_cost.desc': '每日 LLM 支出上限(美分,0 = 无限制)',
'cfg.agent_max_actions.label': '每小时最大操作数',
'cfg.agent_max_actions.desc': '每小时工具调用速率限制(0 = 无限制)',
'cfg.agent_allow_local.label': '允许本地工具',
'cfg.agent_allow_local.desc': '启用本地文件系统工具执行',
// 心跳设置
'cfg.heartbeat_enabled.label': '启用',
'cfg.heartbeat_enabled.desc': '运行定期后台检查',
'cfg.heartbeat_interval.label': '间隔',
'cfg.heartbeat_interval.desc': '心跳间隔秒数(默认:1800',
'cfg.heartbeat_notify_channel.label': '通知频道',
'cfg.heartbeat_notify_channel.desc': '发送心跳发现的频道',
'cfg.heartbeat_notify_user.label': '通知用户',
'cfg.heartbeat_notify_user.desc': '要通知的用户 ID',
'cfg.heartbeat_quiet_start.label': '静默时段开始',
'cfg.heartbeat_quiet_start.desc': '停止心跳的小时(0-23',
'cfg.heartbeat_quiet_end.label': '静默时段结束',
'cfg.heartbeat_quiet_end.desc': '恢复心跳的小时(0-23',
'cfg.heartbeat_timezone.label': '时区',
'cfg.heartbeat_timezone.desc': '静默时段的时区(IANA',
// 沙箱设置
'cfg.sandbox_enabled.label': '启用',
'cfg.sandbox_enabled.desc': '启用 Docker 沙箱以运行后台任务',
'cfg.sandbox_policy.label': '策略',
'cfg.sandbox_policy.desc': '沙箱安全策略',
'cfg.sandbox_timeout.label': '超时',
'cfg.sandbox_timeout.desc': '最大任务持续时间(秒)',
'cfg.sandbox_memory.label': '内存限制',
'cfg.sandbox_memory.desc': '容器内存限制(MB',
'cfg.sandbox_image.label': 'Docker 镜像',
'cfg.sandbox_image.desc': '沙箱任务的容器镜像',
// 定时任务设置
'cfg.routines_max_concurrent.label': '最大并发数',
'cfg.routines_max_concurrent.desc': '同时运行的最大定时任务数',
'cfg.routines_cooldown.label': '默认冷却时间',
'cfg.routines_cooldown.desc': '定时任务触发间的最小秒数',
// 安全设置
'cfg.safety_max_output.label': '最大输出长度',
'cfg.safety_max_output.desc': '每次响应的最大输出令牌数',
'cfg.safety_injection_check.label': '注入检查',
'cfg.safety_injection_check.desc': '启用提示注入检测',
// 技能设置
'cfg.skills_max_active.label': '最大活跃技能数',
'cfg.skills_max_active.desc': '同时活跃的最大技能数',
'cfg.skills_max_tokens.label': '最大上下文令牌数',
'cfg.skills_max_tokens.desc': '技能提示的令牌预算',
// 搜索设置
'cfg.search_fusion.label': '融合策略',
'cfg.search_fusion.desc': '混合搜索排名方法',
// 网络设置
'cfg.tunnel_provider.label': '提供商',
'cfg.tunnel_provider.desc': '公网 URL 隧道提供商',
'cfg.tunnel_public_url.label': '公网 URL',
'cfg.tunnel_public_url.desc': '静态公网 URL(不使用隧道提供商时)',
'cfg.gateway_rate_limit.label': '速率限制',
'cfg.gateway_rate_limit.desc': '每分钟最大聊天消息数',
'cfg.gateway_max_connections.label': '最大连接数',
'cfg.gateway_max_connections.desc': '最大同时 SSE/WS 连接数',
// 频道子标签
'channels.builtin': '内置频道',
'channels.messaging': '消息频道',
'channels.webGateway': 'Web 网关',
'channels.webGatewayDesc': '基于浏览器的聊天界面',
'channels.httpWebhook': 'HTTP Webhook',
'channels.httpWebhookDesc': '用于外部集成的传入 webhook 端点',
'channels.cli': 'CLI',
'channels.cliDesc': '使用 Ratatui 的终端 UI',
'channels.repl': 'REPL',
'channels.replDesc': '用于测试的简单读取-求值-打印循环',
'channels.configureVia': '通过 {env} 配置',
'channels.runWith': '运行命令: {cmd}',
});
+64 -111
View File
@@ -95,7 +95,8 @@
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
<button data-tab="settings" data-i18n="tab.settings">Settings</button>
<button data-tab="extensions" data-i18n="tab.extensions">Extensions</button>
<button data-tab="skills" data-i18n="tab.skills">Skills</button>
<div class="spacer"></div>
<!-- Language Switcher -->
@@ -270,125 +271,77 @@
</div>
</div>
<!-- Settings Tab -->
<div class="tab-panel" id="tab-settings">
<div class="settings-layout">
<div class="settings-sidebar">
<button class="settings-subtab active" data-settings-subtab="inference" data-i18n="settings.inference">Inference</button>
<button class="settings-subtab" data-settings-subtab="agent" data-i18n="settings.agent">Agent</button>
<button class="settings-subtab" data-settings-subtab="channels" data-i18n="settings.channels">Channels</button>
<button class="settings-subtab" data-settings-subtab="networking" data-i18n="settings.networking">Networking</button>
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
<!-- Extensions Tab -->
<div class="tab-panel" id="tab-extensions">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="settings-content">
<div class="settings-toolbar">
<div class="settings-search">
<input type="text" id="settings-search-input" data-i18n-placeholder="settings.searchPlaceholder" placeholder="Search settings..." data-i18n-attr="aria-label" data-i18n="settings.searchPlaceholder" aria-label="Search settings...">
</div>
<button id="settings-export-btn" class="settings-toolbar-btn" data-i18n="settings.export">Export</button>
<button id="settings-import-btn" class="settings-toolbar-btn" data-i18n="settings.import">Import</button>
<div class="extensions-section" id="available-wasm-section">
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
<div class="extensions-list" id="available-wasm-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
<div class="settings-subpanel active" id="settings-inference">
<div class="extensions-container" id="settings-inference-content">
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
<div class="ext-install-form">
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
<button id="wasm-install-btn" data-i18n="extensions.install">Install</button>
</div>
<div class="settings-subpanel" id="settings-agent">
<div class="extensions-container" id="settings-agent-content">
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="mcp.servers">MCP Servers</h3>
<div class="extensions-list" id="mcp-servers-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
<div class="settings-subpanel" id="settings-channels">
<div class="extensions-container" id="settings-channels-content">
<div class="empty-state" data-i18n="common.loading">Loading channels...</div>
</div>
</div>
<div class="settings-subpanel" id="settings-networking">
<div class="extensions-container" id="settings-networking-content">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="settings-subpanel" id="settings-extensions">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section" id="available-wasm-section">
<h3 data-i18n="extensions.available">Available Extensions</h3>
<div class="extensions-list" id="available-wasm-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="extensions.installWasm">Install Extension</h3>
<div class="ext-install-form">
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
<button id="wasm-install-btn" data-i18n="extensions.install">Install</button>
</div>
</div>
</div>
</div>
<div class="settings-subpanel" id="settings-mcp">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="mcp.servers">MCP Servers</h3>
<div class="extensions-list" id="mcp-servers-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
<div class="ext-install-form">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
<button id="mcp-add-btn" data-i18n="mcp.add">Add</button>
</div>
</div>
</div>
</div>
<div class="settings-subpanel" id="settings-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search for skills...">
<button id="skill-search-btn" data-i18n="skills.search">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installed">Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
<div class="ext-install-form">
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
<button id="skill-install-btn" data-i18n="extensions.install">Install</button>
</div>
</div>
</div>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
<div class="ext-install-form">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
<button id="mcp-add-btn" data-i18n="mcp.add">Add</button>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="tools.registered">Registered Tools</h3>
<table class="tools-table" id="tools-table">
<thead><tr><th data-i18n="tools.name">Name</th><th data-i18n="tools.description">Description</th></tr></thead>
<tbody id="tools-tbody"></tbody>
</table>
<div class="empty-state" id="tools-empty" style="display:none" data-i18n="tools.empty">No tools registered</div>
</div>
</div>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirm-modal" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
<div class="modal">
<h3 id="confirm-modal-title"></h3>
<p id="confirm-modal-message"></p>
<div class="modal-actions">
<button id="confirm-modal-cancel-btn" class="btn-secondary" data-i18n="btn.cancel">Cancel</button>
<button id="confirm-modal-btn" class="btn-danger">Confirm</button>
<!-- Skills Tab -->
<div class="tab-panel" id="tab-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
<button id="skill-search-btn" data-i18n="skills.search">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installed">Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
<div class="ext-install-form">
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
<button id="skill-install-btn" data-i18n="extensions.install">Install</button>
</div>
</div>
</div>
</div>
</div>
+61 -545
View File
@@ -18,12 +18,6 @@
--radius-lg: 12px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
--text-muted: #71717a;
--bg-hover: rgba(255, 255, 255, 0.03);
--danger-soft: rgba(230, 76, 76, 0.15);
--warning-soft: rgba(245, 166, 35, 0.15);
--transition-fast: 150ms ease;
--transition-base: 0.2s ease;
}
* {
@@ -338,10 +332,10 @@ body {
.restart-loader-content {
position: relative;
z-index: 10000;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -358,7 +352,7 @@ body {
}
.restart-title {
color: var(--text);
color: #e0e0e0;
font-size: 0.85rem;
margin-bottom: 1rem;
margin-top: 0;
@@ -394,10 +388,10 @@ body {
.restart-modal-content {
position: relative;
z-index: 10000;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -409,11 +403,11 @@ body {
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid #2a2a2a;
}
.restart-modal-header h2 {
color: var(--text);
color: #e0e0e0;
font-size: 0.95rem;
margin: 0;
}
@@ -432,8 +426,8 @@ body {
}
.restart-modal-close:hover {
color: var(--text-secondary);
background-color: var(--bg-tertiary);
color: #ccc;
background-color: #2a2a2a;
}
.restart-modal-body {
@@ -441,21 +435,21 @@ body {
}
.restart-modal-description {
color: var(--text-secondary);
color: #aaa;
font-size: 0.85rem;
margin: 0;
}
.restart-modal-warning {
margin-top: 1rem;
background-color: var(--warning-soft);
border: 1px solid rgba(245, 166, 35, 0.25);
background-color: #1e1400;
border: 1px solid #3a2a00;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
.restart-modal-warning p {
color: var(--warning);
color: #facc15;
font-size: 0.8rem;
margin: 0;
}
@@ -466,7 +460,7 @@ body {
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
border-top: 1px solid #2a2a2a;
}
.restart-modal-btn {
@@ -479,28 +473,28 @@ body {
}
.restart-modal-btn.cancel {
color: var(--text-secondary);
color: #ccc;
background-color: transparent;
}
.restart-modal-btn.cancel:hover {
background-color: var(--bg-tertiary);
background-color: #2a2a2a;
}
.restart-modal-btn.confirm {
background-color: var(--accent);
color: #09090b;
background-color: #00D894;
color: #111;
}
.restart-modal-btn.confirm:hover {
background-color: var(--accent-hover);
background-color: #00be82;
}
/* Progress Bar for Restart */
.restart-progress-bar {
width: 100%;
height: 0.375rem;
background-color: var(--bg-tertiary);
background-color: #2a2a2a;
border-radius: 9999px;
overflow: hidden;
}
@@ -508,7 +502,7 @@ body {
.restart-progress-fill {
height: 100%;
border-radius: 9999px;
background-color: var(--accent);
background-color: #00D894;
width: 40%;
animation: indeterminate 1.5s ease-in-out infinite;
}
@@ -529,14 +523,14 @@ body {
}
.restart-modal-info {
color: var(--text-secondary);
color: #666;
font-size: 0.8rem;
margin-top: 1.25rem;
margin-bottom: 0;
}
.restart-modal-info a {
color: var(--accent);
color: #00D894;
text-decoration: none;
}
@@ -2528,21 +2522,17 @@ body {
}
.extensions-section h3 {
font-size: 11px;
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text);
}
.extensions-section h4 {
font-size: 11px;
font-size: 13px;
font-weight: 600;
margin: 16px 0 8px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.extensions-list {
@@ -2554,29 +2544,12 @@ body {
.ext-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-left: 3px solid transparent;
border-radius: var(--radius-lg);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color var(--transition-base), box-shadow var(--transition-base), transform 0.2s;
}
.ext-card.state-active {
border-left-color: var(--success);
}
.ext-card.state-inactive {
border-left-color: var(--text-muted);
}
.ext-card.state-error {
border-left-color: var(--danger);
}
.ext-card.state-pairing {
border-left-color: var(--warning);
transition: border-color 0.2s, transform 0.2s;
}
.ext-card:hover {
@@ -2619,11 +2592,6 @@ body {
color: var(--warning);
}
.ext-kind.kind-builtin {
background: rgba(161, 161, 170, 0.15);
color: var(--text-secondary);
}
.ext-version {
font-size: 11px;
color: var(--text-muted);
@@ -2799,20 +2767,13 @@ body {
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text);
transition: all var(--transition-fast);
}
.btn-ext:hover {
background: var(--border);
transform: translateY(-1px);
}
.btn-ext:active {
transform: scale(0.97);
}
.btn-ext.activate {
@@ -2912,7 +2873,6 @@ body {
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
@@ -2933,7 +2893,7 @@ body {
.configure-modal h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: var(--text);
color: var(--text-primary);
}
.configure-hint {
@@ -3076,6 +3036,31 @@ body {
justify-content: flex-end;
}
.tools-table {
width: 100%;
border-collapse: collapse;
}
.tools-table th,
.tools-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.tools-table th {
color: var(--text-secondary);
font-weight: 500;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
}
.tools-table tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
/* --- Activity tab (unified sandbox job events) --- */
.activity-terminal {
@@ -3729,14 +3714,10 @@ mark {
gap: 8px;
align-items: center;
flex-wrap: wrap;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px;
}
.ext-install-form input {
padding: 8px 12px;
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -3778,10 +3759,6 @@ mark {
gap: 8px;
align-items: center;
margin-bottom: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px;
}
.skill-search-box input {
@@ -3818,10 +3795,10 @@ mark {
}
.skill-trust {
font-size: 11px;
padding: 3px 8px;
border-radius: 9999px;
font-weight: 600;
font-size: 10px;
padding: 2px 6px;
border-radius: 8px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
}
@@ -3965,27 +3942,6 @@ mark {
border-bottom: 1px solid var(--border);
}
/* Settings layout: horizontal subtabs on mobile */
.settings-layout { flex-direction: column; }
.settings-sidebar {
width: 100%;
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
padding: 0;
}
.settings-subtab {
border-left: none;
border-bottom: 2px solid transparent;
white-space: nowrap;
padding: 8px 16px;
}
.settings-subtab.active {
border-left-color: transparent;
border-bottom-color: var(--accent);
}
/* Extension install form */
.ext-install-form {
flex-direction: column;
@@ -4012,238 +3968,6 @@ mark {
}
}
/* --- Settings Tab Layout --- */
.settings-layout {
flex: 1;
display: flex;
overflow: hidden;
}
.settings-sidebar {
width: 180px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
background: var(--bg-secondary);
padding: 12px 0;
flex-shrink: 0;
}
.settings-subtab {
display: block;
width: 100%;
padding: 10px 20px;
background: none;
border: none;
border-left: 2px solid transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
font-weight: 500;
text-align: left;
transition: color 0.2s, background 0.2s, border-color 0.2s;
}
.settings-subtab:hover {
color: var(--text);
background: var(--bg-tertiary);
}
.settings-subtab.active {
color: var(--accent);
border-left-color: var(--accent);
background: var(--bg-tertiary);
}
.settings-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.settings-subpanel {
display: none;
flex: 1;
overflow: hidden;
flex-direction: column;
opacity: 0;
}
.settings-subpanel.active {
display: flex;
animation: settingsFadeIn 0.2s ease forwards;
}
@keyframes settingsFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Settings form styles (General subtab) */
.settings-group {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 16px;
}
.settings-group-title {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
margin: 0 -12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
border-radius: 6px;
gap: 16px;
max-height: 80px;
overflow: hidden;
transition: max-height 0.2s ease, opacity 0.2s ease, margin 0.2s ease, padding 0.2s ease, background var(--transition-fast);
opacity: 1;
}
.settings-row:hover {
background: var(--bg-hover);
}
.settings-row.hidden {
max-height: 0;
opacity: 0;
margin: 0;
padding: 0;
border-bottom: none;
}
.settings-row.search-hidden {
display: none;
}
.settings-row:last-child { border-bottom: none; }
.settings-label {
font-size: 13px;
color: var(--text);
font-weight: 500;
flex-shrink: 0;
min-width: 180px;
}
.settings-input {
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: 'IBM Plex Mono', monospace;
width: 240px;
max-width: 100%;
}
.settings-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
}
.settings-saved-indicator {
font-size: 11px;
color: var(--success);
opacity: 0;
transform: translateY(4px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.settings-saved-indicator.visible {
opacity: 1;
transform: translateY(0);
}
.settings-description {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
}
.restart-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--warning-soft);
border: 1px solid rgba(245, 166, 35, 0.25);
border-radius: var(--radius);
color: var(--text);
font-size: 12px;
margin: 8px 16px;
animation: settingsFadeIn 0.25s ease forwards;
}
.restart-banner-text {
flex: 1;
}
.restart-banner-btn {
padding: 4px 12px;
background: var(--warning);
color: #09090b;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
transition: opacity var(--transition-fast);
}
.restart-banner-btn:hover {
opacity: 0.85;
}
.settings-label-wrap {
display: flex;
flex-direction: column;
flex-shrink: 0;
min-width: 180px;
}
.settings-select {
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: 'IBM Plex Mono', monospace;
width: 240px;
max-width: 100%;
cursor: pointer;
}
.settings-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
}
input[type="checkbox"]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Slash command autocomplete dropdown */
.slash-autocomplete {
position: relative;
@@ -4432,211 +4156,3 @@ input[type="checkbox"]:focus-visible {
padding: 4px 8px;
background: var(--bg-secondary);
}
/* Settings toolbar (search + import/export) */
.settings-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.settings-search {
flex: 1;
}
.settings-search input {
width: 100%;
padding: 6px 10px 6px 32px;
background: var(--bg);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='M21 21l-4.35-4.35'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 10px center;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: 'IBM Plex Mono', monospace;
}
.settings-search input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
}
.settings-toolbar-btn {
padding: 6px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.settings-toolbar-btn:hover {
background: var(--bg-secondary);
color: var(--text);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
.settings-toolbar-btn:active {
transform: scale(0.98);
}
/* Confirmation modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: modalFadeIn 0.15s ease;
}
@keyframes modalFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalSlideIn {
from { opacity: 0; transform: translateY(10px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 0;
max-width: 420px;
width: 90%;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
animation: modalSlideIn 0.2s ease;
}
.modal h3 {
margin: 0;
padding: 16px 20px;
font-size: 16px;
color: var(--text);
border-bottom: 1px solid var(--border);
}
.modal p {
margin: 0;
padding: 16px 20px;
font-size: 13px;
color: var(--text-secondary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border);
}
.btn-secondary {
padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
cursor: pointer;
font-size: 13px;
}
.btn-secondary:hover {
background: var(--bg);
}
.btn-danger {
padding: 8px 16px;
background: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius);
color: white;
cursor: pointer;
font-size: 13px;
}
.btn-danger:hover {
opacity: 0.9;
}
/* Mobile settings responsiveness */
@media (max-width: 768px) {
.settings-row {
flex-direction: column;
align-items: stretch;
max-height: 140px;
}
.settings-label-wrap {
min-width: unset;
}
.settings-input, .settings-select {
width: 100%;
}
.settings-toolbar {
flex-wrap: wrap;
}
.settings-search {
min-width: 150px;
}
}
/* Loading skeletons */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
gap: 16px;
}
.skeleton-bar {
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, var(--bg-tertiary) 25%, rgba(255,255,255,0.06) 50%, var(--bg-tertiary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Settings search empty state */
.settings-search-empty {
padding: 32px 16px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
-1
View File
@@ -87,7 +87,6 @@ impl TestGatewayBuilder {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
})
}
-1
View File
@@ -521,7 +521,6 @@ mod tests {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
}
}
}
-24
View File
@@ -476,28 +476,4 @@ impl RoutineStore for LibSqlBackend {
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
.query(
&format!(
"SELECT {} FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
ROUTINE_RUN_COLUMNS
),
params![],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
let mut runs = Vec::new();
while let Some(row) = rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
runs.push(row_to_routine_run_libsql(&row)?);
}
Ok(runs)
}
}
-3
View File
@@ -525,9 +525,6 @@ pub trait RoutineStore: Send + Sync {
run_id: Uuid,
job_id: Uuid,
) -> Result<(), DatabaseError>;
/// List routine runs that were dispatched as full_job but have not yet
/// been finalized (status='running' with a linked job_id).
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError>;
}
#[async_trait]
-4
View File
@@ -503,10 +503,6 @@ impl RoutineStore for PgBackend {
) -> Result<(), DatabaseError> {
self.store.link_routine_run_to_job(run_id, job_id).await
}
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
self.store.list_dispatched_routine_runs().await
}
}
// ==================== ToolFailureStore ====================
-12
View File
@@ -1348,18 +1348,6 @@ impl Store {
.await?;
Ok(())
}
/// List routine runs dispatched as full_job that have not yet been finalized.
pub async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT * FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
&[],
)
.await?;
rows.iter().map(row_to_routine_run).collect()
}
}
#[cfg(feature = "postgres")]
+2 -10
View File
@@ -22,7 +22,6 @@ use crate::llm::provider::{
ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
strip_unsupported_tool_params,
};
use crate::llm::retry::cap_retry_after;
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
@@ -151,7 +150,6 @@ impl AnthropicOAuthProvider {
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
let response_text = response
@@ -768,14 +766,9 @@ mod tests {
#[test]
fn test_retry_after_large_number() {
// Verify large numbers are capped to the safe maximum
// Verify large numbers are accepted
let duration = parse_retry_after_anthropic_for_test("7200"); // 2 hours
assert_eq!(
duration,
Some(std::time::Duration::from_secs(
crate::llm::retry::MAX_RETRY_AFTER_SECS
))
);
assert_eq!(duration, Some(std::time::Duration::from_secs(7200)));
}
/// Helper function to test Retry-After header parsing logic for Anthropic
@@ -786,7 +779,6 @@ mod tests {
.parse::<u64>()
.ok()
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}
-6
View File
@@ -167,12 +167,6 @@ impl CircuitBreakerProvider {
}
}
CircuitState::Open => {
debug_assert!(
false,
"BUG: record_success() called while circuit breaker is Open — \
check_allowed() was bypassed for provider {}",
self.inner.model_name()
);
// Shouldn't get here (check_allowed blocks Open), but recover
state.state = CircuitState::Closed;
state.consecutive_failures = 0;
+10 -18
View File
@@ -22,7 +22,7 @@ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse,
};
use crate::llm::{costs, retry::cap_retry_after, session::SessionManager};
use crate::llm::{costs, session::SessionManager};
/// Information about an available model from NEAR AI API.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -252,7 +252,7 @@ impl NearAiChatProvider {
.and_then(|v| {
// Try delay-seconds first (most common from API providers)
if let Ok(secs) = v.trim().parse::<u64>() {
return Some(cap_retry_after(std::time::Duration::from_secs(secs)));
return Some(std::time::Duration::from_secs(secs));
}
// Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT")
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
@@ -260,9 +260,9 @@ impl NearAiChatProvider {
let delta = dt.signed_duration_since(now);
// Use max(0) so past/present dates yield Duration::ZERO
// rather than None (which would cause an immediate retry).
return Some(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64,
)));
return Some(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64
));
}
None
})
@@ -2306,17 +2306,9 @@ mod tests {
#[test]
fn test_retry_after_large_number() {
// Verify large numbers are capped to the safe maximum
// Verify large numbers are accepted
let duration = parse_retry_after_for_test("3600"); // 1 hour
assert_eq!(duration, Some(std::time::Duration::from_secs(3600)));
let huge = parse_retry_after_for_test("18446744073709551615");
assert_eq!(
huge,
Some(std::time::Duration::from_secs(
crate::llm::retry::MAX_RETRY_AFTER_SECS
))
);
}
/// Helper function to test Retry-After header parsing logic
@@ -2324,13 +2316,13 @@ mod tests {
fn parse_retry_after_for_test(header_value: &str) -> Option<std::time::Duration> {
let trimmed = header_value.trim();
let parsed = if let Ok(secs) = trimmed.parse::<u64>() {
Some(cap_retry_after(std::time::Duration::from_secs(secs)))
Some(std::time::Duration::from_secs(secs))
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
let now = chrono::Utc::now();
let delta = dt.signed_duration_since(now);
Some(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64,
)))
Some(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64
))
} else {
None
};
-2
View File
@@ -108,8 +108,6 @@ mod tests {
assert!(has_native_thinking("nanbeige-4.1-3b"));
assert!(has_native_thinking("step-3.5-flash-197b"));
assert!(has_native_thinking("minimax-m2.5-139b"));
assert!(has_native_thinking("MiniMax-M2.7"));
assert!(has_native_thinking("MiniMax-M2.7-highspeed"));
}
#[test]
-23
View File
@@ -19,12 +19,6 @@ use crate::llm::provider::{
ToolCompletionResponse,
};
/// Upper bound for provider-suggested `Retry-After` delays.
///
/// This prevents malicious or malformed headers from turning a retryable
/// response into an effectively unbounded sleep.
pub(crate) const MAX_RETRY_AFTER_SECS: u64 = 3600;
/// Returns `true` if the `LlmError` is transient and the request should be retried.
///
/// Used by `RetryProvider` (retry the same provider) and `FailoverProvider`
@@ -73,11 +67,6 @@ pub(crate) fn retry_backoff_delay(attempt: u32) -> Duration {
Duration::from_millis(delay_ms)
}
/// Clamp a provider-suggested retry delay to a safe maximum.
pub(crate) fn cap_retry_after(duration: Duration) -> Duration {
duration.min(Duration::from_secs(MAX_RETRY_AFTER_SECS))
}
/// Configuration for the retry decorator.
#[derive(Debug, Clone)]
pub struct RetryConfig {
@@ -432,16 +421,4 @@ mod tests {
panic!("Expected RateLimited error");
}
}
#[test]
fn cap_retry_after_clamps_huge_delays() {
assert_eq!(
cap_retry_after(Duration::from_secs(u64::MAX)),
Duration::from_secs(MAX_RETRY_AFTER_SECS)
);
assert_eq!(
cap_retry_after(Duration::from_secs(0)),
Duration::from_secs(0)
);
}
}
-22
View File
@@ -323,17 +323,6 @@ async fn async_main() -> anyhow::Result<()> {
}));
// Load WASM channels and register their webhook routes.
// Ensure the channels directory exists so the WASM runtime initializes even when
// no channels are installed yet — hot-activation needs the runtime to be available.
if config.channels.wasm_channels_enabled
&& let Err(e) = std::fs::create_dir_all(&config.channels.wasm_channels_dir)
{
tracing::warn!(
path = %config.channels.wasm_channels_dir.display(),
error = %e,
"Failed to create WASM channels directory"
);
}
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
&config,
@@ -522,16 +511,6 @@ async fn async_main() -> anyhow::Result<()> {
gw = gw.with_skill_catalog(Arc::clone(sc));
}
gw = gw.with_cost_guard(Arc::clone(&components.cost_guard));
{
let active_model = components.llm.model_name().to_string();
let mut enabled = channel_names.clone();
enabled.push("gateway".into());
gw = gw.with_active_config(ironclaw::channels::web::server::ActiveConfigSnapshot {
llm_backend: config.llm.backend.to_string(),
llm_model: active_model,
enabled_channels: enabled,
});
}
if config.sandbox.enabled {
gw = gw.with_prompt_queue(Arc::clone(&prompt_queue));
@@ -748,7 +727,6 @@ async fn async_main() -> anyhow::Result<()> {
document_extraction: Some(Arc::new(
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
)),
builder: components.builder,
};
let mut agent = Agent::new(
+3 -16
View File
@@ -518,7 +518,7 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, Cha
.save_secret("http_webhook_secret", &SecretString::from(secret))
.await?;
print_success("Webhook secret generated and saved to database");
print_info(http_webhook_secret_hint());
print_info("Retrieve it later with: ironclaw secret get http_webhook_secret");
}
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
@@ -535,10 +535,6 @@ pub fn generate_webhook_secret() -> String {
generate_secret_with_length(32)
}
fn http_webhook_secret_hint() -> &'static str {
"The secret is stored in the encrypted secrets database and will be loaded automatically on startup."
}
fn validate_e164(account: &str) -> Result<(), String> {
if !account.starts_with('+') {
return Err("E.164 account must start with '+'".to_string());
@@ -1140,9 +1136,8 @@ mod tests {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
use crate::setup::channels::{
SecretsContext, generate_webhook_secret, http_webhook_secret_hint,
substitute_validation_placeholders, validate_cloudflare_token_format,
validate_public_https_url,
SecretsContext, generate_webhook_secret, substitute_validation_placeholders,
validate_cloudflare_token_format, validate_public_https_url,
};
fn test_secrets_context() -> SecretsContext {
@@ -1342,12 +1337,4 @@ mod tests {
.to_string();
assert!(err.contains("DNS resolution failed"));
}
#[test]
fn test_http_webhook_secret_hint_reflects_current_behavior() {
let hint = http_webhook_secret_hint();
assert!(hint.contains("encrypted secrets database"));
assert!(hint.contains("loaded automatically on startup"));
assert!(!hint.contains("ironclaw secret get"));
}
}
-432
View File
@@ -1,432 +0,0 @@
//! Fault injection framework for testing retry, failover, and circuit breaker behavior.
//!
//! Provides [`FaultInjector`] which can be attached to [`StubLlm`](super::StubLlm) to
//! produce configurable error sequences, random failures, and delays.
//!
//! # Example
//!
//! ```rust,no_run
//! use ironclaw::testing::fault_injection::*;
//!
//! // Fail twice with transient errors, then succeed
//! let injector = FaultInjector::sequence([
//! FaultAction::Fail(FaultType::RequestFailed),
//! FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
//! FaultAction::Succeed,
//! ]);
//! ```
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use crate::llm::error::LlmError;
/// The type of fault to inject.
#[derive(Debug, Clone)]
pub enum FaultType {
/// Transient request failure (retryable).
RequestFailed,
/// Rate limited with optional retry-after duration.
RateLimited { retry_after: Option<Duration> },
/// Authentication failure (non-retryable).
AuthFailed,
/// Invalid response from provider (retryable).
InvalidResponse,
/// I/O error (retryable).
IoError,
/// Context length exceeded (non-retryable).
ContextLengthExceeded,
/// Session expired (transient for circuit breaker, not retryable).
SessionExpired,
}
impl FaultType {
/// Convert to the corresponding `LlmError`.
pub fn to_llm_error(&self, provider: &str) -> LlmError {
match self {
FaultType::RequestFailed => LlmError::RequestFailed {
provider: provider.to_string(),
reason: "injected fault: request failed".to_string(),
},
FaultType::RateLimited { retry_after } => LlmError::RateLimited {
provider: provider.to_string(),
retry_after: *retry_after,
},
FaultType::AuthFailed => LlmError::AuthFailed {
provider: provider.to_string(),
},
FaultType::InvalidResponse => LlmError::InvalidResponse {
provider: provider.to_string(),
reason: "injected fault: invalid response".to_string(),
},
FaultType::IoError => LlmError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"injected fault: connection reset",
)),
FaultType::ContextLengthExceeded => LlmError::ContextLengthExceeded {
used: 100_000,
limit: 50_000,
},
FaultType::SessionExpired => LlmError::SessionExpired {
provider: provider.to_string(),
},
}
}
}
/// Action to take on a given call.
#[derive(Debug, Clone)]
pub enum FaultAction {
/// Return a successful response.
Succeed,
/// Return an error of the given type.
Fail(FaultType),
/// Sleep for the given duration, then succeed.
Delay(Duration),
}
/// How the fault sequence is consumed.
#[derive(Debug, Clone)]
pub enum FaultMode {
/// Play the sequence once, then succeed for all subsequent calls.
SequenceOnce,
/// Loop the sequence forever.
SequenceLoop,
/// Fail randomly at the given rate (0.0 = never, 1.0 = always) with
/// the specified fault type. Uses a seeded RNG for reproducibility.
/// The seed is stored so that [`FaultInjector::reset()`] can re-initialize
/// the RNG for test reproducibility.
Random {
error_rate: f64,
fault: FaultType,
seed: u64,
},
}
/// A configurable fault injector for [`StubLlm`](super::StubLlm).
///
/// Thread-safe: uses atomic call counter and mutex-protected RNG.
pub struct FaultInjector {
actions: Vec<FaultAction>,
mode: FaultMode,
call_index: AtomicU32,
/// Seeded RNG for Random mode, behind Mutex for Sync.
rng_state: Mutex<u64>,
}
impl std::fmt::Debug for FaultInjector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FaultInjector")
.field("call_index", &self.call_index.load(Ordering::Relaxed))
.field("mode", &self.mode)
.finish()
}
}
impl FaultInjector {
/// Create a fault injector that plays actions once, then succeeds.
pub fn sequence(actions: impl IntoIterator<Item = FaultAction>) -> Self {
Self {
actions: actions.into_iter().collect(),
mode: FaultMode::SequenceOnce,
call_index: AtomicU32::new(0),
rng_state: Mutex::new(0),
}
}
/// Create a fault injector that loops the action sequence forever.
pub fn sequence_loop(actions: impl IntoIterator<Item = FaultAction>) -> Self {
Self {
actions: actions.into_iter().collect(),
mode: FaultMode::SequenceLoop,
call_index: AtomicU32::new(0),
rng_state: Mutex::new(0),
}
}
/// Create a fault injector with random failures at the given rate.
///
/// # Panics
///
/// Panics if `error_rate` is not in `0.0..=1.0` or is NaN.
///
/// The seed is guarded against zero, which is a fixed point for xorshift.
pub fn random(error_rate: f64, fault: FaultType, seed: u64) -> Self {
assert!(
!error_rate.is_nan() && (0.0..=1.0).contains(&error_rate),
"error_rate must be in 0.0..=1.0 and not NaN, got {error_rate}"
);
let seed = if seed == 0 { 1 } else { seed };
Self {
actions: Vec::new(),
mode: FaultMode::Random {
error_rate,
fault,
seed,
},
call_index: AtomicU32::new(0),
rng_state: Mutex::new(seed),
}
}
/// Get the action for the next call.
pub fn next_action(&self) -> FaultAction {
let index = self.call_index.fetch_add(1, Ordering::Relaxed) as usize;
match &self.mode {
FaultMode::SequenceOnce => {
if index < self.actions.len() {
self.actions[index].clone()
} else {
FaultAction::Succeed
}
}
FaultMode::SequenceLoop => {
if self.actions.is_empty() {
FaultAction::Succeed
} else {
self.actions[index % self.actions.len()].clone()
}
}
FaultMode::Random {
error_rate, fault, ..
} => {
// Simple xorshift64 PRNG for reproducible randomness.
let random_val = {
let mut state = self.rng_state.lock().unwrap_or_else(|p| p.into_inner());
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
(*state as f64) / (u64::MAX as f64)
};
if random_val <= *error_rate {
FaultAction::Fail(fault.clone())
} else {
FaultAction::Succeed
}
}
}
}
/// Get the total number of calls made.
pub fn call_count(&self) -> u32 {
self.call_index.load(Ordering::Relaxed)
}
/// Reset the injector to its initial state.
///
/// For `Random` mode, re-initializes the RNG from the stored seed,
/// which is useful for test reproducibility.
/// For all modes, resets the call counter to zero.
pub fn reset(&self) {
self.call_index.store(0, Ordering::Relaxed);
if let FaultMode::Random { seed, .. } = &self.mode {
let mut state = self.rng_state.lock().unwrap_or_else(|p| p.into_inner());
*state = *seed;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sequence_once_plays_then_succeeds() {
let injector = FaultInjector::sequence([
FaultAction::Fail(FaultType::RequestFailed),
FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
FaultAction::Succeed,
]);
// First two calls should fail
assert!(matches!(
injector.next_action(),
FaultAction::Fail(FaultType::RequestFailed)
));
assert!(matches!(
injector.next_action(),
FaultAction::Fail(FaultType::RateLimited { .. })
));
// Third call is explicit succeed
assert!(matches!(injector.next_action(), FaultAction::Succeed));
// Beyond sequence: implicit succeed
assert!(matches!(injector.next_action(), FaultAction::Succeed));
assert!(matches!(injector.next_action(), FaultAction::Succeed));
assert_eq!(injector.call_count(), 5);
}
#[test]
fn sequence_loop_repeats() {
let injector = FaultInjector::sequence_loop([
FaultAction::Fail(FaultType::RequestFailed),
FaultAction::Succeed,
]);
assert!(matches!(injector.next_action(), FaultAction::Fail(_)));
assert!(matches!(injector.next_action(), FaultAction::Succeed));
assert!(matches!(injector.next_action(), FaultAction::Fail(_)));
assert!(matches!(injector.next_action(), FaultAction::Succeed));
}
#[test]
fn random_mode_is_deterministic_with_seed() {
let injector1 = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
let injector2 = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
let results1: Vec<bool> = (0..20)
.map(|_| matches!(injector1.next_action(), FaultAction::Fail(_)))
.collect();
let results2: Vec<bool> = (0..20)
.map(|_| matches!(injector2.next_action(), FaultAction::Fail(_)))
.collect();
assert_eq!(results1, results2, "Same seed should produce same sequence");
}
#[test]
fn fault_type_produces_correct_llm_errors() {
let provider = "test-provider";
assert!(matches!(
FaultType::RequestFailed.to_llm_error(provider),
LlmError::RequestFailed { .. }
));
assert!(matches!(
FaultType::RateLimited {
retry_after: Some(Duration::from_secs(5))
}
.to_llm_error(provider),
LlmError::RateLimited { .. }
));
assert!(matches!(
FaultType::AuthFailed.to_llm_error(provider),
LlmError::AuthFailed { .. }
));
assert!(matches!(
FaultType::InvalidResponse.to_llm_error(provider),
LlmError::InvalidResponse { .. }
));
assert!(matches!(
FaultType::IoError.to_llm_error(provider),
LlmError::Io(_)
));
assert!(matches!(
FaultType::ContextLengthExceeded.to_llm_error(provider),
LlmError::ContextLengthExceeded { .. }
));
assert!(matches!(
FaultType::SessionExpired.to_llm_error(provider),
LlmError::SessionExpired { .. }
));
}
#[test]
fn delay_action_exists() {
let injector = FaultInjector::sequence([FaultAction::Delay(Duration::from_millis(100))]);
assert!(matches!(injector.next_action(), FaultAction::Delay(_)));
}
#[test]
fn random_seed_zero_does_not_always_fail() {
// seed=0 is a fixed point for xorshift; the constructor guards it to 1.
let injector = FaultInjector::random(0.5, FaultType::RequestFailed, 0);
let failures = (0..100)
.filter(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
.count();
assert!(failures < 100, "seed=0 must not produce stuck RNG");
}
#[test]
fn empty_sequence_always_succeeds() {
let injector = FaultInjector::sequence([]);
for _ in 0..10 {
assert!(matches!(injector.next_action(), FaultAction::Succeed));
}
}
#[test]
fn reset_restores_random_rng_from_stored_seed() {
let injector = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
let run1: Vec<bool> = (0..20)
.map(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
.collect();
injector.reset();
assert_eq!(injector.call_count(), 0);
let run2: Vec<bool> = (0..20)
.map(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
.collect();
assert_eq!(run1, run2, "reset() should reproduce the same sequence");
}
#[test]
#[should_panic(expected = "error_rate must be in 0.0..=1.0")]
fn random_rejects_error_rate_above_one() {
FaultInjector::random(1.5, FaultType::RequestFailed, 42);
}
#[test]
#[should_panic(expected = "error_rate must be in 0.0..=1.0")]
fn random_rejects_negative_error_rate() {
FaultInjector::random(-0.1, FaultType::RequestFailed, 42);
}
#[test]
#[should_panic(expected = "error_rate must be in 0.0..=1.0 and not NaN")]
fn random_rejects_nan_error_rate() {
FaultInjector::random(f64::NAN, FaultType::RequestFailed, 42);
}
#[test]
fn error_rate_one_always_fails() {
let injector = FaultInjector::random(1.0, FaultType::RequestFailed, 42);
for _ in 0..100 {
assert!(
matches!(injector.next_action(), FaultAction::Fail(_)),
"error_rate=1.0 must always produce failures"
);
}
}
#[test]
fn error_rate_zero_never_fails() {
let injector = FaultInjector::random(0.0, FaultType::RequestFailed, 42);
for _ in 0..100 {
assert!(
matches!(injector.next_action(), FaultAction::Succeed),
"error_rate=0.0 must never produce failures"
);
}
}
#[tokio::test]
async fn delay_action_pauses_execution() {
tokio::time::pause();
let injector = FaultInjector::sequence([
FaultAction::Delay(Duration::from_secs(10)),
FaultAction::Succeed,
]);
// First action is a delay
let action = injector.next_action();
assert!(matches!(action, FaultAction::Delay(d) if d == Duration::from_secs(10)));
// Simulate what StubLlm does: sleep then succeed
if let FaultAction::Delay(d) = action {
let start = tokio::time::Instant::now();
tokio::time::sleep(d).await;
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_secs(10),
"delay should have paused for at least 10s, got {elapsed:?}"
);
}
// Next action succeeds
assert!(matches!(injector.next_action(), FaultAction::Succeed));
}
}
+4 -66
View File
@@ -19,11 +19,9 @@
//! ```
pub mod credentials;
pub mod fault_injection;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use async_trait::async_trait;
@@ -86,9 +84,6 @@ pub struct StubLlm {
call_count: AtomicU32,
should_fail: AtomicBool,
error_kind: StubErrorKind,
/// Optional fault injector for fine-grained failure control.
/// When set, takes precedence over the `should_fail` / `error_kind` fields.
fault_injector: Option<Arc<fault_injection::FaultInjector>>,
}
impl StubLlm {
@@ -100,7 +95,6 @@ impl StubLlm {
call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(false),
error_kind: StubErrorKind::Transient,
fault_injector: None,
}
}
@@ -112,7 +106,6 @@ impl StubLlm {
call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(true),
error_kind: StubErrorKind::Transient,
fault_injector: None,
}
}
@@ -124,7 +117,6 @@ impl StubLlm {
call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(true),
error_kind: StubErrorKind::NonTransient,
fault_injector: None,
}
}
@@ -139,39 +131,11 @@ impl StubLlm {
self.call_count.load(Ordering::Relaxed)
}
/// Attach a fault injector for fine-grained failure control.
///
/// When set, the injector's `next_action()` is consulted on every call,
/// taking precedence over the `should_fail` / `error_kind` fields.
pub fn with_fault_injector(mut self, injector: Arc<fault_injection::FaultInjector>) -> Self {
self.fault_injector = Some(injector);
self
}
/// Toggle whether calls should fail at runtime.
pub fn set_failing(&self, fail: bool) {
self.should_fail.store(fail, Ordering::Relaxed);
}
/// Check the fault injector or should_fail flag, returning an error if
/// the call should fail, or None if it should succeed.
async fn check_faults(&self) -> Option<LlmError> {
if let Some(ref injector) = self.fault_injector {
match injector.next_action() {
fault_injection::FaultAction::Fail(fault) => {
return Some(fault.to_llm_error(&self.model_name));
}
fault_injection::FaultAction::Delay(duration) => {
tokio::time::sleep(duration).await;
}
fault_injection::FaultAction::Succeed => {}
}
} else if self.should_fail.load(Ordering::Relaxed) {
return Some(self.make_error());
}
None
}
fn make_error(&self) -> LlmError {
match self.error_kind {
StubErrorKind::Transient => LlmError::RequestFailed {
@@ -204,8 +168,8 @@ impl LlmProvider for StubLlm {
async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
self.call_count.fetch_add(1, Ordering::Relaxed);
if let Some(err) = self.check_faults().await {
return Err(err);
if self.should_fail.load(Ordering::Relaxed) {
return Err(self.make_error());
}
Ok(CompletionResponse {
content: self.response.clone(),
@@ -222,8 +186,8 @@ impl LlmProvider for StubLlm {
_request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
self.call_count.fetch_add(1, Ordering::Relaxed);
if let Some(err) = self.check_faults().await {
return Err(err);
if self.should_fail.load(Ordering::Relaxed) {
return Err(self.make_error());
}
Ok(ToolCompletionResponse {
content: Some(self.response.clone()),
@@ -492,7 +456,6 @@ impl TestHarnessBuilder {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
TestHarness {
@@ -1545,29 +1508,4 @@ mod tests {
.await
.expect("update actuals");
}
#[tokio::test]
async fn stub_llm_fault_injector_sequence() {
use crate::llm::LlmProvider;
use crate::testing::fault_injection::{FaultAction, FaultInjector, FaultType};
let injector = Arc::new(FaultInjector::sequence([
FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
FaultAction::Succeed,
]));
let stub = StubLlm::new("hello").with_fault_injector(injector);
let req = crate::llm::CompletionRequest::new(vec![crate::llm::ChatMessage::user("hi")]);
// First call should fail with RateLimited
let result = stub.complete(req.clone()).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), LlmError::RateLimited { .. }));
// Second call should succeed
let result = stub.complete(req).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().content, "hello");
}
}
+343 -1573
View File
File diff suppressed because it is too large Load Diff
+19 -121
View File
@@ -1,9 +1,8 @@
//! On-demand tool discovery (like CLI `--help`).
//!
//! Three levels of detail:
//! Two levels of detail:
//! - Default: name, description, parameter names (compact ~150 bytes)
//! - `detail: "summary"`: adds curated rules, notes, and examples
//! - `detail: "schema"` / `include_schema: true`: adds the full typed JSON Schema
//! - `include_schema: true`: adds the full typed JSON Schema
//!
//! Keeps the tools array compact (WASM tools use permissive schemas)
//! while allowing precise discovery when needed.
@@ -14,59 +13,7 @@ use async_trait::async_trait;
use crate::context::JobContext;
use crate::tools::registry::ToolRegistry;
use crate::tools::tool::{Tool, ToolDiscoverySummary, ToolError, ToolOutput, require_str};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ToolInfoDetail {
Names,
Summary,
Schema,
}
impl ToolInfoDetail {
fn parse(params: &serde_json::Value) -> Result<Self, ToolError> {
if params
.get("include_schema")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Ok(Self::Schema);
}
match params.get("detail").and_then(|v| v.as_str()) {
None | Some("names") => Ok(Self::Names),
Some("summary") => Ok(Self::Summary),
Some("schema") => Ok(Self::Schema),
Some(other) => Err(ToolError::InvalidParameters(format!(
"invalid detail '{other}' (expected 'names', 'summary', or 'schema')"
))),
}
}
}
fn schema_param_names(schema: &serde_json::Value) -> Vec<String> {
schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().cloned().collect())
.unwrap_or_default()
}
fn fallback_summary(schema: &serde_json::Value) -> ToolDiscoverySummary {
ToolDiscoverySummary {
always_required: schema
.get("required")
.and_then(|v| v.as_array())
.map(|required| {
required
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
..ToolDiscoverySummary::default()
}
}
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
pub struct ToolInfoTool {
registry: Weak<ToolRegistry>,
@@ -85,7 +32,8 @@ impl Tool for ToolInfoTool {
}
fn description(&self) -> &str {
"Get info about any tool: description, parameter names, curated summary guidance, or full discovery schema."
"Get info about any tool: description and parameter names. \
Set include_schema to true for the full typed parameter schema."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -96,15 +44,9 @@ impl Tool for ToolInfoTool {
"type": "string",
"description": "Name of the tool to get info about"
},
"detail": {
"type": "string",
"enum": ["names", "summary", "schema"],
"description": "Response detail level. 'names' returns parameter names only. 'summary' adds curated rules/examples. 'schema' returns the full discovery schema.",
"default": "names"
},
"include_schema": {
"type": "boolean",
"description": "Deprecated compatibility alias for detail='schema'. If true, include the full discovery schema.",
"description": "If true, include the full typed JSON Schema for parameters (larger response). Default: false.",
"default": false
}
},
@@ -119,7 +61,10 @@ impl Tool for ToolInfoTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
let detail = ToolInfoDetail::parse(&params)?;
let include_schema = params
.get("include_schema")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let registry = self.registry.upgrade().ok_or_else(|| {
ToolError::ExecutionFailed(
@@ -132,7 +77,13 @@ impl Tool for ToolInfoTool {
})?;
let schema = tool.discovery_schema();
let param_names = schema_param_names(&schema);
// Extract just param names from the schema's "properties" keys
let param_names: Vec<&str> = schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
let mut info = serde_json::json!({
"name": tool.name(),
@@ -140,21 +91,8 @@ impl Tool for ToolInfoTool {
"parameters": param_names,
});
match detail {
ToolInfoDetail::Names => {}
ToolInfoDetail::Summary => {
let summary = tool
.discovery_summary()
.unwrap_or_else(|| fallback_summary(&schema));
info["summary"] = serde_json::to_value(summary).map_err(|err| {
ToolError::ExecutionFailed(format!(
"failed to serialize discovery summary: {err}"
))
})?;
}
ToolInfoDetail::Schema => {
info["schema"] = schema;
}
if include_schema {
info["schema"] = schema;
}
Ok(ToolOutput::success(info, start.elapsed()))
@@ -197,30 +135,6 @@ mod tests {
assert!(info.get("schema").is_none());
}
#[tokio::test]
async fn test_tool_info_with_summary() {
let registry = Arc::new(ToolRegistry::new());
registry.register(Arc::new(EchoTool)).await;
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(
serde_json::json!({"name": "echo", "detail": "summary"}),
&ctx,
)
.await
.unwrap();
let info = &result.result;
assert_eq!(info["name"], "echo");
assert!(info["summary"].is_object());
assert_eq!(
info["summary"]["always_required"],
serde_json::json!(["message"])
);
}
#[tokio::test]
async fn test_tool_info_with_schema() {
let registry = Arc::new(ToolRegistry::new());
@@ -243,22 +157,6 @@ mod tests {
assert!(info["schema"]["properties"].is_object());
}
#[tokio::test]
async fn test_tool_info_invalid_detail() {
let registry = Arc::new(ToolRegistry::new());
registry.register(Arc::new(EchoTool)).await;
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(
serde_json::json!({"name": "echo", "detail": "verbose"}),
&ctx,
)
.await;
assert!(matches!(result, Err(ToolError::InvalidParameters(_))));
}
#[tokio::test]
async fn test_tool_info_unknown_tool() {
let registry = Arc::new(ToolRegistry::new());
-27
View File
@@ -291,33 +291,6 @@ mod tests {
registry
}
#[tokio::test]
async fn test_execute_empty_tool_name_returns_not_found() {
// Regression: execute_tool_with_safety must reject empty tool names
// gracefully via ToolError::NotFound (not a panic).
let registry = registry_with(vec![]).await;
let safety = test_safety();
let result = execute_tool_with_safety(
&registry,
&safety,
"",
&serde_json::json!({}),
&test_job_ctx(),
)
.await;
assert!(
matches!(
result,
Err(crate::error::Error::Tool(
crate::error::ToolError::NotFound { .. }
))
),
"Empty tool name should return ToolError::NotFound, got: {result:?}"
);
}
#[tokio::test]
async fn test_execute_success() {
let registry = registry_with(vec![Arc::new(EchoTool)]).await;
+42 -205
View File
@@ -288,71 +288,6 @@ impl McpClient {
Ok(headers)
}
/// Re-run the MCP initialize handshake outside the OnceCell cache.
///
/// This is used for recoverable session-expiry failures when an MCP server
/// reports that the current session ID is no longer valid.
async fn reinitialize_session(&self) -> Result<InitializeResult, ToolError> {
if let Some(ref session_manager) = self.session_manager {
session_manager.terminate(&self.server_name).await;
session_manager
.get_or_create(&self.server_name, &self.server_url)
.await;
}
let request = McpRequest::initialize(self.next_request_id());
let response = self
.transport
.send(&request, &self.build_request_headers().await?)
.await?;
if let Some(error) = response.error {
return Err(ToolError::ExternalService(format!(
"MCP initialization error: {} (code {})",
error.message, error.code
)));
}
let init_result: InitializeResult = response
.result
.ok_or_else(|| {
ToolError::ExternalService("No result in initialize response".to_string())
})
.and_then(|r| {
serde_json::from_value(r).map_err(|e| {
ToolError::ExternalService(format!("Invalid initialize result: {}", e))
})
})?;
if let Some(ref session_manager) = self.session_manager {
session_manager.mark_initialized(&self.server_name).await;
}
let notification = McpRequest::initialized_notification();
if let Err(e) = self
.transport
.send(&notification, &self.build_request_headers().await?)
.await
{
tracing::debug!(
"Failed to send initialized notification to '{}': {}",
self.server_name,
e
);
}
Ok(init_result)
}
/// Return true when the error looks like a recoverable MCP session expiry.
fn is_session_expiry_error(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("session")
&& (lower.contains("400")
|| lower.contains("missing session id")
|| lower.contains("no valid session id"))
}
/// Send a request to the MCP server with auth and session headers.
/// Automatically attempts token refresh on 401 errors (HTTP transports only).
async fn send_request(&self, request: McpRequest) -> Result<McpResponse, ToolError> {
@@ -362,26 +297,13 @@ impl McpClient {
return self.transport.send(&request, &headers).await;
}
// HTTP transport: try up to 2 times (first attempt, then retry after token refresh
// or recoverable session reinitialization).
// HTTP transport: try up to 2 times (first attempt, then retry after token refresh)
for attempt in 0..2 {
let headers = self.build_request_headers().await?;
let result = self.transport.send(&request, &headers).await;
match result {
Ok(response) => return Ok(response),
Err(ToolError::ExternalService(ref msg))
if attempt == 0
&& self.session_manager.is_some()
&& Self::is_session_expiry_error(msg) =>
{
tracing::debug!(
"MCP session expired, attempting reinitialize for '{}'",
self.server_name
);
self.reinitialize_session().await?;
continue;
}
Err(ToolError::ExternalService(ref msg))
if msg.contains("401")
|| msg.contains("Unauthorized")
@@ -440,7 +362,47 @@ impl McpClient {
{
return Ok(InitializeResult::default());
}
self.reinitialize_session().await
if let Some(ref session_manager) = self.session_manager {
session_manager
.get_or_create(&self.server_name, &self.server_url)
.await;
}
let request = McpRequest::initialize(self.next_request_id());
let response = self.send_request(request).await?;
if let Some(error) = response.error {
return Err(ToolError::ExternalService(format!(
"MCP initialization error: {} (code {})",
error.message, error.code
)));
}
let init_result: InitializeResult = response
.result
.ok_or_else(|| {
ToolError::ExternalService("No result in initialize response".to_string())
})
.and_then(|r| {
serde_json::from_value(r).map_err(|e| {
ToolError::ExternalService(format!("Invalid initialize result: {}", e))
})
})?;
if let Some(ref session_manager) = self.session_manager {
session_manager.mark_initialized(&self.server_name).await;
}
let notification = McpRequest::initialized_notification();
if let Err(e) = self.send_request(notification).await {
tracing::debug!(
"Failed to send initialized notification to '{}': {}",
self.server_name,
e
);
}
Ok(init_result)
})
.await?;
@@ -903,54 +865,6 @@ mod tests {
}
}
/// Mock transport that can return errors and successful responses in a
/// controlled sequence.
struct RetryMockTransport {
supports_http: bool,
outcomes: std::sync::Mutex<std::collections::VecDeque<Result<McpResponse, ToolError>>>,
recorded_headers: std::sync::Mutex<Vec<HashMap<String, String>>>,
}
impl RetryMockTransport {
fn new(supports_http: bool, outcomes: Vec<Result<McpResponse, ToolError>>) -> Self {
Self {
supports_http,
outcomes: std::sync::Mutex::new(outcomes.into()),
recorded_headers: std::sync::Mutex::new(Vec::new()),
}
}
fn recorded_headers(&self) -> Vec<HashMap<String, String>> {
self.recorded_headers.lock().unwrap().clone()
}
}
#[async_trait]
impl McpTransport for RetryMockTransport {
async fn send(
&self,
_request: &McpRequest,
headers: &HashMap<String, String>,
) -> Result<McpResponse, ToolError> {
self.recorded_headers.lock().unwrap().push(headers.clone());
let mut outcomes = self.outcomes.lock().unwrap();
if outcomes.is_empty() {
return Err(ToolError::ExternalService(
"No more mock outcomes".to_string(),
));
}
outcomes.pop_front().unwrap()
}
async fn shutdown(&self) -> Result<(), ToolError> {
Ok(())
}
fn supports_http_features(&self) -> bool {
self.supports_http
}
}
#[tokio::test]
async fn test_non_http_transport_skips_401_retry() {
// initialize response, then notification ack (consumed but ignored),
@@ -1051,83 +965,6 @@ mod tests {
assert_eq!(transport.recorded_headers().len(), 2); // no additional sends
}
#[tokio::test]
async fn test_http_session_error_triggers_reinitialize_and_retry() {
let init_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(1),
result: Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "test", "version": "1.0"}
})),
error: None,
};
let notification_ack = McpResponse {
jsonrpc: "2.0".to_string(),
id: None,
result: None,
error: None,
};
let notification_ack2 = notification_ack.clone();
let session_error = Err(ToolError::ExternalService(
"[test] MCP server returned status: 400 - No valid session ID provided".to_string(),
));
let reinit_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(2),
result: Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "test", "version": "1.0"}
})),
error: None,
};
let call_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(3),
result: Some(serde_json::json!({
"content": [{"type": "text", "text": "pong"}],
"is_error": false
})),
error: None,
};
let transport = Arc::new(RetryMockTransport::new(
true,
vec![
Ok(init_response),
Ok(notification_ack),
session_error,
Ok(reinit_response),
Ok(notification_ack2),
Ok(call_response),
],
));
let session_manager = Arc::new(McpSessionManager::new());
let client = McpClient::new_with_transport(
"test-http",
transport.clone(),
Some(session_manager),
None,
"default",
None,
);
client.initialize().await.expect("initial handshake");
let result = client
.call_tool("echo", serde_json::json!({"input": "hello"}))
.await
.expect("call should recover after session expiry");
assert!(!result.is_error);
assert_eq!(result.content.len(), 1);
assert_eq!(result.content[0].as_text(), Some("pong"));
let headers = transport.recorded_headers();
assert_eq!(headers.len(), 6);
}
#[test]
fn test_strip_top_level_nulls_removes_null_fields() {
let input = serde_json::json!({
+27 -87
View File
@@ -13,9 +13,7 @@ use crate::orchestrator::job_manager::ContainerJobManager;
use crate::secrets::SecretsStore;
use crate::skills::catalog::SkillCatalog;
use crate::skills::registry::SkillRegistry;
use crate::tools::builder::{
BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder, SoftwareBuilder,
};
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
use crate::tools::builtin::{
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
@@ -96,15 +94,6 @@ pub struct ToolRegistry {
}
impl ToolRegistry {
fn tool_definition(tool: &Arc<dyn Tool>) -> ToolDefinition {
let schema = tool.schema();
ToolDefinition {
name: schema.name,
description: schema.description,
parameters: schema.parameters,
}
}
/// Create a new empty registry.
pub fn new() -> Self {
Self {
@@ -217,7 +206,11 @@ impl ToolRegistry {
.read()
.await
.values()
.map(Self::tool_definition)
.map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.collect();
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
defs
@@ -228,7 +221,13 @@ impl ToolRegistry {
let tools = self.tools.read().await;
names
.iter()
.filter_map(|name| tools.get(*name).map(Self::tool_definition))
.filter_map(|name| {
tools.get(*name).map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
})
.collect()
}
@@ -283,7 +282,11 @@ impl ToolRegistry {
.await
.values()
.filter(|tool| tool.domain() == domain)
.map(Self::tool_definition)
.map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.collect()
}
@@ -309,7 +312,11 @@ impl ToolRegistry {
ApprovalRequirement::Never
)
})
.map(Self::tool_definition)
.map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.collect();
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
defs
@@ -578,23 +585,22 @@ impl ToolRegistry {
self: &Arc<Self>,
llm: Arc<dyn LlmProvider>,
config: Option<BuilderConfig>,
) -> Arc<dyn SoftwareBuilder> {
) {
// First register dev tools needed by the builder
self.register_dev_tools();
// Create the builder (arg order: config, llm, tools)
let builder: Arc<dyn SoftwareBuilder> = Arc::new(LlmSoftwareBuilder::new(
let builder = Arc::new(LlmSoftwareBuilder::new(
config.unwrap_or_default(),
llm,
Arc::clone(self),
));
// Register the build_software tool
self.register(Arc::new(BuildSoftwareTool::new(Arc::clone(&builder))))
self.register(Arc::new(BuildSoftwareTool::new(builder)))
.await;
tracing::info!("Registered software builder tool");
builder
tracing::debug!("Registered software builder tool");
}
/// Register a WASM tool from bytes.
@@ -782,7 +788,6 @@ impl std::fmt::Debug for ToolRegistry {
mod tests {
use super::*;
use crate::tools::registry::EchoTool;
use crate::tools::tool::ToolDiscoverySummary;
#[tokio::test]
async fn test_register_and_get() {
@@ -813,71 +818,6 @@ mod tests {
assert_eq!(defs[0].name, "echo");
}
#[tokio::test]
async fn test_tool_definitions_use_tool_schema() {
struct DiscoveryTool;
#[async_trait::async_trait]
impl Tool for DiscoveryTool {
fn name(&self) -> &str {
"discovery_tool"
}
fn description(&self) -> &str {
"Discovery test tool"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" }
}
})
}
fn discovery_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"extra": { "type": "string" }
}
})
}
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
Some(ToolDiscoverySummary {
notes: vec!["extra guidance".into()],
..ToolDiscoverySummary::default()
})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &crate::context::JobContext,
) -> Result<crate::tools::tool::ToolOutput, crate::tools::tool::ToolError> {
unreachable!()
}
}
let registry = ToolRegistry::new();
registry.register(Arc::new(DiscoveryTool)).await;
let defs = registry.tool_definitions().await;
let def = defs
.iter()
.find(|def| def.name == "discovery_tool")
.expect("tool definition should be present");
assert!(
def.description.contains("tool_info"),
"live tool definition should include schema hint: {}",
def.description
);
assert!(def.parameters.get("extra").is_none());
}
#[tokio::test]
async fn test_builtin_tool_cannot_be_shadowed() {
let registry = ToolRegistry::new();
+9 -1
View File
@@ -605,7 +605,15 @@ mod tests {
),
(
"event_emit",
crate::tools::builtin::routine::event_emit_parameters_schema(),
serde_json::json!({
"type": "object",
"properties": {
"event_source": { "type": "string", "description": "Event source" },
"event_type": { "type": "string", "description": "Event type" },
"payload": { "type": "object", "description": "Event payload", "properties": {} }
},
"required": ["event_source", "event_type"]
}),
),
// Job tools with complex deps
(
+2 -35
View File
@@ -231,19 +231,6 @@ impl ToolSchema {
}
}
/// Curated discovery guidance surfaced by `tool_info(detail: "summary")`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ToolDiscoverySummary {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub always_required: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conditional_requirements: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<serde_json::Value>,
}
/// Trait for tools that the agent can use.
#[async_trait]
pub trait Tool: Send + Sync {
@@ -360,32 +347,12 @@ pub trait Tool: Send + Sync {
self.parameters_schema()
}
/// Curated discovery guidance used by `tool_info(detail: "summary")`.
///
/// Default: no custom summary; callers may derive a minimal fallback from
/// `discovery_schema()`.
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
None
}
/// Get the tool schema for LLM function calling.
fn schema(&self) -> ToolSchema {
let parameters = self.parameters_schema();
let has_discovery_hint =
self.discovery_summary().is_some() || self.discovery_schema() != parameters;
let description = if has_discovery_hint {
format!(
"{} (call tool_info(name: \"{}\", detail: \"summary\") for rules/examples or detail: \"schema\" for the full discovery schema)",
self.description(),
self.name()
)
} else {
self.description().to_string()
};
ToolSchema {
name: self.name().to_string(),
description,
parameters,
description: self.description().to_string(),
parameters: self.parameters_schema(),
}
}
}
+3 -3
View File
@@ -163,7 +163,7 @@ impl WasmToolLoader {
tool = name,
path = %cap_path.display(),
"Capabilities file missing \"description\" field; \
tool will use generic fallback description"
using WASM-exported description when available"
);
}
if params.is_none() && cap_file.parameters.is_none() {
@@ -171,7 +171,7 @@ impl WasmToolLoader {
tool = name,
path = %cap_path.display(),
"Capabilities file missing \"parameters\" field; \
tool will accept any JSON object (permissive fallback)"
using exported WASM schema when available"
);
}
(caps, oauth, desc, params)
@@ -186,7 +186,7 @@ impl WasmToolLoader {
tracing::warn!(
tool = name,
"No capabilities file for WASM tool; \
tool will use generic fallback description and accept any JSON object"
using default permissions and WASM-exported metadata when available"
);
(Capabilities::default(), None, None, None)
};
+68 -19
View File
@@ -493,6 +493,10 @@ struct WasmToolSchemas {
}
impl WasmToolSchemas {
/// Keep advertised schemas reasonably small because they are serialized
/// into the main tool list shown to the model.
const MAX_ADVERTISED_SCHEMA_BYTES: usize = 8 * 1024;
fn permissive_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
@@ -521,9 +525,20 @@ impl WasmToolSchemas {
.unwrap_or(0)
}
fn should_advertise_discovery(schema: &serde_json::Value) -> bool {
Self::typed_property_count(schema) > 0
&& schema.to_string().len() <= Self::MAX_ADVERTISED_SCHEMA_BYTES
}
fn new(discovery: serde_json::Value) -> Self {
let advertised = if Self::should_advertise_discovery(&discovery) {
discovery.clone()
} else {
Self::permissive_schema()
};
Self {
advertised: Self::permissive_schema(),
advertised,
discovery,
}
}
@@ -1490,7 +1505,7 @@ mod tests {
}
#[tokio::test]
async fn test_advertised_schema_stays_permissive_until_sidecar_override() {
async fn test_small_exported_schema_is_advertised_directly() {
let discovery_schema = serde_json::json!({
"type": "object",
"properties": {
@@ -1510,30 +1525,19 @@ mod tests {
wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone());
wrapper.description = "Search documents".to_string();
// Advertised schema stays permissive; discovery holds the typed schema
assert_eq!(
wrapper.parameters_schema(),
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
})
);
// Small typed exported schemas should be advertised directly so the
// model sees the actual required parameters.
assert_eq!(wrapper.parameters_schema(), discovery_schema);
assert_eq!(wrapper.discovery_schema(), discovery_schema);
// Raw description is clean — no tool_info hint baked in
assert!(!wrapper.description().contains("tool_info"));
// But schema() composes the hint at display time when advertised is permissive
// When advertised is typed, schema() should not add a tool_info hint.
let schema = wrapper.schema();
assert!(
schema.description.contains("tool_info"),
"schema().description should contain tool_info hint: {}",
schema.description
);
assert!(
schema.description.contains("include_schema: true"),
"hint should mention include_schema: true: {}",
!schema.description.contains("tool_info"),
"schema().description should not contain tool_info hint when typed: {}",
schema.description
);
@@ -1567,6 +1571,51 @@ mod tests {
);
}
#[tokio::test]
async fn test_large_exported_schema_stays_permissive_for_advertising() {
let mut properties = serde_json::Map::new();
for i in 0..200 {
properties.insert(
format!("field_{i:03}"),
serde_json::json!({
"type": "string",
"description": "x".repeat(64)
}),
);
}
let discovery_schema = serde_json::json!({
"type": "object",
"properties": properties,
});
let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::for_testing()).unwrap());
let prepared = runtime
.prepare("search", b"\0asm\x0d\0\x01\0", None)
.await
.unwrap();
let mut wrapper =
super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, Capabilities::default());
wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone());
wrapper.description = "Search documents".to_string();
assert_eq!(
wrapper.parameters_schema(),
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
})
);
assert_eq!(wrapper.discovery_schema(), discovery_schema);
let schema = wrapper.schema();
assert!(
schema.description.contains("tool_info"),
"large schemas should still fall back to the tool_info hint: {}",
schema.description
);
}
#[test]
fn test_capabilities_default() {
let caps = Capabilities::default();
-5
View File
@@ -6,8 +6,6 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::llm::retry::cap_retry_after;
/// Error type for embedding operations.
#[derive(Debug, thiserror::Error)]
pub enum EmbeddingError {
@@ -234,7 +232,6 @@ impl EmbeddingProvider for OpenAiEmbeddings {
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
return Err(EmbeddingError::RateLimited { retry_after });
}
@@ -377,7 +374,6 @@ impl EmbeddingProvider for NearAiEmbeddings {
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
return Err(EmbeddingError::RateLimited { retry_after });
}
@@ -694,7 +690,6 @@ mod tests {
.parse::<u64>()
.ok()
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}
-360
View File
@@ -1,360 +0,0 @@
//! Integration tests for dispatched routine run tracking (#1317).
//!
//! Verifies:
//! 1. list_dispatched_routine_runs returns only running runs with linked jobs
//! 2. Completed jobs cause linked routine runs to be finalized as Ok
//! 3. Failed jobs cause linked routine runs to be finalized as Failed
//! 4. Active (InProgress) jobs are not finalized
//! 5. Orphaned runs (job_id set but no job record) are handled
#[cfg(feature = "libsql")]
mod tests {
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use ironclaw::agent::routine::{
Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
};
use ironclaw::context::{JobContext, JobState};
use ironclaw::db::Database;
async fn create_test_db() -> (Arc<dyn Database>, tempfile::TempDir) {
use ironclaw::db::libsql::LibSqlBackend;
let temp_dir = tempfile::tempdir().expect("tempdir");
let db_path = temp_dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path)
.await
.expect("LibSqlBackend");
backend.run_migrations().await.expect("migrations");
let db: Arc<dyn Database> = Arc::new(backend);
(db, temp_dir)
}
fn make_routine(id: Uuid) -> Routine {
Routine {
id,
name: format!("test-routine-{}", id),
description: "Test routine".to_string(),
user_id: "default".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::FullJob {
title: "Test job".to_string(),
description: "Test description".to_string(),
max_iterations: 5,
tool_permissions: vec![],
},
guardrails: RoutineGuardrails {
cooldown: std::time::Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: Default::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn make_run(routine_id: Uuid, job_id: Option<Uuid>) -> RoutineRun {
RoutineRun {
id: Uuid::new_v4(),
routine_id,
trigger_type: "manual".to_string(),
trigger_detail: None,
started_at: Utc::now(),
completed_at: None,
status: RunStatus::Running,
result_summary: None,
tokens_used: None,
job_id,
created_at: Utc::now(),
}
}
// -----------------------------------------------------------------------
// Test 1: list_dispatched_routine_runs returns only running runs with jobs
// -----------------------------------------------------------------------
#[tokio::test]
async fn list_dispatched_returns_only_running_with_job_id() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create jobs first (FK constraint requires job records to exist)
let job1 = JobContext::new("Job 1", "Dispatched job");
db.save_job(&job1).await.expect("save job1");
let job2 = JobContext::new("Job 2", "Completed job");
db.save_job(&job2).await.expect("save job2");
// Create a running run WITH job_id (dispatched full_job)
let dispatched_run = make_run(routine_id, Some(job1.job_id));
db.create_routine_run(&dispatched_run)
.await
.expect("create dispatched run");
// Create a running run WITHOUT job_id (lightweight in-progress)
let lightweight_run = make_run(routine_id, None);
db.create_routine_run(&lightweight_run)
.await
.expect("create lightweight run");
// Create a completed run WITH job_id (already finalized)
let mut completed_run = make_run(routine_id, Some(job2.job_id));
completed_run.status = RunStatus::Ok;
completed_run.completed_at = Some(Utc::now());
db.create_routine_run(&completed_run)
.await
.expect("create completed run");
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1, "Should return only the dispatched run");
assert_eq!(dispatched[0].id, dispatched_run.id);
assert_eq!(dispatched[0].job_id, Some(job1.job_id));
assert_eq!(dispatched[0].status, RunStatus::Running);
}
// -----------------------------------------------------------------------
// Test 2: Completed job linked to run can be detected
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_completed_job_can_be_finalized() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create and save a job in Completed state
let mut job = JobContext::new("Test job", "Test description");
job.state = JobState::Completed;
db.save_job(&job).await.expect("save job");
// Create a dispatched run linked to that job
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify the run is listed as dispatched
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1);
// Verify we can fetch the linked job and see it's completed
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert_eq!(fetched_job.state, JobState::Completed);
// Simulate sync: complete the run
db.complete_routine_run(run.id, RunStatus::Ok, Some("Job completed"), None)
.await
.expect("complete run");
// Run should no longer appear in dispatched list
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after");
assert!(
dispatched_after.is_empty(),
"Finalized run should not appear in dispatched list"
);
}
// -----------------------------------------------------------------------
// Test 3: Failed job causes run to be finalized as Failed
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_failed_job() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
let mut job = JobContext::new("Failing job", "Will fail");
job.state = JobState::Failed;
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify job is failed
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert_eq!(fetched_job.state, JobState::Failed);
// Simulate sync: complete the run as failed
db.complete_routine_run(run.id, RunStatus::Failed, Some("Job failed"), None)
.await
.expect("complete run as failed");
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert!(dispatched.is_empty(), "Failed run should be finalized");
}
// -----------------------------------------------------------------------
// Test 4: Active (InProgress) job leaves run as running
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_active_job_stays_running() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
let mut job = JobContext::new("Active job", "Still running");
job.state = JobState::InProgress;
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify job is still active
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert!(!fetched_job.state.is_terminal());
// Run should still be in dispatched list (not finalized)
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(
dispatched.len(),
1,
"Run with active job should remain dispatched"
);
assert_eq!(dispatched[0].status, RunStatus::Running);
}
// -----------------------------------------------------------------------
// Test 5: Orphaned run (job_id set but job record missing)
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_orphan_detection() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create a real job so the FK constraint is satisfied
let job = JobContext::new("Will be orphaned", "Test orphan detection");
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// The run appears in dispatched list
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1);
// Verify orphan detection: a random UUID returns None from get_job
let nonexistent_id = Uuid::new_v4();
let missing = db
.get_job(nonexistent_id)
.await
.expect("get_job should not error");
assert!(
missing.is_none(),
"get_job for nonexistent ID should return None"
);
// Simulate sync handling of an orphaned run: mark as failed
db.complete_routine_run(
run.id,
RunStatus::Failed,
Some(&format!("Linked job {} not found (orphaned)", job.job_id)),
None,
)
.await
.expect("complete orphaned run");
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after");
assert!(
dispatched_after.is_empty(),
"Finalized run should not appear in dispatched list"
);
}
// -----------------------------------------------------------------------
// Test 6: link_routine_run_to_job then list shows linked run
// -----------------------------------------------------------------------
#[tokio::test]
async fn link_and_list_dispatched_run() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create job record (FK constraint)
let job = JobContext::new("Linked job", "Test linking");
db.save_job(&job).await.expect("save job");
// Create a running run without job_id initially
let run = make_run(routine_id, None);
db.create_routine_run(&run).await.expect("create run");
// Should not appear in dispatched list yet
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert!(
dispatched.is_empty(),
"Run without job_id should not be dispatched"
);
// Link the run to the job
db.link_routine_run_to_job(run.id, job.job_id)
.await
.expect("link run to job");
// Now it should appear
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after link");
assert_eq!(
dispatched_after.len(),
1,
"Linked run should appear in dispatched list"
);
assert_eq!(dispatched_after[0].job_id, Some(job.job_id));
}
}
+4 -11
View File
@@ -45,13 +45,12 @@ SEL = {
"approval_always_btn": ".approval-actions button.always",
"approval_deny_btn": ".approval-actions button.deny",
"approval_resolved": ".approval-resolved",
# Settings subtabs
"settings_subtab": '.settings-subtab[data-settings-subtab="{subtab}"]',
"settings_subpanel": "#settings-{subtab}",
# Extensions section
# Extensions tab sections
"extensions_list": "#extensions-list",
"available_wasm_list": "#available-wasm-list",
"mcp_servers_list": "#mcp-servers-list",
"tools_tbody": "#tools-tbody",
"tools_empty": "#tools-empty",
# Extensions tab cards
"ext_card_installed": "#extensions-list .ext-card",
"ext_card_available": "#available-wasm-list .ext-card.ext-available",
@@ -93,12 +92,6 @@ SEL = {
"ext_stepper": ".ext-stepper",
"stepper_step": ".stepper-step",
"stepper_circle": ".stepper-circle",
# Confirm modal (custom, replaces window.confirm)
"confirm_modal": "#confirm-modal",
"confirm_modal_btn": "#confirm-modal-btn",
"confirm_modal_cancel": "#confirm-modal-cancel-btn",
# Channels subtab cards
"channels_ext_card": "#settings-channels-content .ext-card",
# Toast notifications
"toast": ".toast",
"toast_success": ".toast.toast-success",
@@ -113,7 +106,7 @@ SEL = {
"routines_empty": "#routines-empty",
}
TABS = ["chat", "memory", "jobs", "routines", "settings"]
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
# Auth token used across all tests
AUTH_TOKEN = "e2e-test-token"
+133 -71
View File
@@ -87,21 +87,23 @@ _REGISTRY_MCP = {
"installed": False,
}
_SAMPLE_TOOL = {"name": "echo", "description": "Echo a message"}
_SAMPLE_TOOL_2 = {"name": "time", "description": "Get current time"}
# ─── Navigation helpers ────────────────────────────────────────────────────────
async def go_to_extensions(page):
"""Navigate to Settings > Extensions subtab and wait for content.
"""Click the Extensions tab and wait for the panel to appear.
Waits for loadExtensions() to finish rendering by polling for the first
content signal (empty-state div or an installed card) rather than sleeping.
"""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="extensions")).wait_for(
await page.locator(SEL["tab_button"].format(tab="extensions")).click()
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for(
state="visible", timeout=5000
)
# loadExtensions() fires parallel fetches then renders. Wait for the
# loadExtensions() fires three parallel fetches then renders. Wait for the
# first concrete DOM signal instead of a hard sleep so the test is
# deterministic even under CI load.
await page.locator(
@@ -109,39 +111,19 @@ async def go_to_extensions(page):
).first.wait_for(state="visible", timeout=8000)
async def go_to_channels(page):
"""Navigate to Settings > Channels subtab and wait for content."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="channels")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="channels")).wait_for(
state="visible", timeout=5000
)
async def mock_ext_apis(page, *, installed=None, tools=None, registry=None):
"""Intercept the three extension list APIs with fixture data.
async def go_to_mcp(page):
"""Navigate to Settings > MCP subtab and wait for content."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="mcp")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="mcp")).wait_for(
state="visible", timeout=5000
)
await page.locator(
f"{SEL['mcp_servers_list']} .empty-state, {SEL['ext_card_mcp']}"
).first.wait_for(state="visible", timeout=8000)
async def mock_ext_apis(page, *, installed=None, registry=None):
"""Intercept the extension list APIs with fixture data.
Must be called BEFORE navigating to the extensions subtab.
Must be called BEFORE navigating to the extensions tab.
"""
ext_body = json.dumps({"extensions": installed or []})
tools_body = json.dumps({"tools": tools or []})
registry_body = json.dumps({"entries": registry or []})
# Playwright evaluates route handlers in LIFO order (last-registered fires
# first). Register the broad handler first so it is checked last; the
# specific /registry handler is registered after and therefore checked
# first — no continue_() fallthrough needed.
# specific /tools and /registry handlers are registered after and therefore
# checked first — no continue_() fallthrough needed.
async def handle_ext_list(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
@@ -151,9 +133,13 @@ async def mock_ext_apis(page, *, installed=None, registry=None):
await page.route("**/api/extensions*", handle_ext_list)
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body=tools_body)
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body=registry_body)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
@@ -165,17 +151,46 @@ async def wait_for_toast(page, text: str, *, timeout: int = 5000):
# ─── Group A: Structural / empty state ────────────────────────────────────────
async def test_extensions_empty_tab_layout(page):
"""Extensions subtab with no data shows sections with correct empty-state messages."""
await mock_ext_apis(page)
"""Extensions tab with no data shows all three sections with correct empty-state messages."""
await mock_ext_apis(page, tools=[])
await go_to_extensions(page)
panel = page.locator(SEL["settings_subpanel"].format(subtab="extensions"))
panel = page.locator(SEL["tab_panel"].format(tab="extensions"))
assert await panel.is_visible()
ext_list = page.locator(SEL["extensions_list"])
assert await ext_list.is_visible()
assert "No extensions installed" in await ext_list.text_content()
wasm_list = page.locator(SEL["available_wasm_list"])
assert await wasm_list.is_visible()
assert "No additional WASM extensions available" in await wasm_list.text_content()
mcp_list = page.locator(SEL["mcp_servers_list"])
assert await mcp_list.is_visible()
assert "No MCP servers available" in await mcp_list.text_content()
# Tools table should be empty
tbody = page.locator(SEL["tools_tbody"])
rows = await tbody.locator("tr").count()
empty_visible = await page.locator(SEL["tools_empty"]).is_visible()
assert empty_visible or rows == 0, "Expected tools table to be empty"
async def test_extensions_tools_table_populated(page):
"""Two mock tools produce two rows in the tools table."""
await mock_ext_apis(page, tools=[_SAMPLE_TOOL, _SAMPLE_TOOL_2])
await go_to_extensions(page)
tbody = page.locator(SEL["tools_tbody"])
rows = tbody.locator("tr")
await rows.first.wait_for(state="visible", timeout=5000)
assert await rows.count() == 2
text = await tbody.text_content()
assert "echo" in text
assert "time" in text
# ─── Group B: Installed WASM tool cards ───────────────────────────────────────
@@ -233,9 +248,9 @@ async def test_installed_wasm_tool_authed_shows_reconfigure_btn(page):
async def test_installed_mcp_server_active(page):
"""Active MCP server shows 'Active' label and no Activate button."""
await mock_ext_apis(page, installed=[_MCP_ACTIVE])
await go_to_mcp(page)
await go_to_extensions(page)
card = page.locator(SEL["ext_card_mcp"]).first
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
assert await card.locator(SEL["ext_active_label"]).count() == 1
assert await card.locator(SEL["ext_activate_btn"]).count() == 0
@@ -245,9 +260,9 @@ async def test_installed_mcp_server_active(page):
async def test_installed_mcp_server_inactive_shows_activate(page):
"""Inactive MCP server shows Activate button."""
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
await go_to_mcp(page)
await go_to_extensions(page)
card = page.locator(SEL["ext_card_mcp"]).first
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
assert await card.locator(SEL["ext_activate_btn"]).count() == 1
@@ -255,7 +270,7 @@ async def test_installed_mcp_server_inactive_shows_activate(page):
async def test_mcp_server_in_registry_not_installed(page):
"""Registry MCP entry (not installed) appears in the MCP section with Install button."""
await mock_ext_apis(page, registry=[_REGISTRY_MCP])
await go_to_mcp(page)
await go_to_extensions(page)
mcp_list = page.locator(SEL["mcp_servers_list"])
card = mcp_list.locator(".ext-card").first
@@ -270,7 +285,7 @@ async def test_mcp_server_installed_auth_dot(page):
installed_mcp = {**_MCP_ACTIVE, "name": "registry-mcp", "authenticated": False}
registry_mcp = {**_REGISTRY_MCP, "name": "registry-mcp"}
await mock_ext_apis(page, installed=[installed_mcp], registry=[registry_mcp])
await go_to_mcp(page)
await go_to_extensions(page)
mcp_list = page.locator(SEL["mcp_servers_list"])
card = mcp_list.locator(".ext-card").first
@@ -284,9 +299,8 @@ async def test_mcp_server_installed_auth_dot(page):
async def _load_wasm_channel(page, activation_status, activation_error=None):
ext = {**_WASM_CHANNEL, "activation_status": activation_status, "activation_error": activation_error}
await mock_ext_apis(page, installed=[ext])
await go_to_channels(page)
# Find the WASM channel card specifically (not built-in channel cards)
card = page.locator(SEL["channels_ext_card"], has_text="Test Channel").first
await go_to_extensions(page)
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
return card
@@ -432,9 +446,9 @@ async def test_install_wasm_channel_triggers_configure(page):
await page.route("**/api/extensions/test-channel/setup", handle_channel_setup)
await page.route("**/api/extensions/install", handle_channel_install)
await go_to_channels(page)
await go_to_extensions(page)
install_btn = page.locator(SEL["channels_ext_card"]).locator(SEL["ext_install_btn"]).first
install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first
await install_btn.wait_for(state="visible", timeout=5000)
await install_btn.click()
@@ -509,14 +523,13 @@ async def test_remove_installed_extension_confirmed(page):
# Override for subsequent calls
await page.route("**/api/extensions*", handle_ext_empty)
# Auto-accept confirm dialog
await page.evaluate("window.confirm = () => true")
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click()
# Confirm via custom modal
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
await page.locator(SEL["confirm_modal_btn"]).click()
# Card should disappear
await page.wait_for_function(
"() => document.querySelectorAll('#extensions-list .ext-card').length === 0",
@@ -530,14 +543,13 @@ async def test_remove_cancelled_keeps_card(page):
await mock_ext_apis(page, installed=[_WASM_TOOL])
await go_to_extensions(page)
# Reject the confirm dialog
await page.evaluate("window.confirm = () => false")
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click()
# Cancel via custom modal
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
await page.locator(SEL["confirm_modal_cancel"]).click()
assert await page.locator(SEL["ext_card_installed"]).count() >= 1, "Card should remain after cancel"
@@ -961,10 +973,14 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await go_to_extensions(page)
@@ -973,9 +989,6 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth")
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1
# Inject a counter to confirm refreshCurrentSettingsTab is called
await page.evaluate("window.__refreshCount = 0; var _origRefresh = refreshCurrentSettingsTab; refreshCurrentSettingsTab = function() { window.__refreshCount++; _origRefresh(); };")
await page.evaluate("""
handleAuthCompleted({
extension_name: 'gmail',
@@ -986,11 +999,14 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
await wait_for_toast(page, "OAuth flow expired. Please try again.")
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0
assert (
await page.locator(
SEL["toast_error"], has_text="OAuth flow expired. Please try again."
).count()
>= 1
)
# Wait for the refresh to complete
await page.wait_for_function("() => window.__refreshCount > 0", timeout=5000)
# Give the async fetch time to complete
await page.wait_for_timeout(1000)
await page.wait_for_timeout(600)
assert len(reload_count) > count_before, "Extensions list did not reload after auth failure"
@@ -1010,9 +1026,9 @@ async def test_activate_mcp_server_success(page):
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_mcp(page)
await go_to_extensions(page)
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
async with page.expect_response("**/api/extensions/test-mcp-inactive/activate", timeout=5000):
@@ -1035,9 +1051,9 @@ async def test_activate_awaiting_token_opens_configure(page):
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await page.route("**/api/extensions/test-mcp-inactive/setup", handle_setup)
await go_to_mcp(page)
await go_to_extensions(page)
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1054,9 +1070,9 @@ async def test_activate_failure_shows_error_toast(page):
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": False, "message": "Config missing"}))
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_mcp(page)
await go_to_extensions(page)
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1072,9 +1088,9 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"}))
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_mcp(page)
await go_to_extensions(page)
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1090,7 +1106,7 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
# ─── Group J: Tab reload behaviour ────────────────────────────────────────────
async def test_extensions_tab_reloads_on_revisit(page):
"""loadExtensions() is called again when re-navigating to the extensions subtab."""
"""loadExtensions() is called again when re-navigating to the extensions tab."""
call_count = []
async def counting_handler(route):
@@ -1105,10 +1121,14 @@ async def test_extensions_tab_reloads_on_revisit(page):
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
# First visit
@@ -1128,6 +1148,48 @@ async def test_extensions_tab_reloads_on_revisit(page):
assert count_after_second > count_after_first, "loadExtensions not called on return visit"
async def test_auth_completed_sse_triggers_extensions_reload(page):
"""auth_completed SSE event while on the extensions tab triggers a reload."""
reload_count = []
async def counting_handler(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
reload_count.append(1)
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"extensions": []}),
)
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await go_to_extensions(page)
count_before = len(reload_count)
# Simulate auth_completed via the shared handler.
await page.evaluate("""
handleAuthCompleted({
extension_name: 'reload-ext',
success: true,
message: 'Reloaded.',
});
""")
await page.wait_for_timeout(600)
assert len(reload_count) > count_before, "loadExtensions was not called after auth_completed"
# ─── Regression tests ─────────────────────────────────────────────────────────
# Each test below is a regression for a specific bug found after the initial
# test suite was written. The bug description is in the docstring.
@@ -1205,9 +1267,9 @@ async def test_oauth_url_injection_blocked(page):
)
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_mcp(page)
await go_to_extensions(page)
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
+7 -18
View File
@@ -4,18 +4,11 @@ import pytest
from helpers import SEL
async def go_to_skills(page):
"""Navigate to Settings > Skills subtab."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="skills")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="skills")).wait_for(
state="visible", timeout=5000
)
async def test_skills_tab_visible(page):
"""Skills subtab shows the search interface."""
await go_to_skills(page)
"""Skills tab shows the search interface."""
await page.locator(SEL["tab_button"].format(tab="skills")).click()
panel = page.locator(SEL["tab_panel"].format(tab="skills"))
await panel.wait_for(state="visible", timeout=5000)
search_input = page.locator(SEL["skill_search_input"])
assert await search_input.is_visible(), "Skills search input not visible"
@@ -23,7 +16,7 @@ async def test_skills_tab_visible(page):
async def test_skills_search(page):
"""Search ClawHub for skills and verify results appear."""
await go_to_skills(page)
await page.locator(SEL["tab_button"].format(tab="skills")).click()
search_input = page.locator(SEL["skill_search_input"])
await search_input.fill("markdown")
@@ -42,7 +35,7 @@ async def test_skills_search(page):
async def test_skills_install_and_remove(page):
"""Install a skill from search results, then remove it."""
await go_to_skills(page)
await page.locator(SEL["tab_button"].format(tab="skills")).click()
# Search
search_input = page.locator(SEL["skill_search_input"])
@@ -75,14 +68,10 @@ async def test_skills_install_and_remove(page):
installed_count = await installed.count()
assert installed_count >= 1, "Skill should appear in installed list after install"
# Remove the skill via confirm modal
# Remove the skill (confirm is already overridden)
remove_btn = installed.first.locator("button", has_text="Remove")
if await remove_btn.count() > 0:
await remove_btn.click()
# Confirm in the modal
confirm_btn = page.locator(SEL["confirm_modal_btn"])
await confirm_btn.wait_for(state="visible", timeout=5000)
await confirm_btn.click()
# Wait for the card to disappear or list to shrink
await page.wait_for_timeout(3000)
new_count = await page.locator(SEL["skill_installed"]).count()
@@ -33,16 +33,14 @@ _TELEGRAM_ACTIVE = {
}
async def go_to_channels(page):
"""Navigate to Settings → Channels subtab (where wasm_channel extensions live)."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="channels")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="channels")).wait_for(
async def go_to_extensions(page):
await page.locator(SEL["tab_button"].format(tab="extensions")).click()
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for(
state="visible", timeout=5000
)
await page.locator(SEL["channels_ext_card"]).first.wait_for(
state="visible", timeout=8000
)
await page.locator(
f"{SEL['extensions_list']} .empty-state, {SEL['ext_card_installed']}"
).first.wait_for(state="visible", timeout=8000)
async def mock_extension_lists(page, ext_handler):
@@ -67,18 +65,10 @@ async def mock_extension_lists(page, ext_handler):
body=json.dumps({"entries": []}),
)
async def handle_gateway_status(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"enabled_channels": [], "sse_connections": 0, "ws_connections": 0}),
)
# Register the broad route first so the specific endpoints below win.
await page.route("**/api/extensions*", handle_ext_list)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await page.route("**/api/gateway/status", handle_gateway_status)
async def wait_for_toast(page, text: str, *, timeout: int = 5000):
@@ -116,9 +106,9 @@ async def test_telegram_setup_modal_shows_bot_token_field(page):
await mock_extension_lists(page, handle_ext_list)
await page.route("**/api/extensions/telegram/setup", handle_setup)
await go_to_channels(page)
await go_to_extensions(page)
card = page.locator(SEL["channels_ext_card"]).first
card = page.locator(SEL["ext_card_installed"]).first
await card.locator(SEL["ext_configure_btn"], has_text="Setup").click()
modal = page.locator(SEL["configure_modal"])
@@ -208,9 +198,9 @@ async def test_telegram_hot_activation_transitions_installed_to_active(page):
await mock_extension_lists(page, handle_ext_list)
await page.route("**/api/extensions/telegram/setup", handle_setup)
await go_to_channels(page)
await go_to_extensions(page)
card = page.locator(SEL["channels_ext_card"]).first
card = page.locator(SEL["ext_card_installed"]).first
await card.locator(SEL["ext_configure_btn"], has_text="Setup").click()
modal = page.locator(SEL["configure_modal"])
+4 -4
View File
@@ -507,10 +507,10 @@ async def test_configure_noninstalled(ironclaw_server):
async def test_extensions_tab_shows_registry(page):
"""Extensions subtab loads and shows available extensions from registry."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click()
panel = page.locator(SEL["settings_subpanel"].format(subtab="extensions"))
"""Extensions tab loads and shows available extensions from registry."""
tab_btn = page.locator(SEL["tab_button"].format(tab="extensions"))
await tab_btn.click()
panel = page.locator(SEL["tab_panel"].format(tab="extensions"))
await panel.wait_for(state="visible", timeout=5000)
available_section = page.locator(SEL["available_wasm_list"])
+20 -175
View File
@@ -142,18 +142,16 @@ mod tests {
match &routine.action {
RoutineAction::Lightweight {
prompt,
context_paths,
use_tools,
max_tool_rounds,
..
} => {
assert!(prompt.contains("Check system status"));
assert_eq!(context_paths, &vec!["context/priorities.md".to_string()]);
assert!(*use_tools, "lightweight routine should keep use_tools=true");
assert_eq!(*max_tool_rounds, 2);
}
other => panic!("expected lightweight routine action, got {other:?}"),
other => panic!("expected lightweight action, got {other:?}"),
}
assert_eq!(routine.notify.channel.as_deref(), Some("telegram"));
@@ -371,132 +369,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 8: routine_create_grouped
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_create_grouped() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_create_grouped.json"
))
.expect("failed to load routine_create_grouped.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Create a grouped cron routine with delivery settings")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let routine = rig
.database()
.get_routine_by_name("test-user", "weekday-digest")
.await
.expect("get_routine_by_name")
.expect("weekday-digest should exist");
match &routine.trigger {
Trigger::Cron { schedule, timezone } => {
assert_eq!(schedule, "0 0 9 * * MON-FRI");
assert_eq!(timezone.as_deref(), Some("UTC"));
}
other => panic!("expected cron trigger, got {other:?}"),
}
match &routine.action {
RoutineAction::FullJob {
description,
tool_permissions,
..
} => {
assert!(description.contains("Prepare the morning digest"));
assert_eq!(
tool_permissions,
&vec!["message".to_string(), "http".to_string()]
);
}
other => panic!("expected full_job action, got {other:?}"),
}
assert_eq!(routine.notify.channel.as_deref(), Some("telegram"));
assert_eq!(routine.notify.user.as_deref(), Some("ops-team"));
assert_eq!(routine.guardrails.cooldown.as_secs(), 30);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 9: routine_system_event_emit_grouped
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_system_event_emit_grouped() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_system_event_emit_grouped.json"
))
.expect("failed to load routine_system_event_emit_grouped.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Create a grouped system-event routine and emit a matching event")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let routine = rig
.database()
.get_routine_by_name("test-user", "grouped-gh-issue-watch")
.await
.expect("get_routine_by_name")
.expect("grouped-gh-issue-watch should exist");
match &routine.trigger {
Trigger::SystemEvent {
source,
event_type,
filters,
} => {
assert_eq!(source, "github");
assert_eq!(event_type, "issue.opened");
assert_eq!(
filters.get("repository").map(String::as_str),
Some("nearai/ironclaw")
);
assert_eq!(filters.get("priority").map(String::as_str), Some("p1"));
}
other => panic!("expected system_event trigger, got {other:?}"),
}
let results = rig.tool_results();
let emit_result = results
.iter()
.find(|(n, _)| n == "event_emit")
.expect("event_emit result missing");
let emit_json: serde_json::Value =
serde_json::from_str(&emit_result.1).expect("event_emit result should be valid JSON");
assert!(
emit_json["fired_routines"].as_u64().unwrap_or(0) > 0,
"event_emit should have fired at least one grouped routine: {:?}",
emit_result.1
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 10: skill_install_routine_webhook_sim
// Test 8: skill_install_routine_webhook_sim
// -----------------------------------------------------------------------
#[tokio::test]
@@ -698,11 +571,10 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test: tool_info_discovery (three-level detail)
// Test: tool_info_discovery (two-level detail)
// -----------------------------------------------------------------------
// Verifies the tool_info built-in returns:
// - Default (no include_schema): name, description, parameter names array
// - `detail: "summary"`: curated summary guidance
// - With include_schema: true: adds full typed JSON Schema
#[tokio::test]
@@ -725,13 +597,13 @@ mod tests {
rig.verify_trace_expects(&trace, &responses);
// tool_info should have been called three times (echo + routine_create + time), all succeeding.
// tool_info should have been called twice (echo + time), both succeeding.
let completed = rig.tool_calls_completed();
let tool_info_calls: Vec<_> = completed.iter().filter(|(n, _)| n == "tool_info").collect();
assert_eq!(
tool_info_calls.len(),
3,
"Expected 3 tool_info calls, got {tool_info_calls:?}"
2,
"Expected 2 tool_info calls, got {tool_info_calls:?}"
);
assert!(
tool_info_calls.iter().all(|(_, ok)| *ok),
@@ -741,71 +613,44 @@ mod tests {
// Verify the results contain expected fields.
let results = rig.tool_results();
let info_results: Vec<_> = results.iter().filter(|(n, _)| n == "tool_info").collect();
let info_json: Vec<serde_json::Value> = info_results
.iter()
.map(|(_, preview)| {
serde_json::from_str(preview)
.expect("tool_info result preview should be valid JSON")
})
.collect();
// First call was for "echo" (default, no include_schema) — result should
// contain "echo" and "parameters" as an array of names (not full schema).
let echo_json = info_json
let echo_result = info_results
.iter()
.find(|info| info["name"] == "echo")
.find(|(_, preview)| preview.contains("echo"))
.expect("tool_info result should contain 'echo'");
assert!(
echo_json["parameters"]
.as_array()
.is_some_and(|params| params.iter().any(|param| param == "message")),
echo_result.1.contains("message"),
"echo default result should list 'message' parameter name: {:?}",
echo_json
echo_result.1
);
// Default mode should NOT include the full "schema" key
let echo_json: serde_json::Value = serde_json::from_str(&echo_result.1)
.expect("echo tool_info result should be valid JSON");
assert!(
echo_json.get("schema").is_none(),
"Default tool_info should not include schema field: {:?}",
echo_json
echo_result.1
);
// Second call was for "routine_create" with detail: "summary" — result
// should contain a summary object with rules/examples.
let routine_json = info_json
.iter()
.find(|info| info["name"] == "routine_create")
.expect("tool_info result should contain 'routine_create'");
assert!(
routine_json.get("summary").is_some(),
"detail: summary should include summary field: {:?}",
routine_json
);
assert!(
routine_json["summary"]["conditional_requirements"]
.as_array()
.is_some_and(|rules| rules.iter().any(|rule| {
rule.as_str()
.is_some_and(|rule| rule.contains("request.kind='cron'"))
})),
"routine_create summary should mention cron requirement: {:?}",
routine_json
);
// Third call was for "time" with include_schema: true — result should
// Second call was for "time" with include_schema: true — result should
// contain "time", "schema" field with full object.
let time_json = info_json
let time_result = info_results
.iter()
.find(|info| info["name"] == "time")
.find(|(_, preview)| preview.contains("time"))
.expect("tool_info result should contain 'time'");
let time_json: serde_json::Value = serde_json::from_str(&time_result.1)
.expect("time tool_info result should be valid JSON");
assert!(
time_json.get("schema").is_some(),
"include_schema: true should include schema field: {:?}",
time_json
time_result.1
);
assert!(
time_json["schema"]["properties"].is_object(),
"schema should have properties: {:?}",
time_json
time_result.1
);
rig.shutdown();
+10 -245
View File
@@ -238,13 +238,7 @@ mod tests {
"default",
"deploy to production now",
);
let fired = engine
.check_event_triggers(
&matching_msg.user_id,
&matching_msg.channel,
&matching_msg.content,
)
.await;
let fired = engine.check_event_triggers(&matching_msg).await;
assert!(
fired >= 1,
"Expected >= 1 routine fired on match, got {fired}"
@@ -261,13 +255,7 @@ mod tests {
"default",
"check the staging environment",
);
let fired_neg = engine
.check_event_triggers(
&non_matching_msg.user_id,
&non_matching_msg.channel,
&non_matching_msg.content,
)
.await;
let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match");
}
@@ -327,9 +315,7 @@ mod tests {
"guest-sender",
"deploy to production now",
);
let guest_fired = engine
.check_event_triggers(&guest_msg.user_id, &guest_msg.channel, &guest_msg.content)
.await;
let guest_fired = engine.check_event_triggers(&guest_msg).await;
assert_eq!(
guest_fired, 0,
"Guest scope must not fire owner event routines"
@@ -352,9 +338,7 @@ mod tests {
"owner-sender",
"deploy to production now",
);
let owner_fired = engine
.check_event_triggers(&owner_msg.user_id, &owner_msg.channel, &owner_msg.content)
.await;
let owner_fired = engine.check_event_triggers(&owner_msg).await;
assert!(
owner_fired >= 1,
"Owner scope should fire matching owner event routine"
@@ -578,9 +562,7 @@ mod tests {
"default",
"test-cooldown trigger",
);
let fired1 = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
let fired1 = engine.check_event_triggers(&msg).await;
assert!(fired1 >= 1, "First fire should work");
// Give spawn time, then update last_run_at to simulate recent execution.
@@ -595,9 +577,7 @@ mod tests {
engine.refresh_event_cache().await;
// Second fire should be blocked by cooldown.
let fired2 = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
let fired2 = engine.check_event_triggers(&msg).await;
assert_eq!(fired2, 0, "Second fire should be blocked by cooldown");
}
@@ -765,9 +745,7 @@ mod tests {
engine.refresh_event_cache().await;
let msg = IncomingMessage::new("test", "default", "DISABLE_ME");
let fired_before = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
let fired_before = engine.check_event_triggers(&msg).await;
assert!(fired_before >= 1, "Expected routine to fire before disable");
// Simulate what routines_toggle_handler now does: update DB, then refresh.
@@ -776,9 +754,7 @@ mod tests {
db.update_routine(&routine).await.expect("update_routine");
engine.refresh_event_cache().await;
let fired_after = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
let fired_after = engine.check_event_triggers(&msg).await;
assert_eq!(
fired_after, 0,
"Disabled routine must not fire after cache refresh"
@@ -804,10 +780,7 @@ mod tests {
let msg = IncomingMessage::new("test", "default", "DELETE_ME");
assert!(
engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await
>= 1,
engine.check_event_triggers(&msg).await >= 1,
"Expected routine to fire before delete"
);
@@ -816,217 +789,9 @@ mod tests {
engine.refresh_event_cache().await;
assert_eq!(
engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await,
engine.check_event_triggers(&msg).await,
0,
"Deleted routine must not fire after cache refresh"
);
}
// -----------------------------------------------------------------------
// Test: full_job per-routine concurrency blocks second fire (issue #1318)
// -----------------------------------------------------------------------
#[tokio::test]
async fn full_job_max_concurrent_blocks_second_fire_while_first_active() {
use ironclaw::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
};
use ironclaw::error::RoutineError;
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
// Stub LLM — fire_manual will be rejected before any LLM call
let trace = LlmTrace::single_turn(
"stub",
"stub",
vec![TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "ROUTINE_OK".to_string(),
input_tokens: 10,
output_tokens: 5,
},
expected_tool_results: vec![],
}],
);
let llm = Arc::new(TraceLlm::from_trace(trace));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(4);
let tools = Arc::new(ToolRegistry::new());
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let engine = Arc::new(RoutineEngine::new(
RoutineConfig::default(),
db.clone(),
llm,
ws,
notify_tx,
None, // no scheduler — rejected before dispatch
tools,
safety,
));
// Create a full_job routine with max_concurrent = 1
let routine = Routine {
id: Uuid::new_v4(),
name: "concurrent-guard".to_string(),
description: "test max_concurrent for full_job".to_string(),
user_id: "default".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::FullJob {
title: "t".to_string(),
description: "d".to_string(),
max_iterations: 3,
tool_permissions: vec![],
},
guardrails: RoutineGuardrails {
cooldown: Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
db.create_routine(&routine).await.expect("create_routine");
// Simulate first full_job run still active: the fix keeps the
// routine_run in Running state while the linked job executes.
let active_run = RoutineRun {
id: Uuid::new_v4(),
routine_id: routine.id,
trigger_type: "cron".to_string(),
trigger_detail: None,
started_at: Utc::now(),
completed_at: None,
status: RunStatus::Running,
result_summary: None,
tokens_used: None,
job_id: None,
created_at: Utc::now(),
};
db.create_routine_run(&active_run)
.await
.expect("create_routine_run");
// Attempt to fire the same routine again — must be rejected
let result = engine.fire_manual(routine.id, None).await;
assert!(
matches!(result, Err(RoutineError::MaxConcurrent { .. })),
"second fire while first full_job active must be rejected by max_concurrent=1, got: {:?}",
result
);
}
// -----------------------------------------------------------------------
// Test: global running_count tracks live full_job runs (issue #1318)
// -----------------------------------------------------------------------
#[tokio::test]
async fn global_concurrency_counts_live_full_job_runs() {
use std::sync::atomic::Ordering;
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
let trace = LlmTrace::single_turn(
"test-global-limit",
"check",
vec![TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "ROUTINE_OK".to_string(),
input_tokens: 50,
output_tokens: 5,
},
expected_tool_results: vec![],
}],
);
let llm = Arc::new(TraceLlm::from_trace(trace));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
let tools = Arc::new(ToolRegistry::new());
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: true,
}));
// Configure global limit of 1
let config = RoutineConfig {
max_concurrent_routines: 1,
..RoutineConfig::default()
};
let engine = Arc::new(RoutineEngine::new(
config,
db.clone(),
llm,
ws,
notify_tx,
None,
tools,
safety,
));
// Insert a due cron routine
let mut routine = make_routine(
"global-limit-test",
Trigger::Cron {
schedule: "* * * * *".to_string(),
timezone: None,
},
"Check status.",
);
routine.next_fire_at = Some(Utc::now() - chrono::Duration::minutes(1));
db.create_routine(&routine).await.expect("create_routine");
// Simulate one full_job from another routine holding the global slot.
// With the fix, running_count stays elevated for the full job duration.
engine
.running_count_for_test()
.fetch_add(1, Ordering::Relaxed);
// check_cron_triggers should see global limit hit and skip
engine.check_cron_triggers().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let runs = db
.list_routine_runs(routine.id, 10)
.await
.expect("list_routine_runs");
assert!(
runs.is_empty(),
"cron routine must not fire when global limit is reached by live full_job"
);
// Release the global slot
engine
.running_count_for_test()
.fetch_sub(1, Ordering::Relaxed);
// Now the routine should fire
engine.check_cron_triggers().await;
tokio::time::sleep(Duration::from_millis(200)).await;
// Because the first check skipped it, next_fire_at is unchanged —
// the second check should see it as still due and fire it.
let runs_after = db
.list_routine_runs(routine.id, 10)
.await
.expect("list_routine_runs");
assert!(
!runs_after.is_empty(),
"cron routine should fire after global slot is released"
);
}
}
-1
View File
@@ -198,7 +198,6 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
let gateway = Arc::new(TestChannel::new());
@@ -1,66 +0,0 @@
{
"model_name": "test-routine-create-grouped",
"expects": {
"tools_used": ["routine_create", "routine_list"],
"all_tools_succeeded": true,
"min_responses": 1
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rc_grouped_1",
"name": "routine_create",
"arguments": {
"name": "weekday-digest",
"prompt": "Prepare the morning digest for the ops team.",
"description": "Weekday digest for morning operations",
"request": {
"kind": "cron",
"schedule": "0 0 9 * * MON-FRI",
"timezone": "UTC"
},
"execution": {
"mode": "full_job",
"tool_permissions": ["message", "http"]
},
"delivery": {
"channel": "telegram",
"user": "ops-team"
},
"advanced": {
"cooldown_secs": 30
}
}
}
],
"input_tokens": 130,
"output_tokens": 44
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rl_grouped_1",
"name": "routine_list",
"arguments": {}
}
],
"input_tokens": 190,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "Created the weekday-digest routine with a grouped cron request and listed the active routines.",
"input_tokens": 250,
"output_tokens": 24
}
}
]
}
@@ -1,74 +0,0 @@
{
"model_name": "test-routine-system-event-emit-grouped",
"expects": {
"tools_used": ["routine_create", "event_emit"],
"all_tools_succeeded": true,
"tool_results_contain": {
"event_emit": "fired_routines"
}
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rc_grouped_system_1",
"name": "routine_create",
"arguments": {
"name": "grouped-gh-issue-watch",
"prompt": "Summarize the new issue and propose next steps.",
"description": "React to important GitHub issue.opened events",
"request": {
"kind": "system_event",
"source": "github",
"event_type": "issue.opened",
"filters": {
"repository": "nearai/ironclaw",
"priority": "p1"
}
},
"execution": {
"mode": "full_job",
"tool_permissions": ["shell"]
}
}
}
],
"input_tokens": 120,
"output_tokens": 40
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_ee_grouped_1",
"name": "event_emit",
"arguments": {
"event_source": "github",
"event_type": "issue.opened",
"payload": {
"repository": "nearai/ironclaw",
"priority": "p1",
"issue_number": 123,
"title": "Support grouped routine create requests"
}
}
}
],
"input_tokens": 180,
"output_tokens": 30
}
},
{
"response": {
"type": "text",
"content": "Created the grouped system-event routine and emitted a matching GitHub event.",
"input_tokens": 230,
"output_tokens": 18
}
}
]
}
+4 -18
View File
@@ -24,20 +24,6 @@
"output_tokens": 20
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_tool_info_routine_create",
"name": "tool_info",
"arguments": { "name": "routine_create", "detail": "summary" }
}
],
"input_tokens": 160,
"output_tokens": 25
}
},
{
"response": {
"type": "tool_calls",
@@ -48,16 +34,16 @@
"arguments": { "name": "time", "include_schema": true }
}
],
"input_tokens": 240,
"input_tokens": 200,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "I found the info for all three tools. The echo tool has a 'message' parameter. routine_create's summary explains that cron needs request.schedule, message_event needs request.pattern, and system_event needs request.source plus request.event_type. The time tool accepts an 'operation' parameter with options like 'now', 'parse', and 'diff'.",
"input_tokens": 520,
"output_tokens": 60
"content": "I found the info for both tools. The echo tool has a 'message' parameter. The time tool accepts an 'operation' parameter with options like 'now', 'parse', and 'diff'.",
"input_tokens": 400,
"output_tokens": 40
}
}
]
-2
View File
@@ -214,7 +214,6 @@ async fn start_test_server_with_provider(
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
@@ -706,7 +705,6 @@ async fn test_no_llm_provider_returns_503() {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
@@ -234,7 +234,6 @@ impl GatewayWorkflowHarness {
cost_guard: Some(Arc::clone(&components.cost_guard)),
routine_engine: Arc::clone(&routine_slot),
startup_time: Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let mut agent = Agent::new(
@@ -257,7 +256,6 @@ impl GatewayWorkflowHarness {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
},
channels,
None,
-1
View File
@@ -642,7 +642,6 @@ impl TestRigBuilder {
},
transcription: None,
document_extraction: None,
builder: None,
};
// 7. Create TestChannel and ChannelManager.
-1
View File
@@ -62,7 +62,6 @@ async fn start_test_server() -> (
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();