diff --git a/.env.example b/.env.example index 55c3adb5..8fd44c5a 100644 --- a/.env.example +++ b/.env.example @@ -78,7 +78,7 @@ NEARAI_AUTH_URL=https://private.near.ai # === MiniMax === # LLM_BACKEND=minimax # MINIMAX_API_KEY=... -# MINIMAX_MODEL=MiniMax-M2.5 +# MINIMAX_MODEL=MiniMax-M2.7 # MINIMAX_BASE_URL=https://api.minimax.io/v1 # default (global); use https://api.minimaxi.com/v1 for China # === Anthropic Direct === diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e7371677..2f885b16 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -174,7 +174,7 @@ jobs: - name: Run E2E tests run: | - pytest tests/e2e/ -v -x --timeout=120 + pytest tests/e2e/ -v --timeout=120 env: RUST_LOG: ironclaw=info RUST_BACKTRACE: "1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c4d103..6aad4993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,153 @@ 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 diff --git a/Cargo.lock b/Cargo.lock index 854d103a..2c5547e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3436,7 +3436,7 @@ dependencies = [ [[package]] name = "ironclaw" -version = "0.18.0" +version = "0.19.0" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index b396b18d..5b452651 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ exclude = [ [package] name = "ironclaw" -version = "0.18.0" +version = "0.19.0" edition = "2024" rust-version = "1.92" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 00000000..887cf67e --- /dev/null +++ b/README.ja.md @@ -0,0 +1,330 @@ +

+ IronClaw +

+ +

IronClaw

+ +

+ あなたの味方になる、安全なパーソナルAIアシスタント +

+ +

+ License: MIT OR Apache-2.0 + Telegram: @ironclawAI + Reddit: r/ironclawAI +

+ +

+ English | + 简体中文 | + Русский | + 日本語 +

+ +

+ フィロソフィー • + 機能 • + インストール • + 設定 • + セキュリティ • + アーキテクチャ +

+ +--- + +## フィロソフィー + +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/)をご覧ください。 + +
+ Windowsインストーラーでインストール(Windows) + +[Windowsインストーラー](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)をダウンロードして実行してください。 + +
+ +
+ PowerShellスクリプトでインストール(Windows) + +```sh +irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex +``` + +
+ +
+ シェルスクリプトでインストール(macOS、Linux、Windows/WSL) + +```sh +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh +``` +
+ +
+ Homebrewでインストール(macOS/Linux) + +```sh +brew install ironclaw +``` + +
+ +
+ ソースコードからコンパイル(Windows、Linux、macOSでCargo) + +`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`を実行してチャネルを再ビルドしてください。 + +
+ +### データベースのセットアップ + +```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)) + +お好みに応じて選択してください。 diff --git a/README.md b/README.md index 9684ee4d..fa73dc45 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@

English | 简体中文 | - Русский + Русский | + 日本語

diff --git a/README.ru.md b/README.ru.md index c64770a9..0546e7f4 100644 --- a/README.ru.md +++ b/README.ru.md @@ -17,7 +17,8 @@

English | 简体中文 | - Русский + Русский | + 日本語

diff --git a/README.zh-CN.md b/README.zh-CN.md index 34023822..a337d713 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,7 +17,8 @@

English | 简体中文 | - Русский + Русский | + 日本語

