From 2d14bc247b44ee1543a93fcf6f84e4d1b9972033 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 9 Jul 2026 11:04:35 +0800 Subject: [PATCH 1/2] feat(cli): add agent-facing config and diagnose commands --- crates/aionui-ai-agent/src/factory/acp.rs | 74 +- crates/aionui-ai-agent/src/factory/aionrs.rs | 65 +- .../aionui-troubleshooting/SKILL.md | 339 ++-- .../scripts/aion_diag.py | 517 ----- .../auto-inject/aionui-config/SKILL.md | 1003 ++++----- .../aionui-config/scripts/aionui_api.py | 120 -- .../builtin-skills/auto-inject/cron/SKILL.md | 60 +- crates/aionui-app/src/cli.rs | 518 ++++- .../src/commands/cmd_capabilities.rs | 130 ++ crates/aionui-app/src/commands/cmd_config.rs | 1785 +++++++++++++++++ .../src/commands/cmd_cron_helper.rs | 239 --- .../aionui-app/src/commands/cmd_diagnose.rs | 1186 +++++++++++ .../src/commands/config_capabilities.rs | 252 +++ .../src/commands/diagnose_capabilities.rs | 179 ++ crates/aionui-app/src/commands/error.rs | 22 - crates/aionui-app/src/commands/mod.rs | 10 +- crates/aionui-app/src/main.rs | 4 +- .../aionui-app/tests/capabilities_cli_e2e.rs | 58 + crates/aionui-app/tests/config_cli_e2e.rs | 1045 ++++++++++ crates/aionui-app/tests/cron_e2e.rs | 322 +-- crates/aionui-app/tests/diagnose_cli_e2e.rs | 502 +++++ 21 files changed, 6397 insertions(+), 2033 deletions(-) delete mode 100644 crates/aionui-app/assets/builtin-skills/aionui-troubleshooting/scripts/aion_diag.py delete mode 100644 crates/aionui-app/assets/builtin-skills/auto-inject/aionui-config/scripts/aionui_api.py create mode 100644 crates/aionui-app/src/commands/cmd_capabilities.rs create mode 100644 crates/aionui-app/src/commands/cmd_config.rs delete mode 100644 crates/aionui-app/src/commands/cmd_cron_helper.rs create mode 100644 crates/aionui-app/src/commands/cmd_diagnose.rs create mode 100644 crates/aionui-app/src/commands/config_capabilities.rs create mode 100644 crates/aionui-app/src/commands/diagnose_capabilities.rs create mode 100644 crates/aionui-app/tests/capabilities_cli_e2e.rs create mode 100644 crates/aionui-app/tests/config_cli_e2e.rs create mode 100644 crates/aionui-app/tests/diagnose_cli_e2e.rs diff --git a/crates/aionui-ai-agent/src/factory/acp.rs b/crates/aionui-ai-agent/src/factory/acp.rs index 475d63984..98745c29e 100644 --- a/crates/aionui-ai-agent/src/factory/acp.rs +++ b/crates/aionui-ai-agent/src/factory/acp.rs @@ -506,9 +506,12 @@ fn session_server_supported_by_capabilities(server: &SessionMcpServer, capabilit mod tests { use super::*; use aionui_realtime::BroadcastEventBus; - use aionui_runtime::init as init_runtime; + use aionui_runtime::{ManagedResourcesMode, init as init_runtime, set_managed_resources_mode}; use std::sync::OnceLock; - use std::{mem, path::PathBuf}; + use std::{ + mem, + path::{Path, PathBuf}, + }; fn make_row( name: &str, @@ -574,7 +577,7 @@ mod tests { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::tempdir().expect("tempdir"); - let runtime_root = tmp.path().join("node").join("node-v24.11.0-darwin-arm64"); + let runtime_root = tmp.path().join("node").join(current_node_runtime_directory_name()); let bin = runtime_root.join("bin"); std::fs::create_dir_all(&bin).expect("create bin"); @@ -589,13 +592,66 @@ mod tests { tmp } + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + fn current_node_runtime_directory_name() -> &'static str { + "node-v24.11.0-darwin-arm64" + } + + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + fn current_node_runtime_directory_name() -> &'static str { + "node-v24.11.0-darwin-x64" + } + + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + fn current_node_runtime_directory_name() -> &'static str { + "node-v24.11.0-linux-arm64" + } + + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + fn current_node_runtime_directory_name() -> &'static str { + "node-v24.11.0-linux-x64" + } + + #[cfg(all( + unix, + not(any( + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "macos", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "aarch64"), + all(target_os = "linux", target_arch = "x86_64") + )) + ))] + fn current_node_runtime_directory_name() -> &'static str { + panic!("unsupported managed Node runtime test platform") + } + + #[cfg(unix)] + struct BundledRuntimeModeGuard; + + #[cfg(unix)] + impl BundledRuntimeModeGuard { + fn install(root: &Path) -> Self { + unsafe { std::env::set_var("AIONUI_BUNDLED_MANAGED_RESOURCES", root) }; + set_managed_resources_mode(ManagedResourcesMode::Bundled); + Self + } + } + + #[cfg(unix)] + impl Drop for BundledRuntimeModeGuard { + fn drop(&mut self) { + unsafe { std::env::remove_var("AIONUI_BUNDLED_MANAGED_RESOURCES") }; + set_managed_resources_mode(ManagedResourcesMode::Download); + } + } + #[cfg(unix)] #[tokio::test] async fn row_to_sdk_stdio_flattens_resolved_npx_command() { let _lock = path_test_lock().lock().await; let runtime = install_fake_bundled_runtime(); let _runtime_data_dir = test_runtime_data_dir(); - unsafe { std::env::set_var("AIONUI_BUNDLED_MANAGED_RESOURCES", runtime.path()) }; + let _runtime_mode = BundledRuntimeModeGuard::install(runtime.path()); let row = make_row( "ctx7", @@ -606,7 +662,6 @@ mod tests { ); let server = row_to_sdk_mcp_server(&row).await.expect("convert"); - unsafe { std::env::remove_var("AIONUI_BUNDLED_MANAGED_RESOURCES") }; match server { McpServer::Stdio(s) => { let command = s.command.to_string_lossy(); @@ -624,7 +679,7 @@ mod tests { let _lock = path_test_lock().lock().await; let runtime = install_fake_bundled_runtime(); let _runtime_data_dir = test_runtime_data_dir(); - unsafe { std::env::set_var("AIONUI_BUNDLED_MANAGED_RESOURCES", runtime.path()) }; + let _runtime_mode = BundledRuntimeModeGuard::install(runtime.path()); let meta = aionui_api_types::AgentMetadata { id: "agent-1".into(), @@ -676,7 +731,6 @@ mod tests { .await .expect("resolved command spec"); - unsafe { std::env::remove_var("AIONUI_BUNDLED_MANAGED_RESOURCES") }; let command = spec.command.to_string_lossy(); assert_ne!(command, "npx"); assert!(command.ends_with("/npx"), "unexpected stdio command path: {command}"); @@ -685,8 +739,14 @@ mod tests { assert_eq!(spec.cwd.as_deref(), Some("/tmp/workspace")); } + #[cfg(unix)] #[tokio::test] async fn row_to_sdk_stdio_roundtrip() { + let _lock = path_test_lock().lock().await; + let runtime = install_fake_bundled_runtime(); + let _runtime_data_dir = test_runtime_data_dir(); + let _runtime_mode = BundledRuntimeModeGuard::install(runtime.path()); + let row = make_row( "ctx7", "stdio", diff --git a/crates/aionui-ai-agent/src/factory/aionrs.rs b/crates/aionui-ai-agent/src/factory/aionrs.rs index 0d6a2d875..9414f4294 100644 --- a/crates/aionui-ai-agent/src/factory/aionrs.rs +++ b/crates/aionui-ai-agent/src/factory/aionrs.rs @@ -623,9 +623,12 @@ fn team_mcp_to_config(cfg: &TeamMcpStdioConfig) -> HashMap &'static tokio::sync::Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); @@ -649,7 +652,7 @@ mod tests { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::tempdir().expect("tempdir"); - let runtime_root = tmp.path().join("node").join("node-v24.11.0-darwin-arm64"); + let runtime_root = tmp.path().join("node").join(current_node_runtime_directory_name()); let bin = runtime_root.join("bin"); std::fs::create_dir_all(&bin).expect("create bin"); @@ -664,6 +667,59 @@ mod tests { tmp } + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + fn current_node_runtime_directory_name() -> &'static str { + "node-v24.11.0-darwin-arm64" + } + + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + fn current_node_runtime_directory_name() -> &'static str { + "node-v24.11.0-darwin-x64" + } + + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + fn current_node_runtime_directory_name() -> &'static str { + "node-v24.11.0-linux-arm64" + } + + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + fn current_node_runtime_directory_name() -> &'static str { + "node-v24.11.0-linux-x64" + } + + #[cfg(all( + unix, + not(any( + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "macos", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "aarch64"), + all(target_os = "linux", target_arch = "x86_64") + )) + ))] + fn current_node_runtime_directory_name() -> &'static str { + panic!("unsupported managed Node runtime test platform") + } + + #[cfg(unix)] + struct BundledRuntimeModeGuard; + + #[cfg(unix)] + impl BundledRuntimeModeGuard { + fn install(root: &Path) -> Self { + unsafe { std::env::set_var("AIONUI_BUNDLED_MANAGED_RESOURCES", root) }; + set_managed_resources_mode(ManagedResourcesMode::Bundled); + Self + } + } + + #[cfg(unix)] + impl Drop for BundledRuntimeModeGuard { + fn drop(&mut self) { + unsafe { std::env::remove_var("AIONUI_BUNDLED_MANAGED_RESOURCES") }; + set_managed_resources_mode(ManagedResourcesMode::Download); + } + } + fn make_row( name: &str, transport_type: &str, @@ -777,7 +833,7 @@ mod tests { let _lock = path_test_lock().lock().await; let runtime = install_fake_bundled_runtime(); let _runtime_data_dir = test_runtime_data_dir(); - unsafe { std::env::set_var("AIONUI_BUNDLED_MANAGED_RESOURCES", runtime.path()) }; + let _runtime_mode = BundledRuntimeModeGuard::install(runtime.path()); let row = make_row( "ctx7", @@ -790,7 +846,6 @@ mod tests { let config = row_to_mcp_server_config(&row, "conv-row", test_broadcaster()) .await .expect("convert"); - unsafe { std::env::remove_var("AIONUI_BUNDLED_MANAGED_RESOURCES") }; let command = config.command.as_deref().expect("resolved command"); assert_ne!(command, "npx"); assert!(command.ends_with("/npx"), "unexpected stdio command path: {command}"); diff --git a/crates/aionui-app/assets/builtin-skills/aionui-troubleshooting/SKILL.md b/crates/aionui-app/assets/builtin-skills/aionui-troubleshooting/SKILL.md index 4f739499b..3d55d44eb 100644 --- a/crates/aionui-app/assets/builtin-skills/aionui-troubleshooting/SKILL.md +++ b/crates/aionui-app/assets/builtin-skills/aionui-troubleshooting/SKILL.md @@ -1,240 +1,241 @@ --- name: aionui-troubleshooting description: >- - Diagnose a running AionUi installation — locate and inspect conversations (including stuck/running ones), read aioncore logs, check LLM provider health, list scheduled cron jobs and their last run status, inspect teams and member state, and check MCP server health. Use when the user reports that AionUi is misbehaving: a conversation is stuck or errored, an LLM/provider call is failing, a scheduled task did not run, an MCP server has no tools, a team member is hung, or they just ask "what's wrong with AionUi" / "排查一下 aionui". Engine-agnostic — works the same for claude / aionrs / gemini / openclaw conversations. + Diagnose a running AionUi installation: inspect stuck or errored conversations, read provider health, scheduled task state, MCP server health, team member state, backend health, and aioncore logs. Use when the user reports AionUi is misbehaving, a conversation is stuck, an LLM/provider call is failing, a scheduled task did not run, an MCP server has no tools, a team member is hung, or they ask to troubleshoot AionUi. --- -> **⚠️ Platform note — read before running any command.** The shell snippets in this skill are written for **macOS / Linux** (bash/zsh). Always check which OS you are on first. On **Windows** do **not** run them verbatim — the underlying tool/CLI commands are usually cross-platform, but the surrounding shell syntax is not. Translate it to PowerShell before running: -> -> | bash (macOS / Linux) | PowerShell (Windows) | -> | --- | --- | -> | `a && b` | run as two steps, or `a; if ($?) { b }` | -> | `cat <<'EOF' \| tool …` (heredoc) | write the text to a temp file, then pipe/pass that file to the tool | -> | `VAR=$(cmd)` … `$VAR` | `$VAR = cmd` … `$VAR` | -> | `cmd > /dev/null` | `cmd > $null` | -> | `… \| grep PAT` | `… \| Select-String PAT` | -> | `… \| jq …` | `… \| ConvertFrom-Json`, then read the fields | -> | `python3 x.py` | `python x.py` (or `py x.py`) | -> | `~/dir`, `/tmp` | `$env:USERPROFILE\dir`, `$env:TEMP` | -> | `cp` / `mkdir -p` / `rm -rf` | `Copy-Item` / `New-Item -ItemType Directory -Force` / `Remove-Item -Recurse -Force` | -> -> If a command has no obvious Windows equivalent, prefer the built-in file/HTTP tools over raw shell. - # AionUi Troubleshooting -Diagnose a running AionUi installation by reading its **project-level** data: -the aioncore REST API, the unified SQLite store, and the aioncore log files. +Use the bundled `aioncore diagnose` CLI for read-only troubleshooting. It uses +the runtime context injected into the current agent conversation, so do not +discover ports or call backend endpoints by hand. -This is **engine-agnostic**. AionUi runs conversations on several backends -(`acp`/claude, `aionrs`, `gemini`), but troubleshooting goes -through AionUi's own data — the `conversations` API, the unified `messages` -table, provider health, crons, teams, MCP — so the same checks work no matter -which engine a conversation uses. Do **not** reach into engine-specific -transcript files (e.g. `~/.claude/projects/*.jsonl` or -`~/.aionui/aionrs-sessions/*.json`); they are implementation details of one -backend and are already covered by the unified `messages` table. +Examples in this skill use English. When reporting findings or writing user +content, use the user's language. -## How it works +## Rules -AionUi is front/back separated: the Electron UI talks to a local `aioncore` -backend. The backend's REST port is **dynamic** (aioncore launches with -`--port 0`), so the first step is always discovery. The helper script discovers -everything from the running process and wraps every read. +1. Use only `"$AIONUI_HELPER_BIN" diagnose ...`. +2. Start with `diagnose overview` for broad "what is wrong" requests. +3. Use named diagnose commands first. Use `diagnose http get` only when no named + command covers the diagnostic need. +4. Treat every command as read-only. To change AionUi configuration, use the + separate `aionui-config` skill. +5. Never print raw provider, MCP header, token, password, or secret values. The + CLI redacts known secret fields by default, but summarize sensitive findings + carefully. +6. A single `running` snapshot does not prove a hang. Re-check the same + conversation and compare runtime fields, message progress, and logs. -## Setup +## Capability Discovery -One self-contained helper — no external dependencies, read-only everywhere. +When unsure which command or stdin fields are available, ask the CLI: ```bash -python3 scripts/aion_diag.py discover +"$AIONUI_HELPER_BIN" diagnose capabilities ``` -`discover` finds the live backend and prints what every other command relies on: +The output is the agent-readable contract: domains, command names, stdin JSON +fields, selector behavior, redacted fields, and the controlled HTTP escape +hatch. + +## Runtime Context + +The main stable selector is the current conversation: ```json { - "pid": 86716, - "base_url": "http://127.0.0.1:58188", - "port": 58188, - "log_dir": "/Users/you/Library/Logs/AionUi", - "data_dir": "/Users/you/.aionui", - "version": "2.1.18", - "db_path": "/Users/you/.aionui/aionui-backend.db" + "conversation_id": "current" } ``` -How discovery works (and why it's robust): -- The long-lived backend process is `aioncore`; short-lived helper subprocesses - may include `mcp-team-stdio` for active Team sessions. -- Reads `--log-dir`, `--data-dir`, `--app-version` straight from its argv — so - if the user changed the log directory, **we follow it**, never hardcode it. -- The REST port is NOT in argv (`--port 0`), so the script probes every port the - process listens on and keeps the one that answers `/health` with `status:ok`. +The CLI resolves `"current"` from `AIONUI_CONVERSATION_ID`. Commands that read +the backend also use `AIONUI_BASE_URL` and `AIONUI_USER_ID` from the same +runtime context. + +## Start Wide + +For a vague "AionUi is broken" report, run: + +```bash +"$AIONUI_HELPER_BIN" diagnose overview +``` + +Use the overview to decide where to drill in: -If `discover` exits with an error / code 3, AionUi is **not running**. Tell the -user to launch it — do not guess a port. +- `providers.unhealthy` means inspect provider health. +- `mcp.enabled_but_no_tools` means inspect MCP startup/tool registration. +- `cron.failing` means inspect scheduled task state. +- `running_conversations` means inspect the conversation runtime repeatedly. -> The script redacts secrets (`api_key`, tokens, …) in all output. Provider -> records contain plaintext API keys; never print raw provider JSON yourself — -> go through the helper. +## Commands By Symptom -## Golden rule: start wide, then drill in +### Conversation Stuck Or Errored -For "something is wrong with AionUi" with no specifics, run `overview` first — -it's a one-shot snapshot across health, providers, MCP, crons, and running -conversations. Then drill into whatever it flags. +Inspect the current conversation: ```bash -python3 scripts/aion_diag.py overview +"$AIONUI_HELPER_BIN" diagnose conversations get <<'JSON' +{ + "conversation_id": "current" +} +JSON ``` ---- - -## Commands by symptom +Inspect a known conversation: -All commands take the discovered backend automatically. Output is JSON unless -noted. +```bash +"$AIONUI_HELPER_BIN" diagnose conversations get <<'JSON' +{ + "conversation_id": "conv_123" +} +JSON +``` -### "A conversation is stuck / errored / behaving wrong" +Read recent messages: ```bash -python3 scripts/aion_diag.py conversations [--limit N] # list: id, name, engine type, status -python3 scripts/aion_diag.py conversation # status + runtime + recent errors + stuck hint -python3 scripts/aion_diag.py messages [--limit N] [--errors] # message history from SQLite +"$AIONUI_HELPER_BIN" diagnose conversations messages <<'JSON' +{ + "conversation_id": "current", + "limit": 30, + "errors_only": true +} +JSON ``` -- `conversation ` is the workhorse. It returns the live `runtime` block - (`state`, `task_status`, `is_processing`, `turn_id`, plus `can_send_message`, - `has_task`, `pending_confirmations`), the 5 most recent **error** messages, and - a `stuck_hint` when `state=running` + `is_processing=true`. Note `state` (the - runtime machine state) and `task_status` are different fields — stuck detection - keys off `state`. -- **Stuck detection is comparative, not absolute.** A single `running` snapshot - is normal — that may just be the active turn. To confirm a hang, run - `conversation ` a few times seconds apart: if `turn_id` and runtime never - change while no new messages arrive, it's stuck. Cross-check with - `logs --conv `. -- **Not every non-progressing turn is stuck.** `state=waiting_confirmation` (or - `pending_confirmations > 0`) means the turn is *blocked on a user approval*, - not hung — the fix is to answer the pending confirmation, not to restart the - conversation. The `stuck_hint` distinguishes these two cases. -- `messages --errors` pulls just the failed messages/tool-calls for that - conversation from the unified `messages` table (engine-agnostic). - -### "An LLM / provider call is failing" +Interpretation: + +- `state=running` with `is_processing=true` is only a suspected hang after + repeated checks show no `turn_id`, runtime, or message progress. +- `state=waiting_confirmation` or `pending_confirmations > 0` means the turn is + waiting for user approval, not hung. + +### Provider Or Model Failure ```bash -python3 scripts/aion_diag.py providers +"$AIONUI_HELPER_BIN" diagnose providers summary ``` -Lists every configured provider with its `model_health` (`status`, `latency`, -`last_check`, and an `error` string on the last failed check) and an -`unhealthy_models` summary. A provider whose models show non-`healthy` status, -huge `latency`, or a stale `last_check` is the suspect. -Then confirm with the log (filter by the provider's base_url or id): +Look for non-`healthy` model health, stale `last_check`, high latency, or an +error string. The summary is redacted; do not ask for raw provider JSON unless +the named command is insufficient. + +### Scheduled Task Did Not Run ```bash -python3 scripts/aion_diag.py logs --errors --lines 100 +"$AIONUI_HELPER_BIN" diagnose cron summary ``` -> `model_health` only holds the **most recent** check per model — there's no -> historical error log in it. For the actual failure cause (timeout, 401, 429, -> bad base_url), the aioncore log is the source of truth. +Check `enabled`, `last_status`, `last_error`, `next_run_at`, `last_run_at`, +`run_count`, and `retry_count` when present. -### "A scheduled task didn't run" +### MCP Server Has No Tools ```bash -python3 scripts/aion_diag.py crons +"$AIONUI_HELPER_BIN" diagnose mcp summary ``` -This reads the `cron_jobs` table from the SQLite store directly (read-only). A -REST API for crons does exist (`/api/cron/jobs`, used by the `aionui-config` -skill to *create/manage* jobs), but for diagnosis the table read is more direct -and surfaces the run-state columns in one shot. It surfaces a `failing` list -(jobs whose `last_status` is `error` or `missed`) plus every job's `schedule_*`, -`last_status`, `last_error`, `next_run_at`, `last_run_at`, `run_count`, -`retry_count`. Check `enabled`, compare `next_run_at` to now, and read -`last_error` for failed jobs. +An enabled server with `tool_count=0` usually means startup failed, the command +is unavailable, credentials are invalid, or the server crashed before tool +registration. -### "An MCP server has no tools / isn't working" +### Team Member Hung ```bash -python3 scripts/aion_diag.py mcp +"$AIONUI_HELPER_BIN" diagnose teams summary ``` -Lists MCP servers with `enabled`, `transport`, and `tool_count`. It flags any -server that is **enabled but exposes 0 tools** — the classic "failed to start" -signature (bad command, missing binary, crashed on launch). Then check the log -around startup. A `disabled` server with 0 tools is fine — the user turned it off. +Find the member's `conversation_id`, then drill into that conversation: + +```bash +"$AIONUI_HELPER_BIN" diagnose conversations get <<'JSON' +{ + "conversation_id": "member_conv_123" +} +JSON +``` -### "A team / team member is hung" +### Backend Health ```bash -python3 scripts/aion_diag.py teams +"$AIONUI_HELPER_BIN" diagnose health ``` -Lists teams and members; for each member it resolves the linked -`conversation_id` to its live `conv_state`. A member stuck in `running` (while -others are `idle`) is the one to drill into with `conversation `. +Use this to confirm the backend is reachable and read the core version/build +metadata. -### "Is the backend even alive? What version?" +### Logs + +Tail logs when the runtime provides `AIONUI_LOG_DIR`: ```bash -python3 scripts/aion_diag.py health # GET /health: status + core version + build_time -python3 scripts/aion_diag.py discover # also shows app version, port, dirs +"$AIONUI_HELPER_BIN" diagnose logs tail <<'JSON' +{ + "lines": 100, + "errors_only": true, + "conversation_id": "current" +} +JSON ``` -### Reading logs +If `AIONUI_LOG_DIR` is unavailable and the user gives a log directory, pass it +explicitly: ```bash -python3 scripts/aion_diag.py logs [--lines N] [--errors] [--conv ] +"$AIONUI_HELPER_BIN" diagnose logs tail <<'JSON' +{ + "log_dir": "/Users/alex/Library/Logs/AionUi", + "lines": 100, + "errors_only": true, + "conversation_id": "conv_123" +} +JSON ``` -- Tails the latest `*.aioncore.log` in the discovered `log_dir` (NDJSON: one - JSON object per line — timestamp, level, target, HTTP status/path). -- `--errors` keeps only ERROR/WARN/error/panic lines. -- `--conv ` keeps only lines mentioning that conversation id — the fastest - way to see one conversation's request/response trace. -- Need an arbitrary endpoint not wrapped above? `python3 scripts/aion_diag.py - get /api/` does a raw (redacted) GET. +Known benign noise: `No onPostToolUseHook found for tool use ID` warnings can +appear around tool calls and are not automatically the root cause. -> Known log noise: `"No onPostToolUseHook found for tool use ID: ..."` WARNs fire -> on nearly every tool call and are benign SDK-level chatter — don't treat them -> as the fault. +## Controlled HTTP Escape Hatch ---- +Use this only when a named command does not cover the diagnostic read. + +```bash +"$AIONUI_HELPER_BIN" diagnose http get <<'JSON' +{ + "path": "/api/teams", + "reason": "Inspect team fields not covered by diagnose teams summary." +} +JSON +``` -## Data source map - -| Concern | Source | Access | -| --- | --- | --- | -| Backend alive / version | `GET /health` | REST | -| Conversation list + runtime state | `GET /api/conversations[/{id}]` | REST | -| Conversation messages / errors | `messages` table (by `conversation_id`) | SQLite (read-only) | -| LLM provider health | `GET /api/providers` → `model_health` | REST (api_key redacted) | -| Scheduled jobs | `cron_jobs` table (REST `/api/cron/jobs` exists too) | SQLite (read-only) | -| Teams + members | `GET /api/teams` | REST | -| MCP servers | `GET /api/mcp/servers` | REST | -| Logs | `*.aioncore.log` in `--log-dir` | File tail | - -`db_path` and `log_dir` are always taken from `discover` (process argv), never -hardcoded, so they track whatever the user configured. - -## Verification / safety notes - -- All SQLite access is opened **read-only** (`mode=ro`) — diagnosis never mutates - the live store. -- All output is run through secret redaction. If you ever need to show a provider - record, use the helper's `providers` command, not a raw `get`. -- Confirm "stuck" only after repeated snapshots — one `running` reading is not a - fault. -- This skill is **read-only diagnosis**. To *change* configuration (create/edit - assistants, add MCP servers, attach skills), use the separate - `aionui-config` skill. - -## Not yet covered - -- No streaming/live log follow — only tail-on-demand. -- No historical provider-error timeline (only the latest `model_health` check); - reconstruct history from the logs. -- Repairing a broken conversation (vs. diagnosing it) is out of scope. +Constraints: + +- GET only. +- Path must be `/health` or start with `/api/`. +- Output is redacted and may be truncated. +- Prefer adding a named CLI command later if the same raw read becomes common. + +## Data Source Map + +| Concern | Command | +| --- | --- | +| Backend alive / version | `diagnose health` | +| Cross-domain snapshot | `diagnose overview` | +| Conversation runtime | `diagnose conversations get` | +| Conversation messages | `diagnose conversations messages` | +| Provider health | `diagnose providers summary` | +| Scheduled jobs | `diagnose cron summary` | +| MCP servers | `diagnose mcp summary` | +| Teams and member state | `diagnose teams summary` | +| Logs | `diagnose logs tail` | +| Uncovered read-only API | `diagnose http get` | + +## Safety Notes + +- This skill diagnoses; it does not repair. +- For configuration changes, switch to `aionui-config`. +- For scheduled task creation or updates, use the `cron` skill or + `aionui-config` cron commands. +- When reporting results, explain evidence and uncertainty: "suspected stuck" + after one snapshot, "confirmed stuck" only after repeated unchanged snapshots. diff --git a/crates/aionui-app/assets/builtin-skills/aionui-troubleshooting/scripts/aion_diag.py b/crates/aionui-app/assets/builtin-skills/aionui-troubleshooting/scripts/aion_diag.py deleted file mode 100644 index 256bde1ff..000000000 --- a/crates/aionui-app/assets/builtin-skills/aionui-troubleshooting/scripts/aion_diag.py +++ /dev/null @@ -1,517 +0,0 @@ -#!/usr/bin/env python3 -"""AionUi troubleshooting helper — engine-agnostic. - -Discovers the live aioncore backend (port, log-dir, data-dir, version) from the -running process, then exposes troubleshooting reads over the backend's REST API, -its SQLite store, and its log files. Nothing here is specific to a single engine -(claude / aionrs / gemini / openclaw); everything goes through AionUi's own -project-level data so the same checks work no matter which agent backend a -conversation runs on. - -Usage: - python3 aion_diag.py discover # locate backend: port, log-dir, data-dir, version - python3 aion_diag.py health # GET /health - python3 aion_diag.py get # raw GET against the REST API (e.g. /api/teams) - - python3 aion_diag.py conversations [--limit N] # list conversations + runtime state - python3 aion_diag.py conversation # one conversation: status + runtime + recent errors - python3 aion_diag.py messages [--limit N] [--errors] # messages for a conversation (SQLite) - - python3 aion_diag.py providers # LLM providers + model_health (api_key REDACTED) - python3 aion_diag.py mcp # MCP servers + tool counts (flags enabled-but-0-tools) - python3 aion_diag.py teams # teams + members + each member's conversation state - python3 aion_diag.py crons # scheduled jobs read straight from SQLite (a REST API also exists) - - python3 aion_diag.py logs [--lines N] [--errors] [--conv ] # tail aioncore log - python3 aion_diag.py overview # one-shot health snapshot across everything - -Exit code 3 == backend not running / not discoverable. -""" -import sys -import os -import re -import json -import sqlite3 -import subprocess -import urllib.request -import urllib.error -import glob - -# ── discovery ──────────────────────────────────────────────────────────────── - - -def _ps_aioncore(): - """Return (pid, full_command) for the aioncore backend process, or (None, None). - - The long-lived backend process is aioncore. Short-lived helper subprocesses - may include `mcp-team-stdio` for active Team sessions. - """ - try: - out = subprocess.check_output(["ps", "-axo", "pid,command"], text=True) - except Exception: - return None, None - for line in out.splitlines(): - if "aioncore" in line and "--data-dir" in line and "--port" in line: - line = line.strip() - pid = line.split(None, 1)[0] - cmd = line.split(None, 1)[1] if " " in line else "" - if pid.isdigit(): - return int(pid), cmd - return None, None - - -def _arg(cmd, flag): - """Extract a `--flag VALUE` from a command string (VALUE has no spaces here).""" - m = re.search(re.escape(flag) + r"\s+(\S+)", cmd or "") - return m.group(1) if m else None - - -def _listen_ports(pid): - """All TCP ports the given pid is LISTENing on.""" - try: - out = subprocess.check_output(["lsof", "-nP", "-p", str(pid)], text=True, - stderr=subprocess.DEVNULL) - except Exception: - return [] - ports = set() - for line in out.splitlines(): - if "LISTEN" in line: - m = re.search(r":(\d+)\s+\(LISTEN\)", line) - if m: - ports.add(int(m.group(1))) - return sorted(ports) - - -def _try_health(port): - try: - with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1.5) as r: - body = json.loads(r.read().decode()) - if isinstance(body, dict) and body.get("status") == "ok": - return body - except Exception: - pass - return None - - -_CACHE = {} - - -def discover(quiet=False): - """Locate the backend. Returns dict with base_url/port/log_dir/data_dir/version, - or exits(3) if not found. Memoized per-process.""" - if _CACHE: - return _CACHE - pid, cmd = _ps_aioncore() - if not pid: - if not quiet: - sys.stderr.write( - "AionUi backend (aioncore) is not running. Ask the user to launch " - "AionUi — do not guess a port.\n") - sys.exit(3) - log_dir = _arg(cmd, "--log-dir") - data_dir = _arg(cmd, "--data-dir") - version = _arg(cmd, "--app-version") - # aioncore is started with `--port 0` (dynamic), so the real REST port is NOT - # in argv. Probe every port it listens on and keep the one answering /health. - base = None - port = None - for p in _listen_ports(pid): - if _try_health(p): - base, port = f"http://127.0.0.1:{p}", p - break - if not base: - if not quiet: - sys.stderr.write( - f"Found aioncore pid {pid} but no REST port answered /health " - f"(ports tried: {_listen_ports(pid)}).\n") - sys.exit(3) - info = { - "pid": pid, - "base_url": base, - "port": port, - "log_dir": log_dir, - "data_dir": data_dir or os.path.expanduser("~/.aionui"), - "version": version, - "db_path": os.path.join(data_dir or os.path.expanduser("~/.aionui"), - "aionui-backend.db"), - } - _CACHE.update(info) - return info - - -# ── REST helpers ───────────────────────────────────────────────────────────── - - -def api_get(path): - base = discover()["base_url"] - url = base + path if path.startswith("/") else base + "/" + path - try: - with urllib.request.urlopen(url, timeout=10) as r: - return r.getcode(), json.loads(r.read().decode()) - except urllib.error.HTTPError as e: - return e.code, None - except Exception as e: - return None, {"_error": str(e)} - - -def _unwrap(body): - """REST responses are {success, data}. Return data (or body itself).""" - if isinstance(body, dict) and "data" in body: - return body["data"] - return body - - -# ── SQLite helpers ─────────────────────────────────────────────────────────── - - -def _db(): - path = discover()["db_path"] - if not os.path.exists(path): - sys.stderr.write(f"SQLite store not found at {path}\n") - sys.exit(3) - # read-only so we never risk mutating the live store - conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5) - conn.row_factory = sqlite3.Row - return conn - - -def _rows(sql, params=()): - conn = _db() - try: - return [dict(r) for r in conn.execute(sql, params).fetchall()] - finally: - conn.close() - - -# ── redaction ──────────────────────────────────────────────────────────────── - - -def _redact(obj): - """Recursively mask secret-ish fields so we never print plaintext keys.""" - SECRET = ("api_key", "apikey", "token", "secret", "password", "authorization") - if isinstance(obj, dict): - out = {} - for k, v in obj.items(): - if any(s in k.lower() for s in SECRET) and isinstance(v, str) and v: - out[k] = v[:4] + "…REDACTED" if len(v) > 4 else "REDACTED" - else: - out[k] = _redact(v) - return out - if isinstance(obj, list): - return [_redact(x) for x in obj] - return obj - - -def _print(obj): - print(json.dumps(_redact(obj), ensure_ascii=False, indent=2)) - - -# ── commands ───────────────────────────────────────────────────────────────── - - -def cmd_discover(argv): - _print(discover()) - - -def cmd_health(argv): - code, body = api_get("/health") - print(f"HTTP {code}") - _print(body) - - -def cmd_get(argv): - if not argv: - sys.exit("usage: get ") - code, body = api_get(argv[0]) - print(f"HTTP {code}") - _print(body) - - -def _conv_list(limit=50): - code, body = api_get(f"/api/conversations?limit={limit}") - data = _unwrap(body) or {} - if isinstance(data, dict): - return data.get("conversations") or data.get("items") or [] - return data if isinstance(data, list) else [] - - -def cmd_conversations(argv): - limit = _opt_int(argv, "--limit", 50) - convs = _conv_list(limit) - rows = [] - for c in convs: - rows.append({ - "id": c.get("id"), - "name": c.get("name"), - "type": c.get("type"), # engine: acp/aionrs/gemini/... - "status": c.get("status"), - }) - print(f"{len(rows)} conversations") - _print(rows) - - -def cmd_conversation(argv): - if not argv: - sys.exit("usage: conversation ") - cid = argv[0] - code, body = api_get(f"/api/conversations/{cid}") - d = _unwrap(body) or {} - if not d or d.get("id") is None: - _print({ - "error": "conversation not found", - "id": cid, - "hint": ("No conversation with this id in the live backend. It may have " - "been deleted, or the id is wrong — check `conversations` for the " - "current list."), - }) - return - runtime = d.get("runtime", {}) - # engine-agnostic stuck-detection hint. `state` is the runtime machine state - # (idle/starting/running/cancelling/waiting_confirmation); `task_status` is a - # different field (pending/running/finished), so key off `state` here. - stuck_hint = None - if runtime.get("is_processing") and runtime.get("state") == "running": - stuck_hint = ("state=running & is_processing=true — if this has not changed " - "across repeated checks, the turn may be stuck. Compare turn_id " - "over time and check recent errors + logs.") - elif runtime.get("state") == "waiting_confirmation" or runtime.get("pending_confirmations"): - stuck_hint = ("state=waiting_confirmation — the turn is blocked awaiting a " - "user approval, not stuck. Resolve the pending confirmation to " - "let it continue (pending_confirmations > 0).") - errs = _rows( - "SELECT msg_id, type, substr(content,1,300) AS content, created_at " - "FROM messages WHERE conversation_id=? AND status='error' " - "ORDER BY created_at DESC LIMIT 5", (cid,)) - _print({ - "id": d.get("id"), - "name": d.get("name"), - "type": d.get("type"), - "status": d.get("status"), - "runtime": runtime, - "stuck_hint": stuck_hint, - "recent_errors": errs, - }) - - -def cmd_messages(argv): - if not argv: - sys.exit("usage: messages [--limit N] [--errors]") - cid = argv[0] - limit = _opt_int(argv, "--limit", 30) - where = "conversation_id=?" - params = [cid] - if "--errors" in argv: - where += " AND status='error'" - rows = _rows( - f"SELECT msg_id, type, status, position, substr(content,1,500) AS content, " - f"created_at FROM messages WHERE {where} " - f"ORDER BY created_at DESC LIMIT ?", (*params, limit)) - print(f"{len(rows)} messages for {cid}") - _print(rows) - - -def cmd_providers(argv): - code, body = api_get("/api/providers") - data = _unwrap(body) or [] - out = [] - for p in data: - mh = p.get("model_health") or {} - unhealthy = {m: h for m, h in mh.items() - if isinstance(h, dict) and h.get("status") != "healthy"} - out.append({ - "id": p.get("id"), - "name": p.get("name"), - "platform": p.get("platform"), - "base_url": p.get("base_url"), - "enabled": p.get("enabled"), - "models": p.get("models"), - "model_health": mh, - "unhealthy_models": unhealthy or None, - }) - _print(out) # api_key is redacted by _print - - -def cmd_mcp(argv): - code, body = api_get("/api/mcp/servers") - data = _unwrap(body) or [] - out = [] - for s in data: - tools = s.get("tools") or [] - enabled = s.get("enabled") - n = len(tools) if isinstance(tools, list) else tools - warn = None - if enabled and (n == 0): - warn = "enabled but 0 tools — server may have failed to start; check logs" - out.append({ - "id": s.get("id"), - "name": s.get("name"), - "enabled": enabled, - "builtin": s.get("builtin"), - "transport": (s.get("transport") or {}).get("type"), - "tool_count": n, - "warning": warn, - }) - _print(out) - - -def cmd_teams(argv): - code, body = api_get("/api/teams") - data = _unwrap(body) or [] - out = [] - for t in data: - members = [] - for a in t.get("assistants") or t.get("agents") or []: - cid = a.get("conversation_id") - runtime = None - if cid: - _, cb = api_get(f"/api/conversations/{cid}") - cd = _unwrap(cb) or {} - runtime = cd.get("runtime", {}).get("state") - members.append({ - "name": a.get("name"), - "role": a.get("role"), - "backend": a.get("backend"), - "conversation_id": cid, - "conv_state": runtime, - }) - out.append({"id": t.get("id"), "name": t.get("name"), "members": members}) - _print(out) - - -def cmd_crons(argv): - # A REST API exists (/api/cron/jobs), but for diagnosis we read AionUi's - # SQLite store directly — it surfaces the run-state columns in one shot and - # needs no auth token. - rows = _rows( - "SELECT id, name, enabled, schedule_kind, schedule_value, " - "schedule_description, last_status, last_error, " - "next_run_at, last_run_at, run_count, retry_count " - "FROM cron_jobs ORDER BY last_run_at DESC") - failing = [r for r in rows if r.get("last_status") in ("error", "missed")] - _print({"total": len(rows), "failing": failing, "all": rows}) - - -def _log_files(): - info = discover() - log_dir = info.get("log_dir") - if not log_dir or not os.path.isdir(log_dir): - # platform fallback (Electron app.getPath('logs') convention) - log_dir = os.path.expanduser("~/Library/Logs/AionUi") - files = sorted(glob.glob(os.path.join(log_dir, "*.aioncore.log")), - key=lambda f: os.path.getmtime(f), reverse=True) - return log_dir, files - - -def cmd_logs(argv): - lines = _opt_int(argv, "--lines", 80) - conv = _opt_str(argv, "--conv") - only_err = "--errors" in argv - log_dir, files = _log_files() - print(f"log_dir: {log_dir}") - if not files: - print("no *.aioncore.log files found") - return - latest = files[0] - print(f"latest: {latest}") - with open(latest, "r", errors="replace") as f: - tail = f.readlines()[-(lines * 5):] # over-read, then filter - out = [] - for ln in tail: - if conv and conv not in ln: - continue - if only_err and not re.search(r'"(ERROR|WARN)"|error|panic', ln, re.I): - continue - out.append(ln.rstrip()) - for ln in out[-lines:]: - print(ln) - - -def cmd_overview(argv): - info = discover() - snap = {"backend": {"version": info["version"], "port": info["port"], - "log_dir": info["log_dir"], "data_dir": info["data_dir"]}} - # health - _, h = api_get("/health") - snap["health"] = h - # providers - _, pb = api_get("/api/providers") - provs = _unwrap(pb) or [] - bad_prov = [] - for p in provs: - mh = p.get("model_health") or {} - for m, hh in mh.items(): - if isinstance(hh, dict) and hh.get("status") != "healthy": - bad_prov.append({"provider": p.get("name"), "model": m, - "status": hh.get("status")}) - snap["providers"] = {"count": len(provs), "unhealthy": bad_prov} - # mcp - _, mb = api_get("/api/mcp/servers") - mcps = _unwrap(mb) or [] - bad_mcp = [s.get("name") for s in mcps - if s.get("enabled") and not (s.get("tools") or [])] - snap["mcp"] = {"count": len(mcps), "enabled_but_no_tools": bad_mcp} - # crons - try: - crons = _rows("SELECT last_status FROM cron_jobs") - snap["crons"] = {"total": len(crons), - "failing": sum(1 for c in crons - if c.get("last_status") in ("error", "missed"))} - except Exception as e: - snap["crons"] = {"_error": str(e)} - # stuck conversations (running + processing) - stuck = [] - for c in _conv_list(50): - if c.get("status") == "running": - stuck.append({"id": c.get("id"), "name": c.get("name"), - "type": c.get("type")}) - snap["running_conversations"] = stuck - _print(snap) - - -# ── arg helpers ────────────────────────────────────────────────────────────── - - -def _opt_int(argv, flag, default): - if flag in argv: - i = argv.index(flag) - if i + 1 < len(argv): - try: - return int(argv[i + 1]) - except ValueError: - pass - return default - - -def _opt_str(argv, flag, default=None): - if flag in argv: - i = argv.index(flag) - if i + 1 < len(argv): - return argv[i + 1] - return default - - -COMMANDS = { - "discover": cmd_discover, - "health": cmd_health, - "get": cmd_get, - "conversations": cmd_conversations, - "conversation": cmd_conversation, - "messages": cmd_messages, - "providers": cmd_providers, - "mcp": cmd_mcp, - "teams": cmd_teams, - "crons": cmd_crons, - "logs": cmd_logs, - "overview": cmd_overview, -} - - -def main(): - if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: - print(__doc__) - sys.exit(0 if len(sys.argv) < 2 else 2) - COMMANDS[sys.argv[1]](sys.argv[2:]) - - -if __name__ == "__main__": - main() diff --git a/crates/aionui-app/assets/builtin-skills/auto-inject/aionui-config/SKILL.md b/crates/aionui-app/assets/builtin-skills/auto-inject/aionui-config/SKILL.md index 6d1fa2f33..a08fbe797 100644 --- a/crates/aionui-app/assets/builtin-skills/auto-inject/aionui-config/SKILL.md +++ b/crates/aionui-app/assets/builtin-skills/auto-inject/aionui-config/SKILL.md @@ -1,740 +1,593 @@ --- name: aionui-config description: >- - Configure AionUi itself through its backend API — create and edit assistants (name, avatar, system prompt, quick-start prompts, engine), import and attach skills, manage MCP servers, configure LLM providers (add/edit a model endpoint, set the API key, fetch the model list, pick the default model), change app/UI settings (language, theme, font size, zoom, notifications), and create or manage scheduled tasks (cron jobs) from a natural-language schedule. Use when the user wants you to set up an AionUi assistant, sink a skill into AionUi's skill registry, attach skills to an assistant, change an assistant's avatar or system prompt, add or configure an MCP server, add an LLM/model provider or API key, switch the default model, change the theme or language, schedule a recurring or one-off task ("every morning at 9", "remind me in 2 hours", "run this daily"), or otherwise configure their AionUi installation. This is "Agent-assisted AionUi configuration": you act on the user's behalf via the local backend. + Configure AionUi itself through the bundled aioncore config CLI: create and edit assistants, update assistant rules, inspect and import skills, manage MCP servers, configure model providers, update settings, manage agents, configure scheduled tasks, and manage app configuration from an agent conversation. Use when the user wants you to set up or modify an AionUi assistant, attach skills, change an assistant's system prompt, add MCP or model provider configuration, schedule recurring work, or otherwise configure their AionUi installation, including when the user needs to know whether assistant changes affect the current conversation or only new conversations. --- -> **⚠️ Platform note — read before running any command.** The shell snippets in this skill are written for **macOS / Linux** (bash/zsh). Always check which OS you are on first. On **Windows** do **not** run them verbatim — the underlying tool/CLI commands are usually cross-platform, but the surrounding shell syntax is not. Translate it to PowerShell before running: -> -> | bash (macOS / Linux) | PowerShell (Windows) | -> | --- | --- | -> | `a && b` | run as two steps, or `a; if ($?) { b }` | -> | `cat <<'EOF' \| tool …` (heredoc) | write the text to a temp file, then pipe/pass that file to the tool | -> | `VAR=$(cmd)` … `$VAR` | `$VAR = cmd` … `$VAR` | -> | `cmd > /dev/null` | `cmd > $null` | -> | `… \| grep PAT` | `… \| Select-String PAT` | -> | `… \| jq …` | `… \| ConvertFrom-Json`, then read the fields | -> | `python3 x.py` | `python x.py` (or `py x.py`) | -> | `~/dir`, `/tmp` | `$env:USERPROFILE\dir`, `$env:TEMP` | -> | `cp` / `mkdir -p` / `rm -rf` | `Copy-Item` / `New-Item -ItemType Directory -Force` / `Remove-Item -Recurse -Force` | -> -> If a command has no obvious Windows equivalent, prefer the built-in file/HTTP tools over raw shell. - # AionUi Config -Configure a running AionUi installation by calling its backend (aioncore) REST API. -Everything here has been verified end-to-end against a live backend. - -## How it works - -AionUi is front/back separated. The Electron UI talks to a local `aioncore` -backend over HTTP. Assistants, skills, and their rules all live behind that -backend — there is no config file to edit anymore. You configure AionUi by -calling the API. +Configure AionUi with the bundled agent-facing CLI. Do not discover ports, do +not call raw backend paths, and do not depend on tools outside the bundled +`aioncore` binary. + +## Rules + +1. Use only `"$AIONUI_HELPER_BIN" config ...`. +2. Never pass, inline, export, echo, or set any `AIONUI_...` environment variable. +3. Put all command input in stdin JSON. +4. Do not use flags for business fields. +5. Use `"$AIONUI_HELPER_BIN" config capabilities` when unsure which config command or stdin fields are supported. +6. Read context before changing the current assistant. +7. Read before writing, then read back after writing. +8. Use `"assistant_id": "current"` when the user asks to change the assistant used by this conversation. +9. Use `"conversation_id": "current"` when a command accepts a conversation selector. +10. Do not show internal ids unless the user needs them for a follow-up operation. +11. Never reveal provider keys, MCP headers, environment values, or other secrets. +12. If the CLI fails, report the stable `CONFIG_...` error from stderr in normal prose and do not claim the change was made. +13. After assistant changes, explain both persistence and effect timing. Saving and read-back do not mean the current running conversation has reloaded the changed runtime behavior. + +## Output + +Successful commands print a JSON envelope: + +```json +{ + "success": true, + "data": {}, + "meta": { + "schema_version": 1 + } +} +``` -The backend port is **dynamic** (it changes every launch and is not persisted -to a file), so the first step is always to discover it. +Failures print one stable error line to stderr. Treat stderr as authoritative. -## Setup +## Capability Discovery -A helper script wraps discovery + requests. Use it for every call. +Ask aioncore what this version supports: ```bash -cd -python3 scripts/aionui_api.py discover # prints e.g. http://127.0.0.1:57282 +"$AIONUI_HELPER_BIN" config capabilities ``` -If `discover` fails, AionUi is not running — tell the user to launch it, don't guess a port. +The result is a JSON envelope whose `data.domains[].commands[]` entries list +supported command paths, input mode, expected stdin fields, selector fields, +read-back behavior, destructive behavior, context requirements, and fields +redacted from ordinary output. + +## Context -Helper commands (all print the JSON response): +Read the current user, conversation, assistant, and local runtime context: ```bash -python3 scripts/aionui_api.py get -python3 scripts/aionui_api.py post '' -python3 scripts/aionui_api.py put '' -python3 scripts/aionui_api.py patch '' -python3 scripts/aionui_api.py delete +"$AIONUI_HELPER_BIN" config context ``` -## Golden rule: read before you write +If `data.assistant` is `null`, the current conversation is not backed by an +assistant. Ask the user which assistant to edit before changing assistant +rules or defaults. -Before editing anything, `get` the current state and show the user what you're -about to change. Configuration changes take effect on the user's live app. -After every write, read it back to confirm. The user does not need a dry-run -unless they ask, but they should always see what changed. +## Assistant Change Timing ---- +AionUi persists assistant configuration immediately, but running conversations +may keep the assistant snapshot created when the conversation started. Use this +timing model when reporting successful assistant changes: -## Assistants - -An assistant has two parts stored separately: +- Identity fields such as name, description, avatar, and recommended prompts are + saved immediately. If the open UI still shows old values, tell the user to + refresh or reopen the assistant view. +- Runtime fields such as agent, default model, default permission, default + skills, default MCPs, thought level, and rules apply to new conversations + created from that assistant. Do not claim they change the current running + conversation. +- Skills and MCP defaults are not retroactively injected into the current agent + runtime. If a tool is already available in the current conversation, it can be + used; otherwise the user should start a new conversation with the assistant. -1. **Metadata** — name, description, avatar, engine, quick-start prompts, defaults. - Lives in the assistant record (`/api/assistants`). -2. **System prompt (rules)** — the long instruction text that gives the - assistant its behavior. Stored in a **separate file** (`storage_mode: - user_file`), written via a dedicated endpoint. Creating an assistant does - NOT set its system prompt — that's a second call. +When reporting a successful runtime-field change, say that the change was saved +and read back, then state that it will affect new conversations only. -Assistant `source` is `builtin` (shipped with the app, limited edits), `user` -(custom, fully editable), or `generated` (auto-materialized from an online ACP -agent — identity fields locked, not deletable). Custom IDs look like -`custom--`. +## Assistants -### List / inspect +List assistants: ```bash -python3 scripts/aionui_api.py get /api/assistants -# Add ?locale= to load the per-locale rules file into rules.content: -python3 scripts/aionui_api.py get "/api/assistants/?locale=zh-CN" +"$AIONUI_HELPER_BIN" config assistants list ``` -> `?locale=` is optional. Without it, `rules.content` falls back to the -> assistant's inline rule content (empty if none is stored). With it, content is -> loaded from the per-locale rule file. Pass `?locale=` whenever you need the -> locale-specific prompt — it's recommended, not required. - -### Create - -`POST /api/assistants`. Only `name` is required. The backend assigns the `id`. +Inspect the current assistant: ```bash -python3 scripts/aionui_api.py post /api/assistants '{ - "name": "需求梳理官", - "description": "以新人视角梳理产品需求文档(PRD)", - "agent_id": "", - "prompts": [ - "我来描述一个需求,你帮我梳理成一份 PRD", - "review 这份 PRD,挑出对新人不友好的地方" - ] -}' -``` - -Key fields in the create/update body: - -| Field | Meaning | -| --- | --- | -| `name`, `description` | display text (required: name) | -| `agent_id` | **engine binding** — the id of an agent row from `/api/agents/management` (see "Picking the engine" below). This is the only field that sets the engine | -| `prompts` | quick-start prompts shown on the assistant (NOT the system prompt) | -| `avatar` | emoji, image URL, `data:` URI, or absolute local path | -| `enabled_skills` | skill names attached to this assistant | -| `custom_skill_names` | extra custom skill names to attach beyond `enabled_skills` | -| `disabled_builtin_skills` | builtin skill names to turn OFF for this assistant | -| `recommended_prompts` (+ `recommended_prompts_i18n`) | optional secondary prompt set | -| `models`, `name_i18n`, `description_i18n`, `prompts_i18n` | optional | -| `defaults` | per-assistant defaults — see below (settable on **create**, not just update) | - -> The create and update bodies take the same fields. On GET, the assistant also -> carries read-only `context` / `context_i18n` and `last_used_at` (unix ms) — you -> can't set those via POST/PUT. - -### Picking the engine (`agent_id`) - -The engine is bound by the request-body field **`agent_id`**, whose value is an -agent row id from `/api/agents/management` — not a friendly name like -`"claude"`. Read the engine catalog first and copy the id you want: - -```bash -# the LIST response is flat — each row carries agent_id + agent directly: -python3 scripts/aionui_api.py get /api/assistants -# {"id": "...", "agent_id": "2d23ff1c", "agent": {"type": "acp", "acp_backend": "claude"}, ...} -# (only the single-assistant detail read nests these under an `engine` block: -# GET /api/assistants/?locale=en -> "engine": {"agent_id": "...", "agent": {...}}) -# reuse an existing agent_id for a new assistant on the same engine: -python3 scripts/aionui_api.py put /api/assistants/ '{"agent_id":"2d23ff1c"}' -``` - -> If you omit `agent_id` on create, the backend does NOT default to a CLI engine: -> with at least one enabled provider it falls back to `aionrs` (its built-in -> agent), and with no provider configured it returns a 400. CLI engines -> (`claude`, `gemini`, `codex`, …) must be opted into explicitly with their -> `agent_id` — an Anthropic key alone doesn't put the Claude CLI on `PATH`. -> On read, the bound engine shows up in the assistant's `engine.agent_id` / -> `engine.agent.acp_backend`. There is no `preset_agent_type` request field — the -> create/update bodies don't accept it (it's silently dropped and never read -> back), so bind the engine only via `agent_id`. - -### Per-assistant defaults - -`defaults` holds five entries; each is `{mode}` or `{mode, value}`. `mode:"auto"` -means "inherit the global default / let the user pick each time" and carries NO -`value`. `mode:"fixed"` locks the assistant to `value` (the user can't change it -while using this assistant). Send only the entries you want to change; read the -assistant first to keep the others. - -Every entry's `mode` is only ever `auto` or `fixed` — those two are the only -modes the backend accepts. The `value` is what `fixed` locks to: - -| Entry | `fixed` → `value` is | Example | -| --- | --- | --- | -| `model` | a model name (string) | `{"mode":"fixed","value":"gemini-2.5-pro"}` | -| `permission` | a permission-name string (free-form; the backend does not enum-validate it). Common names: `plan`, `default` | `{"mode":"fixed","value":"plan"}` | -| `thought_level` | a thinking-level string (opaque; stored, not enum-validated) | `{"mode":"fixed","value":"high"}` | -| `skills` | skill names (string[]) | `{"mode":"fixed","value":["aionui-config"]}` | -| `mcps` | MCP server names (string[]) | `{"mode":"fixed","value":["filesystem"]}` | - -> `permission.value` is whatever permission name the active agent/permission -> system understands — it is stored as an opaque string, not checked against a -> fixed list. (`acceptEdits` / `bypassPermissions` / `dontAsk` are **agent-level -> YOLO IDs**, a separate concept — don't assume they're valid here.) - -```bash -python3 scripts/aionui_api.py put /api/assistants/ '{ - "defaults": { - "model": {"mode": "fixed", "value": "gemini-2.5-pro"}, - "permission": {"mode": "fixed", "value": "plan"}, - "thought_level": {"mode": "auto"}, - "skills": {"mode": "auto"}, - "mcps": {"mode": "fixed", "value": ["filesystem"]} - } -}' +"$AIONUI_HELPER_BIN" config assistants get <<'JSON' +{ + "assistant_id": "current", + "locale": "en-US" +} +JSON ``` -> Verified end-to-end: the backend stores all five entries verbatim and returns -> them on the `?locale=` detail read. On read, `defaults` is always present with -> all five entries, never `null`. A brand-new assistant starts with `model`, -> `permission`, `thought_level`, and `mcps` at `{"mode":"auto"}` (no `value`), -> while `skills` starts at `{"mode":"fixed"}` seeded with the assistant's -> `enabled_skills` (an empty list when it has none). Lock any entry by sending -> `{"mode":"fixed","value":...}`, or set `skills` to `{"mode":"auto"}` to let the -> user pick. - -### Update +Examples use English sample text and `en-US`. For real localized assistant +content, use the user's actual locale. -`PUT /api/assistants/`, sending only the fields you want to change. The `id` -comes from the URL path — a body `id`, if sent, is ignored. - -### Set the system prompt (rules) - -This is the separate second step — the actual behavior of the assistant. +Create an assistant: ```bash -python3 scripts/aionui_api.py post /api/skills/assistant-rule/write '{ - "assistant_id": "", - "content": "", - "locale": "zh-CN" -}' +"$AIONUI_HELPER_BIN" config assistants create <<'JSON' +{ + "name": "Requirements Analyst", + "description": "Turn rough product ideas into clear PRDs", + "agent_id": "2d23ff1c", + "prompts": [ + "Turn this feature idea into a PRD", + "Review this PRD and identify confusing parts for new users" + ], + "enabled_skills": ["aionui-config"] +} +JSON ``` -Read it back: +Update assistant metadata or defaults: ```bash -python3 scripts/aionui_api.py post /api/skills/assistant-rule/read '{"assistant_id":"","locale":"zh-CN"}' +"$AIONUI_HELPER_BIN" config assistants update <<'JSON' +{ + "assistant_id": "current", + "locale": "en-US", + "description": "Updated assistant description", + "defaults": { + "permission": { + "mode": "fixed", + "value": "plan" + } + } +} +JSON ``` -For multi-line / long prompts, write the text to a temp file and build the JSON -body in Python rather than inlining a giant shell string. - -> To wipe an assistant's rule across all locales, `DELETE -> /api/skills/assistant-rule/`. A parallel trio exists for -> per-assistant **skill** content (distinct from the shared registry): -> `POST /api/skills/assistant-skill/{read,write}` (same body shape as the rule -> endpoints) and `DELETE /api/skills/assistant-skill/`. +For `name`, `description`, `avatar`, or recommended prompt changes, report that +the change is saved and may require refreshing or reopening the UI to see. For +`agent_id`, `defaults`, `enabled_skills`, `default_mcp_ids`, or other runtime +defaults, report that the saved change applies to new conversations only. -### Avatar - -The `avatar` field accepts an emoji (`"📋"`), an image URL, a `data:` URI, or an -absolute local path. A self-contained inline SVG `data:` URI is a good default — -no external dependency, renders offline: +Enable, disable, or reorder an assistant: ```bash -python3 scripts/aionui_api.py put /api/assistants/ '{"avatar":"data:image/svg+xml;base64,<...>"}' +"$AIONUI_HELPER_BIN" config assistants state <<'JSON' +{ + "assistant_id": "current", + "enabled": true, + "sort_order": 10 +} +JSON ``` -> `GET /api/assistants//avatar` also serves the raw avatar binary (Content-Type -> inferred; 404 if none) — handy for an ``, but a `data:` URI is still the -> better default for self-contained config. +## Assistant Rules -### Enable / disable / reorder +Assistant rules are the system prompt that defines assistant behavior. -`PATCH /api/assistants//state` with any of `enabled`, `sort_order`, and -`last_used_at` (unix ms). Disabling hides the assistant from the homepage and -team picker without deleting it. +Read the current assistant rule: -### Delete +```bash +"$AIONUI_HELPER_BIN" config assistants rule read <<'JSON' +{ + "assistant_id": "current", + "locale": "en-US" +} +JSON +``` -`DELETE /api/assistants/` — only `source: user` assistants can be deleted. -`builtin` and `generated` assistants can only be disabled (check the `deletable` -flag on the detail read). +Write the current assistant rule: -### Bulk import +```bash +"$AIONUI_HELPER_BIN" config assistants rule write <<'JSON' +{ + "assistant_id": "current", + "locale": "en-US", + "content": "# Role\nYou are..." +} +JSON +``` -`POST /api/assistants/import` inserts many assistants at once (e.g. restoring a -backup or migrating from a legacy Electron config). Body is `{"assistants": -[, …]}`; the response reports `imported` / `skipped` / -`failed` counts plus a per-row `errors` array. It's insert-only — it won't -overwrite an existing id. +For rule edits, preserve the user's existing useful instructions unless the +user explicitly asks to replace them. ---- +After a successful rule write or delete, always tell the user that the rule was +saved and read back, but it applies only to new conversations created from this +assistant. The current conversation continues using the rule snapshot it started +with. ## Skills -A skill is a folder containing a `SKILL.md` (YAML frontmatter `name` + -`description`, then instruction body). The `description` decides when the agent -auto-triggers the skill, so write it carefully. +List available skills: -Four sources: `builtin` (`~/.aionui/builtin-skills/`), `custom` -(`~/.aionui/skills/`), `cron` (`~/.aionui/cron/skills/`, per-scheduled-task -skills), `extension` (external, symlinked). +```bash +"$AIONUI_HELPER_BIN" config skills list +``` -### List / inspect the registry +Inspect a skill directory before importing: ```bash -python3 scripts/aionui_api.py get /api/skills -python3 scripts/aionui_api.py get /api/skills/paths # where skills live on disk -python3 scripts/aionui_api.py post /api/skills/info '{"skill_path":"/abs/path/to/skill-folder"}' # read a SKILL.md's name/description WITHOUT importing +"$AIONUI_HELPER_BIN" config skills info <<'JSON' +{ + "skill_path": "/absolute/path/to/skill" +} +JSON ``` -### Import a skill into the registry +Import a skill: + +```bash +"$AIONUI_HELPER_BIN" config skills import <<'JSON' +{ + "skill_path": "/absolute/path/to/skill-or-parent-or-zip" +} +JSON +``` -`POST /api/skills/import` copies the skill(s) into the user skills dir and -registers them. The one endpoint handles all three source shapes: a single skill -folder, a PARENT folder containing many skills, or a `.zip` package. +Attach skills to an assistant by updating the assistant's full skill list: ```bash -python3 scripts/aionui_api.py post /api/skills/import '{"skill_path":"/abs/path/to/skill-or-parent-or-zip"}' -python3 scripts/aionui_api.py get /api/skills/import-limits # server-side max file/total byte caps -python3 scripts/aionui_api.py get /api/skills/import-history # recent import records +"$AIONUI_HELPER_BIN" config assistants update <<'JSON' +{ + "assistant_id": "current", + "enabled_skills": ["aionui-config", "cron"] +} +JSON ``` -> For external skills you keep editing in place (registered without copying), see -> "Discover & manage skill sources" below (`external-paths`) — that's how a live, -> non-copied source is wired in. There is no `import-symlink` endpoint; the -> reverse operation, `POST /api/skills/export-symlink` -> (`{"skill_path":"...","target_dir":"..."}`), symlinks an already-installed skill -> back out to an external directory. +Do not append blindly. Read the assistant first, merge the list locally, then +send the full intended `enabled_skills` value. -> Caution: importing (copy) from a path that is ALREADY inside the user skills -> dir can race with the copy step. When editing an installed skill, edit the -> files in place, then re-import from a separate staging copy — or just verify -> the SKILL.md is non-empty afterwards. An empty SKILL.md unregisters the skill. +Enabled skills are assistant defaults for new conversations. Do not tell the +user that newly attached skills are available in the current conversation unless +the current runtime already exposes them. -### Discover & manage skill sources +Manage external skill paths: -For skills that live outside the standard dirs: +```bash +"$AIONUI_HELPER_BIN" config skills external-paths list +``` ```bash -python3 scripts/aionui_api.py post /api/skills/scan '{"folder_path":"/abs/dir"}' # find skills under a dir -python3 scripts/aionui_api.py get /api/skills/detect-paths # candidate skill locations -python3 scripts/aionui_api.py get /api/skills/detect-external # external skill dirs -python3 scripts/aionui_api.py get /api/skills/external-paths # list registered external paths -python3 scripts/aionui_api.py post /api/skills/external-paths '{"name":"