diff --git a/codecov.yml b/codecov.yml index 3e31b00a..723c1175 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,9 +2,13 @@ coverage: status: project: default: - target: auto - threshold: 1% + target: 80% + threshold: 2% patch: default: - target: 80% - threshold: 5% \ No newline at end of file + target: 90% + +comment: + layout: "reach,diff,flags" + behavior: default + require_changes: true diff --git a/docs/LLM_PROVIDERS.md b/docs/LLM_PROVIDERS.md index a581a56b..0623ce25 100644 --- a/docs/LLM_PROVIDERS.md +++ b/docs/LLM_PROVIDERS.md @@ -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.5 models | +| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 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.5` (default), `MiniMax-M2.5-highspeed` +Available models: `MiniMax-M2.7` (default), `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed` To use the China mainland endpoint, set: diff --git a/providers.json b/providers.json index 12723a6f..550edd64 100644 --- a/providers.json +++ b/providers.json @@ -393,8 +393,8 @@ "api_key_required": true, "base_url_env": "MINIMAX_BASE_URL", "model_env": "MINIMAX_MODEL", - "default_model": "MiniMax-M2.5", - "description": "MiniMax API (MiniMax-M2.5 and MiniMax-M2.5-highspeed models)", + "default_model": "MiniMax-M2.7", + "description": "MiniMax API (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5 and MiniMax-M2.5-highspeed models)", "setup": { "kind": "api_key", "secret_name": "llm_minimax_api_key", diff --git a/registry/channels/discord.json b/registry/channels/discord.json index 50ef85ee..dc545d75 100644 --- a/registry/channels/discord.json +++ b/registry/channels/discord.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/discord-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-discord-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "6159cb54aa44a9d8219e29bf0aea9404213b20ff567506fe75f23d4698d6ec18" } }, "auth_summary": { diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json index 0446a442..66cecf1d 100644 --- a/registry/channels/feishu.json +++ b/registry/channels/feishu.json @@ -17,7 +17,12 @@ "capabilities": "feishu.capabilities.json", "crate_name": "feishu-channel" }, - "artifacts": {}, + "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" + } + }, "auth_summary": { "method": "manual", "provider": "Feishu / Lark", diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json index e44061e5..bd07208f 100644 --- a/registry/channels/telegram.json +++ b/registry/channels/telegram.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.3-wasm32-wasip2.tar.gz", - "sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-telegram-0.2.4-wasm32-wasip2.tar.gz", + "sha256": "a7cb300ec1c946831cfceaa95c1dc8f30d0f42a3924f3cb5de8098821573f4b8" } }, "auth_summary": { diff --git a/registry/tools/github.json b/registry/tools/github.json index e775ac82..e760c4df 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -19,8 +19,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/github-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758" } }, "auth_summary": { diff --git a/registry/tools/llm-context.json b/registry/tools/llm-context.json index a647a153..e4e9808c 100644 --- a/registry/tools/llm-context.json +++ b/registry/tools/llm-context.json @@ -21,8 +21,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/llm-context-wasm32-wasip2.tar.gz", - "sha256": "581cc5867ef3b75116b7ddc8161e63dd92befe2b53e6ad8213c007639aa243c3" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz", + "sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e" } }, "auth_summary": { diff --git a/registry/tools/slack.json b/registry/tools/slack.json index 11bd7fff..8e1df989 100644 --- a/registry/tools/slack.json +++ b/registry/tools/slack.json @@ -17,8 +17,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz", - "sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz", + "sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb" } }, "auth_summary": { diff --git a/registry/tools/telegram.json b/registry/tools/telegram.json index 680d6fdb..12e58c68 100644 --- a/registry/tools/telegram.json +++ b/registry/tools/telegram.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.2-wasm32-wasip2.tar.gz", - "sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz", + "sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4" } }, "auth_summary": { diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json index 1722c391..5c1dedef 100644 --- a/registry/tools/web-search.json +++ b/registry/tools/web-search.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/web-search-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218" } }, "auth_summary": { diff --git a/release-plz.toml b/release-plz.toml index ee7037df..b003952d 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -3,4 +3,5 @@ git_release_enable = false [[package]] name = "ironclaw_safety" +publish = false release = false diff --git a/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md index 8afa857d..5e64a2b2 100644 --- a/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md +++ b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md @@ -8,15 +8,21 @@ 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.", - "cooldown_secs": 30 + "request": { + "kind": "system_event", + "source": "github", + "event_type": "issue.opened", + "filters": { + "repository_name": "{{repository}}" + } + }, + "execution": { + "mode": "full_job" + }, + "advanced": { + "cooldown_secs": 30 + } } ``` @@ -28,16 +34,22 @@ 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.", - "cooldown_secs": 20 + "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 + } } ``` @@ -47,15 +59,21 @@ 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.", - "cooldown_secs": 20 + "request": { + "kind": "system_event", + "source": "github", + "event_type": "pr.synchronize", + "filters": { + "repository_name": "{{repository}}" + } + }, + "execution": { + "mode": "full_job" + }, + "advanced": { + "cooldown_secs": 20 + } } ``` @@ -65,16 +83,22 @@ 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.", - "cooldown_secs": 20 + "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 + } } ``` @@ -84,11 +108,17 @@ 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.", - "cooldown_secs": 120 + "request": { + "kind": "cron", + "schedule": "0 0 */{{batch_interval_hours}} * * *" + }, + "execution": { + "mode": "full_job" + }, + "advanced": { + "cooldown_secs": 120 + } } ``` @@ -98,16 +128,22 @@ 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.", - "cooldown_secs": 30 + "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 + } } ``` @@ -115,7 +151,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared ```json { - "source": "github", + "event_source": "github", "event_type": "issue.opened", "payload": { "repository_name": "{{repository}}", diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 83d971ef..132ba4a1 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -161,9 +161,10 @@ pub struct Agent { pub(super) heartbeat_config: Option, pub(super) hygiene_config: Option, pub(super) routine_config: Option, - /// Optional slot to expose the routine engine to the gateway for manual triggering. + /// Shared routine-engine slot used for internal event matching and for exposing + /// the engine to gateway/manual trigger entry points. pub(super) routine_engine_slot: - Option>>>>, + Arc>>>, } impl Agent { @@ -228,16 +229,21 @@ impl Agent { heartbeat_config, hygiene_config, routine_config, - routine_engine_slot: None, + routine_engine_slot: Arc::new(tokio::sync::RwLock::new(None)), } } - /// Set the routine engine slot for exposing the engine to the gateway. + /// Replace the routine-engine slot with a shared one so the gateway and + /// agent reference the same engine. pub fn set_routine_engine_slot( &mut self, slot: Arc>>>, ) { - self.routine_engine_slot = Some(slot); + self.routine_engine_slot = slot; + } + + async fn routine_engine(&self) -> Option> { + self.routine_engine_slot.read().await.clone() } // Convenience accessors @@ -633,9 +639,7 @@ impl Agent { // via a local to use in the message loop below. // Expose engine to gateway for manual triggering - if let Some(ref slot) = self.routine_engine_slot { - *slot.write().await = Some(Arc::clone(&engine)); - } + *self.routine_engine_slot.write().await = Some(Arc::clone(&engine)); tracing::debug!( "Routines enabled: cron ticker every {}s, max {} concurrent", @@ -655,9 +659,6 @@ 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); @@ -693,29 +694,6 @@ 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 @@ -1032,6 +1010,24 @@ 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 } => { diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs index 519f16c2..2487ac05 100644 --- a/src/agent/routine_engine.rs +++ b/src/agent/routine_engine.rs @@ -10,6 +10,7 @@ //! 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; @@ -23,19 +24,19 @@ use crate::agent::Scheduler; use crate::agent::routine::{ NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire, }; -use crate::channels::{IncomingMessage, OutgoingResponse}; +use crate::channels::OutgoingResponse; use crate::config::RoutineConfig; -use crate::context::JobContext; +use crate::context::{JobContext, JobState}; 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 }, @@ -60,6 +61,10 @@ pub struct RoutineEngine { tools: Arc, /// Safety layer for tool output sanitization. safety: Arc, + /// 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, } impl RoutineEngine { @@ -85,9 +90,16 @@ 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 { + &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 { @@ -135,10 +147,19 @@ impl RoutineEngine { /// Check incoming message against event triggers. Returns number of routines fired. /// - /// 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 { + /// 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 { 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 @@ -155,16 +176,9 @@ impl RoutineEngine { } // Single batch query instead of N queries - 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; - } + let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await { + Some(counts) => counts, + None => return 0, }; for matcher in cache.iter() { @@ -173,7 +187,7 @@ impl RoutineEngine { EventMatcher::System { .. } => continue, }; - if routine.user_id != message.user_id { + if routine.user_id != user_id { continue; } @@ -181,13 +195,13 @@ impl RoutineEngine { if let Trigger::Event { channel: Some(ch), .. } = &routine.trigger - && ch != &message.channel + && ch != channel { continue; } // Regex match - if !re.is_match(&message.content) { + if !re.is_match(content) { continue; } @@ -210,7 +224,7 @@ impl RoutineEngine { continue; } - let detail = truncate(&message.content, 200); + let detail = truncate(content, 200); self.spawn_fire(routine.clone(), "event", Some(detail)); fired += 1; } @@ -229,6 +243,15 @@ 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 @@ -245,19 +268,9 @@ impl RoutineEngine { } // Single batch query instead of N queries - 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; - } + let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await { + Some(counts) => counts, + None => return 0, }; for matcher in cache.iter() { @@ -331,6 +344,23 @@ 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> { + 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 { @@ -365,6 +395,230 @@ 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). @@ -508,6 +762,92 @@ 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, + 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, 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) { + 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, @@ -682,8 +1022,10 @@ 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, and returns immediately. The job runs independently via the -/// existing Worker/Scheduler with full tool access. +/// 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. async fn execute_full_job( ctx: &EngineContext, routine: &Routine, @@ -726,25 +1068,30 @@ async fn execute_full_job( reason: format!("failed to dispatch 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 - ); - } + // 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}"), + })?; tracing::info!( routine = %routine.name, job_id = %job_id, max_iterations = max_iterations, - "Dispatched full job for routine" + "Dispatched full job for routine, watching for completion" ); - let summary = format!( - "Dispatched job {job_id} for full execution with tool access (max_iterations: {max_iterations})" - ); - Ok((RunStatus::Ok, Some(summary), None)) + // 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)) } /// Execute a lightweight routine with optional tool support. @@ -784,23 +1131,12 @@ async fn execute_lightweight( Err(_) => None, }; - // 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.", + let full_prompt = build_lightweight_prompt( + prompt, + &context_parts, + state_content.as_deref(), + &routine.notify, + use_tools, ); // Get system prompt @@ -844,6 +1180,65 @@ 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, @@ -901,8 +1296,8 @@ fn handle_text_response( }; } - // Check for the "nothing to do" sentinel - if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") { + // Check for the "nothing to do" sentinel (exact match on trimmed content). + if content == "ROUTINE_OK" { let total_tokens = Some((total_input_tokens + total_output_tokens) as i32); return Ok((RunStatus::Ok, None, total_tokens)); } @@ -1268,14 +1663,22 @@ pub fn spawn_cron_ticker( interval: Duration, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { - // Run one check immediately so routines due at startup don't wait - // an extra full polling interval. + // 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. 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; } }) @@ -1385,22 +1788,78 @@ 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, + ¬ify, + 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, ¬ify, 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() { - // The execute_lightweight_no_tools checks: content == "ROUTINE_OK" || content.contains("ROUTINE_OK") - // After trim(), whitespace is removed + // Sentinel detection uses exact match on trimmed content to avoid + // false positives from substrings like "NOT_ROUTINE_OK". let test_cases = vec![ ("ROUTINE_OK", true), (" ROUTINE_OK ", true), // After trim, whitespace is removed so matches - ("something ROUTINE_OK something", true), - ("ROUTINE_OK is done", true), - ("done ROUTINE_OK", true), + ("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 ("no sentinel here", false), ]; for (content, should_match) in test_cases { let trimmed = content.trim(); - let matches = trimmed == "ROUTINE_OK" || trimmed.contains("ROUTINE_OK"); + let matches = trimmed == "ROUTINE_OK"; assert_eq!( matches, should_match, "Content '{}' sentinel detection should be {}, got {}", @@ -1514,4 +1973,86 @@ 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 + ); + } + } } diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 6ca79831..65f978ac 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -492,8 +492,16 @@ impl near::agent::channel_host::Host for ChannelStoreData { tracing::debug!(body = %truncated, "Response body"); } - // Leak detection on response body (best-effort) - if let Ok(body_str) = std::str::from_utf8(&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_detector .scan_and_clean(body_str) .map_err(|e| format!("Potential secret leak in response: {}", e))?; @@ -3122,6 +3130,19 @@ fn extract_host_from_url(url: &str) -> Option { }) } +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 @@ -4386,6 +4407,22 @@ 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] diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 0d970569..a96f7c7b 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -102,6 +102,7 @@ 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 { @@ -139,6 +140,7 @@ 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); @@ -250,6 +252,12 @@ 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 diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 74cfb240..f0bd4c9a 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -126,6 +126,14 @@ 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, +} + /// Shared state for all gateway handlers. pub struct GatewayState { /// Channel to send messages to the agent loop. @@ -177,6 +185,8 @@ 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. @@ -2932,6 +2942,9 @@ 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(), }) } @@ -2957,6 +2970,9 @@ struct GatewayStatusResponse { actions_this_hour: Option, #[serde(skip_serializing_if = "Option::is_none")] model_usage: Option>, + llm_backend: String, + llm_model: String, + enabled_channels: Vec, } #[cfg(test)] @@ -3153,6 +3169,7 @@ mod tests { cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), + active_config: ActiveConfigSnapshot::default(), }) } diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 3d47810b..e5c43296 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -21,6 +21,7 @@ const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100; let stagedImages = []; let authFlowPending = false; let _ghostSuggestion = ''; +let currentSettingsSubtab = 'inference'; // --- Slash Commands --- @@ -135,6 +136,7 @@ function apiFetch(path, options) { throw new Error(body || (res.status + ' ' + res.statusText)); }); } + if (res.status === 204) return null; return res.json(); }); } @@ -364,8 +366,8 @@ function connectSSE() { debouncedLoadThreads(); } - // Extension setup flows can surface approvals while user is on Extensions tab. - if (currentTab === 'extensions') loadExtensions(); + // Extension setup flows can surface approvals from any settings subtab. + if (currentTab === 'settings') refreshCurrentSettingsTab(); }); eventSource.addEventListener('auth_required', (e) => { @@ -373,11 +375,12 @@ function connectSSE() { }); eventSource.addEventListener('auth_completed', (e) => { - handleAuthCompleted(JSON.parse(e.data)); + const data = JSON.parse(e.data); + handleAuthCompleted(data); }); eventSource.addEventListener('extension_status', (e) => { - if (currentTab === 'extensions') loadExtensions(); + if (currentTab === 'settings') refreshCurrentSettingsTab(); }); eventSource.addEventListener('image_generated', (e) => { @@ -1232,7 +1235,7 @@ function handleAuthCompleted(data) { if (shouldShowChannelConnectedMessage(data.extension_name, data.success)) { addMessage('system', 'Telegram is now connected. You can message me there and I can send you notifications.'); } - if (currentTab === 'extensions') loadExtensions(); + if (currentTab === 'settings') refreshCurrentSettingsTab(); enableChatInput(); } @@ -1877,14 +1880,12 @@ function switchTab(tab) { if (tab === 'jobs') loadJobs(); if (tab === 'routines') loadRoutines(); if (tab === 'logs') applyLogFilters(); - if (tab === 'extensions') { - loadExtensions(); - startPairingPoll(); + if (tab === 'config') loadConfig(); + if (tab === 'settings') { + loadSettingsSubtab(currentSettingsSubtab); } else { stopPairingPoll(); } - if (tab === 'skills') loadSkills(); - if (tab === 'config') loadConfig(); } // --- Memory (filesystem tree) --- @@ -2271,61 +2272,42 @@ var kindLabels = { 'wasm_channel': 'Channel', 'wasm_tool': 'Tool', 'mcp_server': function loadExtensions() { const extList = document.getElementById('extensions-list'); const wasmList = document.getElementById('available-wasm-list'); - const mcpList = document.getElementById('mcp-servers-list'); - const toolsTbody = document.getElementById('tools-tbody'); - const toolsEmpty = document.getElementById('tools-empty'); + extList.innerHTML = renderCardsSkeleton(3); - // Fetch all three in parallel + // Fetch extensions and registry in parallel Promise.all([ apiFetch('/api/extensions').catch(() => ({ extensions: [] })), - apiFetch('/api/extensions/tools').catch(() => ({ tools: [] })), apiFetch('/api/extensions/registry').catch(function(err) { console.warn('registry fetch failed:', err); return { entries: [] }; }), - ]).then(([extData, toolData, registryData]) => { - // Render installed extensions - if (extData.extensions.length === 0) { + ]).then(([extData, registryData]) => { + // Render installed extensions (exclude wasm_channel and mcp_server — shown in their own tabs) + var nonChannelExts = extData.extensions.filter(function(e) { + return e.kind !== 'wasm_channel' && e.kind !== 'mcp_server'; + }); + if (nonChannelExts.length === 0) { extList.innerHTML = '

' + I18n.t('extensions.noInstalled') + '
'; } else { extList.innerHTML = ''; - for (const ext of extData.extensions) { + for (const ext of nonChannelExts) { extList.appendChild(renderExtensionCard(ext)); } } - // Split registry entries by kind - var wasmEntries = registryData.entries.filter(function(e) { return e.kind !== 'mcp_server' && !e.installed; }); - var mcpEntries = registryData.entries.filter(function(e) { return e.kind === 'mcp_server'; }); + // Available extensions (exclude MCP servers and channels — they have their own tabs) + var wasmEntries = registryData.entries.filter(function(e) { + return e.kind !== 'mcp_server' && e.kind !== 'wasm_channel' && e.kind !== 'channel' && !e.installed; + }); - // Available WASM extensions + var wasmSection = document.getElementById('available-wasm-section'); if (wasmEntries.length === 0) { - wasmList.innerHTML = '
' + I18n.t('extensions.noAvailable') + '
'; + if (wasmSection) wasmSection.style.display = 'none'; } else { + if (wasmSection) wasmSection.style.display = ''; wasmList.innerHTML = ''; for (const entry of wasmEntries) { wasmList.appendChild(renderAvailableExtensionCard(entry)); } } - // MCP servers (show both installed and uninstalled) - if (mcpEntries.length === 0) { - mcpList.innerHTML = '
' + I18n.t('mcp.noServers') + '
'; - } else { - mcpList.innerHTML = ''; - for (const entry of mcpEntries) { - var installedExt = extData.extensions.find(function(e) { return e.name === entry.name; }); - mcpList.appendChild(renderMcpServerCard(entry, installedExt)); - } - } - - // Render tools - if (toolData.tools.length === 0) { - toolsTbody.innerHTML = ''; - toolsEmpty.style.display = 'block'; - } else { - toolsEmpty.style.display = 'none'; - toolsTbody.innerHTML = toolData.tools.map((t) => - '' + escapeHtml(t.name) + '' + escapeHtml(t.description) + '' - ).join(''); - } }); } @@ -2391,18 +2373,18 @@ function renderAvailableExtensionCard(entry) { showToast('Opening authentication for ' + entry.display_name, 'info'); openOAuthUrl(res.auth_url); } - loadExtensions(); + refreshCurrentSettingsTab(); // Auto-open configure for WASM channels if (entry.kind === 'wasm_channel') { showConfigureModal(entry.name); } } else { showToast('Install: ' + (res.message || 'unknown error'), 'error'); - loadExtensions(); + refreshCurrentSettingsTab(); } }).catch(function(err) { showToast('Install failed: ' + err.message, 'error'); - loadExtensions(); + refreshCurrentSettingsTab(); }); }); actions.appendChild(installBtn); @@ -2458,6 +2440,13 @@ function renderMcpServerCard(entry, installedExt) { activeLabel.textContent = I18n.t('ext.active'); actions.appendChild(activeLabel); } + if (installedExt.needs_setup || (installedExt.has_auth && installedExt.authenticated)) { + var configBtn = document.createElement('button'); + configBtn.className = 'btn-ext configure'; + configBtn.textContent = installedExt.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure'); + configBtn.addEventListener('click', function() { showConfigureModal(installedExt.name); }); + actions.appendChild(configBtn); + } var removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; removeBtn.textContent = I18n.t('ext.remove'); @@ -2479,10 +2468,10 @@ function renderMcpServerCard(entry, installedExt) { } else { showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error'); } - loadExtensions(); + loadMcpServers(); }).catch(function(err) { showToast(I18n.t('ext.installFailed', { message: err.message }), 'error'); - loadExtensions(); + loadMcpServers(); }); }); actions.appendChild(installBtn); @@ -2502,7 +2491,16 @@ function createReconfigureButton(extName) { function renderExtensionCard(ext) { const card = document.createElement('div'); - card.className = 'ext-card'; + var stateClass = 'state-inactive'; + if (ext.kind === 'wasm_channel') { + var s = ext.activation_status || 'installed'; + if (s === 'active') stateClass = 'state-active'; + else if (s === 'failed') stateClass = 'state-error'; + else if (s === 'pairing') stateClass = 'state-pairing'; + } else if (ext.active) { + stateClass = 'state-active'; + } + card.className = 'ext-card ' + stateClass; const header = document.createElement('div'); header.className = 'ext-header'; @@ -2647,6 +2645,12 @@ function renderExtensionCard(ext) { return card; } +function refreshCurrentSettingsTab() { + if (currentSettingsSubtab === 'extensions') loadExtensions(); + if (currentSettingsSubtab === 'channels') loadChannelsStatus(); + if (currentSettingsSubtab === 'mcp') loadMcpServers(); +} + function activateExtension(name) { apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' }) .then((res) => { @@ -2660,7 +2664,7 @@ function activateExtension(name) { showToast('Opening authentication for ' + name, 'info'); openOAuthUrl(res.auth_url); } - loadExtensions(); + refreshCurrentSettingsTab(); return; } @@ -2676,23 +2680,24 @@ function activateExtension(name) { } else { showToast('Activate failed: ' + res.message, 'error'); } - loadExtensions(); + refreshCurrentSettingsTab(); }) .catch((err) => showToast('Activate failed: ' + err.message, 'error')); } function removeExtension(name) { - if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return; - apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' }) - .then((res) => { - if (!res.success) { - showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error'); - } else { - showToast(I18n.t('ext.removed', { name: name }), 'success'); - } - loadExtensions(); - }) - .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error')); + showConfirmModal(I18n.t('ext.confirmRemove', { name: name }), '', function() { + apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' }) + .then((res) => { + if (!res.success) { + showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error'); + } else { + showToast(I18n.t('ext.removed', { name: name }), 'success'); + } + refreshCurrentSettingsTab(); + }) + .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error')); + }, I18n.t('common.remove'), 'btn-danger'); } function showConfigureModal(name) { @@ -2970,7 +2975,7 @@ function submitConfigureModal(name, fields, options) { }); showToast('Opening OAuth authorization for ' + name, 'info'); openOAuthUrl(res.auth_url); - loadExtensions(); + refreshCurrentSettingsTab(); } // For non-OAuth success: the server always broadcasts auth_completed SSE, // which will show the toast and refresh extensions — no need to do it here too. @@ -3079,7 +3084,7 @@ function approvePairing(channel, code, container) { }).then(res => { if (res.success) { showToast('Pairing approved', 'success'); - loadExtensions(); + refreshCurrentSettingsTab(); } else { showToast(res.message || 'Approve failed', 'error'); } @@ -4185,7 +4190,7 @@ function addMcpServer() { showToast('Added MCP server ' + name, 'success'); document.getElementById('mcp-install-name').value = ''; document.getElementById('mcp-install-url').value = ''; - loadExtensions(); + loadMcpServers(); } else { showToast('Failed to add MCP server: ' + (res.message || 'unknown error'), 'error'); } @@ -4198,6 +4203,7 @@ function addMcpServer() { function loadSkills() { var skillsList = document.getElementById('skills-list'); + skillsList.innerHTML = renderCardsSkeleton(3); apiFetch('/api/skills').then(function(data) { if (!data.skills || data.skills.length === 0) { skillsList.innerHTML = '
' + I18n.t('skills.noInstalled') + '
'; @@ -4214,7 +4220,7 @@ function loadSkills() { function renderSkillCard(skill) { var card = document.createElement('div'); - card.className = 'ext-card'; + card.className = 'ext-card state-active'; var header = document.createElement('div'); header.className = 'ext-header'; @@ -4481,20 +4487,21 @@ function installSkill(nameOrSlug, url, btn) { } function removeSkill(name) { - if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return; - apiFetch('/api/skills/' + encodeURIComponent(name), { - method: 'DELETE', - headers: { 'X-Confirm-Action': 'true' }, - }).then(function(res) { - if (res.success) { - showToast(I18n.t('skills.removed', { name: name }), 'success'); - } else { - showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error'); - } - loadSkills(); - }).catch(function(err) { - showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error'); - }); + showConfirmModal(I18n.t('skills.confirmRemove', { name: name }), '', function() { + apiFetch('/api/skills/' + encodeURIComponent(name), { + method: 'DELETE', + headers: { 'X-Confirm-Action': 'true' }, + }).then(function(res) { + if (res.success) { + showToast(I18n.t('skills.removed', { name: name }), 'success'); + } else { + showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error'); + } + loadSkills(); + }).catch(function(err) { + showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error'); + }); + }, I18n.t('common.remove'), 'btn-danger'); } function installSkillFromForm() { @@ -4523,10 +4530,10 @@ document.addEventListener('keydown', (e) => { const tag = (e.target.tagName || '').toLowerCase(); const inInput = tag === 'input' || tag === 'textarea'; - // Mod+1-6: switch tabs - if (mod && e.key >= '1' && e.key <= '6') { + // Mod+1-5: switch tabs + if (mod && e.key >= '1' && e.key <= '5') { e.preventDefault(); - const tabs = ['chat', 'memory', 'jobs', 'routines', 'extensions', 'skills']; + const tabs = ['chat', 'memory', 'jobs', 'routines', 'settings']; const idx = parseInt(e.key) - 1; if (tabs[idx]) switchTab(tabs[idx]); return; @@ -4566,6 +4573,684 @@ document.addEventListener('keydown', (e) => { } }); +// --- Settings Tab --- + +document.querySelectorAll('.settings-subtab').forEach(function(btn) { + btn.addEventListener('click', function() { + switchSettingsSubtab(btn.getAttribute('data-settings-subtab')); + }); +}); + +function switchSettingsSubtab(subtab) { + currentSettingsSubtab = subtab; + document.querySelectorAll('.settings-subtab').forEach(function(b) { + b.classList.toggle('active', b.getAttribute('data-settings-subtab') === subtab); + }); + document.querySelectorAll('.settings-subpanel').forEach(function(p) { + p.classList.toggle('active', p.id === 'settings-' + subtab); + }); + // Clear search when switching subtabs so stale filters don't apply + var searchInput = document.getElementById('settings-search-input'); + if (searchInput && searchInput.value) { + searchInput.value = ''; + searchInput.dispatchEvent(new Event('input')); + } + loadSettingsSubtab(subtab); +} + +function loadSettingsSubtab(subtab) { + if (subtab === 'inference') loadInferenceSettings(); + else if (subtab === 'agent') loadAgentSettings(); + else if (subtab === 'channels') { loadChannelsStatus(); startPairingPoll(); } + else if (subtab === 'networking') loadNetworkingSettings(); + else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); } + else if (subtab === 'mcp') loadMcpServers(); + else if (subtab === 'skills') loadSkills(); + if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll(); +} + +// --- Structured Settings Definitions --- + +var INFERENCE_SETTINGS = [ + { + group: 'cfg.group.llm', + settings: [ + { key: 'llm_backend', label: 'cfg.llm_backend.label', description: 'cfg.llm_backend.desc', + type: 'select', options: ['nearai', 'anthropic', 'openai', 'ollama', 'openai_compatible', 'tinfoil', 'bedrock'] }, + { key: 'selected_model', label: 'cfg.selected_model.label', description: 'cfg.selected_model.desc', type: 'text' }, + { key: 'ollama_base_url', label: 'cfg.ollama_base_url.label', description: 'cfg.ollama_base_url.desc', type: 'text', + showWhen: { key: 'llm_backend', value: 'ollama' } }, + { key: 'openai_compatible_base_url', label: 'cfg.openai_compatible_base_url.label', description: 'cfg.openai_compatible_base_url.desc', type: 'text', + showWhen: { key: 'llm_backend', value: 'openai_compatible' } }, + { key: 'bedrock_region', label: 'cfg.bedrock_region.label', description: 'cfg.bedrock_region.desc', type: 'text', + showWhen: { key: 'llm_backend', value: 'bedrock' } }, + { key: 'bedrock_cross_region', label: 'cfg.bedrock_cross_region.label', description: 'cfg.bedrock_cross_region.desc', + type: 'select', options: ['us', 'eu', 'apac', 'global'], + showWhen: { key: 'llm_backend', value: 'bedrock' } }, + { key: 'bedrock_profile', label: 'cfg.bedrock_profile.label', description: 'cfg.bedrock_profile.desc', type: 'text', + showWhen: { key: 'llm_backend', value: 'bedrock' } }, + ] + }, + { + group: 'cfg.group.embeddings', + settings: [ + { key: 'embeddings.enabled', label: 'cfg.embeddings_enabled.label', description: 'cfg.embeddings_enabled.desc', type: 'boolean' }, + { key: 'embeddings.provider', label: 'cfg.embeddings_provider.label', description: 'cfg.embeddings_provider.desc', + type: 'select', options: ['openai', 'nearai'] }, + { key: 'embeddings.model', label: 'cfg.embeddings_model.label', description: 'cfg.embeddings_model.desc', type: 'text' }, + ] + }, +]; + +var AGENT_SETTINGS = [ + { + group: 'cfg.group.agent', + settings: [ + { key: 'agent.name', label: 'cfg.agent_name.label', description: 'cfg.agent_name.desc', type: 'text' }, + { key: 'agent.max_parallel_jobs', label: 'cfg.agent_max_parallel_jobs.label', description: 'cfg.agent_max_parallel_jobs.desc', type: 'number' }, + { key: 'agent.job_timeout_secs', label: 'cfg.agent_job_timeout.label', description: 'cfg.agent_job_timeout.desc', type: 'number' }, + { key: 'agent.max_tool_iterations', label: 'cfg.agent_max_tool_iterations.label', description: 'cfg.agent_max_tool_iterations.desc', type: 'number' }, + { key: 'agent.use_planning', label: 'cfg.agent_use_planning.label', description: 'cfg.agent_use_planning.desc', type: 'boolean' }, + { key: 'agent.auto_approve_tools', label: 'cfg.agent_auto_approve.label', description: 'cfg.agent_auto_approve.desc', type: 'boolean' }, + { key: 'agent.default_timezone', label: 'cfg.agent_timezone.label', description: 'cfg.agent_timezone.desc', type: 'text' }, + { key: 'agent.session_idle_timeout_secs', label: 'cfg.agent_session_idle.label', description: 'cfg.agent_session_idle.desc', type: 'number' }, + { key: 'agent.stuck_threshold_secs', label: 'cfg.agent_stuck_threshold.label', description: 'cfg.agent_stuck_threshold.desc', type: 'number' }, + { key: 'agent.max_repair_attempts', label: 'cfg.agent_max_repair.label', description: 'cfg.agent_max_repair.desc', type: 'number' }, + { key: 'agent.max_cost_per_day_cents', label: 'cfg.agent_max_cost.label', description: 'cfg.agent_max_cost.desc', type: 'number', min: 0 }, + { key: 'agent.max_actions_per_hour', label: 'cfg.agent_max_actions.label', description: 'cfg.agent_max_actions.desc', type: 'number', min: 0 }, + { key: 'agent.allow_local_tools', label: 'cfg.agent_allow_local.label', description: 'cfg.agent_allow_local.desc', type: 'boolean' }, + ] + }, + { + group: 'cfg.group.heartbeat', + settings: [ + { key: 'heartbeat.enabled', label: 'cfg.heartbeat_enabled.label', description: 'cfg.heartbeat_enabled.desc', type: 'boolean' }, + { key: 'heartbeat.interval_secs', label: 'cfg.heartbeat_interval.label', description: 'cfg.heartbeat_interval.desc', type: 'number' }, + { key: 'heartbeat.notify_channel', label: 'cfg.heartbeat_notify_channel.label', description: 'cfg.heartbeat_notify_channel.desc', type: 'text' }, + { key: 'heartbeat.notify_user', label: 'cfg.heartbeat_notify_user.label', description: 'cfg.heartbeat_notify_user.desc', type: 'text' }, + { key: 'heartbeat.quiet_hours_start', label: 'cfg.heartbeat_quiet_start.label', description: 'cfg.heartbeat_quiet_start.desc', type: 'number', min: 0, max: 23 }, + { key: 'heartbeat.quiet_hours_end', label: 'cfg.heartbeat_quiet_end.label', description: 'cfg.heartbeat_quiet_end.desc', type: 'number', min: 0, max: 23 }, + { key: 'heartbeat.timezone', label: 'cfg.heartbeat_timezone.label', description: 'cfg.heartbeat_timezone.desc', type: 'text' }, + ] + }, + { + group: 'cfg.group.sandbox', + settings: [ + { key: 'sandbox.enabled', label: 'cfg.sandbox_enabled.label', description: 'cfg.sandbox_enabled.desc', type: 'boolean' }, + { key: 'sandbox.policy', label: 'cfg.sandbox_policy.label', description: 'cfg.sandbox_policy.desc', + type: 'select', options: ['readonly', 'workspace_write', 'full_access'] }, + { key: 'sandbox.timeout_secs', label: 'cfg.sandbox_timeout.label', description: 'cfg.sandbox_timeout.desc', type: 'number', min: 0 }, + { key: 'sandbox.memory_limit_mb', label: 'cfg.sandbox_memory.label', description: 'cfg.sandbox_memory.desc', type: 'number', min: 0 }, + { key: 'sandbox.image', label: 'cfg.sandbox_image.label', description: 'cfg.sandbox_image.desc', type: 'text' }, + ] + }, + { + group: 'cfg.group.routines', + settings: [ + { key: 'routines.max_concurrent', label: 'cfg.routines_max_concurrent.label', description: 'cfg.routines_max_concurrent.desc', type: 'number', min: 0 }, + { key: 'routines.default_cooldown_secs', label: 'cfg.routines_cooldown.label', description: 'cfg.routines_cooldown.desc', type: 'number', min: 0 }, + ] + }, + { + group: 'cfg.group.safety', + settings: [ + { key: 'safety.max_output_length', label: 'cfg.safety_max_output.label', description: 'cfg.safety_max_output.desc', type: 'number', min: 0 }, + { key: 'safety.injection_check_enabled', label: 'cfg.safety_injection_check.label', description: 'cfg.safety_injection_check.desc', type: 'boolean' }, + ] + }, + { + group: 'cfg.group.skills', + settings: [ + { key: 'skills.max_active', label: 'cfg.skills_max_active.label', description: 'cfg.skills_max_active.desc', type: 'number', min: 0 }, + { key: 'skills.max_context_tokens', label: 'cfg.skills_max_tokens.label', description: 'cfg.skills_max_tokens.desc', type: 'number', min: 0 }, + ] + }, + { + group: 'cfg.group.search', + settings: [ + { key: 'search.fusion_strategy', label: 'cfg.search_fusion.label', description: 'cfg.search_fusion.desc', + type: 'select', options: ['rrf', 'weighted'] }, + ] + }, +]; + +function renderSettingsSkeleton(rows) { + var html = '
'; + for (var i = 0; i < (rows || 5); i++) { + var w1 = 100 + Math.floor(Math.random() * 60); + var w2 = 140 + Math.floor(Math.random() * 60); + html += '
'; + } + html += '
'; + return html; +} + +function renderCardsSkeleton(count) { + var html = ''; + for (var i = 0; i < (count || 3); i++) { + html += '
'; + } + return html; +} + +function loadInferenceSettings() { + var container = document.getElementById('settings-inference-content'); + container.innerHTML = renderSettingsSkeleton(6); + + Promise.all([ + apiFetch('/api/settings/export'), + apiFetch('/api/gateway/status').catch(function() { return {}; }), + apiFetch('/v1/models').catch(function() { return { data: [] }; }) + ]).then(function(results) { + var settings = results[0].settings || {}; + var status = results[1]; + var modelsData = results[2]; + var activeValues = { + 'llm_backend': status.llm_backend, + 'selected_model': status.llm_model + }; + // Inject available model IDs as suggestions for the selected_model field + var modelIds = (modelsData.data || []).map(function(m) { return m.id; }).filter(Boolean); + var llmGroup = INFERENCE_SETTINGS[0]; + for (var i = 0; i < llmGroup.settings.length; i++) { + if (llmGroup.settings[i].key === 'selected_model') { + llmGroup.settings[i].suggestions = modelIds; + break; + } + } + container.innerHTML = ''; + renderStructuredSettingsInto(container, INFERENCE_SETTINGS, settings, activeValues); + }).catch(function(err) { + container.innerHTML = '
' + I18n.t('common.loadFailed') + ': ' + + escapeHtml(err.message) + '
'; + }); +} + +function loadAgentSettings() { + loadStructuredSettings('settings-agent-content', AGENT_SETTINGS); +} + +function loadStructuredSettings(containerId, settingsDefs) { + var container = document.getElementById(containerId); + container.innerHTML = renderSettingsSkeleton(8); + + apiFetch('/api/settings/export').then(function(data) { + var settings = data.settings || {}; + container.innerHTML = ''; + renderStructuredSettingsInto(container, settingsDefs, settings, {}); + }).catch(function(err) { + container.innerHTML = '
' + I18n.t('common.loadFailed') + ': ' + + escapeHtml(err.message) + '
'; + }); +} + +function renderStructuredSettingsInto(container, settingsDefs, settings, activeValues) { + for (var gi = 0; gi < settingsDefs.length; gi++) { + var groupDef = settingsDefs[gi]; + var group = document.createElement('div'); + group.className = 'settings-group'; + + var title = document.createElement('div'); + title.className = 'settings-group-title'; + title.textContent = I18n.t(groupDef.group); + group.appendChild(title); + + var rows = []; + for (var si = 0; si < groupDef.settings.length; si++) { + var def = groupDef.settings[si]; + var activeVal = activeValues ? activeValues[def.key] : undefined; + var row = renderStructuredSettingsRow(def, settings[def.key], activeVal); + if (def.showWhen) { + row.setAttribute('data-show-when-key', def.showWhen.key); + row.setAttribute('data-show-when-value', def.showWhen.value); + var currentVal = settings[def.showWhen.key]; + if (currentVal === def.showWhen.value) { + row.classList.remove('hidden'); + } else { + row.classList.add('hidden'); + } + } + rows.push(row); + group.appendChild(row); + } + + container.appendChild(group); + + // Wire up showWhen reactivity for select fields in this group + (function(groupRows, allSettings) { + for (var ri = 0; ri < groupRows.length; ri++) { + var sel = groupRows[ri].querySelector('.settings-select'); + if (sel) { + sel.addEventListener('change', function() { + var changedKey = this.getAttribute('data-setting-key'); + var changedVal = this.value; + for (var rj = 0; rj < groupRows.length; rj++) { + var whenKey = groupRows[rj].getAttribute('data-show-when-key'); + var whenVal = groupRows[rj].getAttribute('data-show-when-value'); + if (whenKey === changedKey) { + if (changedVal === whenVal) { + groupRows[rj].classList.remove('hidden'); + } else { + groupRows[rj].classList.add('hidden'); + } + } + } + }); + } + } + })(rows, settings); + } + + if (container.children.length === 0) { + container.innerHTML = '
' + I18n.t('settings.noSettings') + '
'; + } +} + +function renderStructuredSettingsRow(def, value, activeValue) { + var row = document.createElement('div'); + row.className = 'settings-row'; + + var labelWrap = document.createElement('div'); + labelWrap.className = 'settings-label-wrap'; + + var label = document.createElement('div'); + label.className = 'settings-label'; + label.textContent = I18n.t(def.label); + labelWrap.appendChild(label); + + if (def.description) { + var desc = document.createElement('div'); + desc.className = 'settings-description'; + desc.textContent = I18n.t(def.description); + labelWrap.appendChild(desc); + } + + row.appendChild(labelWrap); + + var inputWrap = document.createElement('div'); + inputWrap.style.display = 'flex'; + inputWrap.style.alignItems = 'center'; + inputWrap.style.gap = '8px'; + + var ariaLabel = I18n.t(def.label) + (def.description ? '. ' + I18n.t(def.description) : ''); + var placeholderText = activeValue ? I18n.t('settings.envValue', { value: activeValue }) : (def.placeholder || I18n.t('settings.envDefault')); + + if (def.type === 'boolean') { + var boolSel = document.createElement('select'); + boolSel.className = 'settings-select'; + boolSel.setAttribute('data-setting-key', def.key); + boolSel.setAttribute('aria-label', ariaLabel); + var boolDefault = document.createElement('option'); + boolDefault.value = ''; + boolDefault.textContent = activeValue !== undefined && activeValue !== null + ? '\u2014 ' + I18n.t('settings.envValue', { value: String(activeValue) }) + ' \u2014' + : '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014'; + if (value === null || value === undefined) boolDefault.selected = true; + boolSel.appendChild(boolDefault); + var boolOn = document.createElement('option'); + boolOn.value = 'true'; + boolOn.textContent = I18n.t('settings.on'); + if (value === true) boolOn.selected = true; + boolSel.appendChild(boolOn); + var boolOff = document.createElement('option'); + boolOff.value = 'false'; + boolOff.textContent = I18n.t('settings.off'); + if (value === false) boolOff.selected = true; + boolSel.appendChild(boolOff); + boolSel.addEventListener('change', (function(k, el) { + return function() { + if (el.value === '') saveSetting(k, null); + else saveSetting(k, el.value === 'true'); + }; + })(def.key, boolSel)); + inputWrap.appendChild(boolSel); + } else if (def.type === 'select' && def.options) { + var sel = document.createElement('select'); + sel.className = 'settings-select'; + sel.setAttribute('data-setting-key', def.key); + sel.setAttribute('aria-label', ariaLabel); + var emptyOpt = document.createElement('option'); + emptyOpt.value = ''; + emptyOpt.textContent = activeValue ? '\u2014 ' + I18n.t('settings.envValue', { value: activeValue }) + ' \u2014' : '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014'; + if (!value && value !== false && value !== 0) emptyOpt.selected = true; + sel.appendChild(emptyOpt); + for (var oi = 0; oi < def.options.length; oi++) { + var opt = document.createElement('option'); + opt.value = def.options[oi]; + opt.textContent = def.options[oi]; + if (String(value) === def.options[oi]) opt.selected = true; + sel.appendChild(opt); + } + sel.addEventListener('change', (function(k, el) { + return function() { saveSetting(k, el.value === '' ? null : el.value); }; + })(def.key, sel)); + inputWrap.appendChild(sel); + } else if (def.type === 'number') { + var numInp = document.createElement('input'); + numInp.type = 'number'; + numInp.step = '1'; + numInp.className = 'settings-input'; + numInp.setAttribute('aria-label', ariaLabel); + numInp.value = (value === null || value === undefined) ? '' : value; + if (!value && value !== 0) numInp.placeholder = placeholderText; + if (def.min !== undefined) numInp.min = def.min; + if (def.max !== undefined) numInp.max = def.max; + numInp.addEventListener('change', (function(k, el) { + return function() { + if (el.value === '') return saveSetting(k, null); + var parsed = parseInt(el.value, 10); + if (isNaN(parsed)) return; + el.value = parsed; + saveSetting(k, parsed); + }; + })(def.key, numInp)); + inputWrap.appendChild(numInp); + } else { + var textInp = document.createElement('input'); + textInp.type = 'text'; + textInp.className = 'settings-input'; + textInp.setAttribute('aria-label', ariaLabel); + textInp.value = (value === null || value === undefined) ? '' : String(value); + if (!value) textInp.placeholder = placeholderText; + // Attach datalist for autocomplete suggestions (e.g., model list) + if (def.suggestions && def.suggestions.length > 0) { + var dlId = 'dl-' + def.key.replace(/\./g, '-'); + var dl = document.createElement('datalist'); + dl.id = dlId; + for (var di = 0; di < def.suggestions.length; di++) { + var dlOpt = document.createElement('option'); + dlOpt.value = def.suggestions[di]; + dl.appendChild(dlOpt); + } + textInp.setAttribute('list', dlId); + inputWrap.appendChild(dl); + } + textInp.addEventListener('change', (function(k, el) { + return function() { saveSetting(k, el.value === '' ? null : el.value); }; + })(def.key, textInp)); + inputWrap.appendChild(textInp); + } + + var saved = document.createElement('span'); + saved.className = 'settings-saved-indicator'; + saved.textContent = '\u2713 ' + I18n.t('settings.saved'); + saved.setAttribute('data-key', def.key); + saved.setAttribute('role', 'status'); + saved.setAttribute('aria-live', 'polite'); + inputWrap.appendChild(saved); + + row.appendChild(inputWrap); + return row; +} + +var RESTART_REQUIRED_KEYS = ['llm_backend', 'selected_model', 'ollama_base_url', 'openai_compatible_base_url', + 'bedrock_region', 'bedrock_cross_region', 'bedrock_profile', 'embeddings.enabled', 'embeddings.provider', 'embeddings.model', + 'agent.auto_approve_tools', 'tunnel.provider', 'tunnel.public_url', 'gateway.rate_limit', 'gateway.max_connections']; + +var _settingsSavedTimers = {}; + +function saveSetting(key, value) { + var method = (value === null || value === undefined) ? 'DELETE' : 'PUT'; + var opts = { method: method }; + if (method === 'PUT') opts.body = { value: value }; + apiFetch('/api/settings/' + encodeURIComponent(key), opts).then(function() { + var indicator = document.querySelector('.settings-saved-indicator[data-key="' + key + '"]'); + if (indicator) { + if (_settingsSavedTimers[key]) clearTimeout(_settingsSavedTimers[key]); + indicator.classList.add('visible'); + _settingsSavedTimers[key] = setTimeout(function() { indicator.classList.remove('visible'); }, 2000); + } + // Show restart banner for inference settings + if (RESTART_REQUIRED_KEYS.indexOf(key) !== -1) { + showRestartBanner(); + } + }).catch(function(err) { + showToast('Failed to save ' + key + ': ' + err.message, 'error'); + }); +} + +function showRestartBanner() { + var container = document.querySelector('.settings-content'); + if (!container || container.querySelector('.restart-banner')) return; + var banner = document.createElement('div'); + banner.className = 'restart-banner'; + banner.setAttribute('role', 'alert'); + var textSpan = document.createElement('span'); + textSpan.className = 'restart-banner-text'; + textSpan.textContent = '\u26A0\uFE0F ' + I18n.t('settings.restartRequired'); + banner.appendChild(textSpan); + var restartBtn = document.createElement('button'); + restartBtn.className = 'restart-banner-btn'; + restartBtn.textContent = I18n.t('settings.restartNow'); + restartBtn.addEventListener('click', function() { triggerRestart(); }); + banner.appendChild(restartBtn); + container.insertBefore(banner, container.firstChild); +} + +function loadMcpServers() { + var mcpList = document.getElementById('mcp-servers-list'); + mcpList.innerHTML = renderCardsSkeleton(2); + + Promise.all([ + apiFetch('/api/extensions').catch(function() { return { extensions: [] }; }), + apiFetch('/api/extensions/registry').catch(function() { return { entries: [] }; }), + ]).then(function(results) { + var extData = results[0]; + var registryData = results[1]; + var mcpEntries = (registryData.entries || []).filter(function(e) { return e.kind === 'mcp_server'; }); + var installedMcp = (extData.extensions || []).filter(function(e) { return e.kind === 'mcp_server'; }); + + mcpList.innerHTML = ''; + var renderedNames = {}; + + // Registry entries (cross-referenced with installed) + for (var i = 0; i < mcpEntries.length; i++) { + renderedNames[mcpEntries[i].name] = true; + var installedExt = installedMcp.find(function(e) { return e.name === mcpEntries[i].name; }); + mcpList.appendChild(renderMcpServerCard(mcpEntries[i], installedExt)); + } + + // Custom installed MCP servers not in registry + for (var j = 0; j < installedMcp.length; j++) { + if (!renderedNames[installedMcp[j].name]) { + mcpList.appendChild(renderExtensionCard(installedMcp[j])); + } + } + + if (mcpList.children.length === 0) { + mcpList.innerHTML = '
' + I18n.t('mcp.noServers') + '
'; + } + }).catch(function(err) { + mcpList.innerHTML = '
' + I18n.t('common.loadFailed') + ': ' + + escapeHtml(err.message) + '
'; + }); +} + +function loadChannelsStatus() { + var container = document.getElementById('settings-channels-content'); + container.innerHTML = renderCardsSkeleton(4); + + Promise.all([ + apiFetch('/api/gateway/status').catch(function() { return {}; }), + apiFetch('/api/extensions').catch(function() { return { extensions: [] }; }), + apiFetch('/api/extensions/registry').catch(function() { return { entries: [] }; }), + ]).then(function(results) { + var status = results[0]; + var extensions = results[1].extensions || []; + var registry = results[2].entries || []; + + container.innerHTML = ''; + + // Built-in Channels section + var builtinSection = document.createElement('div'); + builtinSection.className = 'extensions-section'; + var builtinTitle = document.createElement('h3'); + builtinTitle.textContent = I18n.t('channels.builtin'); + builtinSection.appendChild(builtinTitle); + var builtinList = document.createElement('div'); + builtinList.className = 'extensions-list'; + + builtinList.appendChild(renderBuiltinChannelCard( + I18n.t('channels.webGateway'), + I18n.t('channels.webGatewayDesc'), + true, + 'SSE: ' + (status.sse_connections || 0) + ' \u00B7 WS: ' + (status.ws_connections || 0) + )); + + var enabledChannels = status.enabled_channels || []; + + builtinList.appendChild(renderBuiltinChannelCard( + I18n.t('channels.httpWebhook'), + I18n.t('channels.httpWebhookDesc'), + enabledChannels.indexOf('http') !== -1, + I18n.t('channels.configureVia', { env: 'ENABLE_HTTP=true' }) + )); + + builtinList.appendChild(renderBuiltinChannelCard( + I18n.t('channels.cli'), + I18n.t('channels.cliDesc'), + enabledChannels.indexOf('cli') !== -1, + I18n.t('channels.runWith', { cmd: 'ironclaw run --cli' }) + )); + + builtinList.appendChild(renderBuiltinChannelCard( + I18n.t('channels.repl'), + I18n.t('channels.replDesc'), + enabledChannels.indexOf('repl') !== -1, + I18n.t('channels.runWith', { cmd: 'ironclaw run --repl' }) + )); + + builtinSection.appendChild(builtinList); + container.appendChild(builtinSection); + + // Messaging Channels section — use extension cards with full stepper/pairing UI + var channelEntries = registry.filter(function(e) { + return e.kind === 'wasm_channel' || e.kind === 'channel'; + }); + var installedChannels = extensions.filter(function(e) { + return e.kind === 'wasm_channel'; + }); + + if (channelEntries.length > 0 || installedChannels.length > 0) { + var messagingSection = document.createElement('div'); + messagingSection.className = 'extensions-section'; + var messagingTitle = document.createElement('h3'); + messagingTitle.textContent = I18n.t('channels.messaging'); + messagingSection.appendChild(messagingTitle); + var messagingList = document.createElement('div'); + messagingList.className = 'extensions-list'; + + var renderedNames = {}; + + // Registry entries: show full ext card if installed, available card if not + for (var i = 0; i < channelEntries.length; i++) { + var entry = channelEntries[i]; + renderedNames[entry.name] = true; + var installed = null; + for (var k = 0; k < installedChannels.length; k++) { + if (installedChannels[k].name === entry.name) { installed = installedChannels[k]; break; } + } + if (installed) { + messagingList.appendChild(renderExtensionCard(installed)); + } else { + messagingList.appendChild(renderAvailableExtensionCard(entry)); + } + } + + // Installed channels not in registry (custom installs) + for (var j = 0; j < installedChannels.length; j++) { + if (!renderedNames[installedChannels[j].name]) { + messagingList.appendChild(renderExtensionCard(installedChannels[j])); + } + } + + messagingSection.appendChild(messagingList); + container.appendChild(messagingSection); + } + }); +} + +function renderBuiltinChannelCard(name, description, active, detail) { + var card = document.createElement('div'); + card.className = 'ext-card ' + (active ? 'state-active' : 'state-inactive'); + + var header = document.createElement('div'); + header.className = 'ext-header'; + + var nameEl = document.createElement('span'); + nameEl.className = 'ext-name'; + nameEl.textContent = name; + header.appendChild(nameEl); + + var kindEl = document.createElement('span'); + kindEl.className = 'ext-kind kind-builtin'; + kindEl.textContent = I18n.t('ext.builtin'); + header.appendChild(kindEl); + + var statusDot = document.createElement('span'); + statusDot.className = 'ext-auth-dot ' + (active ? 'authed' : 'unauthed'); + statusDot.title = active ? I18n.t('ext.active') : I18n.t('ext.inactive'); + header.appendChild(statusDot); + + card.appendChild(header); + + var desc = document.createElement('div'); + desc.className = 'ext-desc'; + desc.textContent = description; + card.appendChild(desc); + + if (detail) { + var detailEl = document.createElement('div'); + detailEl.className = 'ext-url'; + detailEl.textContent = detail; + card.appendChild(detailEl); + } + + var actions = document.createElement('div'); + actions.className = 'ext-actions'; + var label = document.createElement('span'); + label.className = 'ext-active-label'; + label.textContent = active ? I18n.t('ext.active') : I18n.t('ext.inactive'); + actions.appendChild(label); + card.appendChild(actions); + + return card; +} + +// --- Networking Settings --- + +var NETWORKING_SETTINGS = [ + { + group: 'cfg.group.tunnel', + settings: [ + { key: 'tunnel.provider', label: 'cfg.tunnel_provider.label', description: 'cfg.tunnel_provider.desc', + type: 'select', options: ['none', 'cloudflare', 'ngrok', 'tailscale', 'custom'] }, + { key: 'tunnel.public_url', label: 'cfg.tunnel_public_url.label', description: 'cfg.tunnel_public_url.desc', type: 'text' }, + ] + }, + { + group: 'cfg.group.gateway', + settings: [ + { key: 'gateway.rate_limit', label: 'cfg.gateway_rate_limit.label', description: 'cfg.gateway_rate_limit.desc', type: 'number', min: 0 }, + { key: 'gateway.max_connections', label: 'cfg.gateway_max_connections.label', description: 'cfg.gateway_max_connections.desc', type: 'number', min: 0 }, + ] + }, +]; + +function loadNetworkingSettings() { + var container = document.getElementById('settings-networking-content'); + container.innerHTML = renderSettingsSkeleton(4); + + apiFetch('/api/settings/export').then(function(data) { + var settings = data.settings || {}; + container.innerHTML = ''; + renderStructuredSettingsInto(container, NETWORKING_SETTINGS, settings, {}); + }).catch(function(err) { + container.innerHTML = '
' + I18n.t('common.loadFailed') + ': ' + + escapeHtml(err.message) + '
'; + }); +} + // --- Toasts --- function showToast(message, type) { @@ -4618,6 +5303,8 @@ document.getElementById('wasm-install-btn').addEventListener('click', () => inst document.getElementById('mcp-add-btn').addEventListener('click', () => addMcpServer()); document.getElementById('skill-search-btn').addEventListener('click', () => searchClawHub()); document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm()); +document.getElementById('settings-export-btn').addEventListener('click', () => exportSettings()); +document.getElementById('settings-import-btn').addEventListener('click', () => importSettings()); // --- Delegated Event Handlers (for dynamically generated HTML) --- @@ -4699,6 +5386,129 @@ document.getElementById('language-btn').addEventListener('click', function() { if (typeof toggleLanguageMenu === 'function') toggleLanguageMenu(); }); +// --- Confirmation Modal --- + +var _confirmModalCallback = null; + +function showConfirmModal(title, message, onConfirm, confirmLabel, confirmClass) { + var modal = document.getElementById('confirm-modal'); + document.getElementById('confirm-modal-title').textContent = title; + document.getElementById('confirm-modal-message').textContent = message || ''; + document.getElementById('confirm-modal-message').style.display = message ? '' : 'none'; + var btn = document.getElementById('confirm-modal-btn'); + btn.textContent = confirmLabel || I18n.t('btn.confirm'); + btn.className = confirmClass || 'btn-danger'; + _confirmModalCallback = onConfirm; + modal.style.display = 'flex'; + btn.focus(); +} + +function closeConfirmModal() { + document.getElementById('confirm-modal').style.display = 'none'; + _confirmModalCallback = null; +} + +document.getElementById('confirm-modal-btn').addEventListener('click', function() { + if (_confirmModalCallback) _confirmModalCallback(); + closeConfirmModal(); +}); +document.getElementById('confirm-modal-cancel-btn').addEventListener('click', closeConfirmModal); +document.getElementById('confirm-modal').addEventListener('click', function(e) { + if (e.target === this) closeConfirmModal(); +}); +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && document.getElementById('confirm-modal').style.display === 'flex') { + closeConfirmModal(); + } +}); + +// --- Settings Import/Export --- + +function exportSettings() { + apiFetch('/api/settings/export').then(function(data) { + var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'ironclaw-settings.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast(I18n.t('settings.exportSuccess'), 'success'); + }).catch(function(err) { + showToast(I18n.t('settings.exportFailed', { message: err.message }), 'error'); + }); +} + +function importSettings() { + var input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,application/json'; + input.addEventListener('change', function() { + if (!input.files || !input.files[0]) return; + var reader = new FileReader(); + reader.onload = function() { + try { + var data = JSON.parse(reader.result); + apiFetch('/api/settings/import', { + method: 'POST', + body: data, + }).then(function() { + showToast(I18n.t('settings.importSuccess'), 'success'); + loadSettingsSubtab(currentSettingsSubtab); + }).catch(function(err) { + showToast(I18n.t('settings.importFailed', { message: err.message }), 'error'); + }); + } catch (e) { + showToast(I18n.t('settings.importFailed', { message: e.message }), 'error'); + } + }; + reader.readAsText(input.files[0]); + }); + input.click(); +} + +// --- Settings Search --- + +document.getElementById('settings-search-input').addEventListener('input', function() { + var query = this.value.toLowerCase(); + var activePanel = document.querySelector('.settings-subpanel.active'); + if (!activePanel) return; + var rows = activePanel.querySelectorAll('.settings-row'); + if (rows.length === 0) return; + var visibleCount = 0; + rows.forEach(function(row) { + var text = row.textContent.toLowerCase(); + if (query === '' || text.indexOf(query) !== -1) { + row.classList.remove('search-hidden'); + if (!row.classList.contains('hidden')) visibleCount++; + } else { + row.classList.add('search-hidden'); + } + }); + // Show/hide group titles based on visible children + var groups = activePanel.querySelectorAll('.settings-group'); + groups.forEach(function(group) { + var visibleRows = group.querySelectorAll('.settings-row:not(.search-hidden):not(.hidden)'); + if (visibleRows.length === 0 && query !== '') { + group.style.display = 'none'; + } else { + group.style.display = ''; + } + }); + // Show/hide empty state + var existingEmpty = activePanel.querySelector('.settings-search-empty'); + if (existingEmpty) existingEmpty.remove(); + if (query !== '' && visibleCount === 0) { + var empty = document.createElement('div'); + empty.className = 'settings-search-empty'; + empty.textContent = I18n.t('settings.noMatchingSettings', { query: this.value }); + activePanel.appendChild(empty); + } +}); + + // --- Config Tab --- // Like apiFetch but for endpoints that return 204 No Content diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index 25163396..fe3b8b5d 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -29,10 +29,16 @@ I18n.register('en', { 'tab.memory': 'Memory', 'tab.jobs': 'Jobs', 'tab.routines': 'Routines', + 'tab.settings': 'Settings', 'tab.extensions': 'Extensions', 'tab.skills': 'Skills', 'tab.config': 'Config', 'tab.logs': 'Logs', + 'settings.inference': 'Inference', + 'settings.agent': 'Agent', + 'settings.channels': 'Channels', + 'settings.networking': 'Networking', + 'settings.mcp': 'MCP', // Status 'status.connected': 'Connected', @@ -132,10 +138,10 @@ I18n.register('en', { // Extensions Tab 'extensions.installed': 'Installed Extensions', - 'extensions.available': 'Available WASM Extensions', - 'extensions.installWasm': 'Install WASM Extension', + 'extensions.available': 'Available Extensions', + 'extensions.installWasm': 'Install Extension', 'extensions.noInstalled': 'No extensions installed', - 'extensions.noAvailable': 'No additional WASM extensions available', + 'extensions.noAvailable': 'No additional extensions available', 'extensions.loading': 'Loading...', 'extensions.install': 'Install', 'extensions.installing': 'Installing...', @@ -157,13 +163,8 @@ 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,6 +304,7 @@ I18n.register('en', { // Common 'common.loading': 'Loading...', + 'common.loadFailed': 'Failed to load', 'common.noData': 'No data', 'common.search': 'Search', 'common.add': 'Add', @@ -329,6 +331,8 @@ I18n.register('en', { // Extensions 'ext.active': 'Active', + 'ext.inactive': 'Inactive', + 'ext.builtin': 'Built-in', 'ext.remove': 'Remove', 'ext.install': 'Install', 'ext.installing': 'Installing...', @@ -395,4 +399,160 @@ 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}', }); diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index 0d814b2c..cb31fc36 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -29,10 +29,16 @@ I18n.register('zh-CN', { 'tab.memory': '记忆', 'tab.jobs': '任务', 'tab.routines': '定时任务', + 'tab.settings': '设置', 'tab.extensions': '扩展', 'tab.skills': '技能', 'tab.config': '配置', 'tab.logs': '日志', + 'settings.inference': '推理', + 'settings.agent': '代理', + 'settings.channels': '频道', + 'settings.networking': '网络', + 'settings.mcp': 'MCP', // 状态 'status.connected': '已连接', @@ -132,10 +138,10 @@ I18n.register('zh-CN', { // 扩展标签页 'extensions.installed': '已安装扩展', - 'extensions.available': '可用 WASM 扩展', - 'extensions.installWasm': '安装 WASM 扩展', + 'extensions.available': '可用扩展', + 'extensions.installWasm': '安装扩展', 'extensions.noInstalled': '没有安装扩展', - 'extensions.noAvailable': '没有其他可用的 WASM 扩展', + 'extensions.noAvailable': '没有其他可用扩展', 'extensions.loading': '加载中...', 'extensions.install': '安装', 'extensions.installing': '安装中...', @@ -157,13 +163,8 @@ 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,6 +304,7 @@ I18n.register('zh-CN', { // 通用 'common.loading': '加载中...', + 'common.loadFailed': '加载失败', 'common.noData': '暂无数据', 'common.search': '搜索', 'common.add': '添加', @@ -329,6 +331,8 @@ I18n.register('zh-CN', { // 扩展 'ext.active': '已激活', + 'ext.inactive': '未激活', + 'ext.builtin': '内置', 'ext.remove': '移除', 'ext.install': '安装', 'ext.installing': '安装中...', @@ -394,4 +398,160 @@ 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}', }); diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index c354dd41..250cb515 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -149,9 +149,8 @@ - - +
@@ -326,100 +325,148 @@ - -
-
-
-

Installed Extensions

-
-
Loading...
-
+ +
+
+
+ + + + + + +
-
-

Available WASM Extensions

-
-
Loading...
+
+
+ + +
-
-
-

Install WASM Extension

-
- - - +
+
+
Loading settings...
+
-
-
-

MCP Servers

-
-
Loading...
+
+
+
Loading settings...
+
-

Add Custom MCP Server

-
- - - +
+
+
Loading channels...
+
-
-
-

Registered Tools

- - - -
NameDescription
- -
-
-
- - -
-
-
-
-

Model Providers

- +
+
+
Loading...
+
-
+
- -
-
-
-

Search ClawHub

- -
+ +
+
+
+
+

Model Providers

+
-
-

Installed Skills

-
-
Loading skills...
-
+ -
-

Install Skill by URL

-
- - - -
+
+
Loading...
+ + +
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 31861421..07aacede 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -18,6 +18,12 @@ --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; } * { @@ -332,10 +338,10 @@ body { .restart-loader-content { position: relative; z-index: 10000; - background-color: #1a1a1a; - border: 1px solid #333; + background-color: var(--bg-secondary); + border: 1px solid var(--border); border-radius: 0.75rem; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); width: 100%; max-width: 28rem; margin: 0 1rem; @@ -352,7 +358,7 @@ body { } .restart-title { - color: #e0e0e0; + color: var(--text); font-size: 0.85rem; margin-bottom: 1rem; margin-top: 0; @@ -388,10 +394,10 @@ body { .restart-modal-content { position: relative; z-index: 10000; - background-color: #1a1a1a; - border: 1px solid #333; + background-color: var(--bg-secondary); + border: 1px solid var(--border); border-radius: 0.75rem; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); width: 100%; max-width: 28rem; margin: 0 1rem; @@ -403,11 +409,11 @@ body { align-items: center; justify-content: space-between; padding: 1rem 1.25rem; - border-bottom: 1px solid #2a2a2a; + border-bottom: 1px solid var(--border); } .restart-modal-header h2 { - color: #e0e0e0; + color: var(--text); font-size: 0.95rem; margin: 0; } @@ -426,8 +432,8 @@ body { } .restart-modal-close:hover { - color: #ccc; - background-color: #2a2a2a; + color: var(--text-secondary); + background-color: var(--bg-tertiary); } .restart-modal-body { @@ -435,21 +441,21 @@ body { } .restart-modal-description { - color: #aaa; + color: var(--text-secondary); font-size: 0.85rem; margin: 0; } .restart-modal-warning { margin-top: 1rem; - background-color: #1e1400; - border: 1px solid #3a2a00; + background-color: var(--warning-soft); + border: 1px solid rgba(245, 166, 35, 0.25); border-radius: 0.5rem; padding: 0.75rem 1rem; } .restart-modal-warning p { - color: #facc15; + color: var(--warning); font-size: 0.8rem; margin: 0; } @@ -460,7 +466,7 @@ body { justify-content: flex-end; gap: 0.75rem; padding: 1rem 1.25rem; - border-top: 1px solid #2a2a2a; + border-top: 1px solid var(--border); } .restart-modal-btn { @@ -473,28 +479,28 @@ body { } .restart-modal-btn.cancel { - color: #ccc; + color: var(--text-secondary); background-color: transparent; } .restart-modal-btn.cancel:hover { - background-color: #2a2a2a; + background-color: var(--bg-tertiary); } .restart-modal-btn.confirm { - background-color: #00D894; - color: #111; + background-color: var(--accent); + color: #09090b; } .restart-modal-btn.confirm:hover { - background-color: #00be82; + background-color: var(--accent-hover); } /* Progress Bar for Restart */ .restart-progress-bar { width: 100%; height: 0.375rem; - background-color: #2a2a2a; + background-color: var(--bg-tertiary); border-radius: 9999px; overflow: hidden; } @@ -502,7 +508,7 @@ body { .restart-progress-fill { height: 100%; border-radius: 9999px; - background-color: #00D894; + background-color: var(--accent); width: 40%; animation: indeterminate 1.5s ease-in-out infinite; } @@ -523,14 +529,14 @@ body { } .restart-modal-info { - color: #666; + color: var(--text-secondary); font-size: 0.8rem; margin-top: 1.25rem; margin-bottom: 0; } .restart-modal-info a { - color: #00D894; + color: var(--accent); text-decoration: none; } @@ -2522,17 +2528,21 @@ body { } .extensions-section h3 { - font-size: 15px; + font-size: 11px; font-weight: 600; margin-bottom: 12px; - color: var(--text); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; } .extensions-section h4 { - font-size: 13px; + font-size: 11px; font-weight: 600; margin: 16px 0 8px; - color: var(--text-secondary); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; } .extensions-list { @@ -2544,12 +2554,29 @@ 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 0.2s, transform 0.2s; + 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); } .ext-card:hover { @@ -2592,6 +2619,11 @@ 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); @@ -2767,13 +2799,20 @@ 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 { @@ -2873,6 +2912,7 @@ body { width: 100%; height: 100%; background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); z-index: 1000; display: flex; align-items: center; @@ -2893,7 +2933,7 @@ body { .configure-modal h3 { margin: 0 0 16px 0; font-size: 16px; - color: var(--text-primary); + color: var(--text); } .configure-hint { @@ -3036,31 +3076,6 @@ 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 { @@ -3714,10 +3729,14 @@ 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: 6px 10px; + padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); @@ -3759,6 +3778,10 @@ 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 { @@ -3795,10 +3818,10 @@ mark { } .skill-trust { - font-size: 10px; - padding: 2px 6px; - border-radius: 8px; - font-weight: 500; + font-size: 11px; + padding: 3px 8px; + border-radius: 9999px; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; } @@ -3942,6 +3965,27 @@ 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; @@ -3968,6 +4012,238 @@ 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; @@ -4157,6 +4433,215 @@ mark { 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; +} + + /* --- Config Tab --- */ .config-section-header { diff --git a/src/channels/web/test_helpers.rs b/src/channels/web/test_helpers.rs index 981eacdd..76b2a760 100644 --- a/src/channels/web/test_helpers.rs +++ b/src/channels/web/test_helpers.rs @@ -87,6 +87,7 @@ 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(), }) } diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs index 7bf50e52..8efc69f6 100644 --- a/src/channels/web/ws.rs +++ b/src/channels/web/ws.rs @@ -521,6 +521,7 @@ 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(), } } } diff --git a/src/db/libsql/routines.rs b/src/db/libsql/routines.rs index b75afb47..3151e75b 100644 --- a/src/db/libsql/routines.rs +++ b/src/db/libsql/routines.rs @@ -476,4 +476,28 @@ impl RoutineStore for LibSqlBackend { .map_err(|e| DatabaseError::Query(e.to_string()))?; Ok(()) } + + async fn list_dispatched_routine_runs(&self) -> Result, 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) + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 6d2eb296..49287308 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -525,6 +525,9 @@ 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, DatabaseError>; } #[async_trait] diff --git a/src/db/postgres.rs b/src/db/postgres.rs index 8c18e252..eaa6e049 100644 --- a/src/db/postgres.rs +++ b/src/db/postgres.rs @@ -503,6 +503,10 @@ 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, DatabaseError> { + self.store.list_dispatched_routine_runs().await + } } // ==================== ToolFailureStore ==================== diff --git a/src/history/store.rs b/src/history/store.rs index 04e3167f..2deffab5 100644 --- a/src/history/store.rs +++ b/src/history/store.rs @@ -1348,6 +1348,18 @@ 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, 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")] diff --git a/src/llm/anthropic_oauth.rs b/src/llm/anthropic_oauth.rs index ae6674dc..8c701101 100644 --- a/src/llm/anthropic_oauth.rs +++ b/src/llm/anthropic_oauth.rs @@ -22,6 +22,7 @@ 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. @@ -150,6 +151,7 @@ impl AnthropicOAuthProvider { .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) .map(std::time::Duration::from_secs) + .map(cap_retry_after) .or(Some(std::time::Duration::from_secs(60))); let response_text = response @@ -766,9 +768,14 @@ mod tests { #[test] fn test_retry_after_large_number() { - // Verify large numbers are accepted + // Verify large numbers are capped to the safe maximum let duration = parse_retry_after_anthropic_for_test("7200"); // 2 hours - assert_eq!(duration, Some(std::time::Duration::from_secs(7200))); + assert_eq!( + duration, + Some(std::time::Duration::from_secs( + crate::llm::retry::MAX_RETRY_AFTER_SECS + )) + ); } /// Helper function to test Retry-After header parsing logic for Anthropic @@ -779,6 +786,7 @@ mod tests { .parse::() .ok() .map(std::time::Duration::from_secs) + .map(cap_retry_after) .or(Some(std::time::Duration::from_secs(60))) } } diff --git a/src/llm/circuit_breaker.rs b/src/llm/circuit_breaker.rs index db47647e..46f29ded 100644 --- a/src/llm/circuit_breaker.rs +++ b/src/llm/circuit_breaker.rs @@ -167,6 +167,12 @@ 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; diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index 0a9e1fdc..f0d711a9 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -22,7 +22,7 @@ use crate::llm::provider::{ ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, }; -use crate::llm::{costs, session::SessionManager}; +use crate::llm::{costs, retry::cap_retry_after, 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::() { - return Some(std::time::Duration::from_secs(secs)); + return Some(cap_retry_after(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(std::time::Duration::from_secs( - delta.num_seconds().max(0) as u64 - )); + return Some(cap_retry_after(std::time::Duration::from_secs( + delta.num_seconds().max(0) as u64, + ))); } None }) @@ -2306,9 +2306,17 @@ mod tests { #[test] fn test_retry_after_large_number() { - // Verify large numbers are accepted + // Verify large numbers are capped to the safe maximum 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 @@ -2316,13 +2324,13 @@ mod tests { fn parse_retry_after_for_test(header_value: &str) -> Option { let trimmed = header_value.trim(); let parsed = if let Ok(secs) = trimmed.parse::() { - Some(std::time::Duration::from_secs(secs)) + Some(cap_retry_after(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(std::time::Duration::from_secs( - delta.num_seconds().max(0) as u64 - )) + Some(cap_retry_after(std::time::Duration::from_secs( + delta.num_seconds().max(0) as u64, + ))) } else { None }; diff --git a/src/llm/reasoning_models.rs b/src/llm/reasoning_models.rs index 307cb0a3..ab691086 100644 --- a/src/llm/reasoning_models.rs +++ b/src/llm/reasoning_models.rs @@ -108,6 +108,8 @@ 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] diff --git a/src/llm/retry.rs b/src/llm/retry.rs index 2875fbd3..6250de33 100644 --- a/src/llm/retry.rs +++ b/src/llm/retry.rs @@ -19,6 +19,12 @@ 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` @@ -67,6 +73,11 @@ 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 { @@ -421,4 +432,16 @@ 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) + ); + } } diff --git a/src/main.rs b/src/main.rs index 745cae09..65c04dda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -323,6 +323,17 @@ 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, @@ -511,6 +522,16 @@ 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)); diff --git a/src/setup/channels.rs b/src/setup/channels.rs index 1c184b0b..2612076d 100644 --- a/src/setup/channels.rs +++ b/src/setup/channels.rs @@ -518,7 +518,7 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result 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()); @@ -1136,8 +1140,9 @@ mod tests { use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore}; use crate::setup::channels::{ - SecretsContext, generate_webhook_secret, substitute_validation_placeholders, - validate_cloudflare_token_format, validate_public_https_url, + SecretsContext, generate_webhook_secret, http_webhook_secret_hint, + substitute_validation_placeholders, validate_cloudflare_token_format, + validate_public_https_url, }; fn test_secrets_context() -> SecretsContext { @@ -1337,4 +1342,12 @@ 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")); + } } diff --git a/src/tools/builtin/routine.rs b/src/tools/builtin/routine.rs index 347cb4ff..22db7c74 100644 --- a/src/tools/builtin/routine.rs +++ b/src/tools/builtin/routine.rs @@ -9,11 +9,13 @@ //! - `routine_history` - View past runs //! - `event_emit` - Emit a structured system event to `system_event`-triggered routines -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use async_trait::async_trait; use chrono::Utc; +use serde_json::{Map, Value}; use uuid::Uuid; use crate::agent::routine::{ @@ -22,135 +24,1012 @@ use crate::agent::routine::{ use crate::agent::routine_engine::RoutineEngine; use crate::context::JobContext; use crate::db::Database; -use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str}; +use crate::tools::tool::{ + ApprovalRequirement, Tool, ToolDiscoverySummary, ToolError, ToolOutput, require_str, +}; -pub(crate) fn routine_create_parameters_schema() -> serde_json::Value { +// ==================== routine_create ==================== + +#[derive(Debug, Clone, PartialEq, Eq)] +enum NormalizedTriggerRequest { + Cron { + schedule: String, + timezone: Option, + }, + Manual, + MessageEvent { + pattern: String, + channel: Option, + }, + SystemEvent { + source: String, + event_type: String, + filters: HashMap, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NormalizedExecutionMode { + Lightweight, + FullJob, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NormalizedExecutionRequest { + mode: NormalizedExecutionMode, + context_paths: Vec, + use_tools: bool, + max_tool_rounds: u32, + tool_permissions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NormalizedDeliveryRequest { + channel: Option, + user: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NormalizedRoutineCreateRequest { + name: String, + description: String, + prompt: String, + trigger: NormalizedTriggerRequest, + execution: NormalizedExecutionRequest, + delivery: NormalizedDeliveryRequest, + cooldown_secs: u64, +} + +fn routine_request_properties() -> Value { + serde_json::json!({ + "kind": { + "type": "string", + "enum": ["cron", "manual", "message_event", "system_event"], + "description": "How the routine should start." + }, + "schedule": { + "type": "string", + "description": "Cron expression for request.kind='cron'. Uses 6-field cron: second minute hour day month weekday." + }, + "timezone": { + "type": "string", + "description": "IANA timezone for request.kind='cron', such as 'America/New_York'." + }, + "pattern": { + "type": "string", + "description": "Regex pattern for request.kind='message_event'." + }, + "channel": { + "type": "string", + "description": "Optional channel filter for request.kind='message_event'." + }, + "source": { + "type": "string", + "description": "Event source namespace for request.kind='system_event', such as 'github'." + }, + "event_type": { + "type": "string", + "description": "Event type for request.kind='system_event', such as 'issue.opened'." + }, + "filters": { + "type": "object", + "properties": {}, + "additionalProperties": { + "type": ["string", "number", "boolean"] + }, + "description": "Optional exact-match filters for request.kind='system_event'. Only top-level string, number, and boolean payload fields are matched." + } + }) +} + +fn execution_properties() -> Value { + serde_json::json!({ + "mode": { + "type": "string", + "enum": ["lightweight", "full_job"], + "description": "Execution mode. 'lightweight' is the default. 'full_job' runs a multi-turn autonomous job." + }, + "context_paths": { + "type": "array", + "items": { "type": "string" }, + "description": "Workspace paths to preload for lightweight routines." + }, + "use_tools": { + "type": "boolean", + "description": "Only applies to lightweight mode. When true, safe non-approval tools are available." + }, + "max_tool_rounds": { + "type": "integer", + "minimum": 1, + "maximum": crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT, + "default": 3, + "description": "Only applies when execution.mode='lightweight' and use_tools=true. Runtime-capped to prevent loops." + }, + "tool_permissions": { + "type": "array", + "items": { "type": "string" }, + "description": "Only applies when execution.mode='full_job'. These tools are pre-authorized for Always-approval checks." + } + }) +} + +fn delivery_properties() -> Value { + serde_json::json!({ + "channel": { + "type": "string", + "description": "Default channel for notifications and routine job message calls." + }, + "user": { + "type": "string", + "description": "Default user or target for notifications and routine job message calls. If omitted, the owner's last-seen notification target is used." + } + }) +} + +fn advanced_properties() -> Value { + serde_json::json!({ + "cooldown_secs": { + "type": "integer", + "description": "Minimum seconds between automatic fires. Manual fires still bypass cooldown." + } + }) +} + +fn manual_request_variant() -> Value { serde_json::json!({ "type": "object", + "description": "Manual routines run only when explicitly fired.", "properties": { - "name": { + "kind": { "type": "string", - "description": "Unique routine name, for example 'daily-pr-review'." - }, - "description": { + "enum": ["manual"], + "description": "Manual trigger." + } + }, + "required": ["kind"] + }) +} + +fn cron_request_variant() -> Value { + serde_json::json!({ + "type": "object", + "description": "Cron routines require request.schedule and may optionally set request.timezone.", + "properties": { + "kind": { "type": "string", - "description": "Short summary of what the routine is for." - }, - "trigger_type": { - "type": "string", - "enum": ["cron", "event", "system_event", "manual"], - "description": "When the routine fires: 'cron' for schedules, 'event' for incoming messages, 'system_event' for structured emitted events, or 'manual' for explicit runs." + "enum": ["cron"], + "description": "Scheduled trigger." }, "schedule": { "type": "string", - "description": "Cron schedule for 'cron' triggers. Uses 6 fields: second minute hour day month weekday." + "description": "Cron expression for request.kind='cron'. Uses 6-field cron: second minute hour day month weekday." }, - "event_pattern": { + "timezone": { "type": "string", - "description": "Regex matched against incoming message text for 'event' triggers, for example '^bug\\\\b'." + "description": "IANA timezone for request.kind='cron', such as 'America/New_York'." + } + }, + "required": ["kind", "schedule"] + }) +} + +fn message_event_request_variant() -> Value { + serde_json::json!({ + "type": "object", + "description": "Message-event routines require request.pattern and may optionally filter by request.channel.", + "properties": { + "kind": { + "type": "string", + "enum": ["message_event"], + "description": "Pattern-matching message trigger." }, - "event_channel": { + "pattern": { "type": "string", - "description": "Optional platform filter for 'event' triggers, for example 'telegram'. Omit to match any channel. Not a chat or thread ID." + "description": "Regex pattern for request.kind='message_event'." }, - "event_source": { + "channel": { "type": "string", - "description": "Structured event source for 'system_event' triggers, for example 'github'." + "description": "Optional channel filter for request.kind='message_event'." + } + }, + "required": ["kind", "pattern"] + }) +} + +fn system_event_request_variant() -> Value { + serde_json::json!({ + "type": "object", + "description": "System-event routines require request.source and request.event_type. request.filters is optional.", + "properties": { + "kind": { + "type": "string", + "enum": ["system_event"], + "description": "Structured event trigger." + }, + "source": { + "type": "string", + "description": "Event source namespace for request.kind='system_event', such as 'github'." }, "event_type": { "type": "string", - "description": "Structured event type for 'system_event' triggers, for example 'issue.opened'." + "description": "Event type for request.kind='system_event', such as 'issue.opened'." }, - "event_filters": { + "filters": { "type": "object", "properties": {}, "additionalProperties": { "type": ["string", "number", "boolean"] }, - "description": "Optional exact-match payload filters for 'system_event' triggers. Values can be strings, numbers, or booleans." - }, - "prompt": { + "description": "Optional exact-match filters for request.kind='system_event'. Only top-level string, number, and boolean payload fields are matched." + } + }, + "required": ["kind", "source", "event_type"] + }) +} + +fn routine_request_discovery_schema() -> Value { + serde_json::json!({ + "type": "object", + "description": "Canonical trigger config. Set request.kind first, then follow the matching variant branch below.", + "properties": routine_request_properties(), + "required": ["kind"], + "oneOf": [ + manual_request_variant(), + cron_request_variant(), + message_event_request_variant(), + system_event_request_variant() + ], + "examples": [ + { "kind": "manual" }, + { "kind": "cron", "schedule": "0 0 9 * * MON-FRI", "timezone": "UTC" }, + { "kind": "message_event", "pattern": "deploy\\s+prod", "channel": "slack" }, + { "kind": "system_event", "source": "github", "event_type": "issue.opened", "filters": { "repository": "nearai/ironclaw" } } + ] + }) +} + +fn lightweight_execution_variant() -> Value { + serde_json::json!({ + "type": "object", + "description": "Default lightweight execution. Applies when execution is omitted or execution.mode='lightweight'.", + "properties": { + "mode": { "type": "string", - "description": "Instructions for what the routine should do after it fires." + "enum": ["lightweight"], + "description": "Lightweight execution mode." }, "context_paths": { "type": "array", "items": { "type": "string" }, - "description": "Workspace paths to load as extra context before running the routine." - }, - "action_type": { - "type": "string", - "enum": ["lightweight", "full_job"], - "description": "Execution mode: 'lightweight' for one LLM turn or 'full_job' for a multi-step job with tools." + "description": "Workspace paths to preload for lightweight routines." }, "use_tools": { "type": "boolean", - "description": "Enable safe tool use in 'lightweight' mode. Ignored for 'full_job'." + "description": "When true, safe non-approval tools are available." }, "max_tool_rounds": { "type": "integer", - "description": "Maximum tool-call rounds in 'lightweight' mode when 'use_tools' is true." - }, - "cooldown_secs": { - "type": "integer", - "description": "Minimum seconds between fires." + "minimum": 1, + "maximum": crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT, + "default": 3, + "description": "Only applies when use_tools=true. Runtime-capped to prevent loops." + } + } + }) +} + +fn full_job_execution_variant() -> Value { + serde_json::json!({ + "type": "object", + "description": "Full-job execution. Uses tool_permissions and ignores lightweight-only fields such as use_tools, max_tool_rounds, and context_paths.", + "properties": { + "mode": { + "type": "string", + "enum": ["full_job"], + "description": "Full-job execution mode." }, "tool_permissions": { "type": "array", "items": { "type": "string" }, - "description": "Pre-authorized tool names for 'full_job' routines." - }, - "notify_channel": { - "type": "string", - "description": "Where routine output should be sent, for example 'telegram' or 'slack'. This does not control what triggers the routine." - }, - "notify_user": { - "type": "string", - "description": "Optional explicit user or destination to notify, for example a username or chat ID. Omit it to use the configured owner's last-seen target for that channel." - }, - "timezone": { - "type": "string", - "description": "IANA timezone used to evaluate 'cron' schedules, for example 'America/New_York'." + "description": "Tools pre-authorized for Always-approval checks." } }, - "required": ["name", "trigger_type", "prompt"] + "required": ["mode"] }) } -pub(crate) fn routine_update_parameters_schema() -> serde_json::Value { +fn execution_discovery_schema() -> Value { + serde_json::json!({ + "type": "object", + "description": "Optional execution settings. Omit this block for the default lightweight mode.", + "properties": execution_properties(), + "oneOf": [ + lightweight_execution_variant(), + full_job_execution_variant() + ], + "examples": [ + { "mode": "lightweight", "use_tools": true, "max_tool_rounds": 3 }, + { "mode": "full_job", "tool_permissions": ["message", "http"] } + ] + }) +} + +fn routine_create_examples() -> Vec { + vec![ + serde_json::json!({ + "name": "manual-check", + "prompt": "Inspect the repo for issues.", + "request": { "kind": "manual" } + }), + serde_json::json!({ + "name": "weekday-digest", + "prompt": "Prepare the morning digest.", + "request": { + "kind": "cron", + "schedule": "0 0 9 * * MON-FRI", + "timezone": "UTC" + }, + "delivery": { + "channel": "telegram", + "user": "ops-team" + } + }), + serde_json::json!({ + "name": "deploy-watch", + "prompt": "Look for deploy requests.", + "request": { + "kind": "message_event", + "pattern": "deploy\\s+prod", + "channel": "slack" + }, + "execution": { + "mode": "lightweight", + "use_tools": true, + "max_tool_rounds": 5 + } + }), + serde_json::json!({ + "name": "issue-watch", + "prompt": "Summarize new GitHub issues.", + "request": { + "kind": "system_event", + "source": "github", + "event_type": "issue.opened", + "filters": { "repository": "nearai/ironclaw" } + }, + "execution": { + "mode": "full_job", + "tool_permissions": ["message"] + } + }), + ] +} + +fn routine_create_tool_summary() -> ToolDiscoverySummary { + ToolDiscoverySummary { + always_required: vec!["name".into(), "prompt".into(), "request.kind".into()], + conditional_requirements: vec![ + "request.kind='cron' requires request.schedule.".into(), + "request.kind='message_event' requires request.pattern.".into(), + "request.kind='system_event' requires request.source and request.event_type.".into(), + "execution.mode='full_job' uses tool_permissions and ignores use_tools, max_tool_rounds, and context_paths.".into(), + ], + notes: vec![ + "Omitting execution defaults to lightweight mode.".into(), + "Omitting delivery.user falls back to the owner's last-seen notification target.".into(), + "advanced.cooldown_secs defaults to 300.".into(), + "Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(), + ], + examples: routine_create_examples(), + } +} + +fn routine_create_schema(include_compatibility_aliases: bool) -> Value { + let mut schema = serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique name for the routine (e.g. 'daily-pr-review')." + }, + "prompt": { + "type": "string", + "description": "Instructions for what the routine should do when it fires." + }, + "description": { + "type": "string", + "description": "Optional human-readable summary of what the routine does." + }, + "request": if include_compatibility_aliases { + routine_request_discovery_schema() + } else { + serde_json::json!({ + "type": "object", + "description": "Canonical trigger config. Set request.kind first, then only fill fields that match that kind.", + "properties": routine_request_properties(), + "required": ["kind"] + }) + }, + "execution": if include_compatibility_aliases { + execution_discovery_schema() + } else { + serde_json::json!({ + "type": "object", + "description": "Optional execution settings. Omit for the default lightweight mode.", + "properties": execution_properties() + }) + }, + "delivery": { + "type": "object", + "description": "Optional delivery defaults for notifications and message tool calls inside routine jobs.", + "properties": delivery_properties() + }, + "advanced": { + "type": "object", + "description": "Optional advanced knobs. Most routines can omit this block.", + "properties": advanced_properties() + } + }, + "required": ["name", "prompt"] + }); + + if include_compatibility_aliases { + if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) { + properties.insert( + "trigger_type".to_string(), + serde_json::json!({ + "type": "string", + "enum": ["cron", "event", "system_event", "manual"], + "description": "Compatibility alias for request.kind. Prefer request.kind." + }), + ); + properties.insert( + "schedule".to_string(), + serde_json::json!({ + "type": "string", + "description": "Compatibility alias for request.schedule. Prefer request.schedule." + }), + ); + properties.insert( + "timezone".to_string(), + serde_json::json!({ + "type": "string", + "description": "Compatibility alias for request.timezone. Prefer request.timezone." + }), + ); + properties.insert( + "event_pattern".to_string(), + serde_json::json!({ + "type": "string", + "description": "Compatibility alias for request.pattern when request.kind='message_event'." + }), + ); + properties.insert( + "event_channel".to_string(), + serde_json::json!({ + "type": "string", + "description": "Compatibility alias for request.channel when request.kind='message_event'." + }), + ); + properties.insert( + "event_source".to_string(), + serde_json::json!({ + "type": "string", + "description": "Compatibility alias for request.source when request.kind='system_event'." + }), + ); + properties.insert( + "event_type".to_string(), + serde_json::json!({ + "type": "string", + "description": "Compatibility alias for request.event_type when request.kind='system_event'." + }), + ); + properties.insert( + "event_filters".to_string(), + serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": { + "type": ["string", "number", "boolean"] + }, + "description": "Compatibility alias for request.filters when request.kind='system_event'." + }), + ); + properties.insert( + "action_type".to_string(), + serde_json::json!({ + "type": "string", + "enum": ["lightweight", "full_job"], + "description": "Compatibility alias for execution.mode." + }), + ); + properties.insert( + "context_paths".to_string(), + serde_json::json!({ + "type": "array", + "items": { "type": "string" }, + "description": "Compatibility alias for execution.context_paths." + }), + ); + properties.insert( + "use_tools".to_string(), + serde_json::json!({ + "type": "boolean", + "description": "Compatibility alias for execution.use_tools." + }), + ); + properties.insert( + "max_tool_rounds".to_string(), + serde_json::json!({ + "type": "integer", + "minimum": 1, + "maximum": crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT, + "default": 3, + "description": "Compatibility alias for execution.max_tool_rounds." + }), + ); + properties.insert( + "tool_permissions".to_string(), + serde_json::json!({ + "type": "array", + "items": { "type": "string" }, + "description": "Compatibility alias for execution.tool_permissions." + }), + ); + properties.insert( + "notify_channel".to_string(), + serde_json::json!({ + "type": "string", + "description": "Compatibility alias for delivery.channel." + }), + ); + properties.insert( + "notify_user".to_string(), + serde_json::json!({ + "type": "string", + "description": "Compatibility alias for delivery.user." + }), + ); + properties.insert( + "cooldown_secs".to_string(), + serde_json::json!({ + "type": "integer", + "description": "Compatibility alias for advanced.cooldown_secs." + }), + ); + } + if let Some(schema_obj) = schema.as_object_mut() { + schema_obj.insert( + "anyOf".to_string(), + serde_json::json!([ + { "required": ["request"] }, + { "required": ["trigger_type"] } + ]), + ); + schema_obj.insert( + "examples".to_string(), + Value::Array(routine_create_examples()), + ); + } + } else if let Some(required) = schema.get_mut("required").and_then(Value::as_array_mut) { + required.push(Value::String("request".to_string())); + } + + schema +} + +pub(crate) fn routine_create_parameters_schema() -> Value { + routine_create_schema(false) +} + +fn routine_create_discovery_schema() -> Value { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| routine_create_schema(true)).clone() +} + +pub(crate) fn routine_update_parameters_schema() -> Value { serde_json::json!({ "type": "object", "properties": { "name": { "type": "string", - "description": "Name of the routine to update." + "description": "Name of the routine to update" }, "enabled": { "type": "boolean", - "description": "Set to true to enable the routine or false to disable it." + "description": "Enable or disable the routine" }, "prompt": { "type": "string", - "description": "Replace the routine instructions for what it should do after it fires." + "description": "New prompt/instructions" }, "schedule": { "type": "string", - "description": "New cron schedule for existing 'cron' routines only. This does not convert other trigger types." + "description": "New cron schedule (for cron triggers)" }, "timezone": { "type": "string", - "description": "New IANA timezone for existing 'cron' routines only, for example 'America/New_York'." + "description": "IANA timezone for cron schedule (e.g. 'America/New_York'). Only valid for cron triggers." }, "description": { "type": "string", - "description": "Replace the routine summary." + "description": "New description" } }, "required": ["name"] }) } -// ==================== routine_create ==================== +fn nested_object<'a>(params: &'a Value, field: &str) -> Option<&'a Map> { + params.get(field).and_then(Value::as_object) +} + +fn string_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Option { + nested_object(params, group) + .and_then(|obj| obj.get(field)) + .and_then(Value::as_str) + .map(String::from) + .or_else(|| { + aliases + .iter() + .find_map(|alias| params.get(*alias).and_then(Value::as_str).map(String::from)) + }) +} + +fn bool_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Option { + nested_object(params, group) + .and_then(|obj| obj.get(field)) + .and_then(Value::as_bool) + .or_else(|| { + aliases + .iter() + .find_map(|alias| params.get(*alias).and_then(Value::as_bool)) + }) +} + +fn u64_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Option { + nested_object(params, group) + .and_then(|obj| obj.get(field)) + .and_then(Value::as_u64) + .or_else(|| { + aliases + .iter() + .find_map(|alias| params.get(*alias).and_then(Value::as_u64)) + }) +} + +fn string_array_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Vec { + nested_object(params, group) + .and_then(|obj| obj.get(field)) + .and_then(Value::as_array) + .or_else(|| { + aliases + .iter() + .find_map(|alias| params.get(*alias).and_then(Value::as_array)) + }) + .map(|arr| { + arr.iter() + .filter_map(|value| value.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +fn object_field( + params: &Value, + group: &str, + field: &str, + aliases: &[&str], +) -> Option> { + nested_object(params, group) + .and_then(|obj| obj.get(field)) + .and_then(Value::as_object) + .cloned() + .or_else(|| { + aliases + .iter() + .find_map(|alias| params.get(*alias).and_then(Value::as_object).cloned()) + }) +} + +fn validate_timezone_param(timezone: Option) -> Result, ToolError> { + timezone + .map(|tz| { + crate::timezone::parse_timezone(&tz) + .map(|_| tz.clone()) + .ok_or_else(|| { + ToolError::InvalidParameters(format!("invalid IANA timezone: '{tz}'")) + }) + }) + .transpose() +} + +fn parse_system_event_filters( + filters: Option>, +) -> Result, ToolError> { + let Some(obj) = filters else { + return Ok(HashMap::new()); + }; + + let mut parsed = HashMap::with_capacity(obj.len()); + for (key, value) in obj { + let rendered = crate::agent::routine::json_value_as_filter_string(&value).ok_or_else(|| { + ToolError::InvalidParameters(format!( + "system_event filters only support string, number, and boolean values (invalid '{key}')" + )) + })?; + parsed.insert(key, rendered); + } + + Ok(parsed) +} + +fn parse_routine_trigger(params: &Value) -> Result { + let kind = string_field(params, "request", "kind", &["trigger_type"]) + .map(|value| match value.as_str() { + "event" => "message_event".to_string(), + other => other.to_string(), + }) + .ok_or_else(|| { + ToolError::InvalidParameters( + "routine_create requires request.kind (canonical) or trigger_type (legacy)" + .to_string(), + ) + })?; + + match kind.as_str() { + "cron" => { + let schedule = + string_field(params, "request", "schedule", &["schedule"]).ok_or_else(|| { + ToolError::InvalidParameters("cron request requires 'schedule'".to_string()) + })?; + let timezone = validate_timezone_param(string_field( + params, + "request", + "timezone", + &["timezone"], + ))?; + next_cron_fire(&schedule, timezone.as_deref()) + .map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?; + Ok(NormalizedTriggerRequest::Cron { schedule, timezone }) + } + "manual" => Ok(NormalizedTriggerRequest::Manual), + "message_event" => { + let pattern = string_field(params, "request", "pattern", &["event_pattern"]) + .ok_or_else(|| { + ToolError::InvalidParameters( + "message_event request requires 'pattern'".to_string(), + ) + })?; + regex::RegexBuilder::new(&pattern) + .size_limit(64 * 1024) + .build() + .map_err(|e| { + ToolError::InvalidParameters(format!("invalid or too complex regex: {e}")) + })?; + let channel = string_field(params, "request", "channel", &["event_channel"]); + Ok(NormalizedTriggerRequest::MessageEvent { pattern, channel }) + } + "system_event" => { + let source = + string_field(params, "request", "source", &["event_source"]).ok_or_else(|| { + ToolError::InvalidParameters( + "system_event request requires 'source'".to_string(), + ) + })?; + let event_type = string_field(params, "request", "event_type", &["event_type"]) + .ok_or_else(|| { + ToolError::InvalidParameters( + "system_event request requires 'event_type'".to_string(), + ) + })?; + let filters = parse_system_event_filters(object_field( + params, + "request", + "filters", + &["event_filters"], + ))?; + Ok(NormalizedTriggerRequest::SystemEvent { + source, + event_type, + filters, + }) + } + other => Err(ToolError::InvalidParameters(format!( + "unknown request.kind: {other}" + ))), + } +} + +fn parse_execution_mode(value: Option) -> Result { + match value.as_deref().unwrap_or("lightweight") { + "lightweight" => Ok(NormalizedExecutionMode::Lightweight), + "full_job" => Ok(NormalizedExecutionMode::FullJob), + other => Err(ToolError::InvalidParameters(format!( + "unknown execution mode: {other}" + ))), + } +} + +fn parse_routine_execution(params: &Value) -> Result { + let mode = parse_execution_mode(string_field(params, "execution", "mode", &["action_type"]))?; + let context_paths = + string_array_field(params, "execution", "context_paths", &["context_paths"]); + let use_tools = bool_field(params, "execution", "use_tools", &["use_tools"]).unwrap_or(false); + let max_tool_rounds = u64_field(params, "execution", "max_tool_rounds", &["max_tool_rounds"]) + .unwrap_or(3) + .clamp(1, crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT as u64) + as u32; + let tool_permissions = string_array_field( + params, + "execution", + "tool_permissions", + &["tool_permissions"], + ); + + Ok(NormalizedExecutionRequest { + mode, + context_paths, + use_tools, + max_tool_rounds, + tool_permissions, + }) +} + +fn parse_routine_delivery(params: &Value) -> NormalizedDeliveryRequest { + NormalizedDeliveryRequest { + channel: string_field(params, "delivery", "channel", &["notify_channel"]), + user: string_field(params, "delivery", "user", &["notify_user"]), + } +} + +fn parse_routine_create_request( + params: &Value, +) -> Result { + let name = require_str(params, "name")?.to_string(); + let prompt = require_str(params, "prompt")?.to_string(); + let description = params + .get("description") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let trigger = parse_routine_trigger(params)?; + let execution = parse_routine_execution(params)?; + let delivery = parse_routine_delivery(params); + let cooldown_secs = + u64_field(params, "advanced", "cooldown_secs", &["cooldown_secs"]).unwrap_or(300); + + Ok(NormalizedRoutineCreateRequest { + name, + description, + prompt, + trigger, + execution, + delivery, + cooldown_secs, + }) +} + +fn build_routine_trigger(trigger: &NormalizedTriggerRequest) -> Trigger { + match trigger { + NormalizedTriggerRequest::Cron { schedule, timezone } => Trigger::Cron { + schedule: schedule.clone(), + timezone: timezone.clone(), + }, + NormalizedTriggerRequest::Manual => Trigger::Manual, + NormalizedTriggerRequest::MessageEvent { pattern, channel } => Trigger::Event { + channel: channel.clone(), + pattern: pattern.clone(), + }, + NormalizedTriggerRequest::SystemEvent { + source, + event_type, + filters, + } => Trigger::SystemEvent { + source: source.clone(), + event_type: event_type.clone(), + filters: filters.clone(), + }, + } +} + +fn build_routine_action( + name: &str, + prompt: &str, + execution: &NormalizedExecutionRequest, +) -> RoutineAction { + match execution.mode { + NormalizedExecutionMode::Lightweight => RoutineAction::Lightweight { + prompt: prompt.to_string(), + context_paths: execution.context_paths.clone(), + max_tokens: 4096, + use_tools: execution.use_tools, + max_tool_rounds: execution.max_tool_rounds, + }, + NormalizedExecutionMode::FullJob => RoutineAction::FullJob { + title: name.to_string(), + description: prompt.to_string(), + max_iterations: 10, + tool_permissions: execution.tool_permissions.clone(), + }, + } +} + +fn event_emit_schema(include_source_alias: bool) -> Value { + let mut schema = serde_json::json!({ + "type": "object", + "properties": { + "event_source": { + "type": "string", + "description": "Canonical event source, such as 'github'." + }, + "event_type": { + "type": "string", + "description": "Event type, such as 'issue.opened'." + }, + "payload": { + "properties": {}, + "type": "object", + "description": "Structured event payload." + } + }, + "required": ["event_type"] + }); + + if include_source_alias { + if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) { + properties.insert( + "source".to_string(), + serde_json::json!({ + "type": "string", + "description": "Compatibility alias for event_source." + }), + ); + } + if let Some(schema_obj) = schema.as_object_mut() { + schema_obj.insert( + "anyOf".to_string(), + serde_json::json!([ + { "required": ["event_source"] }, + { "required": ["source"] } + ]), + ); + } + } else if let Some(required) = schema.get_mut("required").and_then(Value::as_array_mut) { + required.push(Value::String("event_source".to_string())); + } + + schema +} + +pub(crate) fn event_emit_parameters_schema() -> Value { + event_emit_schema(false) +} + +fn event_emit_discovery_schema() -> Value { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| event_emit_schema(true)).clone() +} + +fn parse_event_emit_args(params: &Value) -> Result<(String, String, Value), ToolError> { + let source = params + .get("event_source") + .and_then(Value::as_str) + .or_else(|| params.get("source").and_then(Value::as_str)) + .ok_or_else(|| { + ToolError::InvalidParameters( + "event_emit requires 'event_source' (canonical) or 'source' (alias)".to_string(), + ) + })? + .to_string(); + let event_type = require_str(params, "event_type")?.to_string(); + let payload = params + .get("payload") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + Ok((source, event_type, payload)) +} pub struct RoutineCreateTool { store: Arc, @@ -179,181 +1058,24 @@ impl Tool for RoutineCreateTool { routine_create_parameters_schema() } + fn discovery_schema(&self) -> serde_json::Value { + routine_create_discovery_schema() + } + + fn discovery_summary(&self) -> Option { + Some(routine_create_tool_summary()) + } + async fn execute( &self, params: serde_json::Value, ctx: &JobContext, ) -> Result { let start = std::time::Instant::now(); - - let name = require_str(¶ms, "name")?; - - let description = params - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let trigger_type = require_str(¶ms, "trigger_type")?; - - let prompt = require_str(¶ms, "prompt")?; - - // Build trigger - let trigger = match trigger_type { - "cron" => { - let schedule = - params - .get("schedule") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - ToolError::InvalidParameters( - "cron trigger requires 'schedule'".to_string(), - ) - })?; - let timezone = params - .get("timezone") - .and_then(|v| v.as_str()) - .map(|tz| { - crate::timezone::parse_timezone(tz) - .map(|_| tz.to_string()) - .ok_or_else(|| { - ToolError::InvalidParameters(format!( - "invalid IANA timezone: '{tz}'" - )) - }) - }) - .transpose()?; - // Validate cron expression - next_cron_fire(schedule, timezone.as_deref()).map_err(|e| { - ToolError::InvalidParameters(format!("invalid cron schedule: {e}")) - })?; - Trigger::Cron { - schedule: schedule.to_string(), - timezone, - } - } - "event" => { - let pattern = params - .get("event_pattern") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - ToolError::InvalidParameters( - "event trigger requires 'event_pattern'".to_string(), - ) - })?; - // Validate regex with size limit to prevent ReDoS (issue #825) - regex::RegexBuilder::new(pattern) - .size_limit(64 * 1024) - .build() - .map_err(|e| { - ToolError::InvalidParameters(format!("invalid or too complex regex: {e}")) - })?; - let channel = params - .get("event_channel") - .and_then(|v| v.as_str()) - .map(String::from); - Trigger::Event { - channel, - pattern: pattern.to_string(), - } - } - "system_event" => { - let source = params - .get("event_source") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - ToolError::InvalidParameters( - "system_event trigger requires 'event_source'".to_string(), - ) - })?; - let event_type = params - .get("event_type") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - ToolError::InvalidParameters( - "system_event trigger requires 'event_type'".to_string(), - ) - })?; - let filters = params - .get("event_filters") - .and_then(|v| v.as_object()) - .map(|obj| { - obj.iter() - .filter_map(|(k, v)| { - crate::agent::routine::json_value_as_filter_string(v) - .map(|s| (k.to_string(), s)) - }) - .collect::>() - }) - .unwrap_or_default(); - Trigger::SystemEvent { - source: source.to_string(), - event_type: event_type.to_string(), - filters, - } - } - "manual" => Trigger::Manual, - other => { - return Err(ToolError::InvalidParameters(format!( - "unknown trigger_type: {other}" - ))); - } - }; - - // Build action - let action_type = params - .get("action_type") - .and_then(|v| v.as_str()) - .unwrap_or("lightweight"); - - let context_paths: Vec = params - .get("context_paths") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - let use_tools = params - .get("use_tools") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let max_tool_rounds = params - .get("max_tool_rounds") - .and_then(|v| v.as_u64()) - .map(|v| v.clamp(1, crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT as u64) as u32) - .unwrap_or(3); - - let action = match action_type { - "lightweight" => RoutineAction::Lightweight { - prompt: prompt.to_string(), - context_paths, - max_tokens: 4096, - use_tools, - max_tool_rounds, - }, - "full_job" => { - let tool_permissions = crate::agent::routine::parse_tool_permissions(¶ms); - RoutineAction::FullJob { - title: name.to_string(), - description: prompt.to_string(), - max_iterations: 10, - tool_permissions, - } - } - other => { - return Err(ToolError::InvalidParameters(format!( - "unknown action_type: {other}" - ))); - } - }; - - let cooldown_secs = params - .get("cooldown_secs") - .and_then(|v| v.as_u64()) - .unwrap_or(300); + let normalized = parse_routine_create_request(¶ms)?; + let trigger = build_routine_trigger(&normalized.trigger); + let action = + build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution); // Compute next fire time for cron let next_fire = if let Trigger::Cron { @@ -368,26 +1090,20 @@ impl Tool for RoutineCreateTool { let routine = Routine { id: Uuid::new_v4(), - name: name.to_string(), - description: description.to_string(), + name: normalized.name.clone(), + description: normalized.description.clone(), user_id: ctx.user_id.clone(), enabled: true, trigger, action, guardrails: RoutineGuardrails { - cooldown: Duration::from_secs(cooldown_secs), + cooldown: Duration::from_secs(normalized.cooldown_secs), max_concurrent: 1, dedup_window: None, }, notify: NotifyConfig { - channel: params - .get("notify_channel") - .and_then(|v| v.as_str()) - .map(String::from), - user: params - .get("notify_user") - .and_then(|v| v.as_str()) - .map(String::from), + channel: normalized.delivery.channel.clone(), + user: normalized.delivery.user.clone(), ..NotifyConfig::default() }, last_run_at: None, @@ -522,9 +1238,8 @@ impl Tool for RoutineUpdateTool { } fn description(&self) -> &str { - "Update an existing routine. Can change prompt, description, enabled state, or cron timing. \ - Pass the routine name and only the fields you want to change. \ - This does not convert one trigger type into another." + "Update an existing routine. Can change prompt, description, enabled state, or cron schedule/timezone. \ + Pass the routine name and only the fields you want to change. This does not convert trigger types." } fn parameters_schema(&self) -> serde_json::Value { @@ -916,24 +1631,11 @@ impl Tool for EventEmitTool { } fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "event_source": { - "type": "string", - "description": "Event source (e.g. 'github', 'workflow', 'tool')" - }, - "event_type": { - "type": "string", - "description": "Event type (e.g. 'issue.opened', 'pr.ready')" - }, - "payload": { - "type": "object", - "description": "Structured event payload" - } - }, - "required": ["event_source", "event_type"] - }) + event_emit_parameters_schema() + } + + fn discovery_schema(&self) -> serde_json::Value { + event_emit_discovery_schema() } async fn execute( @@ -942,22 +1644,16 @@ impl Tool for EventEmitTool { ctx: &JobContext, ) -> Result { let start = std::time::Instant::now(); - - let source = require_str(¶ms, "event_source")?; - let event_type = require_str(¶ms, "event_type")?; - let payload = params - .get("payload") - .cloned() - .unwrap_or_else(|| serde_json::json!({})); + let (source, event_type, payload) = parse_event_emit_args(¶ms)?; let fired = self .engine - .emit_system_event(source, event_type, &payload, Some(&ctx.user_id)) + .emit_system_event(&source, &event_type, &payload, Some(&ctx.user_id)) .await; let result = serde_json::json!({ - "event_source": source, - "event_type": event_type, + "event_source": &source, + "event_type": &event_type, "user_id": &ctx.user_id, "fired_routines": fired, }); @@ -972,81 +1668,569 @@ impl Tool for EventEmitTool { #[cfg(test)] mod tests { - use super::{routine_create_parameters_schema, routine_update_parameters_schema}; + use super::*; use crate::tools::validate_tool_schema; - fn property<'a>(schema: &'a serde_json::Value, name: &str) -> &'a serde_json::Value { + // These tests intentionally use direct assertion macros. + const ROUTINE_CREATE_LEGACY_ALIASES: &[&str] = &[ + "trigger_type", + "schedule", + "timezone", + "event_pattern", + "event_channel", + "event_source", + "event_type", + "event_filters", + "action_type", + "context_paths", + "use_tools", + "max_tool_rounds", + "tool_permissions", + "notify_channel", + "notify_user", + "cooldown_secs", + ]; + + fn schema_property<'a>(schema: &'a Value, name: &str) -> &'a Value { schema .get("properties") - .and_then(|props| props.get(name)) + .and_then(Value::as_object) + .and_then(|properties| properties.get(name)) .unwrap_or_else(|| panic!("missing schema property {name}")) } - #[test] - fn routine_create_schema_exposes_all_trigger_and_delivery_fields() { - let schema = routine_create_parameters_schema(); - let errors = validate_tool_schema(&schema, "routine_create"); - assert!( - errors.is_empty(), - "routine_create schema should validate cleanly: {errors:?}" - ); + fn maybe_schema_property<'a>(schema: &'a Value, name: &str) -> Option<&'a Value> { + schema + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get(name)) + } - for field in [ - "trigger_type", - "schedule", - "event_pattern", - "event_channel", - "event_source", - "event_type", - "event_filters", - "action_type", - "use_tools", - "max_tool_rounds", - "tool_permissions", - "notify_channel", - "notify_user", - "timezone", - ] { - let _ = property(&schema, field); + fn nested_schema_property<'a>(schema: &'a Value, object_name: &str, name: &str) -> &'a Value { + schema_property(schema, object_name) + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get(name)) + .unwrap_or_else(|| panic!("missing nested schema property {object_name}.{name}")) + } + + fn variant_with_kind<'a>(variants: &'a [Value], kind: &str) -> &'a Value { + variants + .iter() + .find(|variant| { + variant + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("kind")) + .and_then(|kind_schema| kind_schema.get("enum")) + .and_then(Value::as_array) + .is_some_and(|enums| enums.contains(&Value::String(kind.to_string()))) + }) + .unwrap_or_else(|| panic!("missing variant for kind={kind}")) + } + + fn variant_with_mode<'a>(variants: &'a [Value], mode: &str) -> &'a Value { + variants + .iter() + .find(|variant| { + variant + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get("mode")) + .and_then(|mode_schema| mode_schema.get("enum")) + .and_then(Value::as_array) + .is_some_and(|enums| enums.contains(&Value::String(mode.to_string()))) + }) + .unwrap_or_else(|| panic!("missing variant for mode={mode}")) + } + + #[test] + fn parses_grouped_manual_lightweight_request() { + let params = serde_json::json!({ + "name": "manual-check", + "prompt": "Inspect the repo for issues.", + "request": { + "kind": "manual" + } + }); + + let parsed = parse_routine_create_request(¶ms).expect("parse grouped manual request"); + + assert_eq!(parsed.name.as_str(), "manual-check"); + assert_eq!(parsed.prompt.as_str(), "Inspect the repo for issues."); + assert!( + matches!(parsed.trigger, NormalizedTriggerRequest::Manual), + "expected manual trigger", + ); + assert!( + matches!(parsed.execution.mode, NormalizedExecutionMode::Lightweight), + "expected lightweight execution mode", + ); + assert_eq!(parsed.cooldown_secs, 300); + assert!( + parsed.delivery.user.is_none(), + "expected omitted delivery.user to remain unspecified", + ); + } + + #[test] + fn parses_grouped_cron_full_job_request() { + let params = serde_json::json!({ + "name": "weekday-digest", + "prompt": "Prepare the morning digest.", + "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 + } + }); + + let parsed = parse_routine_create_request(¶ms).expect("parse grouped cron request"); + + assert!( + matches!( + parsed.trigger, + NormalizedTriggerRequest::Cron { ref schedule, ref timezone } + if schedule == "0 0 9 * * MON-FRI" && timezone.as_deref() == Some("UTC") + ), + "expected grouped cron trigger", + ); + assert!( + matches!(parsed.execution.mode, NormalizedExecutionMode::FullJob), + "expected full_job execution mode", + ); + assert_eq!( + parsed.execution.tool_permissions, + vec!["message".to_string(), "http".to_string()], + ); + assert_eq!(parsed.delivery.channel.as_deref(), Some("telegram")); + assert_eq!(parsed.delivery.user.as_deref(), Some("ops-team")); + assert_eq!(parsed.cooldown_secs, 30); + } + + #[test] + fn parses_grouped_message_event_with_tools() { + let params = serde_json::json!({ + "name": "deploy-watch", + "prompt": "Look for deploy requests.", + "request": { + "kind": "message_event", + "pattern": "deploy\\s+prod", + "channel": "slack" + }, + "execution": { + "use_tools": true, + "max_tool_rounds": 5, + "context_paths": ["context/deploy.md"] + } + }); + + let parsed = + parse_routine_create_request(¶ms).expect("parse grouped message event request"); + + assert!( + matches!( + parsed.trigger, + NormalizedTriggerRequest::MessageEvent { ref pattern, ref channel } + if pattern == "deploy\\s+prod" && channel.as_deref() == Some("slack") + ), + "expected grouped message_event trigger", + ); + assert!(parsed.execution.use_tools, "expected use_tools=true"); + assert_eq!(parsed.execution.max_tool_rounds, 5); + assert_eq!( + parsed.execution.context_paths, + vec!["context/deploy.md".to_string()], + ); + } + + #[test] + fn parses_grouped_system_event_request() { + let params = serde_json::json!({ + "name": "issue-watch", + "prompt": "Summarize new GitHub issues.", + "request": { + "kind": "system_event", + "source": "github", + "event_type": "issue.opened", + "filters": { + "repository": "nearai/ironclaw", + "public": true, + "issue_number": 42 + } + }, + "execution": { + "mode": "full_job" + } + }); + + let parsed = + parse_routine_create_request(¶ms).expect("parse grouped system event request"); + + assert!( + matches!( + parsed.trigger, + NormalizedTriggerRequest::SystemEvent { ref source, ref event_type, ref filters } + if source == "github" + && event_type == "issue.opened" + && filters.get("repository") == Some(&"nearai/ironclaw".to_string()) + && filters.get("public") == Some(&"true".to_string()) + && filters.get("issue_number") == Some(&"42".to_string()) + ), + "expected grouped system_event trigger", + ); + } + + #[test] + fn rejects_system_event_filters_with_nested_values() { + let params = serde_json::json!({ + "name": "issue-watch", + "prompt": "Summarize new GitHub issues.", + "request": { + "kind": "system_event", + "source": "github", + "event_type": "issue.opened", + "filters": { + "repository": { + "owner": "nearai", + "name": "ironclaw" + } + } + } + }); + + let err = parse_routine_create_request(¶ms) + .expect_err("reject nested system event filter values"); + match err { + ToolError::InvalidParameters(message) => { + assert!( + message.contains( + "system_event filters only support string, number, and boolean values", + ), + "unexpected invalid filter error: {message}", + ) + } + other => panic!("expected InvalidParameters, got {other:?}"), } } #[test] - fn routine_create_schema_descriptions_cover_event_trigger_gotchas() { + fn parses_legacy_flat_shape() { + let params = serde_json::json!({ + "name": "legacy-routine", + "prompt": "Legacy create path.", + "trigger_type": "event", + "event_pattern": "hello", + "event_channel": "telegram", + "action_type": "full_job", + "tool_permissions": ["message"], + "notify_channel": "telegram", + "notify_user": "123" + }); + + let parsed = parse_routine_create_request(¶ms).expect("parse legacy flat request"); + + assert!( + matches!( + parsed.trigger, + NormalizedTriggerRequest::MessageEvent { ref pattern, ref channel } + if pattern == "hello" && channel.as_deref() == Some("telegram") + ), + "expected legacy message_event trigger", + ); + assert!( + matches!(parsed.execution.mode, NormalizedExecutionMode::FullJob), + "expected full_job execution mode", + ); + assert_eq!( + parsed.execution.tool_permissions, + vec!["message".to_string()], + ); + assert_eq!(parsed.delivery.channel.as_deref(), Some("telegram")); + assert_eq!(parsed.delivery.user.as_deref(), Some("123")); + } + + #[test] + fn parses_mixed_grouped_and_legacy_aliases() { + let params = serde_json::json!({ + "name": "mixed-routine", + "prompt": "Mixed payload.", + "request": { + "kind": "cron" + }, + "schedule": "0 0 8 * * *", + "timezone": "UTC", + "execution": { + "mode": "lightweight" + }, + "notify_user": "fallback-user", + "advanced": { + "cooldown_secs": 45 + } + }); + + let parsed = parse_routine_create_request(¶ms).expect("parse mixed request"); + + assert!( + matches!( + parsed.trigger, + NormalizedTriggerRequest::Cron { ref schedule, ref timezone } + if schedule == "0 0 8 * * *" && timezone.as_deref() == Some("UTC") + ), + "expected mixed cron trigger", + ); + assert_eq!(parsed.delivery.user.as_deref(), Some("fallback-user")); + assert_eq!(parsed.cooldown_secs, 45); + } + + #[test] + fn parses_event_emit_with_source_alias() { + let params = serde_json::json!({ + "source": "github", + "event_type": "issue.opened", + "payload": { "issue_number": 7 } + }); + + let (source, event_type, payload) = + parse_event_emit_args(¶ms).expect("parse event_emit source alias"); + + assert_eq!(source, "github".to_string()); + assert_eq!(event_type, "issue.opened".to_string()); + assert_eq!(payload["issue_number"].clone(), serde_json::json!(7)); + } + + #[test] + fn parses_event_emit_with_event_source() { + let params = serde_json::json!({ + "event_source": "github", + "event_type": "issue.opened" + }); + + let (source, event_type, payload) = + parse_event_emit_args(¶ms).expect("parse canonical event_emit args"); + + assert_eq!(source, "github".to_string()); + assert_eq!(event_type, "issue.opened".to_string()); + assert_eq!(payload, serde_json::json!({})); + } + + #[test] + fn routine_create_parameters_schema_prefers_grouped_request_shape() { + let schema = routine_create_parameters_schema(); + let errors = validate_tool_schema(&schema, "routine_create"); + assert!( + errors.is_empty(), + "routine_create schema should validate cleanly: {errors:?}", + ); + + let request = schema_property(&schema, "request"); + assert!( + request.is_object(), + "request should be present in compact schema", + ); + let required = schema + .get("required") + .and_then(Value::as_array) + .expect("routine_create required list"); + assert!( + required.contains(&Value::String("request".to_string())), + "compact parameters schema should require request", + ); + + for legacy_alias in ROUTINE_CREATE_LEGACY_ALIASES { + assert!( + maybe_schema_property(&schema, legacy_alias).is_none(), + "compact parameters schema should hide legacy alias", + ); + } + } + + #[test] + fn routine_create_discovery_schema_keeps_legacy_aliases() { + let schema = routine_create_discovery_schema(); + let any_of = schema + .get("anyOf") + .and_then(Value::as_array) + .expect("routine_create discovery anyOf"); + assert_eq!(any_of.len(), 2usize); + + for legacy_alias in ROUTINE_CREATE_LEGACY_ALIASES { + assert!( + schema_property(&schema, legacy_alias).is_object(), + "discovery schema should retain legacy alias", + ); + } + } + + #[test] + fn routine_create_discovery_schema_splits_request_variants() { + let schema = routine_create_discovery_schema(); + let request = schema_property(&schema, "request"); + let variants = request + .get("oneOf") + .and_then(Value::as_array) + .expect("request.oneOf variants"); + assert_eq!(variants.len(), 4usize); + + let cron = variant_with_kind(variants, "cron"); + let cron_required = cron + .get("required") + .and_then(Value::as_array) + .expect("cron required list"); + assert!( + cron_required.contains(&Value::String("schedule".to_string())), + "cron variant should require schedule", + ); + + let message_event = variant_with_kind(variants, "message_event"); + let message_required = message_event + .get("required") + .and_then(Value::as_array) + .expect("message_event required list"); + assert!( + message_required.contains(&Value::String("pattern".to_string())), + "message_event variant should require pattern", + ); + + let system_event = variant_with_kind(variants, "system_event"); + let system_required = system_event + .get("required") + .and_then(Value::as_array) + .expect("system_event required list"); + assert!( + system_required.contains(&Value::String("source".to_string())) + && system_required.contains(&Value::String("event_type".to_string())), + "system_event variant should require source and event_type", + ); + } + + #[test] + fn routine_create_discovery_schema_splits_execution_variants() { + let schema = routine_create_discovery_schema(); + let execution = schema_property(&schema, "execution"); + let variants = execution + .get("oneOf") + .and_then(Value::as_array) + .expect("execution.oneOf variants"); + assert_eq!(variants.len(), 2usize); + + let lightweight = variant_with_mode(variants, "lightweight"); + let lightweight_props = lightweight + .get("properties") + .and_then(Value::as_object) + .expect("lightweight properties"); + assert!( + lightweight_props.contains_key("use_tools") + && lightweight_props.contains_key("context_paths") + && lightweight_props.contains_key("max_tool_rounds"), + "lightweight variant should expose lightweight-only fields", + ); + + let full_job = variant_with_mode(variants, "full_job"); + let full_job_props = full_job + .get("properties") + .and_then(Value::as_object) + .expect("full_job properties"); + assert!( + full_job_props.contains_key("tool_permissions"), + "full_job variant should expose tool_permissions", + ); + } + + #[test] + fn routine_create_discovery_summary_explains_rules_and_examples() { + let summary = routine_create_tool_summary(); + + assert_eq!( + summary.always_required, + vec![ + "name".to_string(), + "prompt".to_string(), + "request.kind".to_string() + ], + ); + assert!( + summary + .conditional_requirements + .iter() + .any(|rule| rule.contains("request.kind='cron'")), + "summary should explain cron requirement", + ); + assert!( + summary + .notes + .iter() + .any(|note| note.contains("Legacy flat aliases")), + "summary should mention legacy aliases", + ); + assert_eq!(summary.examples.len(), 4usize); + } + + #[test] + fn routine_create_parameters_schema_describes_grouped_trigger_fields() { let schema = routine_create_parameters_schema(); - let trigger_type = property(&schema, "trigger_type") + let request_description = schema_property(&schema, "request") .get("description") - .and_then(|value| value.as_str()) - .expect("trigger_type description"); - assert!(trigger_type.contains("incoming messages")); - assert!(trigger_type.contains("structured emitted events")); + .and_then(Value::as_str) + .expect("request description"); + assert!( + request_description.contains("Set request.kind first"), + "request description should mention kind-first guidance", + ); - let event_pattern = property(&schema, "event_pattern") + let pattern_description = nested_schema_property(&schema, "request", "pattern") .get("description") - .and_then(|value| value.as_str()) - .expect("event_pattern description"); - assert!(event_pattern.contains("incoming message text")); - assert!(event_pattern.contains("^bug\\\\b")); + .and_then(Value::as_str) + .expect("request.pattern description"); + assert!( + pattern_description.contains("message_event"), + "pattern description should mention message_event", + ); - let event_channel = property(&schema, "event_channel") + let source_description = nested_schema_property(&schema, "request", "source") .get("description") - .and_then(|value| value.as_str()) - .expect("event_channel description"); - assert!(event_channel.contains("Omit to match any channel")); - assert!(event_channel.contains("Not a chat or thread ID")); + .and_then(Value::as_str) + .expect("request.source description"); + assert!( + source_description.contains("system_event"), + "source description should mention system_event", + ); - let notify_channel = property(&schema, "notify_channel") + let filters_description = nested_schema_property(&schema, "request", "filters") .get("description") - .and_then(|value| value.as_str()) - .expect("notify_channel description"); - assert!(notify_channel.contains("does not control what triggers")); + .and_then(Value::as_str) + .expect("request.filters description"); + assert!( + filters_description.contains("top-level string, number, and boolean"), + "filters description should mention supported scalar payload types", + ); - let prompt = property(&schema, "prompt") - .get("description") - .and_then(|value| value.as_str()) - .expect("prompt description"); - assert!(prompt.contains("after it fires")); + let filters_schema = nested_schema_property(&schema, "request", "filters"); + let additional_properties = filters_schema + .get("additionalProperties") + .expect("request.filters additionalProperties"); + let allowed_types = additional_properties + .get("type") + .and_then(Value::as_array) + .expect("request.filters additionalProperties.type"); + assert!( + allowed_types.contains(&Value::String("string".to_string())) + && allowed_types.contains(&Value::String("number".to_string())) + && allowed_types.contains(&Value::String("boolean".to_string())), + "filters schema should constrain additionalProperties to scalar values", + ); } #[test] @@ -1055,7 +2239,7 @@ mod tests { let errors = validate_tool_schema(&schema, "routine_update"); assert!( errors.is_empty(), - "routine_update schema should validate cleanly: {errors:?}" + "routine_update schema should validate cleanly: {errors:?}", ); for field in [ @@ -1066,20 +2250,66 @@ mod tests { "timezone", "description", ] { - let _ = property(&schema, field); + let _ = schema_property(&schema, field); } - let schedule = property(&schema, "schedule") + let schedule_description = schema_property(&schema, "schedule") .get("description") - .and_then(|value| value.as_str()) + .and_then(Value::as_str) .expect("schedule description"); - assert!(schedule.contains("existing 'cron' routines only")); - assert!(schedule.contains("does not convert other trigger types")); + assert!( + schedule_description.contains("cron triggers"), + "schedule description should mention cron triggers", + ); - let timezone = property(&schema, "timezone") + let timezone_description = schema_property(&schema, "timezone") .get("description") - .and_then(|value| value.as_str()) + .and_then(Value::as_str) .expect("timezone description"); - assert!(timezone.contains("existing 'cron' routines only")); + assert!( + timezone_description.contains("cron triggers"), + "timezone description should mention cron triggers", + ); + } + + #[test] + fn event_emit_parameters_schema_prefers_canonical_event_source() { + let schema = event_emit_parameters_schema(); + let errors = validate_tool_schema(&schema, "event_emit"); + assert!( + errors.is_empty(), + "event_emit schema should validate cleanly: {errors:?}", + ); + + assert!( + schema_property(&schema, "event_source").is_object(), + "event_emit parameters schema should expose event_source", + ); + let required = schema + .get("required") + .and_then(Value::as_array) + .expect("event_emit required list"); + assert!( + required.contains(&Value::String("event_source".to_string())), + "event_emit parameters schema should require event_source", + ); + assert!( + maybe_schema_property(&schema, "source").is_none(), + "event_emit parameters schema should hide source alias", + ); + } + + #[test] + fn event_emit_discovery_schema_keeps_source_alias() { + let schema = event_emit_discovery_schema(); + let any_of = schema + .get("anyOf") + .and_then(Value::as_array) + .expect("event_emit discovery anyOf"); + assert_eq!(any_of.len(), 2usize); + assert!( + schema_property(&schema, "source").is_object(), + "event_emit discovery schema should keep source alias", + ); } } diff --git a/src/tools/builtin/tool_info.rs b/src/tools/builtin/tool_info.rs index cd94384d..264547aa 100644 --- a/src/tools/builtin/tool_info.rs +++ b/src/tools/builtin/tool_info.rs @@ -1,8 +1,9 @@ //! On-demand tool discovery (like CLI `--help`). //! -//! Two levels of detail: +//! Three levels of detail: //! - Default: name, description, parameter names (compact ~150 bytes) -//! - `include_schema: true`: adds the full typed JSON Schema +//! - `detail: "summary"`: adds curated rules, notes, and examples +//! - `detail: "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. @@ -13,7 +14,59 @@ use async_trait::async_trait; use crate::context::JobContext; use crate::tools::registry::ToolRegistry; -use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; +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 { + 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 { + 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() + } +} pub struct ToolInfoTool { registry: Weak, @@ -32,8 +85,7 @@ impl Tool for ToolInfoTool { } fn description(&self) -> &str { - "Get info about any tool: description and parameter names. \ - Set include_schema to true for the full typed parameter schema." + "Get info about any tool: description, parameter names, curated summary guidance, or full discovery schema." } fn parameters_schema(&self) -> serde_json::Value { @@ -44,9 +96,15 @@ 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": "If true, include the full typed JSON Schema for parameters (larger response). Default: false.", + "description": "Deprecated compatibility alias for detail='schema'. If true, include the full discovery schema.", "default": false } }, @@ -61,10 +119,7 @@ impl Tool for ToolInfoTool { ) -> Result { let start = std::time::Instant::now(); let name = require_str(¶ms, "name")?; - let include_schema = params - .get("include_schema") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + let detail = ToolInfoDetail::parse(¶ms)?; let registry = self.registry.upgrade().ok_or_else(|| { ToolError::ExecutionFailed( @@ -77,13 +132,7 @@ impl Tool for ToolInfoTool { })?; let schema = tool.discovery_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 param_names = schema_param_names(&schema); let mut info = serde_json::json!({ "name": tool.name(), @@ -91,8 +140,21 @@ impl Tool for ToolInfoTool { "parameters": param_names, }); - if include_schema { - info["schema"] = schema; + 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; + } } Ok(ToolOutput::success(info, start.elapsed())) @@ -135,6 +197,30 @@ 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(®istry)); + 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()); @@ -157,6 +243,22 @@ 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(®istry)); + 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()); diff --git a/src/tools/execute.rs b/src/tools/execute.rs index c6c20dc1..bb8a7b9d 100644 --- a/src/tools/execute.rs +++ b/src/tools/execute.rs @@ -291,6 +291,33 @@ 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( + ®istry, + &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; diff --git a/src/tools/mcp/client.rs b/src/tools/mcp/client.rs index c299ac49..148f5a86 100644 --- a/src/tools/mcp/client.rs +++ b/src/tools/mcp/client.rs @@ -288,6 +288,71 @@ 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 { + 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(¬ification, &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 { @@ -297,13 +362,26 @@ impl McpClient { return self.transport.send(&request, &headers).await; } - // HTTP transport: try up to 2 times (first attempt, then retry after token refresh) + // HTTP transport: try up to 2 times (first attempt, then retry after token refresh + // or recoverable session reinitialization). 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") @@ -362,47 +440,7 @@ impl McpClient { { return Ok(InitializeResult::default()); } - 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) + self.reinitialize_session().await }) .await?; @@ -865,6 +903,54 @@ mod tests { } } + /// Mock transport that can return errors and successful responses in a + /// controlled sequence. + struct RetryMockTransport { + supports_http: bool, + outcomes: std::sync::Mutex>>, + recorded_headers: std::sync::Mutex>>, + } + + impl RetryMockTransport { + fn new(supports_http: bool, outcomes: Vec>) -> Self { + Self { + supports_http, + outcomes: std::sync::Mutex::new(outcomes.into()), + recorded_headers: std::sync::Mutex::new(Vec::new()), + } + } + + fn recorded_headers(&self) -> Vec> { + self.recorded_headers.lock().unwrap().clone() + } + } + + #[async_trait] + impl McpTransport for RetryMockTransport { + async fn send( + &self, + _request: &McpRequest, + headers: &HashMap, + ) -> Result { + 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), @@ -965,6 +1051,83 @@ 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!({ diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 0c457a6d..f8110b46 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -94,6 +94,15 @@ pub struct ToolRegistry { } impl ToolRegistry { + fn tool_definition(tool: &Arc) -> 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 { @@ -206,11 +215,7 @@ impl ToolRegistry { .read() .await .values() - .map(|tool| ToolDefinition { - name: tool.name().to_string(), - description: tool.description().to_string(), - parameters: tool.parameters_schema(), - }) + .map(Self::tool_definition) .collect(); defs.sort_unstable_by(|a, b| a.name.cmp(&b.name)); defs @@ -221,13 +226,7 @@ impl ToolRegistry { let tools = self.tools.read().await; names .iter() - .filter_map(|name| { - tools.get(*name).map(|tool| ToolDefinition { - name: tool.name().to_string(), - description: tool.description().to_string(), - parameters: tool.parameters_schema(), - }) - }) + .filter_map(|name| tools.get(*name).map(Self::tool_definition)) .collect() } @@ -282,11 +281,7 @@ impl ToolRegistry { .await .values() .filter(|tool| tool.domain() == domain) - .map(|tool| ToolDefinition { - name: tool.name().to_string(), - description: tool.description().to_string(), - parameters: tool.parameters_schema(), - }) + .map(Self::tool_definition) .collect() } @@ -312,11 +307,7 @@ impl ToolRegistry { ApprovalRequirement::Never ) }) - .map(|tool| ToolDefinition { - name: tool.name().to_string(), - description: tool.description().to_string(), - parameters: tool.parameters_schema(), - }) + .map(Self::tool_definition) .collect(); defs.sort_unstable_by(|a, b| a.name.cmp(&b.name)); defs @@ -788,6 +779,7 @@ 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() { @@ -818,6 +810,71 @@ 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 { + Some(ToolDiscoverySummary { + notes: vec!["extra guidance".into()], + ..ToolDiscoverySummary::default() + }) + } + + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &crate::context::JobContext, + ) -> Result { + 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(); diff --git a/src/tools/schema_validator.rs b/src/tools/schema_validator.rs index 9cc2fa5f..df87afa4 100644 --- a/src/tools/schema_validator.rs +++ b/src/tools/schema_validator.rs @@ -605,15 +605,7 @@ mod tests { ), ( "event_emit", - 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"] - }), + crate::tools::builtin::routine::event_emit_parameters_schema(), ), // Job tools with complex deps ( diff --git a/src/tools/tool.rs b/src/tools/tool.rs index 608c71a6..e80712a9 100644 --- a/src/tools/tool.rs +++ b/src/tools/tool.rs @@ -231,6 +231,19 @@ 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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub conditional_requirements: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub examples: Vec, +} + /// Trait for tools that the agent can use. #[async_trait] pub trait Tool: Send + Sync { @@ -347,12 +360,32 @@ 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 { + 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: self.description().to_string(), - parameters: self.parameters_schema(), + description, + parameters, } } } diff --git a/src/workspace/embeddings.rs b/src/workspace/embeddings.rs index 96fe144b..a8ed0a3e 100644 --- a/src/workspace/embeddings.rs +++ b/src/workspace/embeddings.rs @@ -6,6 +6,8 @@ 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 { @@ -232,6 +234,7 @@ impl EmbeddingProvider for OpenAiEmbeddings { .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().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 }); } @@ -374,6 +377,7 @@ impl EmbeddingProvider for NearAiEmbeddings { .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().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 }); } @@ -690,6 +694,7 @@ mod tests { .parse::() .ok() .map(std::time::Duration::from_secs) + .map(cap_retry_after) .or(Some(std::time::Duration::from_secs(60))) } } diff --git a/tests/dispatched_routine_run_tests.rs b/tests/dispatched_routine_run_tests.rs new file mode 100644 index 00000000..4ab5d2a8 --- /dev/null +++ b/tests/dispatched_routine_run_tests.rs @@ -0,0 +1,360 @@ +//! 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, 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 = 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) -> 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)); + } +} diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index a0c498e5..4cb7afeb 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -45,12 +45,13 @@ SEL = { "approval_always_btn": ".approval-actions button.always", "approval_deny_btn": ".approval-actions button.deny", "approval_resolved": ".approval-resolved", - # Extensions tab – sections + # Settings subtabs + "settings_subtab": '.settings-subtab[data-settings-subtab="{subtab}"]', + "settings_subpanel": "#settings-{subtab}", + # Extensions section "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", @@ -92,6 +93,12 @@ 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", @@ -106,7 +113,7 @@ SEL = { "routines_empty": "#routines-empty", } -TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"] +TABS = ["chat", "memory", "jobs", "routines", "settings"] # Auth token used across all tests AUTH_TOKEN = "e2e-test-token" diff --git a/tests/e2e/scenarios/test_extensions.py b/tests/e2e/scenarios/test_extensions.py index a728a994..03ae9807 100644 --- a/tests/e2e/scenarios/test_extensions.py +++ b/tests/e2e/scenarios/test_extensions.py @@ -87,23 +87,21 @@ _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): - """Click the Extensions tab and wait for the panel to appear. + """Navigate to Settings > Extensions subtab and wait for content. 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="extensions")).click() - await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for( + 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( state="visible", timeout=5000 ) - # loadExtensions() fires three parallel fetches then renders. Wait for the + # loadExtensions() fires 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( @@ -111,19 +109,39 @@ async def go_to_extensions(page): ).first.wait_for(state="visible", timeout=8000) -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_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 + ) - Must be called BEFORE navigating to the extensions tab. + +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. """ 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 /tools and /registry handlers are registered after and therefore - # checked first — no continue_() fallthrough needed. + # specific /registry handler is 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"): @@ -133,13 +151,9 @@ async def mock_ext_apis(page, *, installed=None, tools=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) @@ -151,46 +165,17 @@ 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 tab with no data shows all three sections with correct empty-state messages.""" - await mock_ext_apis(page, tools=[]) + """Extensions subtab with no data shows sections with correct empty-state messages.""" + await mock_ext_apis(page) await go_to_extensions(page) - panel = page.locator(SEL["tab_panel"].format(tab="extensions")) + panel = page.locator(SEL["settings_subpanel"].format(subtab="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 ─────────────────────────────────────── @@ -248,9 +233,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_extensions(page) + await go_to_mcp(page) - card = page.locator(SEL["ext_card_installed"]).first + card = page.locator(SEL["ext_card_mcp"]).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 @@ -260,9 +245,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_extensions(page) + await go_to_mcp(page) - card = page.locator(SEL["ext_card_installed"]).first + card = page.locator(SEL["ext_card_mcp"]).first await card.wait_for(state="visible", timeout=5000) assert await card.locator(SEL["ext_activate_btn"]).count() == 1 @@ -270,7 +255,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_extensions(page) + await go_to_mcp(page) mcp_list = page.locator(SEL["mcp_servers_list"]) card = mcp_list.locator(".ext-card").first @@ -285,7 +270,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_extensions(page) + await go_to_mcp(page) mcp_list = page.locator(SEL["mcp_servers_list"]) card = mcp_list.locator(".ext-card").first @@ -299,8 +284,9 @@ 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_extensions(page) - card = page.locator(SEL["ext_card_installed"]).first + 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 card.wait_for(state="visible", timeout=5000) return card @@ -446,9 +432,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_extensions(page) + await go_to_channels(page) - install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first + install_btn = page.locator(SEL["channels_ext_card"]).locator(SEL["ext_install_btn"]).first await install_btn.wait_for(state="visible", timeout=5000) await install_btn.click() @@ -523,13 +509,14 @@ 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", @@ -543,13 +530,14 @@ 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" @@ -973,14 +961,10 @@ 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) @@ -989,6 +973,9 @@ 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', @@ -999,14 +986,11 @@ 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 - ) - await page.wait_for_timeout(600) + # 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) assert len(reload_count) > count_before, "Extensions list did not reload after auth failure" @@ -1026,9 +1010,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_extensions(page) + await go_to_mcp(page) - activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + activate_btn = page.locator(SEL["ext_card_mcp"]).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): @@ -1051,9 +1035,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_extensions(page) + await go_to_mcp(page) - activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"]) await activate_btn.wait_for(state="visible", timeout=5000) await activate_btn.click() @@ -1070,9 +1054,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_extensions(page) + await go_to_mcp(page) - activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"]) await activate_btn.wait_for(state="visible", timeout=5000) await activate_btn.click() @@ -1088,9 +1072,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_extensions(page) + await go_to_mcp(page) - activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"]) await activate_btn.wait_for(state="visible", timeout=5000) await activate_btn.click() @@ -1106,7 +1090,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 tab.""" + """loadExtensions() is called again when re-navigating to the extensions subtab.""" call_count = [] async def counting_handler(route): @@ -1121,14 +1105,10 @@ 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 @@ -1148,48 +1128,6 @@ 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. @@ -1267,9 +1205,9 @@ async def test_oauth_url_injection_blocked(page): ) await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) - await go_to_extensions(page) + await go_to_mcp(page) - activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"]) await activate_btn.wait_for(state="visible", timeout=5000) await activate_btn.click() diff --git a/tests/e2e/scenarios/test_skills.py b/tests/e2e/scenarios/test_skills.py index 4d92331b..50f5b6be 100644 --- a/tests/e2e/scenarios/test_skills.py +++ b/tests/e2e/scenarios/test_skills.py @@ -4,11 +4,18 @@ 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 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) + """Skills subtab shows the search interface.""" + await go_to_skills(page) search_input = page.locator(SEL["skill_search_input"]) assert await search_input.is_visible(), "Skills search input not visible" @@ -16,7 +23,7 @@ async def test_skills_tab_visible(page): async def test_skills_search(page): """Search ClawHub for skills and verify results appear.""" - await page.locator(SEL["tab_button"].format(tab="skills")).click() + await go_to_skills(page) search_input = page.locator(SEL["skill_search_input"]) await search_input.fill("markdown") @@ -35,7 +42,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 page.locator(SEL["tab_button"].format(tab="skills")).click() + await go_to_skills(page) # Search search_input = page.locator(SEL["skill_search_input"]) @@ -68,10 +75,14 @@ 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 (confirm is already overridden) + # Remove the skill via confirm modal 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() diff --git a/tests/e2e/scenarios/test_wasm_lifecycle.py b/tests/e2e/scenarios/test_wasm_lifecycle.py index 961e7ad0..212cc3ce 100644 --- a/tests/e2e/scenarios/test_wasm_lifecycle.py +++ b/tests/e2e/scenarios/test_wasm_lifecycle.py @@ -507,10 +507,10 @@ async def test_configure_noninstalled(ironclaw_server): async def test_extensions_tab_shows_registry(page): - """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")) + """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")) await panel.wait_for(state="visible", timeout=5000) available_section = page.locator(SEL["available_wasm_list"]) diff --git a/tests/e2e_builtin_tool_coverage.rs b/tests/e2e_builtin_tool_coverage.rs index 2a97a0d5..d08f2204 100644 --- a/tests/e2e_builtin_tool_coverage.rs +++ b/tests/e2e_builtin_tool_coverage.rs @@ -142,16 +142,18 @@ 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 action, got {other:?}"), + other => panic!("expected lightweight routine action, got {other:?}"), } assert_eq!(routine.notify.channel.as_deref(), Some("telegram")); @@ -369,7 +371,132 @@ mod tests { } // ----------------------------------------------------------------------- - // Test 8: skill_install_routine_webhook_sim + // 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 // ----------------------------------------------------------------------- #[tokio::test] @@ -571,10 +698,11 @@ mod tests { } // ----------------------------------------------------------------------- - // Test: tool_info_discovery (two-level detail) + // Test: tool_info_discovery (three-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] @@ -597,13 +725,13 @@ mod tests { rig.verify_trace_expects(&trace, &responses); - // tool_info should have been called twice (echo + time), both succeeding. + // tool_info should have been called three times (echo + routine_create + time), all 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(), - 2, - "Expected 2 tool_info calls, got {tool_info_calls:?}" + 3, + "Expected 3 tool_info calls, got {tool_info_calls:?}" ); assert!( tool_info_calls.iter().all(|(_, ok)| *ok), @@ -613,44 +741,71 @@ 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 = 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_result = info_results + let echo_json = info_json .iter() - .find(|(_, preview)| preview.contains("echo")) + .find(|info| info["name"] == "echo") .expect("tool_info result should contain 'echo'"); assert!( - echo_result.1.contains("message"), + echo_json["parameters"] + .as_array() + .is_some_and(|params| params.iter().any(|param| param == "message")), "echo default result should list 'message' parameter name: {:?}", - echo_result.1 + echo_json ); // 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_result.1 + echo_json ); - // Second call was for "time" with include_schema: true — result should - // contain "time", "schema" field with full object. - let time_result = info_results + // 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(|(_, preview)| preview.contains("time")) + .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 + // contain "time", "schema" field with full object. + let time_json = info_json + .iter() + .find(|info| info["name"] == "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_result.1 + time_json ); assert!( time_json["schema"]["properties"].is_object(), "schema should have properties: {:?}", - time_result.1 + time_json ); rig.shutdown(); diff --git a/tests/e2e_routine_heartbeat.rs b/tests/e2e_routine_heartbeat.rs index 48fb1ef4..25432f3d 100644 --- a/tests/e2e_routine_heartbeat.rs +++ b/tests/e2e_routine_heartbeat.rs @@ -238,7 +238,13 @@ mod tests { "default", "deploy to production now", ); - let fired = engine.check_event_triggers(&matching_msg).await; + let fired = engine + .check_event_triggers( + &matching_msg.user_id, + &matching_msg.channel, + &matching_msg.content, + ) + .await; assert!( fired >= 1, "Expected >= 1 routine fired on match, got {fired}" @@ -255,7 +261,13 @@ mod tests { "default", "check the staging environment", ); - let fired_neg = engine.check_event_triggers(&non_matching_msg).await; + let fired_neg = engine + .check_event_triggers( + &non_matching_msg.user_id, + &non_matching_msg.channel, + &non_matching_msg.content, + ) + .await; assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match"); } @@ -315,7 +327,9 @@ mod tests { "guest-sender", "deploy to production now", ); - let guest_fired = engine.check_event_triggers(&guest_msg).await; + let guest_fired = engine + .check_event_triggers(&guest_msg.user_id, &guest_msg.channel, &guest_msg.content) + .await; assert_eq!( guest_fired, 0, "Guest scope must not fire owner event routines" @@ -338,7 +352,9 @@ mod tests { "owner-sender", "deploy to production now", ); - let owner_fired = engine.check_event_triggers(&owner_msg).await; + let owner_fired = engine + .check_event_triggers(&owner_msg.user_id, &owner_msg.channel, &owner_msg.content) + .await; assert!( owner_fired >= 1, "Owner scope should fire matching owner event routine" @@ -562,7 +578,9 @@ mod tests { "default", "test-cooldown trigger", ); - let fired1 = engine.check_event_triggers(&msg).await; + let fired1 = engine + .check_event_triggers(&msg.user_id, &msg.channel, &msg.content) + .await; assert!(fired1 >= 1, "First fire should work"); // Give spawn time, then update last_run_at to simulate recent execution. @@ -577,7 +595,9 @@ mod tests { engine.refresh_event_cache().await; // Second fire should be blocked by cooldown. - let fired2 = engine.check_event_triggers(&msg).await; + let fired2 = engine + .check_event_triggers(&msg.user_id, &msg.channel, &msg.content) + .await; assert_eq!(fired2, 0, "Second fire should be blocked by cooldown"); } @@ -745,7 +765,9 @@ mod tests { engine.refresh_event_cache().await; let msg = IncomingMessage::new("test", "default", "DISABLE_ME"); - let fired_before = engine.check_event_triggers(&msg).await; + let fired_before = engine + .check_event_triggers(&msg.user_id, &msg.channel, &msg.content) + .await; assert!(fired_before >= 1, "Expected routine to fire before disable"); // Simulate what routines_toggle_handler now does: update DB, then refresh. @@ -754,7 +776,9 @@ mod tests { db.update_routine(&routine).await.expect("update_routine"); engine.refresh_event_cache().await; - let fired_after = engine.check_event_triggers(&msg).await; + let fired_after = engine + .check_event_triggers(&msg.user_id, &msg.channel, &msg.content) + .await; assert_eq!( fired_after, 0, "Disabled routine must not fire after cache refresh" @@ -780,7 +804,10 @@ mod tests { let msg = IncomingMessage::new("test", "default", "DELETE_ME"); assert!( - engine.check_event_triggers(&msg).await >= 1, + engine + .check_event_triggers(&msg.user_id, &msg.channel, &msg.content) + .await + >= 1, "Expected routine to fire before delete" ); @@ -789,9 +816,217 @@ mod tests { engine.refresh_event_cache().await; assert_eq!( - engine.check_event_triggers(&msg).await, + engine + .check_event_triggers(&msg.user_id, &msg.channel, &msg.content) + .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" + ); + } } diff --git a/tests/fixtures/llm_traces/tools/routine_create_grouped.json b/tests/fixtures/llm_traces/tools/routine_create_grouped.json new file mode 100644 index 00000000..ae4b6eb9 --- /dev/null +++ b/tests/fixtures/llm_traces/tools/routine_create_grouped.json @@ -0,0 +1,66 @@ +{ + "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 + } + } + ] +} diff --git a/tests/fixtures/llm_traces/tools/routine_system_event_emit_grouped.json b/tests/fixtures/llm_traces/tools/routine_system_event_emit_grouped.json new file mode 100644 index 00000000..61f159c0 --- /dev/null +++ b/tests/fixtures/llm_traces/tools/routine_system_event_emit_grouped.json @@ -0,0 +1,74 @@ +{ + "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 + } + } + ] +} diff --git a/tests/fixtures/llm_traces/tools/tool_info_discovery.json b/tests/fixtures/llm_traces/tools/tool_info_discovery.json index dc8746ad..5a18e9e9 100644 --- a/tests/fixtures/llm_traces/tools/tool_info_discovery.json +++ b/tests/fixtures/llm_traces/tools/tool_info_discovery.json @@ -24,6 +24,20 @@ "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", @@ -34,16 +48,16 @@ "arguments": { "name": "time", "include_schema": true } } ], - "input_tokens": 200, + "input_tokens": 240, "output_tokens": 20 } }, { "response": { "type": "text", - "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 + "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 } } ] diff --git a/tests/openai_compat_integration.rs b/tests/openai_compat_integration.rs index 939f39eb..a1bc6a64 100644 --- a/tests/openai_compat_integration.rs +++ b/tests/openai_compat_integration.rs @@ -214,6 +214,7 @@ 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(); @@ -705,6 +706,7 @@ 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(); diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs index a4d737b5..13a8a54c 100644 --- a/tests/support/gateway_workflow_harness.rs +++ b/tests/support/gateway_workflow_harness.rs @@ -234,6 +234,7 @@ 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( diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs index 51e39d8d..6702d4ff 100644 --- a/tests/ws_gateway_integration.rs +++ b/tests/ws_gateway_integration.rs @@ -62,6 +62,7 @@ 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();