Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
[package]
|
||||
name = "nomifun-gateway"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
# P3-GW1 路线 A:网关浏览器工具(per-companion BrowserTool registry)。门控因为它拉入
|
||||
# `nomi-browser`(→ chromiumoxide CDP 栈 + 受管 Chromium)——无浏览器的 headless 宿主不应被迫依赖。
|
||||
# 由 `nomifun-app` 的同名 feature 经 `nomifun-gateway/browser-use` 向下传导;关时网关不暴露
|
||||
# nomi_browser_* 工具(dispatch 走 unknown tool,schema 不注册)。依赖方向:gateway → nomi-browser
|
||||
# (nomi-browser 不反依赖 gateway,已核对无环)。
|
||||
browser-use = ["dep:nomi-browser", "dep:nomi-types", "dep:nomi-config", "dep:nomi-tools", "dep:nomifun-secret", "dep:futures"]
|
||||
# Gateway computer-use tools (single shared desktop ComputerTool). Forwarded by
|
||||
# `nomifun-app`'s `computer-use` feature via `nomifun-gateway/computer-use`; off
|
||||
# on headless/web hosts (which never pull the native screen/input/UIA stack).
|
||||
# Reuses the optional nomi-types/-config/-tools deps shared with browser-use.
|
||||
computer-use = ["dep:nomi-computer", "dep:nomi-types", "dep:nomi-config", "dep:nomi-tools"]
|
||||
|
||||
[dependencies]
|
||||
nomifun-common.workspace = true
|
||||
nomifun-api-types.workspace = true
|
||||
nomifun-conversation.workspace = true
|
||||
nomifun-cron.workspace = true
|
||||
nomifun-requirement.workspace = true
|
||||
nomifun-companion.workspace = true
|
||||
nomifun-ai-agent.workspace = true
|
||||
nomifun-db.workspace = true
|
||||
nomifun-terminal.workspace = true
|
||||
nomifun-idmm.workspace = true
|
||||
nomifun-knowledge.workspace = true
|
||||
# System domain capabilities (settings / client preferences / providers / model fetch).
|
||||
nomifun-system.workspace = true
|
||||
# Channel domain capabilities (IM bot plugins / pairing / users / companion binding).
|
||||
nomifun-channel.workspace = true
|
||||
# Filesystem + shell-open domain capabilities.
|
||||
nomifun-file.workspace = true
|
||||
nomifun-shell.workspace = true
|
||||
# MCP-server + extensions/hub/skills domain capabilities.
|
||||
nomifun-mcp.workspace = true
|
||||
nomifun-extension.workspace = true
|
||||
# P3-GW1: per-companion BrowserTool registry(路线 A)。optional + feature 门控,关时不拉浏览器栈。
|
||||
nomi-browser = { workspace = true, optional = true }
|
||||
# Computer-use: shared desktop ComputerTool. optional + feature 门控,关时不拉 screen/input/UIA 栈。
|
||||
nomi-computer = { workspace = true, optional = true }
|
||||
# P3-X2: per-pet secret vault path resolver for gateway-driven secret:NAME (browser-use only).
|
||||
nomifun-secret = { workspace = true, optional = true }
|
||||
nomi-types = { workspace = true, optional = true }
|
||||
nomi-config = { workspace = true, optional = true }
|
||||
# `Tool` trait(execute/category)——facade BrowserTool 实现它,registry 经它驱动动作。
|
||||
nomi-tools = { workspace = true, optional = true }
|
||||
# CC: BrowserRegistry::execute_parallel 用 futures::future::join_all 并行多 key(异 key 真并发/同 key 串行)。
|
||||
futures = { workspace = true, optional = true }
|
||||
axum.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
# Capability registry: single-source JSON Schema generation from each capability's
|
||||
# typed Request struct (same schemars major as rmcp 1.7 re-exports → identical shape
|
||||
# for the MCP clients consuming tools/list). The bridge passes these schema maps
|
||||
# straight through to `rmcp::model::Tool`, so only the gateway needs schemars.
|
||||
schemars = "1"
|
||||
tracing.workspace = true
|
||||
dirs.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest.workspace = true
|
||||
@@ -0,0 +1,646 @@
|
||||
//! P3-GW1 (route A): a per-companion [`BrowserTool`] registry that lives in the
|
||||
//! **main process** [`crate::deps::GatewayDeps`].
|
||||
//!
|
||||
//! ## Why a registry here (the route-A architecture)
|
||||
//!
|
||||
//! `GatewayDeps` is constructed in the main backend process; the bootstrap that
|
||||
//! builds a session's `BrowserTool` runs in a *separate* agent/session process.
|
||||
//! Route A does NOT migrate the engine across processes — it relies on the fact
|
||||
//! that [`BrowserTool`] is **fully self-contained**:
|
||||
//!
|
||||
//! - `BrowserTool::with_data_dir(data_dir, headful).workspace(ws)` constructs the
|
||||
//! facade WITHOUT launching anything (the engine is built lazily inside the
|
||||
//! facade's own `Mutex` on the first action, and a launch failure is cached).
|
||||
//! - so the gateway can simply OWN one `BrowserTool` per companion in the main
|
||||
//! process. Each companion's tool spins up its own in-process CDP engine on its
|
||||
//! first action — the same lazy mechanism the session bootstrap uses, just
|
||||
//! anchored in the gateway instead of a session.
|
||||
//!
|
||||
//! No cross-process engine handle, no engine-ownership migration: the registry is
|
||||
//! the engine's owner for gateway-driven browsing.
|
||||
//!
|
||||
//! ## Per-companion engine slot + serialization (X5); shared browser IDENTITY
|
||||
//!
|
||||
//! [`BrowserTool::is_concurrency_safe`] is `false` — observe ⊥ act and per-target
|
||||
//! actions must be serialized. The registry gives each companion key its own
|
||||
//! [`tokio::sync::Mutex`]; [`BrowserRegistry::execute`] holds that mutex for the
|
||||
//! whole tool call, so the same companion's `observe`/`act`/`navigate` never run
|
||||
//! concurrently. Different companion keys hold different mutexes (and different
|
||||
//! Chrome processes / `user-data-dir`s), so they run independently.
|
||||
//!
|
||||
//! **User decision (去 per-pet 隔离): browser IDENTITY is globally shared.** The
|
||||
//! per-companion *engine slot* (separate Chrome process + serialization mutex) is
|
||||
//! kept — collapsing to one engine would turn per-companion serialization into a
|
||||
//! global one, a behavior change we avoid. But every slot points at the **same
|
||||
//! shared credential vault** (`nomifun_secret::pet_vault_path` now ignores its key
|
||||
//! and routes to `{data_dir}/browser-secrets/shared`), so `secret:NAME` /
|
||||
//! login / domain policy are SHARED across companions and sessions (consistent with
|
||||
//! the unified-memory model). Per-companion slots isolate only the live Chrome
|
||||
//! process, not the persisted identity.
|
||||
//!
|
||||
//! ## Workspace layout (默认 ④)
|
||||
//!
|
||||
//! Each key gets `{data_dir}/browser-profiles/{key}` as its workspace dir, so
|
||||
//! gateway downloads (E4) land in a per-companion sandbox, never the user's real
|
||||
//! Downloads. The key is the companion id when the caller carries one, else a
|
||||
//! `conversation:<id>` fallback (a master/IM session driving a browser without a
|
||||
//! companion binding still gets its own isolated tool).
|
||||
//!
|
||||
//! ## GW2 hook (left for the next task)
|
||||
//!
|
||||
//! Out-of-band approval of irreversible actions is GW2. GW1 wires the tool
|
||||
//! exposure + execution path; the dispatch layer marks where an
|
||||
//! `ApprovalTier::Irreversible` hit would be routed to the confirm channel. The
|
||||
//! gateway-driven `BrowserTool` is constructed as a **non-bypassing** session
|
||||
//! (`session_bypasses_approval = false`), so its own fail-closed redline gate does
|
||||
//! NOT hard-deny — irreversible actions flow through to the engine today and will
|
||||
//! be intercepted by the GW2 confirm hook once that task lands.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomi_browser::{BrowserSecretSource, BrowserTool, OUT_OF_BAND_CONFIRMED_KEY};
|
||||
use nomi_config::config::BrowserConfig;
|
||||
use nomi_tools::Tool;
|
||||
use nomi_types::tool::ToolResult;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
/// One companion's browser slot: a lazily-engined [`BrowserTool`] plus the mutex
|
||||
/// that serializes that companion's tool calls (X5).
|
||||
struct CompanionBrowser {
|
||||
tool: Arc<BrowserTool>,
|
||||
/// Per-companion serialization gate. Held for the duration of a single
|
||||
/// `execute` so observe/act/navigate for the SAME companion never overlap
|
||||
/// (the facade engine is `is_concurrency_safe = false`).
|
||||
lock: AsyncMutex<()>,
|
||||
}
|
||||
|
||||
/// **P3-GW2**: a browser action held awaiting out-of-band approval. Stashed by the
|
||||
/// dispatch layer when an action classifies as `ApprovalTier::Irreversible` in this
|
||||
/// (auto-approving) gateway session, keyed by a synthetic `call_id` the phone/front-end
|
||||
/// confirms. On approval, the registry re-issues `input` with the
|
||||
/// [`OUT_OF_BAND_CONFIRMED_KEY`] sentinel injected so the facade's redline gate
|
||||
/// releases it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PendingBrowserAction {
|
||||
/// The registry key (companion / conversation) the action belongs to — the
|
||||
/// engine it must run against once approved.
|
||||
pub key: String,
|
||||
/// The original, already-sanitized facade input (`{"action": "...", ...}`)
|
||||
/// WITHOUT any out-of-band sentinel (the caller-supplied one is stripped before
|
||||
/// stashing; the trusted one is injected only at resolve time).
|
||||
pub input: Value,
|
||||
}
|
||||
|
||||
/// **P3-GW2**: cap on actions awaiting out-of-band approval across all keys. A
|
||||
/// driving agent that keeps triggering irreversible actions without the user ever
|
||||
/// approving must not be able to grow the store without bound; past this, the
|
||||
/// dispatch layer fails closed (denies + tells the model to retry after the queue
|
||||
/// drains) rather than stashing.
|
||||
const MAX_PENDING: usize = 64;
|
||||
|
||||
/// The per-companion [`BrowserTool`] registry held by [`crate::deps::GatewayDeps`]
|
||||
/// (route A). Clone-cheap: the inner map is behind an `Arc`.
|
||||
#[derive(Clone)]
|
||||
pub struct BrowserRegistry {
|
||||
/// Application data dir; per-companion workspaces hang under
|
||||
/// `{data_dir}/browser-profiles/{key}`.
|
||||
data_dir: PathBuf,
|
||||
/// Whether to request a visible (headful) window. The engine forces headless
|
||||
/// when no display is available regardless.
|
||||
headful: bool,
|
||||
/// PKG-1: bundled Chrome resource dir (Tauri resource dir). When `Some`, each
|
||||
/// lazily-built slot tool prefers `<bundled_dir>/chrome-<platform>/...` over the
|
||||
/// network download fallback. `None` (default / non-packaged) → unchanged
|
||||
/// behavior (env > data_dir > download).
|
||||
bundled_dir: Option<PathBuf>,
|
||||
/// companion-key → slot. A `std::sync::Mutex` guards only the (fast) map
|
||||
/// lookup/insert; the per-companion `AsyncMutex` inside the slot is what's
|
||||
/// held across an await-bound tool call.
|
||||
slots: Arc<std::sync::Mutex<HashMap<String, Arc<CompanionBrowser>>>>,
|
||||
/// **P3-GW2**: actions awaiting out-of-band approval, keyed by the synthetic
|
||||
/// `call_id` the phone/front-end confirms. An irreversible action in this
|
||||
/// auto-approving gateway session is stashed here (instead of forwarded) until
|
||||
/// the user approves it via `nomi_browser_confirm`. Bounded-ish: capped per the
|
||||
/// `MAX_PENDING` guard so a misbehaving agent cannot grow it without bound.
|
||||
pending: Arc<std::sync::Mutex<HashMap<String, PendingBrowserAction>>>,
|
||||
/// **P3-X2: machine-bound `encryption_key`** for loading the **shared** secret
|
||||
/// vault (`{data_dir}/browser-secrets/shared/secrets.json` — user decision: 去
|
||||
/// per-pet 键化, browser identity globally shared). When `Some`, each lazily-built
|
||||
/// slot tool gets a [`BrowserSecretSource`] pointing at that one shared vault so
|
||||
/// gateway-driven `secret:NAME` resolves (origin-gated) and the firewall domain
|
||||
/// allowlist is derived from the registered `allowed_origins` (裁决⑤) — shared
|
||||
/// across companions. `None` (the `default_for_browser_use` convenience ctor) →
|
||||
/// no secret source (empty store → `secret:NAME` fails closed, current behavior).
|
||||
secret_key: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl BrowserRegistry {
|
||||
/// Build the registry from the browser config. Reads `headless` (inverted to
|
||||
/// `headful`) and the app data dir (same derivation as `BrowserTool::new`),
|
||||
/// under which each companion gets an isolated `browser-profiles/{key}`
|
||||
/// workspace. Constructs NO tools and launches NO browser — slots are created
|
||||
/// lazily on first use per companion.
|
||||
pub fn new(config: &BrowserConfig) -> Self {
|
||||
let data_dir = nomi_config::config::app_config_dir()
|
||||
.map(|d| d.join("browser-data"))
|
||||
.unwrap_or_else(|| std::env::temp_dir().join("nomi-browser-data"));
|
||||
Self {
|
||||
data_dir,
|
||||
headful: !config.headless,
|
||||
bundled_dir: None,
|
||||
slots: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
pending: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
secret_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// **P3-X2**: set the machine-bound `encryption_key` so each companion's slot tool
|
||||
/// loads the **shared** secret vault (`{data_dir}/browser-secrets/shared/secrets.json`
|
||||
/// — 去 per-pet 键化, browser identity globally shared) — gateway-driven `secret:NAME`
|
||||
/// then resolves (origin-gated) and the firewall `allow_etld1` is derived from the
|
||||
/// registered `allowed_origins` (裁决⑤), shared across companions. Must be the app's
|
||||
/// `encryption_key` (the same one the registration endpoint encrypted with).
|
||||
pub fn with_secret_key(mut self, key: [u8; 32]) -> Self {
|
||||
self.secret_key = Some(key);
|
||||
self
|
||||
}
|
||||
|
||||
/// **PKG-1**: set the bundled Chrome-for-Testing resource dir so each
|
||||
/// lazily-built companion slot tool prefers bundled chrome over the network
|
||||
/// download fallback. `None` → unchanged (env > data_dir > download).
|
||||
pub fn with_bundled_dir(mut self, dir: Option<PathBuf>) -> Self {
|
||||
self.bundled_dir = dir;
|
||||
self
|
||||
}
|
||||
|
||||
/// Convenience constructor for `nomifun-app`'s gateway wiring: build the
|
||||
/// registry with the default browser config so the app does not need a direct
|
||||
/// `nomi-config` dependency (the gateway already has one behind this feature).
|
||||
/// The engine forces headless when no display is available regardless, so the
|
||||
/// default (headful-requesting) config is the right gateway default.
|
||||
pub fn default_for_browser_use() -> Self {
|
||||
Self::new(&BrowserConfig::default())
|
||||
}
|
||||
|
||||
/// Resolve the registry key for a caller. A companion binding scopes the
|
||||
/// browser to that companion (multi-companion isolation); a session without
|
||||
/// one (e.g. an IM master agent) gets a `conversation:<id>` key so it still
|
||||
/// has its own isolated tool. An empty/unknown caller falls back to a shared
|
||||
/// `"_default"` key.
|
||||
pub fn key_for(companion_id: Option<&str>, conversation_id: &str) -> String {
|
||||
match companion_id {
|
||||
Some(c) if !c.trim().is_empty() => c.trim().to_string(),
|
||||
_ if !conversation_id.trim().is_empty() => format!("conversation:{}", conversation_id.trim()),
|
||||
_ => "_default".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-companion workspace dir (`{data_dir}/browser-profiles/{key}`).
|
||||
/// Pure path join — no I/O (the engine materializes `downloads/` on demand).
|
||||
/// The key is sanitized of path separators so a `conversation:<id>` (or any
|
||||
/// caller-influenced id) can never escape the profiles root.
|
||||
pub fn workspace_for(&self, key: &str) -> PathBuf {
|
||||
let safe: String = key
|
||||
.chars()
|
||||
.map(|c| if c == '/' || c == '\\' || c == ':' { '_' } else { c })
|
||||
.collect();
|
||||
self.data_dir.join("browser-profiles").join(safe)
|
||||
}
|
||||
|
||||
/// Get (or lazily create) the slot for a key. The `BrowserTool` is constructed
|
||||
/// but its engine is NOT launched (that happens lazily inside the facade on the
|
||||
/// first action). The gateway-driven tool is a **non-bypassing** session
|
||||
/// (`session_bypasses_approval = false`, `evaluate_full_power = false`): its own
|
||||
/// fail-closed redline gate does not hard-deny, leaving irreversible actions for
|
||||
/// the GW2 confirm hook (TODO at the dispatch layer).
|
||||
fn slot(&self, key: &str) -> Arc<CompanionBrowser> {
|
||||
let mut map = self.slots.lock().expect("browser registry slots poisoned");
|
||||
if let Some(existing) = map.get(key) {
|
||||
return existing.clone();
|
||||
}
|
||||
let workspace = self.workspace_for(key);
|
||||
let mut tool = BrowserTool::with_data_dir(self.data_dir.clone(), self.headful)
|
||||
.workspace(workspace)
|
||||
.bundled_dir(self.bundled_dir.clone());
|
||||
// P3-X2: give the slot tool the SHARED secret vault source so gateway-driven
|
||||
// `secret:NAME` resolves and the firewall allowlist is derived from the
|
||||
// registered allowed_origins (裁决⑤). User decision (去 per-pet 键化):
|
||||
// `pet_vault_path` now ignores `key` and routes every slot to the one shared
|
||||
// vault `{data_dir}/browser-secrets/shared`, so credentials/login/domain policy
|
||||
// are shared across all companions — the same shared vault the registration
|
||||
// endpoint and the session factory write to/read from.
|
||||
if let Some(secret_key) = self.secret_key {
|
||||
let vault_path = nomifun_secret::pet_vault_path(&self.data_dir, key);
|
||||
tool = tool.secret_source(BrowserSecretSource { vault_path, key: secret_key });
|
||||
}
|
||||
let slot = Arc::new(CompanionBrowser {
|
||||
tool: Arc::new(tool),
|
||||
lock: AsyncMutex::new(()),
|
||||
});
|
||||
map.insert(key.to_string(), slot.clone());
|
||||
slot
|
||||
}
|
||||
|
||||
/// Drive a browser tool call for `key`, serialized against that companion's
|
||||
/// other calls (X5: observe ⊥ act, per-target serial). `input` is the
|
||||
/// `BrowserTool` action object (`{"action": "...", ...}`). Returns the facade's
|
||||
/// [`ToolResult`] for the caller to render to JSON.
|
||||
pub async fn execute(&self, key: &str, input: Value) -> ToolResult {
|
||||
let slot = self.slot(key);
|
||||
// Hold the per-companion mutex for the whole call so the same companion's
|
||||
// observe/act/navigate never run concurrently against one engine.
|
||||
let _guard = slot.lock.lock().await;
|
||||
slot.tool.execute(input).await
|
||||
}
|
||||
|
||||
/// **并行浏览(DESIGN §26 P7 / §22 per-BrowserContext 可并发)**。批量跑 `(key, input)` 浏览器调用:
|
||||
/// **异 key** 并发(各 key 自有 Chrome 引擎 + 序列化锁,相互独立——浏览器**身份**仍经唯一共享 vault
|
||||
/// 全局共享,只有活进程 per-key);**同 key** 仍经该 key 的 [`CompanionBrowser`] `lock` 串行
|
||||
/// (observe⊥act 成立)。结果**按输入序**返回(`join_all` 保序),调用方可一一对应。单个调用的错误作为
|
||||
/// 其 [`ToolResult`] 返回(引擎不可用 / 被拒),绝不中断整批。
|
||||
pub async fn execute_parallel(&self, calls: Vec<(String, Value)>) -> Vec<ToolResult> {
|
||||
// 复用串行 `execute`:异 key 持不同 `CompanionBrowser` 锁 → 真并发;同 key 第二个 future 在该锁上
|
||||
// 等第一个 → 串行,无交错。`join_all` 在当前任务上并发驱动所有 future 并**保输入序**返回。
|
||||
let futs = calls
|
||||
.into_iter()
|
||||
.map(|(key, input)| async move { self.execute(&key, input).await });
|
||||
futures::future::join_all(futs).await
|
||||
}
|
||||
|
||||
/// **P3-GW2: classify an action's approval tier using the per-key facade's full
|
||||
/// runtime context** (its cached observe snapshot resolves a dangerous accname by
|
||||
/// `ref`). This is the AUTHORITATIVE classification the dispatch layer routes on —
|
||||
/// it sees the submit/Pay/删除 button signals a bare `classify_action` (without the
|
||||
/// snapshot) cannot. Pure read (no browser launch); creates the slot lazily if the
|
||||
/// caller classifies before its first execute (the tool, not the engine, is built).
|
||||
pub fn classify(&self, key: &str, action: &str, input: &Value) -> nomi_browser::ApprovalTier {
|
||||
self.slot(key).tool.classify_action_tier(action, input)
|
||||
}
|
||||
|
||||
/// **P3-GW2**: stash an irreversible action awaiting out-of-band approval and
|
||||
/// return the synthetic `call_id` the phone/front-end will confirm. The `input`
|
||||
/// MUST already be sanitized of any caller-supplied out-of-band sentinel (the
|
||||
/// dispatch layer strips it before classifying). Returns `None` (so the caller
|
||||
/// fails closed and denies) when the pending store is at capacity — a
|
||||
/// misbehaving agent cannot grow it without bound.
|
||||
pub fn stash_pending(&self, key: &str, input: Value) -> Option<String> {
|
||||
let call_id = nomifun_common::generate_prefixed_id("browser_oob");
|
||||
let mut map = self.pending.lock().expect("browser registry pending poisoned");
|
||||
if map.len() >= MAX_PENDING {
|
||||
return None;
|
||||
}
|
||||
map.insert(
|
||||
call_id.clone(),
|
||||
PendingBrowserAction {
|
||||
key: key.to_string(),
|
||||
input,
|
||||
},
|
||||
);
|
||||
Some(call_id)
|
||||
}
|
||||
|
||||
/// **P3-GW2**: remove and return a pending action by its `call_id`. `None` when
|
||||
/// the id is unknown (already resolved / never existed / expired). The caller
|
||||
/// (resolve path) treats `None` as "no such pending decision".
|
||||
pub fn take_pending(&self, call_id: &str) -> Option<PendingBrowserAction> {
|
||||
self.pending
|
||||
.lock()
|
||||
.expect("browser registry pending poisoned")
|
||||
.remove(call_id)
|
||||
}
|
||||
|
||||
/// **P3-GW2**: how many actions are currently awaiting approval (diagnostics /
|
||||
/// the `MAX_PENDING` guard; also used by tests).
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.pending
|
||||
.lock()
|
||||
.expect("browser registry pending poisoned")
|
||||
.len()
|
||||
}
|
||||
|
||||
/// **P3-GW2**: execute an out-of-band-APPROVED action — inject the trusted
|
||||
/// [`OUT_OF_BAND_CONFIRMED_KEY`] sentinel into the (sanitized) input so the
|
||||
/// facade's redline gate releases the irreversible action, then forward through
|
||||
/// the normal serialized `execute`. The sentinel is injected HERE (past the
|
||||
/// gateway trust boundary), never copied from caller input.
|
||||
pub async fn execute_confirmed(&self, key: &str, input: Value) -> ToolResult {
|
||||
self.execute(key, inject_out_of_band(input)).await
|
||||
}
|
||||
}
|
||||
|
||||
/// **P3-GW2 [pure]: inject the trusted out-of-band sentinel** into a (sanitized)
|
||||
/// action input so the facade's redline gate releases the irreversible action.
|
||||
/// Called only by [`BrowserRegistry::execute_confirmed`] — past the gateway trust
|
||||
/// boundary, after a real user approval. Pure (no I/O) so the injection is unit
|
||||
/// testable without launching a browser.
|
||||
fn inject_out_of_band(mut input: Value) -> Value {
|
||||
if let Some(obj) = input.as_object_mut() {
|
||||
obj.insert(OUT_OF_BAND_CONFIRMED_KEY.to_string(), Value::Bool(true));
|
||||
}
|
||||
input
|
||||
}
|
||||
|
||||
/// Render a facade [`ToolResult`] into the gateway's JSON envelope. An error
|
||||
/// result becomes `{"error": ...}`; a success result carries the text and any
|
||||
/// images (base64 PNG) so a remote master agent can relay/inspect them.
|
||||
pub fn tool_result_to_value(result: ToolResult) -> Value {
|
||||
if result.is_error {
|
||||
return json!({"error": result.content});
|
||||
}
|
||||
let mut payload = json!({"text": result.content});
|
||||
if !result.images.is_empty() {
|
||||
let imgs: Vec<Value> = result
|
||||
.images
|
||||
.iter()
|
||||
.map(|img| json!({"media_type": img.media_type, "data": img.data}))
|
||||
.collect();
|
||||
payload["images"] = Value::Array(imgs);
|
||||
}
|
||||
json!({"result": payload})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn registry() -> BrowserRegistry {
|
||||
BrowserRegistry::new(&BrowserConfig::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_prefers_companion_then_conversation_then_default() {
|
||||
assert_eq!(BrowserRegistry::key_for(Some("companion_x"), "5"), "companion_x");
|
||||
// Whitespace-only companion id is treated as absent.
|
||||
assert_eq!(BrowserRegistry::key_for(Some(" "), "5"), "conversation:5");
|
||||
assert_eq!(BrowserRegistry::key_for(None, "5"), "conversation:5");
|
||||
assert_eq!(BrowserRegistry::key_for(None, ""), "_default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_is_per_key_and_sanitized() {
|
||||
let r = registry();
|
||||
let a = r.workspace_for("companion_a");
|
||||
let b = r.workspace_for("companion_b");
|
||||
assert_ne!(a, b, "different companions must get different workspaces");
|
||||
assert!(a.ends_with(PathBuf::from("browser-profiles").join("companion_a")));
|
||||
// A conversation key's ':' / separators are sanitized so it stays under
|
||||
// the profiles root (no traversal).
|
||||
let conv = r.workspace_for("conversation:5");
|
||||
assert!(
|
||||
conv.ends_with(PathBuf::from("browser-profiles").join("conversation_5")),
|
||||
"got {conv:?}"
|
||||
);
|
||||
let evil = r.workspace_for("../../etc");
|
||||
assert!(
|
||||
evil.ends_with(PathBuf::from("browser-profiles").join(".._.._etc")),
|
||||
"path separators in a key must be neutralized: {evil:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_is_stable_per_key_and_distinct_across_keys() {
|
||||
let r = registry();
|
||||
let a1 = r.slot("companion_a");
|
||||
let a2 = r.slot("companion_a");
|
||||
let b = r.slot("companion_b");
|
||||
// Same key → same slot (so the engine + its mutex are reused, not rebuilt).
|
||||
assert!(Arc::ptr_eq(&a1, &a2), "same key must reuse the same slot");
|
||||
// Different key → different slot (live Chrome process / mutex isolated per
|
||||
// companion; the persisted IDENTITY is still shared — see secret vault below).
|
||||
assert!(!Arc::ptr_eq(&a1, &b), "different keys must get isolated engine slots");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_companions_resolve_the_same_shared_secret_vault() {
|
||||
// User decision (去 per-pet 键化): every companion key routes to the ONE shared
|
||||
// secret vault, so a secret registered for one companion is usable by every
|
||||
// companion's gateway-driven browser (shared browser identity).
|
||||
let r = registry();
|
||||
let shared_tail = std::path::Path::new("browser-secrets").join("shared").join("secrets.json");
|
||||
for key in ["companion_a", "companion_b", "conversation:5", "_default"] {
|
||||
let p = nomifun_secret::pet_vault_path(&r.data_dir, key);
|
||||
assert!(p.ends_with(&shared_tail), "key {key:?} must resolve the shared secret vault, got {p:?}");
|
||||
}
|
||||
// Distinct companion keys → identical shared vault path (the硬 evidence of sharing).
|
||||
assert_eq!(
|
||||
nomifun_secret::pet_vault_path(&r.data_dir, "companion_a"),
|
||||
nomifun_secret::pet_vault_path(&r.data_dir, "companion_b"),
|
||||
"去 per-pet 键化: two companions share one secret vault file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_constructs_no_slots() {
|
||||
let r = registry();
|
||||
assert!(
|
||||
r.slots.lock().unwrap().is_empty(),
|
||||
"registry must not pre-create any companion slot (lazy per companion)"
|
||||
);
|
||||
assert_eq!(r.pending_count(), 0, "registry must start with no pending approvals");
|
||||
}
|
||||
|
||||
// ── P3-GW2: pending out-of-band approval store ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn stash_then_take_round_trips_the_pending_action() {
|
||||
let r = registry();
|
||||
let input = json!({"action": "click", "ref": "f0e3"});
|
||||
let call_id = r.stash_pending("companion_a", input.clone()).expect("under cap");
|
||||
assert!(call_id.starts_with("browser_oob"), "synthetic call_id prefix: {call_id}");
|
||||
assert_eq!(r.pending_count(), 1);
|
||||
|
||||
let pending = r.take_pending(&call_id).expect("the just-stashed action");
|
||||
assert_eq!(pending.key, "companion_a");
|
||||
assert_eq!(pending.input, input);
|
||||
// Taken once → gone (a second take is None; a confirm cannot be replayed).
|
||||
assert!(r.take_pending(&call_id).is_none(), "take must be single-shot");
|
||||
assert_eq!(r.pending_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_unknown_call_id_is_none() {
|
||||
let r = registry();
|
||||
assert!(r.take_pending("browser_oob_nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stash_keys_are_unique_per_action() {
|
||||
let r = registry();
|
||||
let a = r.stash_pending("k", json!({"action": "click"})).unwrap();
|
||||
let b = r.stash_pending("k", json!({"action": "click"})).unwrap();
|
||||
assert_ne!(a, b, "each stashed action must get its own call_id");
|
||||
assert_eq!(r.pending_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stash_fails_closed_at_capacity() {
|
||||
let r = registry();
|
||||
for _ in 0..MAX_PENDING {
|
||||
assert!(r.stash_pending("k", json!({"action": "click"})).is_some());
|
||||
}
|
||||
// At cap → None (the dispatch layer denies rather than growing unbounded).
|
||||
assert!(
|
||||
r.stash_pending("k", json!({"action": "click"})).is_none(),
|
||||
"stash must fail closed at MAX_PENDING"
|
||||
);
|
||||
assert_eq!(r.pending_count(), MAX_PENDING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_out_of_band_sets_the_trusted_sentinel() {
|
||||
// execute_confirmed's pure core: the sentinel is injected here (past the trust
|
||||
// boundary), so the facade's out_of_band_confirmed reads true and the redline
|
||||
// gate releases the held irreversible action.
|
||||
let injected = inject_out_of_band(json!({"action": "click", "ref": "f0e3"}));
|
||||
assert_eq!(injected.get(OUT_OF_BAND_CONFIRMED_KEY).and_then(Value::as_bool), Some(true));
|
||||
assert_eq!(injected.get("action").and_then(Value::as_str), Some("click"));
|
||||
// Overwrites any pre-existing value to a strict bool true (never trusts input).
|
||||
let over = inject_out_of_band(json!({"action": "click", OUT_OF_BAND_CONFIRMED_KEY: "nope"}));
|
||||
assert_eq!(over.get(OUT_OF_BAND_CONFIRMED_KEY).and_then(Value::as_bool), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_builds_slot_and_returns_a_tier_without_launching() {
|
||||
// The dispatch layer's authoritative routing read: classify a benign read-only
|
||||
// action against a fresh key. The slot (tool) is built lazily but NO engine is
|
||||
// launched (pure read of the not-yet-existing snapshot → conservative tier).
|
||||
let r = registry();
|
||||
use nomi_browser::ApprovalTier;
|
||||
assert_eq!(
|
||||
r.classify("companion_c", "observe", &json!({"action": "observe"})),
|
||||
ApprovalTier::Info,
|
||||
"observe is read-only (Info)"
|
||||
);
|
||||
// A bare click with no cached snapshot → Exec (no accname to upgrade on).
|
||||
assert_eq!(
|
||||
r.classify("companion_c", "click", &json!({"action": "click", "ref": "f0e1"})),
|
||||
ApprovalTier::Exec
|
||||
);
|
||||
// press_key bare Enter → Irreversible even without a snapshot (args-derivable).
|
||||
assert_eq!(
|
||||
r.classify("companion_c", "press_key", &json!({"action": "press_key", "keys": "Enter"})),
|
||||
ApprovalTier::Irreversible
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_result_maps_to_error_envelope() {
|
||||
let v = tool_result_to_value(ToolResult::error("boom"));
|
||||
assert_eq!(v.get("error").and_then(Value::as_str), Some("boom"));
|
||||
assert!(v.get("result").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_result_maps_to_result_envelope() {
|
||||
let v = tool_result_to_value(ToolResult::text("Navigated to https://example.com"));
|
||||
assert_eq!(
|
||||
v.pointer("/result/text").and_then(Value::as_str),
|
||||
Some("Navigated to https://example.com")
|
||||
);
|
||||
assert_eq!(v.get("error"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_result_carries_base64_png() {
|
||||
let img = nomi_types::tool::ToolImage {
|
||||
media_type: "image/png".into(),
|
||||
data: "QUJD".into(), // base64("ABC")
|
||||
};
|
||||
let v = tool_result_to_value(ToolResult::text("Screenshot captured.").with_images(vec![img]));
|
||||
let arr = v.pointer("/result/images").and_then(Value::as_array).expect("images array");
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0].get("media_type").and_then(Value::as_str), Some("image/png"));
|
||||
assert_eq!(arr[0].get("data").and_then(Value::as_str), Some("QUJD"));
|
||||
}
|
||||
|
||||
// ── real-device end-to-end (needs a local/bundled chrome) ────────────────
|
||||
// GW1 round-trip through the registry: a gateway-driven navigate → observe
|
||||
// against a real Chromium, plus per-companion isolation (two keys → two
|
||||
// engines / user-data-dirs, distinct slots). Set NOMIFUN_CHROME_BINARY then:
|
||||
// set NOMIFUN_CHROME_BINARY=C:\Program Files\Google\Chrome\Application\chrome.exe
|
||||
// cargo nextest run -p nomifun-gateway --features browser-use --run-ignored all -E 'test(gateway_browser)'
|
||||
// Asserts the navigate result is non-error and the observe surfaces the
|
||||
// generation header + a frame-local `[ref=f0e…]` ref — i.e. a remote master
|
||||
// agent can drive a browser scoped to its companion. Clean up: no residual
|
||||
// chrome (the facade's engine Drop releases; the Builder kill_on_drop reaps).
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all -E 'test(gateway_browser)'"]
|
||||
async fn gateway_browser_navigate_then_observe_round_trip() {
|
||||
let r = registry();
|
||||
let key = BrowserRegistry::key_for(Some("companion_e2e"), "1");
|
||||
|
||||
let nav = tool_result_to_value(
|
||||
r.execute(&key, json!({"action": "navigate", "url": "https://example.com"}))
|
||||
.await,
|
||||
);
|
||||
assert!(nav.get("error").is_none(), "navigate should succeed: {nav}");
|
||||
assert!(
|
||||
nav.pointer("/result/text").and_then(Value::as_str).is_some(),
|
||||
"navigate result should carry text: {nav}"
|
||||
);
|
||||
|
||||
let obs = tool_result_to_value(r.execute(&key, json!({"action": "observe"})).await);
|
||||
let text = obs
|
||||
.pointer("/result/text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| panic!("observe should carry text: {obs}"));
|
||||
assert!(text.contains("[browser observation"), "missing generation header: {text}");
|
||||
assert!(text.contains("[ref=f0e"), "missing a frame-local ref: {text}");
|
||||
|
||||
// Isolation: a second companion gets a distinct slot (separate engine /
|
||||
// user-data-dir) — gateway-driven browsing is per-companion.
|
||||
let other = BrowserRegistry::key_for(Some("companion_other"), "2");
|
||||
assert!(
|
||||
!Arc::ptr_eq(&r.slot(&key), &r.slot(&other)),
|
||||
"different companions must get isolated browser slots"
|
||||
);
|
||||
}
|
||||
|
||||
// ── P3-GW2 real-device: held-then-confirmed irreversible action round-trip ──
|
||||
// Drives the full out-of-band approval state machine against a real Chromium:
|
||||
// an irreversible action is stashed (NOT run), then the held action is approved
|
||||
// and runs via execute_confirmed (the trusted sentinel makes the facade release
|
||||
// it). Mirrors what `tools_browser::act` → `tools_browser::confirm` do, minus the
|
||||
// GatewayDeps wiring (which the #[ignore] gateway integration covers separately).
|
||||
// set NOMIFUN_CHROME_BINARY=C:\Program Files\Google\Chrome\Application\chrome.exe
|
||||
// cargo nextest run -p nomifun-gateway --features browser-use --run-ignored all -E 'test(gw2_confirmed)'
|
||||
#[tokio::test]
|
||||
#[ignore = "需本机/打包 chrome:set NOMIFUN_CHROME_BINARY 后 --run-ignored all -E 'test(gw2_confirmed)'"]
|
||||
async fn gw2_confirmed_action_runs_held_then_approved() {
|
||||
let r = registry();
|
||||
let key = BrowserRegistry::key_for(Some("companion_gw2"), "1");
|
||||
|
||||
// A data: URL with a real <form> whose submit button navigates on click.
|
||||
let page = "data:text/html,<form action='https://example.com/' method='get'>\
|
||||
<button type='submit' id='go'>Pay now</button></form>";
|
||||
let nav = tool_result_to_value(r.execute(&key, json!({"action": "navigate", "url": page})).await);
|
||||
assert!(nav.get("error").is_none(), "navigate should succeed: {nav}");
|
||||
let obs = tool_result_to_value(r.execute(&key, json!({"action": "observe"})).await);
|
||||
let text = obs.pointer("/result/text").and_then(Value::as_str).unwrap_or("");
|
||||
// Find the submit button's ref from the snapshot.
|
||||
let r#ref = text
|
||||
.split("[ref=")
|
||||
.find(|seg| seg.to_lowercase().contains("pay") || seg.contains("button"))
|
||||
.and_then(|seg| seg.split(']').next())
|
||||
.unwrap_or("f0e1")
|
||||
.to_string();
|
||||
|
||||
// GW2 gate decision: clicking a submit/Pay button is irreversible → stash it.
|
||||
let action = json!({"action": "click", "ref": r#ref});
|
||||
let call_id = r.stash_pending(&key, action.clone()).expect("under cap");
|
||||
assert_eq!(r.pending_count(), 1, "the irreversible click must be HELD, not run");
|
||||
|
||||
// Approve: take the held action and run it confirmed (sentinel injected).
|
||||
let pending = r.take_pending(&call_id).expect("the held action");
|
||||
assert_eq!(pending.key, key);
|
||||
let result = tool_result_to_value(r.execute_confirmed(&key, pending.input).await);
|
||||
assert!(
|
||||
result.get("error").is_none(),
|
||||
"an approved (out-of-band-confirmed) irreversible action must RUN, not be Blocked: {result}"
|
||||
);
|
||||
assert_eq!(r.pending_count(), 0, "the pending action is consumed once resolved");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
//! Agent-stack domain capabilities: agent catalog/health, custom agent CRUD,
|
||||
//! remote agent management, and model failover configuration.
|
||||
//!
|
||||
//! Backed by:
|
||||
//! - `nomifun_ai_agent::AgentService` — installed agent listing, health checks,
|
||||
//! custom agent CRUD, enable/disable.
|
||||
//! - `nomifun_ai_agent::RemoteAgentService` — remote (A2A/MCP) agent CRUD +
|
||||
//! connection testing.
|
||||
//! - `nomifun_conversation::model_failover` — global model-failover config read/write
|
||||
//! (stored in `client_preferences` key `agent.model_failover`).
|
||||
//!
|
||||
//! NEW GatewayDeps fields assumed (parent wires):
|
||||
//! - `agent_service: Arc<nomifun_ai_agent::AgentService>`
|
||||
//! - `remote_agent_service: Arc<nomifun_ai_agent::RemoteAgentService>`
|
||||
//! - `client_pref_repo: Arc<dyn nomifun_db::IClientPreferenceRepository>`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{
|
||||
CustomAgentUpsertRequest, ModelFailoverConfig, ProviderHealthCheckRequest,
|
||||
TestRemoteAgentConnectionRequest, TryConnectCustomAgentRequest,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::GatewayDeps;
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
|
||||
use crate::server::ok;
|
||||
|
||||
// ── param structs (single source: schema + runtime) ──────────────────────
|
||||
|
||||
/// List all installed agent backends with their status and metadata.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct AgentListParams {}
|
||||
|
||||
/// Run an ACP health check against a specific agent backend.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct AgentHealthCheckParams {
|
||||
/// The agent backend identifier to health-check (e.g. "claude", "codex").
|
||||
backend: String,
|
||||
}
|
||||
|
||||
/// Run a provider-level health check (verify model reachability via a provider).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct AgentProviderHealthCheckParams {
|
||||
/// Provider id to test against.
|
||||
provider_id: String,
|
||||
/// Model name to probe (must be enabled on the provider).
|
||||
model: String,
|
||||
}
|
||||
|
||||
/// Enable or disable an agent backend.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct AgentSetEnabledParams {
|
||||
/// Agent id to toggle.
|
||||
id: String,
|
||||
/// Whether to enable (true) or disable (false) the agent.
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
/// Create a custom (user-registered) agent backend.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct AgentCustomCreateParams {
|
||||
/// Display name for the custom agent.
|
||||
name: String,
|
||||
/// CLI command to launch the agent process (absolute path or PATH-resolvable).
|
||||
command: String,
|
||||
/// Optional icon URL or data URI.
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
/// Extra CLI arguments passed after `command`.
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
/// Environment variables injected into the agent process.
|
||||
#[serde(default)]
|
||||
env: Vec<AgentEnvEntryParam>,
|
||||
/// Advanced behavior overrides (yolo_id, native_skills_dirs, behavior_policy, description).
|
||||
#[serde(default)]
|
||||
advanced: Option<Value>,
|
||||
}
|
||||
|
||||
/// Update an existing custom agent backend.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct AgentCustomUpdateParams {
|
||||
/// The custom agent id to update.
|
||||
id: String,
|
||||
/// Display name for the custom agent.
|
||||
name: String,
|
||||
/// CLI command to launch the agent process.
|
||||
command: String,
|
||||
/// Optional icon URL or data URI.
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
/// Extra CLI arguments passed after `command`.
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
/// Environment variables injected into the agent process.
|
||||
#[serde(default)]
|
||||
env: Vec<AgentEnvEntryParam>,
|
||||
/// Advanced behavior overrides.
|
||||
#[serde(default)]
|
||||
advanced: Option<Value>,
|
||||
}
|
||||
|
||||
/// Delete a custom agent backend (irreversible).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct AgentCustomDeleteParams {
|
||||
/// The custom agent id to permanently delete.
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Test connectivity to a custom agent binary (try-connect handshake).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct AgentCustomTryConnectParams {
|
||||
/// CLI command to launch the agent process.
|
||||
command: String,
|
||||
/// ACP protocol arguments (if any).
|
||||
#[serde(default)]
|
||||
acp_args: Vec<String>,
|
||||
/// Environment variables for the test subprocess.
|
||||
#[serde(default)]
|
||||
env: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// An environment variable entry for custom agent configuration.
|
||||
#[derive(Deserialize, JsonSchema, Clone)]
|
||||
struct AgentEnvEntryParam {
|
||||
/// Variable name.
|
||||
name: String,
|
||||
/// Variable value.
|
||||
value: String,
|
||||
/// Optional human-readable description of what this variable controls.
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
// ── Remote agent param structs ──────────────────────────────────────────
|
||||
|
||||
/// List all registered remote agents.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RemoteAgentListParams {}
|
||||
|
||||
/// Get details of a single remote agent by id.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RemoteAgentGetParams {
|
||||
/// Remote agent id (numeric, as string for consistency).
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Register a new remote agent.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RemoteAgentCreateParams {
|
||||
/// Display name.
|
||||
name: String,
|
||||
/// Protocol: "a2a" or "mcp-sse".
|
||||
protocol: String,
|
||||
/// Agent endpoint URL.
|
||||
url: String,
|
||||
/// Authentication type: "none", "bearer", or "header".
|
||||
auth_type: String,
|
||||
/// Auth token (required when auth_type is "bearer" or "header").
|
||||
#[serde(default)]
|
||||
auth_token: Option<String>,
|
||||
/// Allow connecting to HTTP (non-TLS) endpoints.
|
||||
#[serde(default)]
|
||||
allow_insecure: bool,
|
||||
/// Optional avatar URL.
|
||||
#[serde(default)]
|
||||
avatar: Option<String>,
|
||||
/// Optional description.
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
/// Update an existing remote agent (partial — only provided fields are changed).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RemoteAgentUpdateParams {
|
||||
/// Remote agent id to update.
|
||||
id: String,
|
||||
/// New display name.
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// New protocol.
|
||||
#[serde(default)]
|
||||
protocol: Option<String>,
|
||||
/// New endpoint URL.
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
/// New auth type.
|
||||
#[serde(default)]
|
||||
auth_type: Option<String>,
|
||||
/// New auth token (null to clear).
|
||||
#[serde(default)]
|
||||
auth_token: Option<Option<String>>,
|
||||
/// New allow_insecure flag.
|
||||
#[serde(default)]
|
||||
allow_insecure: Option<bool>,
|
||||
/// New avatar (null to clear).
|
||||
#[serde(default)]
|
||||
avatar: Option<Option<String>>,
|
||||
/// New description (null to clear).
|
||||
#[serde(default)]
|
||||
description: Option<Option<String>>,
|
||||
}
|
||||
|
||||
/// Delete a remote agent registration (irreversible).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RemoteAgentDeleteParams {
|
||||
/// Remote agent id to permanently delete.
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Test connectivity to a remote agent endpoint without persisting it.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RemoteAgentTestParams {
|
||||
/// Endpoint URL to test.
|
||||
url: String,
|
||||
/// Auth type for the test connection.
|
||||
#[serde(default)]
|
||||
auth_type: Option<String>,
|
||||
/// Auth token for the test connection.
|
||||
#[serde(default)]
|
||||
auth_token: Option<String>,
|
||||
/// Allow HTTP (non-TLS) endpoints.
|
||||
#[serde(default)]
|
||||
allow_insecure: bool,
|
||||
}
|
||||
|
||||
// ── Model failover param structs ────────────────────────────────────────
|
||||
|
||||
/// Read the global model-failover configuration.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ModelFailoverGetParams {}
|
||||
|
||||
/// Set the global model-failover configuration.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ModelFailoverSetParams {
|
||||
/// Whether model failover is enabled.
|
||||
enabled: bool,
|
||||
/// Ordered list of provider+model pairs to try on failure (first = primary fallback).
|
||||
/// Each entry: { "provider_id": "...", "model": "...", "use_model": null | "..." }.
|
||||
#[serde(default)]
|
||||
queue: Vec<Value>,
|
||||
/// Maximum number of model switches per conversation turn (default: 4).
|
||||
#[serde(default = "default_max_switches")]
|
||||
max_switches: u32,
|
||||
/// Whether to mark the failed provider-model as unhealthy after failover (default: true).
|
||||
#[serde(default = "default_stamp_unhealthy")]
|
||||
stamp_unhealthy: bool,
|
||||
}
|
||||
|
||||
fn default_max_switches() -> u32 {
|
||||
4
|
||||
}
|
||||
fn default_stamp_unhealthy() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ── handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn agent_list(deps: Arc<GatewayDeps>, _p: AgentListParams) -> Value {
|
||||
match deps.agent_service.list_agents().await {
|
||||
Ok(agents) => ok(agents),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn agent_health_check(deps: Arc<GatewayDeps>, p: AgentHealthCheckParams) -> Value {
|
||||
let req = nomifun_api_types::AcpHealthCheckRequest {
|
||||
backend: p.backend,
|
||||
};
|
||||
match deps.agent_service.acp_health_check(req).await {
|
||||
Ok(resp) => ok(resp),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn agent_provider_health_check(
|
||||
deps: Arc<GatewayDeps>,
|
||||
p: AgentProviderHealthCheckParams,
|
||||
) -> Value {
|
||||
let req = ProviderHealthCheckRequest {
|
||||
provider_id: p.provider_id,
|
||||
model: p.model,
|
||||
};
|
||||
match deps.agent_service.provider_health_check(req).await {
|
||||
Ok(resp) => ok(resp),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn agent_set_enabled(deps: Arc<GatewayDeps>, p: AgentSetEnabledParams) -> Value {
|
||||
match deps.agent_service.set_agent_enabled(&p.id, p.enabled).await {
|
||||
Ok(meta) => ok(meta),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn agent_custom_create(deps: Arc<GatewayDeps>, p: AgentCustomCreateParams) -> Value {
|
||||
let advanced = match p.advanced {
|
||||
Some(val) => match serde_json::from_value(val) {
|
||||
Ok(adv) => Some(adv),
|
||||
Err(e) => return json!({ "error": format!("invalid advanced field: {e}") }),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let req = CustomAgentUpsertRequest {
|
||||
name: p.name,
|
||||
command: p.command,
|
||||
icon: p.icon,
|
||||
args: p.args,
|
||||
env: p
|
||||
.env
|
||||
.into_iter()
|
||||
.map(|e| nomifun_api_types::AgentEnvEntry {
|
||||
name: e.name,
|
||||
value: e.value,
|
||||
description: e.description,
|
||||
})
|
||||
.collect(),
|
||||
advanced,
|
||||
};
|
||||
match deps.agent_service.create_custom_agent(req).await {
|
||||
Ok(meta) => ok(meta),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn agent_custom_update(deps: Arc<GatewayDeps>, p: AgentCustomUpdateParams) -> Value {
|
||||
let advanced = match p.advanced {
|
||||
Some(val) => match serde_json::from_value(val) {
|
||||
Ok(adv) => Some(adv),
|
||||
Err(e) => return json!({ "error": format!("invalid advanced field: {e}") }),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let req = CustomAgentUpsertRequest {
|
||||
name: p.name,
|
||||
command: p.command,
|
||||
icon: p.icon,
|
||||
args: p.args,
|
||||
env: p
|
||||
.env
|
||||
.into_iter()
|
||||
.map(|e| nomifun_api_types::AgentEnvEntry {
|
||||
name: e.name,
|
||||
value: e.value,
|
||||
description: e.description,
|
||||
})
|
||||
.collect(),
|
||||
advanced,
|
||||
};
|
||||
match deps.agent_service.update_custom_agent(&p.id, req).await {
|
||||
Ok(meta) => ok(meta),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn agent_custom_delete(deps: Arc<GatewayDeps>, p: AgentCustomDeleteParams) -> Value {
|
||||
match deps.agent_service.delete_custom_agent(&p.id).await {
|
||||
Ok(()) => ok(json!({ "deleted": p.id })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn agent_custom_try_connect(
|
||||
deps: Arc<GatewayDeps>,
|
||||
p: AgentCustomTryConnectParams,
|
||||
) -> Value {
|
||||
let req = TryConnectCustomAgentRequest {
|
||||
command: p.command,
|
||||
acp_args: p.acp_args,
|
||||
env: p.env,
|
||||
};
|
||||
match deps.agent_service.try_connect_custom_agent(req).await {
|
||||
Ok(resp) => ok(resp),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
// ── remote agent handlers ───────────────────────────────────────────────
|
||||
|
||||
async fn remote_agent_list(deps: Arc<GatewayDeps>, _p: RemoteAgentListParams) -> Value {
|
||||
match deps.remote_agent_service.list().await {
|
||||
Ok(list) => ok(list),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remote_agent_get(deps: Arc<GatewayDeps>, p: RemoteAgentGetParams) -> Value {
|
||||
match deps.remote_agent_service.get(&p.id).await {
|
||||
Ok(resp) => ok(resp),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remote_agent_create(deps: Arc<GatewayDeps>, p: RemoteAgentCreateParams) -> Value {
|
||||
// Deserialize protocol/auth_type from string to the typed enums via serde.
|
||||
let protocol = match serde_json::from_value(json!(p.protocol)) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json!({ "error": format!("invalid protocol: {e}") }),
|
||||
};
|
||||
let auth_type = match serde_json::from_value(json!(p.auth_type)) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json!({ "error": format!("invalid auth_type: {e}") }),
|
||||
};
|
||||
let req = nomifun_api_types::CreateRemoteAgentRequest {
|
||||
name: p.name,
|
||||
protocol,
|
||||
url: p.url,
|
||||
auth_type,
|
||||
auth_token: p.auth_token,
|
||||
allow_insecure: p.allow_insecure,
|
||||
avatar: p.avatar,
|
||||
description: p.description,
|
||||
};
|
||||
match deps.remote_agent_service.create(req).await {
|
||||
Ok(resp) => ok(resp),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remote_agent_update(deps: Arc<GatewayDeps>, p: RemoteAgentUpdateParams) -> Value {
|
||||
let protocol = match p.protocol {
|
||||
Some(v) => match serde_json::from_value(json!(v)) {
|
||||
Ok(parsed) => Some(parsed),
|
||||
Err(e) => return json!({ "error": format!("invalid protocol: {e}") }),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let auth_type = match p.auth_type {
|
||||
Some(v) => match serde_json::from_value(json!(v)) {
|
||||
Ok(parsed) => Some(parsed),
|
||||
Err(e) => return json!({ "error": format!("invalid auth_type: {e}") }),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let req = nomifun_api_types::UpdateRemoteAgentRequest {
|
||||
name: p.name,
|
||||
protocol,
|
||||
url: p.url,
|
||||
auth_type,
|
||||
auth_token: p.auth_token,
|
||||
allow_insecure: p.allow_insecure,
|
||||
avatar: p.avatar,
|
||||
description: p.description,
|
||||
};
|
||||
match deps.remote_agent_service.update(&p.id, req).await {
|
||||
Ok(resp) => ok(resp),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remote_agent_delete(deps: Arc<GatewayDeps>, p: RemoteAgentDeleteParams) -> Value {
|
||||
match deps.remote_agent_service.delete(&p.id).await {
|
||||
Ok(()) => ok(json!({ "deleted": p.id })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remote_agent_test(deps: Arc<GatewayDeps>, p: RemoteAgentTestParams) -> Value {
|
||||
let auth_type = match p.auth_type {
|
||||
Some(v) => match serde_json::from_value(json!(v)) {
|
||||
Ok(parsed) => Some(parsed),
|
||||
Err(e) => return json!({ "error": format!("invalid auth_type: {e}") }),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let req = TestRemoteAgentConnectionRequest {
|
||||
url: p.url,
|
||||
auth_type,
|
||||
auth_token: p.auth_token,
|
||||
allow_insecure: p.allow_insecure,
|
||||
};
|
||||
match deps.remote_agent_service.test_connection(req).await {
|
||||
Ok(()) => ok(json!({ "connected": true })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
// ── model failover handlers ─────────────────────────────────────────────
|
||||
|
||||
async fn model_failover_get(deps: Arc<GatewayDeps>, _p: ModelFailoverGetParams) -> Value {
|
||||
let cfg =
|
||||
nomifun_conversation::model_failover::get_global_failover_config(&deps.client_pref_repo)
|
||||
.await;
|
||||
ok(cfg)
|
||||
}
|
||||
|
||||
async fn model_failover_set(deps: Arc<GatewayDeps>, p: ModelFailoverSetParams) -> Value {
|
||||
// Deserialize queue entries into the typed ProviderWithModel vec.
|
||||
let queue: Vec<nomifun_common::ProviderWithModel> = match p
|
||||
.queue
|
||||
.into_iter()
|
||||
.map(serde_json::from_value)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
{
|
||||
Ok(q) => q,
|
||||
Err(e) => {
|
||||
return json!({ "error": format!("invalid queue entry: {e}. Each entry must have provider_id and model fields.") })
|
||||
}
|
||||
};
|
||||
|
||||
let cfg = ModelFailoverConfig {
|
||||
enabled: p.enabled,
|
||||
queue,
|
||||
max_switches: p.max_switches,
|
||||
stamp_unhealthy: p.stamp_unhealthy,
|
||||
};
|
||||
|
||||
match nomifun_conversation::model_failover::set_global_failover_config(
|
||||
&deps.client_pref_repo,
|
||||
&cfg,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => ok(cfg),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
// ── registration ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Register the agent-stack domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
// ─── Agent catalog ───────────────────────────────────────────────────
|
||||
|
||||
// 1. List agents (Read)
|
||||
out.push(Capability::new::<AgentListParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_agent_list",
|
||||
"agent",
|
||||
"List all installed agent backends with their availability status, type, and configuration.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| agent_list(deps, p),
|
||||
));
|
||||
|
||||
// 2. ACP health check (Read)
|
||||
out.push(Capability::new::<AgentHealthCheckParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_agent_health_check",
|
||||
"agent",
|
||||
"Run an ACP health check against a specific agent backend to verify it is responsive.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| agent_health_check(deps, p),
|
||||
));
|
||||
|
||||
// 3. Provider health check (Read)
|
||||
out.push(Capability::new::<AgentProviderHealthCheckParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_agent_provider_health_check",
|
||||
"agent",
|
||||
"Test model reachability through a specific provider (verify API key, model availability, latency).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| agent_provider_health_check(deps, p),
|
||||
));
|
||||
|
||||
// 4. Set agent enabled (Write)
|
||||
out.push(Capability::new::<AgentSetEnabledParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_agent_set_enabled",
|
||||
"agent",
|
||||
"Enable or disable an agent backend. Disabled agents are not available for new conversations.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| agent_set_enabled(deps, p),
|
||||
));
|
||||
|
||||
// ─── Custom agents ───────────────────────────────────────────────────
|
||||
|
||||
// 5. Create custom agent (Write)
|
||||
out.push(Capability::new::<AgentCustomCreateParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_agent_custom_create",
|
||||
"agent",
|
||||
"Register a new custom agent backend (user-provided CLI binary). The process will be launched on demand.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| agent_custom_create(deps, p),
|
||||
));
|
||||
|
||||
// 6. Update custom agent (Write)
|
||||
out.push(Capability::new::<AgentCustomUpdateParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_agent_custom_update",
|
||||
"agent",
|
||||
"Update an existing custom agent backend's configuration (name, command, args, env, advanced overrides).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| agent_custom_update(deps, p),
|
||||
));
|
||||
|
||||
// 7. Delete custom agent (Destructive, deny_on Channel)
|
||||
out.push(Capability::new::<AgentCustomDeleteParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_agent_custom_delete",
|
||||
"agent",
|
||||
"Permanently delete a custom agent backend registration. Running sessions using this agent will fail on next turn.",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, _ctx, p| agent_custom_delete(deps, p),
|
||||
));
|
||||
|
||||
// 8. Try-connect custom agent (Read — network probe, no state change)
|
||||
out.push(Capability::new::<AgentCustomTryConnectParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_agent_custom_try_connect",
|
||||
"agent",
|
||||
"Test connectivity to a custom agent binary by spawning it and performing an ACP handshake (dry-run, no persistence).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| agent_custom_try_connect(deps, p),
|
||||
));
|
||||
|
||||
// ─── Remote agents ───────────────────────────────────────────────────
|
||||
|
||||
// 9. List remote agents (Read)
|
||||
out.push(Capability::new::<RemoteAgentListParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_remote_agent_list",
|
||||
"agent",
|
||||
"List all registered remote agents (A2A / MCP-SSE) with their connection status.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| remote_agent_list(deps, p),
|
||||
));
|
||||
|
||||
// 10. Get remote agent (Read)
|
||||
out.push(Capability::new::<RemoteAgentGetParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_remote_agent_get",
|
||||
"agent",
|
||||
"Get full details of a remote agent by id (includes auth token if present).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| remote_agent_get(deps, p),
|
||||
));
|
||||
|
||||
// 11. Create remote agent (Write)
|
||||
out.push(Capability::new::<RemoteAgentCreateParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_remote_agent_create",
|
||||
"agent",
|
||||
"Register a new remote agent endpoint (A2A or MCP-SSE protocol) with optional authentication.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| remote_agent_create(deps, p),
|
||||
));
|
||||
|
||||
// 12. Update remote agent (Write)
|
||||
out.push(Capability::new::<RemoteAgentUpdateParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_remote_agent_update",
|
||||
"agent",
|
||||
"Update an existing remote agent's configuration. Only provided fields are changed.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| remote_agent_update(deps, p),
|
||||
));
|
||||
|
||||
// 13. Delete remote agent (Destructive, deny_on Channel)
|
||||
out.push(Capability::new::<RemoteAgentDeleteParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_remote_agent_delete",
|
||||
"agent",
|
||||
"Permanently delete a remote agent registration. Active delegations to this agent will fail.",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, _ctx, p| remote_agent_delete(deps, p),
|
||||
));
|
||||
|
||||
// 14. Test remote agent connection (Read — network probe only)
|
||||
out.push(Capability::new::<RemoteAgentTestParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_remote_agent_test",
|
||||
"agent",
|
||||
"Test connectivity to a remote agent endpoint without persisting it (dry-run handshake).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| remote_agent_test(deps, p),
|
||||
));
|
||||
|
||||
// ─── Model failover ──────────────────────────────────────────────────
|
||||
|
||||
// 15. Get model failover config (Read)
|
||||
out.push(Capability::new::<ModelFailoverGetParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_model_failover_get",
|
||||
"agent",
|
||||
"Read the global model-failover configuration (enabled flag, ordered queue of fallback provider+model pairs, max switches).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| model_failover_get(deps, p),
|
||||
));
|
||||
|
||||
// 16. Set model failover config (Write)
|
||||
out.push(Capability::new::<ModelFailoverSetParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_model_failover_set",
|
||||
"agent",
|
||||
"Set the global model-failover configuration. Controls automatic fallback to alternative models when the primary provider fails.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| model_failover_set(deps, p),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! AutoWork-domain capabilities (registry form): enable/disable + inspect the
|
||||
//! AutoWork binding for a conversation or terminal target.
|
||||
//!
|
||||
//! Mirrors `POST /api/requirements/autowork`: persist the config via
|
||||
//! `RequirementService`, then start/stop the live orchestrator loop and
|
||||
//! broadcast the state — a config write alone would only take effect after the
|
||||
//! next desktop boot.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{AutoWorkState, AutoWorkTargetKind};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier};
|
||||
use crate::server::ok;
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct SetAutoworkParams {
|
||||
/// Target kind: "conversation" or "terminal".
|
||||
kind: String,
|
||||
/// The conversation id or terminal id to bind.
|
||||
target_id: String,
|
||||
/// Enable (true) or disable (false) AutoWork on the target.
|
||||
enabled: bool,
|
||||
/// Requirement tag the session works through. REQUIRED when enabling.
|
||||
#[serde(default)]
|
||||
tag: Option<String>,
|
||||
/// Stop after this many completed requirements (omit for unlimited).
|
||||
#[serde(default)]
|
||||
max_requirements: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct GetAutoworkParams {
|
||||
/// Target kind: "conversation" or "terminal".
|
||||
kind: String,
|
||||
/// The conversation id or terminal id to inspect.
|
||||
target_id: String,
|
||||
}
|
||||
|
||||
fn parse_kind(raw: &str) -> Result<AutoWorkTargetKind, Value> {
|
||||
AutoWorkTargetKind::parse(raw)
|
||||
.ok_or_else(|| json!({ "error": format!("unknown kind '{raw}' (expected conversation | terminal)") }))
|
||||
}
|
||||
|
||||
/// Parse the string `target_id` into the integer conversation id the requirement
|
||||
/// service's owner check uses (the AutoWork target handle stays a string).
|
||||
fn parse_conv_id(target_id: &str) -> Result<i64, nomifun_common::AppError> {
|
||||
target_id
|
||||
.parse::<i64>()
|
||||
.map_err(|_| nomifun_common::AppError::NotFound(format!("conversation {target_id}")))
|
||||
}
|
||||
|
||||
/// Assemble the persisted config + the orchestrator's live view into one
|
||||
/// `AutoWorkState` (the same shape the REST routes return and broadcast).
|
||||
async fn build_state(deps: &GatewayDeps, kind: AutoWorkTargetKind, target_id: &str) -> Result<AutoWorkState, Value> {
|
||||
let (enabled, tag, _max) = deps
|
||||
.requirement_service
|
||||
.read_autowork_config(kind, target_id)
|
||||
.await
|
||||
.map_err(|e| json!({ "error": e.to_string() }))?;
|
||||
let running = deps.autowork_orchestrator.is_running(kind, target_id);
|
||||
let live_tag = deps.autowork_orchestrator.running_tag(kind, target_id).or(tag);
|
||||
let (current_requirement_id, completed_count) = deps
|
||||
.autowork_orchestrator
|
||||
.live_progress(kind, target_id)
|
||||
.unwrap_or((None, 0));
|
||||
let run_state = AutoWorkState::run_state(enabled, current_requirement_id.as_deref());
|
||||
Ok(AutoWorkState {
|
||||
kind,
|
||||
target_id: target_id.to_owned(),
|
||||
enabled,
|
||||
tag: live_tag,
|
||||
running,
|
||||
run_state,
|
||||
current_requirement_id,
|
||||
completed_count,
|
||||
})
|
||||
}
|
||||
|
||||
async fn set(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: SetAutoworkParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({ "error": "missing caller user identity" });
|
||||
}
|
||||
let kind = match parse_kind(&p.kind) {
|
||||
Ok(k) => k,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let target_id = p.target_id;
|
||||
if p.enabled && p.tag.is_none() {
|
||||
return json!({ "error": "tag is required when enabling autowork (the tag groups the requirements this session will work through)" });
|
||||
}
|
||||
|
||||
// Ownership + (terminal) eligibility — same gates as the REST route.
|
||||
let owner_check = match kind {
|
||||
AutoWorkTargetKind::Conversation => match parse_conv_id(&target_id) {
|
||||
Ok(conv_id) => deps.requirement_service.verify_conversation_owner(conv_id, &ctx.user_id).await,
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
AutoWorkTargetKind::Terminal => deps.requirement_service.verify_terminal_owner(&target_id, &ctx.user_id).await,
|
||||
};
|
||||
if let Err(e) = owner_check {
|
||||
return json!({ "error": e.to_string() });
|
||||
}
|
||||
if p.enabled
|
||||
&& kind == AutoWorkTargetKind::Terminal
|
||||
&& let Err(e) = deps.requirement_service.ensure_terminal_autowork_eligible(&target_id).await
|
||||
{
|
||||
return json!({ "error": e.to_string() });
|
||||
}
|
||||
|
||||
if let Err(e) = deps
|
||||
.requirement_service
|
||||
.save_autowork_config(kind, &target_id, p.enabled, p.tag.as_deref(), p.max_requirements)
|
||||
.await
|
||||
{
|
||||
return json!({ "error": e.to_string() });
|
||||
}
|
||||
|
||||
if p.enabled {
|
||||
if let Some(tag) = p.tag.clone() {
|
||||
deps.autowork_orchestrator
|
||||
.start(kind, target_id.clone(), tag, p.max_requirements);
|
||||
}
|
||||
} else {
|
||||
deps.autowork_orchestrator.stop(kind, &target_id);
|
||||
}
|
||||
|
||||
match build_state(&deps, kind, &target_id).await {
|
||||
Ok(state) => {
|
||||
deps.requirement_service.emit_autowork_state(&state);
|
||||
ok(state)
|
||||
}
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: GetAutoworkParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({ "error": "missing caller user identity" });
|
||||
}
|
||||
let kind = match parse_kind(&p.kind) {
|
||||
Ok(k) => k,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let target_id = p.target_id;
|
||||
let owner_check = match kind {
|
||||
AutoWorkTargetKind::Conversation => match parse_conv_id(&target_id) {
|
||||
Ok(conv_id) => deps.requirement_service.verify_conversation_owner(conv_id, &ctx.user_id).await,
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
AutoWorkTargetKind::Terminal => deps.requirement_service.verify_terminal_owner(&target_id, &ctx.user_id).await,
|
||||
};
|
||||
if let Err(e) = owner_check {
|
||||
return json!({ "error": e.to_string() });
|
||||
}
|
||||
match build_state(&deps, kind, &target_id).await {
|
||||
Ok(state) => ok(state),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<SetAutoworkParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_set_autowork",
|
||||
"autowork",
|
||||
"Enable/disable AutoWork (autonomous requirement execution) on a conversation or terminal and bind a requirement tag.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
set,
|
||||
));
|
||||
out.push(Capability::new::<GetAutoworkParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_get_autowork",
|
||||
"autowork",
|
||||
"Read the current AutoWork binding + live run state for a conversation or terminal.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
get,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Browser-domain capabilities (registry form, feature-gated). Lets a
|
||||
//! remote/master agent drive the desktop's in-process CDP browser, scoped +
|
||||
//! serialized per companion via [`crate::browser_registry::BrowserRegistry`].
|
||||
//!
|
||||
//! The GW2 out-of-band approval state machine (irreversible actions are held
|
||||
//! for explicit user approval via `nomi_browser_confirm`, never auto-run even
|
||||
//! under a yolo session) is preserved verbatim from the legacy tool — it is
|
||||
//! more specific than the registry's generic confirm-gate, so these tools are
|
||||
//! plain `Write`/`Read` and let GW2 do the gating. Browser tools are NOT denied
|
||||
//! on the Channel surface: remote browser driving is the entire point.
|
||||
//!
|
||||
//! Only compiled when the `browser-use` feature is on.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomi_browser::{ApprovalTier, OUT_OF_BAND_CONFIRMED_KEY};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::browser_registry::{BrowserRegistry, tool_result_to_value};
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier};
|
||||
|
||||
// ── params ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct NavigateParams {
|
||||
/// The URL to load in the caller's browser.
|
||||
url: String,
|
||||
/// Open in a new tab instead of the current one (default false).
|
||||
#[serde(default)]
|
||||
new_tab: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ObserveParams {
|
||||
/// Optional cap on the aria-snapshot depth (for huge pages).
|
||||
#[serde(default)]
|
||||
max_depth: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ActParams {
|
||||
/// The facade action name (click / type / scroll / screenshot /
|
||||
/// get_page_text / back / press_key / …). Re-observe after any action that
|
||||
/// changes the page (refs go stale).
|
||||
action: String,
|
||||
/// Action-specific params (ref / text / url / keys / …), passed through
|
||||
/// verbatim to the browser facade.
|
||||
#[serde(flatten)]
|
||||
rest: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ConfirmParams {
|
||||
/// The call_id from an `approval_required` envelope.
|
||||
call_id: String,
|
||||
/// "proceed_once" to approve the held irreversible action, "cancel" to deny.
|
||||
#[serde(default)]
|
||||
option: Option<String>,
|
||||
}
|
||||
|
||||
// ── per-caller registry + GW2 helpers (ported verbatim) ───────────────────
|
||||
|
||||
fn registry_and_key<'a>(deps: &'a GatewayDeps, ctx: &CallerCtx) -> Result<(&'a BrowserRegistry, String), Value> {
|
||||
let registry = deps
|
||||
.browser_registry
|
||||
.as_ref()
|
||||
.ok_or_else(|| json!({"error": "browser tools are not available on this desktop"}))?;
|
||||
let key = BrowserRegistry::key_for(ctx.companion_id.as_deref(), &ctx.conversation_id);
|
||||
Ok((registry, key))
|
||||
}
|
||||
|
||||
/// Strip any caller-supplied out-of-band sentinel before classify/forward (trust boundary).
|
||||
fn sanitize_out_of_band(mut input: Value) -> Value {
|
||||
if let Some(obj) = input.as_object_mut() {
|
||||
obj.remove(OUT_OF_BAND_CONFIRMED_KEY);
|
||||
}
|
||||
input
|
||||
}
|
||||
|
||||
fn approval_required_value(call_id: &str, action: &str, args: &Value) -> Value {
|
||||
json!({
|
||||
"approval_required": {
|
||||
"call_id": call_id,
|
||||
"title": format!("Approve irreversible browser action: {action}"),
|
||||
"description": describe_pending(action, args),
|
||||
"how_to": "This action is irreversible (submit / payment / delete / send) and your \
|
||||
session auto-approves, so it is held for out-of-band approval. Relay this \
|
||||
to the user; once they approve, call nomi_browser_confirm with this call_id \
|
||||
and option \"proceed_once\" (or \"cancel\" to deny).",
|
||||
"options": [
|
||||
{"label": "Approve once", "value": "proceed_once"},
|
||||
{"label": "Deny", "value": "cancel"},
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe_pending(action: &str, args: &Value) -> String {
|
||||
let detail = match action {
|
||||
"navigate" => args.get("url").and_then(Value::as_str).map(|u| format!("navigate to {u}")),
|
||||
"click" => args.get("ref").and_then(Value::as_str).map(|r| format!("click [ref={r}]")),
|
||||
"press_key" => args.get("keys").and_then(Value::as_str).map(|k| format!("press {k}")),
|
||||
"reload" => Some("reload the page".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
match detail {
|
||||
Some(d) => format!("Will {d} — irreversible (may submit / pay / delete / send)."),
|
||||
None => format!("Will run irreversible action `{action}` (may submit / pay / delete / send)."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gate an outbound action through out-of-band approval. `input` MUST already be
|
||||
/// sanitized. Returns `Some(json)` to short-circuit, `None` to proceed.
|
||||
fn gw2_gate(registry: &BrowserRegistry, key: &str, action: &str, input: &Value) -> Option<Value> {
|
||||
if registry.classify(key, action, input) != ApprovalTier::Irreversible {
|
||||
return None;
|
||||
}
|
||||
match registry.stash_pending(key, input.clone()) {
|
||||
Some(call_id) => Some(approval_required_value(&call_id, action, input)),
|
||||
None => Some(json!({
|
||||
"error": "too many browser actions are awaiting approval; resolve or cancel some via \
|
||||
nomi_browser_confirm before issuing more irreversible actions"
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// ── handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn navigate(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: NavigateParams) -> Value {
|
||||
let (registry, key) = match registry_and_key(&deps, &ctx) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let input = json!({"action": "navigate", "url": p.url, "new_tab": p.new_tab.unwrap_or(false)});
|
||||
if let Some(short_circuit) = gw2_gate(registry, &key, "navigate", &input) {
|
||||
return short_circuit;
|
||||
}
|
||||
tool_result_to_value(registry.execute(&key, input).await)
|
||||
}
|
||||
|
||||
async fn observe(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ObserveParams) -> Value {
|
||||
let (registry, key) = match registry_and_key(&deps, &ctx) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let mut input = json!({"action": "observe"});
|
||||
if let Some(d) = p.max_depth {
|
||||
input["max_depth"] = json!(d);
|
||||
}
|
||||
if let Some(short_circuit) = gw2_gate(registry, &key, "observe", &input) {
|
||||
return short_circuit;
|
||||
}
|
||||
tool_result_to_value(registry.execute(&key, input).await)
|
||||
}
|
||||
|
||||
async fn act(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ActParams) -> Value {
|
||||
let (registry, key) = match registry_and_key(&deps, &ctx) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return e,
|
||||
};
|
||||
// Reconstruct the facade input from the passthrough params, strip any
|
||||
// caller-supplied sentinel (trust boundary), then set the validated action.
|
||||
let mut input = sanitize_out_of_band(Value::Object(p.rest));
|
||||
input["action"] = json!(p.action);
|
||||
if let Some(short_circuit) = gw2_gate(registry, &key, &p.action, &input) {
|
||||
return short_circuit;
|
||||
}
|
||||
tool_result_to_value(registry.execute(&key, input).await)
|
||||
}
|
||||
|
||||
async fn confirm(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ConfirmParams) -> Value {
|
||||
let (registry, key) = match registry_and_key(&deps, &ctx) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let option = p.option.as_deref().map(str::trim).unwrap_or("cancel");
|
||||
let approve = matches!(option, "proceed_once" | "proceed_always" | "approve" | "yes");
|
||||
|
||||
let Some(pending) = registry.take_pending(&p.call_id) else {
|
||||
return json!({"error": format!("no pending browser approval with call_id {} (already resolved, expired, or never existed)", p.call_id)});
|
||||
};
|
||||
if pending.key != key {
|
||||
return json!({"error": "this pending browser approval belongs to a different session and cannot be resolved here"});
|
||||
}
|
||||
if !approve {
|
||||
return json!({"resolved": p.call_id, "approved": false, "result": {"text": "Denied. The irreversible browser action was not run."}});
|
||||
}
|
||||
let mut envelope = tool_result_to_value(registry.execute_confirmed(&key, pending.input).await);
|
||||
if let Some(obj) = envelope.as_object_mut() {
|
||||
obj.insert("resolved".to_string(), json!(p.call_id));
|
||||
obj.insert("approved".to_string(), json!(true));
|
||||
}
|
||||
envelope
|
||||
}
|
||||
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<NavigateParams, _, _>(
|
||||
CapabilityMeta::new("nomi_browser_navigate", "browser", "Load a URL in the caller's browser (optionally a new tab).", DangerTier::Write),
|
||||
navigate,
|
||||
));
|
||||
out.push(Capability::new::<ObserveParams, _, _>(
|
||||
CapabilityMeta::new("nomi_browser_observe", "browser", "Read the page's accessibility tree (aria snapshot + ref table) to target later. Read-only.", DangerTier::Read),
|
||||
observe,
|
||||
));
|
||||
out.push(Capability::new::<ActParams, _, _>(
|
||||
CapabilityMeta::new("nomi_browser_act", "browser", "Run any browser action (click/type/scroll/screenshot/...); irreversible actions are held for out-of-band approval.", DangerTier::Write),
|
||||
act,
|
||||
));
|
||||
out.push(Capability::new::<ConfirmParams, _, _>(
|
||||
CapabilityMeta::new("nomi_browser_confirm", "browser", "Resolve a pending out-of-band browser approval (proceed_once / cancel).", DangerTier::Write),
|
||||
confirm,
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_caller_supplied_out_of_band_sentinel() {
|
||||
let dirty = json!({"action": "click", "ref": "f0e1", OUT_OF_BAND_CONFIRMED_KEY: true});
|
||||
let clean = sanitize_out_of_band(dirty);
|
||||
assert!(clean.get(OUT_OF_BAND_CONFIRMED_KEY).is_none());
|
||||
assert_eq!(clean.get("action").and_then(Value::as_str), Some("click"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_pending_surfaces_action_detail_without_secrets() {
|
||||
assert!(describe_pending("navigate", &json!({"url": "https://shop.test/pay"})).contains("shop.test/pay"));
|
||||
assert!(describe_pending("click", &json!({"ref": "f0e9"})).contains("f0e9"));
|
||||
let d = describe_pending("type", &json!({"text": "secret:CARD"}));
|
||||
assert!(!d.contains("secret:CARD"), "preview must not echo a secret reference: {d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_value_mirrors_confirmation_shape() {
|
||||
let v = approval_required_value("browser_oob_123", "click", &json!({"ref": "f0e3"}));
|
||||
let ar = v.get("approval_required").expect("approval_required envelope");
|
||||
assert_eq!(ar.get("call_id").and_then(Value::as_str), Some("browser_oob_123"));
|
||||
let opts = ar.get("options").and_then(Value::as_array).expect("options");
|
||||
let values: Vec<&str> = opts.iter().filter_map(|o| o.get("value").and_then(Value::as_str)).collect();
|
||||
assert!(values.contains(&"proceed_once") && values.contains(&"cancel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn act_flatten_captures_passthrough_params() {
|
||||
let p: ActParams = serde_json::from_value(json!({"action": "click", "ref": "f0e1", "text": "hi"})).unwrap();
|
||||
assert_eq!(p.action, "click");
|
||||
assert_eq!(p.rest.get("ref").and_then(Value::as_str), Some("f0e1"));
|
||||
assert_eq!(p.rest.get("text").and_then(Value::as_str), Some("hi"));
|
||||
assert!(!p.rest.contains_key("action"), "flatten must exclude the named action field");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
//! Channel-domain capabilities (registry form): IM bot lifecycle,
|
||||
//! pairing/authorization management, and companion binding.
|
||||
//!
|
||||
//! These tools let the LLM agent configure remote IM channels on behalf of the
|
||||
//! user — the headline use case is "set up a Telegram bot and bind it to my
|
||||
//! work companion" spoken via conversation (no manual UI required).
|
||||
//!
|
||||
//! ## Assumed GatewayDeps field
|
||||
//!
|
||||
//! ```ignore
|
||||
//! pub channel_state: nomifun_channel::ChannelRouterState,
|
||||
//! ```
|
||||
//!
|
||||
//! The parent obtains this from `states.channel` (the `ModuleStates.channel`
|
||||
//! field built by `build_module_states` in `nomifun_app::router::state`).
|
||||
//! `ChannelRouterState` bundles `Arc<ChannelManager>`, `Arc<PairingService>`,
|
||||
//! `Arc<SessionManager>`, `Arc<dyn IChannelRepository>`, `Arc<PluginFactory>`,
|
||||
//! `Arc<ChannelSettingsService>`, `Option<Arc<dyn MasterAgentProfile>>`, and
|
||||
//! `ExtensionRegistry`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::GatewayDeps;
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
|
||||
use crate::server::ok;
|
||||
|
||||
// ── param structs ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListPluginsParams {}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct EnablePluginParams {
|
||||
/// Platform type of the bot to create/update. Required when creating a new
|
||||
/// bot (omit `plugin_id`). Supported builtins: "telegram", "discord",
|
||||
/// "slack", "lark", "dingtalk", "weixin", "matrix", "mattermost",
|
||||
/// "twitch", "nostr", "qqbot". Extension plugins use their registered id.
|
||||
#[serde(default)]
|
||||
plugin_type: Option<String>,
|
||||
|
||||
/// Existing channel row id to reconfigure. If omitted, a new bot is
|
||||
/// created (requires `plugin_type`). When provided, updates config in
|
||||
/// place.
|
||||
#[serde(default)]
|
||||
plugin_id: Option<String>,
|
||||
|
||||
/// Companion id to bind this bot to. Messages arriving on the channel will
|
||||
/// be routed to this companion. Omit or pass null to use the default
|
||||
/// companion.
|
||||
#[serde(default)]
|
||||
companion_id: Option<String>,
|
||||
|
||||
/// Platform-specific credentials and configuration as a JSON object.
|
||||
///
|
||||
/// The shape is `{ "credentials": { ... }, "config": { ... } }` where:
|
||||
///
|
||||
/// **credentials** (required fields depend on platform):
|
||||
/// - telegram/discord/twitch: `{ "token": "<bot_token>" }`
|
||||
/// - lark: `{ "token": "<verification_token>", "app_id": "...", "app_secret": "..." }`
|
||||
/// - dingtalk: `{ "client_id": "...", "client_secret": "..." }`
|
||||
/// - slack: `{ "token": "<xoxb-bot-token>", "app_token": "<xapp-token>" }`
|
||||
/// - weixin: `{ "bot_token": "...", "account_id": "..." }`
|
||||
/// - matrix: `{ "access_token": "...", "homeserver_url": "...", "user_id": "@bot:server" }`
|
||||
/// - mattermost: `{ "token": "...", "server_url": "https://..." }`
|
||||
/// - nostr: `{ "nostr_private_key": "<nsec/hex>", "nostr_relays": "wss://r1,wss://r2" }`
|
||||
/// - qqbot: `{ "client_id": "<appId>", "client_secret": "..." }`
|
||||
///
|
||||
/// **config** (optional):
|
||||
/// - `mode`: connection mode if applicable
|
||||
/// - `webhook_url`: for platforms that support webhook mode
|
||||
/// - `require_mention`: whether bot responds only when mentioned
|
||||
/// - `rate_limit`: messages per minute cap
|
||||
///
|
||||
/// Pass the full object; do NOT flatten credentials to the top level.
|
||||
config: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct DisablePluginParams {
|
||||
/// The channel row id (plugin_id) of the bot to disable. The bot is
|
||||
/// stopped but its configuration is retained for re-enabling.
|
||||
plugin_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct DeletePluginParams {
|
||||
/// The channel row id (plugin_id) of the bot to permanently delete. This
|
||||
/// stops the bot, removes all its sessions, and deletes the database row.
|
||||
/// Conversations created through this bot are NOT deleted.
|
||||
plugin_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct TestPluginParams {
|
||||
/// The platform identifier for the bot being tested (e.g. "telegram",
|
||||
/// "lark", "discord", etc.). For an existing channel, use the plugin_id
|
||||
/// from nomi_channel_list_plugins.
|
||||
plugin_id: String,
|
||||
|
||||
/// Primary credential token for the platform. Meaning varies:
|
||||
/// - telegram/discord/twitch: bot token
|
||||
/// - lark: verification token
|
||||
/// - dingtalk/qqbot: client_id (appId)
|
||||
/// - slack: xoxb bot token
|
||||
/// - weixin: bot_token
|
||||
/// - matrix: access_token
|
||||
/// - mattermost: bot token
|
||||
/// - nostr: private key (nsec/hex)
|
||||
token: String,
|
||||
|
||||
/// Additional platform-specific credentials for testing.
|
||||
#[serde(default)]
|
||||
extra_config: Option<TestExtraConfig>,
|
||||
}
|
||||
|
||||
/// Additional credentials needed to test specific platforms beyond the primary
|
||||
/// token.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct TestExtraConfig {
|
||||
/// Lark/DingTalk/QQBot: app_id or related secondary credential.
|
||||
#[serde(default)]
|
||||
app_id: Option<String>,
|
||||
/// Lark: app_secret; DingTalk/QQBot: client_secret.
|
||||
#[serde(default)]
|
||||
app_secret: Option<String>,
|
||||
/// Slack: xapp- level app token for Socket Mode.
|
||||
#[serde(default)]
|
||||
app_token: Option<String>,
|
||||
/// Matrix: homeserver URL (e.g. "https://matrix.org").
|
||||
#[serde(default)]
|
||||
homeserver_url: Option<String>,
|
||||
/// Matrix: bot user id (e.g. "@bot:matrix.org").
|
||||
#[serde(default)]
|
||||
user_id: Option<String>,
|
||||
/// Mattermost: server URL.
|
||||
#[serde(default)]
|
||||
server_url: Option<String>,
|
||||
/// Nostr: comma-separated relay URLs.
|
||||
#[serde(default)]
|
||||
nostr_relays: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListPairingsParams {}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ApprovePairingParams {
|
||||
/// The pairing code to approve (from nomi_channel_list_pairings).
|
||||
code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RejectPairingParams {
|
||||
/// The pairing code to reject (from nomi_channel_list_pairings).
|
||||
code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListUsersParams {}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RevokeUserParams {
|
||||
/// The internal user id to revoke (from nomi_channel_list_users). This
|
||||
/// removes authorization and clears all sessions for this user.
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct SetCompanionParams {
|
||||
/// Target a specific bot/channel by its row id. Takes priority over
|
||||
/// `platform`. The companion binding is scoped to this single bot.
|
||||
#[serde(default)]
|
||||
plugin_id: Option<String>,
|
||||
|
||||
/// Target all bots of a given platform type (legacy path). Used only when
|
||||
/// `plugin_id` is not provided. Supported: "telegram", "lark",
|
||||
/// "dingtalk", "slack", "discord", "weixin", "matrix", "mattermost",
|
||||
/// "twitch", "nostr", "qqbot".
|
||||
#[serde(default)]
|
||||
platform: Option<String>,
|
||||
|
||||
/// Companion id to bind. Pass null or omit to clear the binding (reverts
|
||||
/// to the default companion).
|
||||
#[serde(default)]
|
||||
companion_id: Option<String>,
|
||||
}
|
||||
|
||||
// ── handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn list_plugins(deps: Arc<GatewayDeps>, _p: ListPluginsParams) -> Value {
|
||||
match deps.channel_state.manager.get_plugin_status().await {
|
||||
Ok(statuses) => ok(statuses),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn enable_plugin(deps: Arc<GatewayDeps>, p: EnablePluginParams) -> Value {
|
||||
use nomifun_channel::manager::EnableChannelSpec;
|
||||
|
||||
// Validate companion binding if provided.
|
||||
if let Some(companion_id) = p.companion_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
if let Some(profile) = &deps.channel_state.master_profile {
|
||||
if !profile.companion_exists(companion_id).await {
|
||||
return json!({ "error": format!("companion '{}' not found", companion_id) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let spec = EnableChannelSpec {
|
||||
plugin_id: p.plugin_id.clone().filter(|s| !s.is_empty()),
|
||||
plugin_type: p.plugin_type.clone(),
|
||||
companion_id: p.companion_id.clone(),
|
||||
};
|
||||
|
||||
match deps
|
||||
.channel_state
|
||||
.manager
|
||||
.enable_plugin(&spec, &p.config, deps.channel_state.plugin_factory.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(channel_id) => ok(json!({
|
||||
"channel_id": channel_id,
|
||||
"note": "bot enabled; use nomi_channel_test_plugin to verify credentials connect successfully"
|
||||
})),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn disable_plugin(deps: Arc<GatewayDeps>, p: DisablePluginParams) -> Value {
|
||||
match deps.channel_state.manager.disable_plugin(&p.plugin_id).await {
|
||||
Ok(()) => ok(json!({ "disabled": true, "plugin_id": p.plugin_id })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_plugin(deps: Arc<GatewayDeps>, p: DeletePluginParams) -> Value {
|
||||
match deps.channel_state.manager.delete_channel(&p.plugin_id).await {
|
||||
Ok(()) => json!({ "result": format!("channel {} permanently deleted", p.plugin_id) }),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_plugin(deps: Arc<GatewayDeps>, p: TestPluginParams) -> Value {
|
||||
use nomifun_channel::types::{PluginConfig, PluginCredentials};
|
||||
|
||||
let mut credentials = PluginCredentials::default();
|
||||
let extra = p.extra_config.as_ref();
|
||||
|
||||
match p.plugin_id.as_str() {
|
||||
"lark" => {
|
||||
credentials.token = Some(p.token.clone());
|
||||
if let Some(e) = extra {
|
||||
credentials.app_id = e.app_id.clone();
|
||||
credentials.app_secret = e.app_secret.clone();
|
||||
}
|
||||
}
|
||||
"dingtalk" => {
|
||||
credentials.client_id = Some(p.token.clone());
|
||||
if let Some(e) = extra {
|
||||
credentials.client_secret = e.app_secret.clone();
|
||||
}
|
||||
}
|
||||
"weixin" => {
|
||||
credentials.bot_token = Some(p.token.clone());
|
||||
if let Some(e) = extra {
|
||||
credentials.account_id = e.app_id.clone();
|
||||
}
|
||||
}
|
||||
"slack" => {
|
||||
credentials.token = Some(p.token.clone());
|
||||
if let Some(e) = extra {
|
||||
credentials.app_token = e.app_token.clone();
|
||||
}
|
||||
}
|
||||
"matrix" => {
|
||||
credentials.access_token = Some(p.token.clone());
|
||||
if let Some(e) = extra {
|
||||
credentials.homeserver_url = e.homeserver_url.clone();
|
||||
credentials.user_id = e.user_id.clone();
|
||||
}
|
||||
}
|
||||
"mattermost" => {
|
||||
credentials.token = Some(p.token.clone());
|
||||
if let Some(e) = extra {
|
||||
credentials.server_url = e.server_url.clone();
|
||||
}
|
||||
}
|
||||
"twitch" => {
|
||||
credentials.token = Some(p.token.clone());
|
||||
}
|
||||
"nostr" => {
|
||||
credentials.nostr_private_key = Some(p.token.clone());
|
||||
if let Some(e) = extra {
|
||||
credentials.nostr_relays = e.nostr_relays.clone();
|
||||
}
|
||||
}
|
||||
"qqbot" => {
|
||||
credentials.client_id = Some(p.token.clone());
|
||||
if let Some(e) = extra {
|
||||
credentials.client_secret = e.app_secret.clone();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Default: telegram, discord, and others use generic token.
|
||||
credentials.token = Some(p.token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let config = PluginConfig {
|
||||
credentials,
|
||||
config: None,
|
||||
};
|
||||
|
||||
match deps
|
||||
.channel_state
|
||||
.manager
|
||||
.test_plugin(&p.plugin_id, config, deps.channel_state.plugin_factory.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(bot_username) => ok(json!({
|
||||
"success": true,
|
||||
"bot_username": bot_username,
|
||||
})),
|
||||
Err(e) => ok(json!({
|
||||
"success": false,
|
||||
"error": e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_pairings(deps: Arc<GatewayDeps>, _p: ListPairingsParams) -> Value {
|
||||
match deps.channel_state.pairing_service.get_pending_pairings().await {
|
||||
Ok(rows) => {
|
||||
let pairings: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"code": r.code,
|
||||
"platform_user_id": r.platform_user_id,
|
||||
"platform_type": r.platform_type,
|
||||
"channel_id": r.channel_id,
|
||||
"display_name": r.display_name,
|
||||
"requested_at": r.requested_at,
|
||||
"expires_at": r.expires_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ok(pairings)
|
||||
}
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn approve_pairing(deps: Arc<GatewayDeps>, p: ApprovePairingParams) -> Value {
|
||||
match deps.channel_state.pairing_service.approve_pairing(&p.code).await {
|
||||
Ok(()) => ok(json!({ "approved": true, "code": p.code })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reject_pairing(deps: Arc<GatewayDeps>, p: RejectPairingParams) -> Value {
|
||||
match deps.channel_state.pairing_service.reject_pairing(&p.code).await {
|
||||
Ok(()) => ok(json!({ "rejected": true, "code": p.code })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_users(deps: Arc<GatewayDeps>, _p: ListUsersParams) -> Value {
|
||||
match deps.channel_state.repo.get_all_users().await {
|
||||
Ok(rows) => {
|
||||
let users: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"id": r.id,
|
||||
"platform_user_id": r.platform_user_id,
|
||||
"platform_type": r.platform_type,
|
||||
"channel_id": r.channel_id,
|
||||
"display_name": r.display_name,
|
||||
"authorized_at": r.authorized_at,
|
||||
"last_active": r.last_active,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ok(users)
|
||||
}
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn revoke_user(deps: Arc<GatewayDeps>, p: RevokeUserParams) -> Value {
|
||||
// Clean up sessions first, then delete the user record.
|
||||
if let Err(e) = deps
|
||||
.channel_state
|
||||
.session_manager
|
||||
.cleanup_user_sessions(&p.user_id)
|
||||
.await
|
||||
{
|
||||
return json!({ "error": format!("failed to clean sessions: {}", e) });
|
||||
}
|
||||
match deps.channel_state.repo.delete_user(&p.user_id).await {
|
||||
Ok(()) => json!({ "result": format!("user {} revoked", p.user_id) }),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_companion(deps: Arc<GatewayDeps>, p: SetCompanionParams) -> Value {
|
||||
let companion_id = p.companion_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
// Validate companion existence if binding (not clearing).
|
||||
if let Some(cid) = companion_id {
|
||||
if let Some(profile) = &deps.channel_state.master_profile {
|
||||
if !profile.companion_exists(cid).await {
|
||||
return json!({ "error": format!("companion '{}' not found", cid) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-channel binding (preferred).
|
||||
if let Some(plugin_id) = p.plugin_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
return match deps
|
||||
.channel_state
|
||||
.manager
|
||||
.rebind_channel_companion(plugin_id, companion_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => ok(json!({
|
||||
"bound": true,
|
||||
"plugin_id": plugin_id,
|
||||
"companion_id": companion_id,
|
||||
"note": "channel sessions cleared; next message starts fresh under new companion"
|
||||
})),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy platform-wide binding.
|
||||
let platform_str = match p.platform.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return json!({ "error": "either plugin_id or platform is required" });
|
||||
}
|
||||
};
|
||||
|
||||
use nomifun_channel::types::PluginType;
|
||||
let platform = match PluginType::from_str_opt(platform_str) {
|
||||
Some(pt) => pt,
|
||||
None => {
|
||||
return json!({ "error": format!("invalid platform: {}", platform_str) });
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = deps
|
||||
.channel_state
|
||||
.settings_service
|
||||
.set_master_agent_companion_id(platform, companion_id)
|
||||
.await
|
||||
{
|
||||
return json!({ "error": e.to_string() });
|
||||
}
|
||||
|
||||
// Clear all sessions so next message starts under the new companion.
|
||||
if let Err(e) = deps.channel_state.session_manager.clear_all_sessions().await {
|
||||
return json!({ "error": format!("binding updated but session reset failed: {}", e) });
|
||||
}
|
||||
|
||||
ok(json!({
|
||||
"bound": true,
|
||||
"platform": platform_str,
|
||||
"companion_id": companion_id,
|
||||
"note": "platform-wide companion binding updated; all channel sessions cleared"
|
||||
}))
|
||||
}
|
||||
|
||||
// ── registration ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Register the channel-domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
// 1. List configured channel bots + status (read-only).
|
||||
out.push(Capability::new::<ListPluginsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_channel_list_plugins",
|
||||
"channel",
|
||||
"List all configured IM channel bots (telegram, discord, slack, lark, etc.) with their connection status, companion binding, and authorized user count.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list_plugins(deps, p),
|
||||
));
|
||||
|
||||
// 2. Enable/configure a bot channel (sensitive — writes credentials).
|
||||
out.push(Capability::new::<EnablePluginParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_channel_enable_plugin",
|
||||
"channel",
|
||||
"Enable or reconfigure an IM bot channel with platform-specific credentials. Creates a new bot if plugin_id is omitted, updates existing if provided. Optionally binds to a companion.",
|
||||
DangerTier::Sensitive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel, Surface::Remote]),
|
||||
|deps, _ctx, p| enable_plugin(deps, p),
|
||||
));
|
||||
|
||||
// 3. Disable a bot channel (write — config retained).
|
||||
out.push(Capability::new::<DisablePluginParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_channel_disable_plugin",
|
||||
"channel",
|
||||
"Disable an IM bot channel. The bot is stopped but configuration is retained for re-enabling later.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| disable_plugin(deps, p),
|
||||
));
|
||||
|
||||
// 4. Delete a bot channel permanently (destructive).
|
||||
out.push(Capability::new::<DeletePluginParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_channel_delete_plugin",
|
||||
"channel",
|
||||
"Permanently delete a bot channel: stops the bot, removes all its sessions, and deletes the database row. Conversations created through this bot survive.",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, _ctx, p| delete_plugin(deps, p),
|
||||
));
|
||||
|
||||
// 5. Test bot credentials (sensitive — sends a network probe).
|
||||
out.push(Capability::new::<TestPluginParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_channel_test_plugin",
|
||||
"channel",
|
||||
"Test IM bot credentials by probing the remote platform API. Returns the resolved bot_username on success. Does NOT persist any config changes.",
|
||||
DangerTier::Sensitive,
|
||||
),
|
||||
|deps, _ctx, p| test_plugin(deps, p),
|
||||
));
|
||||
|
||||
// 6. List pending pairing/authorization requests (read-only).
|
||||
out.push(Capability::new::<ListPairingsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_channel_list_pairings",
|
||||
"channel",
|
||||
"List pending pairing requests from IM users waiting to be authorized to interact with the bot.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list_pairings(deps, p),
|
||||
));
|
||||
|
||||
// 7. Approve a pairing request (write).
|
||||
out.push(Capability::new::<ApprovePairingParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_channel_approve_pairing",
|
||||
"channel",
|
||||
"Approve a pending pairing request, granting the IM user authorization to interact with the bot.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| approve_pairing(deps, p),
|
||||
));
|
||||
|
||||
// 8. Reject a pairing request (write).
|
||||
out.push(Capability::new::<RejectPairingParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_channel_reject_pairing",
|
||||
"channel",
|
||||
"Reject a pending pairing request, denying the IM user access to the bot.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| reject_pairing(deps, p),
|
||||
));
|
||||
|
||||
// 9. List authorized users (read-only).
|
||||
out.push(Capability::new::<ListUsersParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_channel_list_users",
|
||||
"channel",
|
||||
"List all authorized IM users across all channel bots, including their platform info and last activity.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list_users(deps, p),
|
||||
));
|
||||
|
||||
// 10. Revoke an authorized user (destructive — deletes access + sessions).
|
||||
out.push(Capability::new::<RevokeUserParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_channel_revoke_user",
|
||||
"channel",
|
||||
"Revoke an authorized user's access: cleans up all their sessions and deletes the authorization record.",
|
||||
DangerTier::Destructive,
|
||||
),
|
||||
|deps, _ctx, p| revoke_user(deps, p),
|
||||
));
|
||||
|
||||
// 11. Bind a channel bot to a companion (write).
|
||||
out.push(Capability::new::<SetCompanionParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_channel_set_companion",
|
||||
"channel",
|
||||
"Bind (or clear) the companion that handles a channel bot's conversations. Per-channel binding (plugin_id) is preferred; platform-wide binding is the legacy fallback. Clears sessions so the next message uses the new companion.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| set_companion(deps, p),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Companion-domain capabilities (registry form): digital companion CRUD,
|
||||
//! runtime status, shared config, and self-improvement suggestions.
|
||||
//!
|
||||
//! These tools let the LLM agent manage the desktop's digital companions on
|
||||
//! behalf of the user — create/configure/delete companions, inspect their
|
||||
//! status, and act on self-improvement suggestions generated by the learning
|
||||
//! subsystem.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::GatewayDeps;
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
|
||||
use crate::server::ok;
|
||||
|
||||
const DEFAULT_SUGGESTION_LIMIT: i64 = 20;
|
||||
|
||||
// ── param structs (single source: schema + runtime) ──────────────────────
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CompanionListParams {}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CompanionGetParams {
|
||||
/// The companion id to retrieve (from nomi_companion_list).
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CompanionCreateParams {
|
||||
/// Display name for the new companion.
|
||||
name: String,
|
||||
/// Character archetype / seed description. This seeds the companion's
|
||||
/// personality and initial system prompt generation.
|
||||
character: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CompanionUpdateParams {
|
||||
/// The companion id to update (from nomi_companion_list).
|
||||
id: String,
|
||||
/// RFC 7396 merge-patch object. Valid top-level keys: "name" (string),
|
||||
/// "character" (string), "persona" (object with "preset"/"custom"),
|
||||
/// "model" (object with "provider_id"/"model"),
|
||||
/// "appearance" (object — companion window config).
|
||||
/// Only provided keys are changed; omitted keys are untouched.
|
||||
patch: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CompanionDeleteParams {
|
||||
/// The companion id to permanently delete. This removes all associated
|
||||
/// data (thread, memories attributed to this companion, figure binding).
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CompanionStatusParams {
|
||||
/// The companion id. If omitted, returns the default companion's status.
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CompanionGetConfigParams {}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CompanionUpdateConfigParams {
|
||||
/// RFC 7396 merge-patch object for the shared companion config.
|
||||
/// Valid top-level keys: "collect" (object — per-source booleans),
|
||||
/// "learn" (object — learning schedule), "default_companion_id" (string),
|
||||
/// "bridge_to_memory_dir" (string | null).
|
||||
patch: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CompanionListSuggestionsParams {
|
||||
/// Filter by status: "new", "accepted", or "dismissed". Omit for all.
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
/// Maximum number of suggestions to return (default 20, clamped to 1..=100).
|
||||
#[serde(default)]
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CompanionDecideSuggestionParams {
|
||||
/// The suggestion id to act on (from nomi_companion_list_suggestions).
|
||||
id: String,
|
||||
/// true = accept the suggestion (applies it and awards XP);
|
||||
/// false = dismiss it. The decision is idempotent — calling again with
|
||||
/// the same id is a no-op.
|
||||
accept: bool,
|
||||
}
|
||||
|
||||
// ── handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn list(deps: Arc<GatewayDeps>, _p: CompanionListParams) -> Value {
|
||||
let companions = deps.companion_service.list_companions().await;
|
||||
ok(companions)
|
||||
}
|
||||
|
||||
async fn get(deps: Arc<GatewayDeps>, p: CompanionGetParams) -> Value {
|
||||
match deps.companion_service.get_companion(&p.id).await {
|
||||
Ok(companion) => ok(companion),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create(deps: Arc<GatewayDeps>, p: CompanionCreateParams) -> Value {
|
||||
let name = p.name.trim();
|
||||
if name.is_empty() {
|
||||
return json!({ "error": "missing required field: name" });
|
||||
}
|
||||
let character = p.character.trim();
|
||||
if character.is_empty() {
|
||||
return json!({ "error": "missing required field: character" });
|
||||
}
|
||||
match deps.companion_service.create_companion(name, character).await {
|
||||
Ok(companion) => ok(companion),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(deps: Arc<GatewayDeps>, p: CompanionUpdateParams) -> Value {
|
||||
if !p.patch.is_object() {
|
||||
return json!({ "error": "patch must be a JSON object" });
|
||||
}
|
||||
if p.patch.as_object().map_or(true, |m| m.is_empty()) {
|
||||
return json!({ "error": "patch must contain at least one field to update" });
|
||||
}
|
||||
match deps.companion_service.patch_companion(&p.id, p.patch).await {
|
||||
Ok(companion) => ok(companion),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(deps: Arc<GatewayDeps>, p: CompanionDeleteParams) -> Value {
|
||||
match deps.companion_service.delete_companion(&p.id).await {
|
||||
Ok(()) => json!({ "result": format!("companion {} deleted", p.id) }),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn status(deps: Arc<GatewayDeps>, p: CompanionStatusParams) -> Value {
|
||||
let result = match p.id {
|
||||
Some(ref id) => deps.companion_service.companion_status(id).await,
|
||||
None => deps.companion_service.status().await,
|
||||
};
|
||||
match result {
|
||||
Ok(s) => ok(s),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_config(deps: Arc<GatewayDeps>, _p: CompanionGetConfigParams) -> Value {
|
||||
let config = deps.companion_service.get_config().await;
|
||||
ok(config)
|
||||
}
|
||||
|
||||
async fn update_config(deps: Arc<GatewayDeps>, p: CompanionUpdateConfigParams) -> Value {
|
||||
if !p.patch.is_object() {
|
||||
return json!({ "error": "patch must be a JSON object" });
|
||||
}
|
||||
if p.patch.as_object().map_or(true, |m| m.is_empty()) {
|
||||
return json!({ "error": "patch must contain at least one field to update" });
|
||||
}
|
||||
match deps.companion_service.patch_config(p.patch).await {
|
||||
Ok(config) => ok(config),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_suggestions(deps: Arc<GatewayDeps>, p: CompanionListSuggestionsParams) -> Value {
|
||||
let limit = p.limit.unwrap_or(DEFAULT_SUGGESTION_LIMIT).clamp(1, 100);
|
||||
match deps
|
||||
.companion_service
|
||||
.list_suggestions(p.status.as_deref(), limit)
|
||||
.await
|
||||
{
|
||||
Ok(suggestions) => ok(suggestions),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn decide_suggestion(deps: Arc<GatewayDeps>, p: CompanionDecideSuggestionParams) -> Value {
|
||||
match deps
|
||||
.companion_service
|
||||
.decide_suggestion(&p.id, p.accept)
|
||||
.await
|
||||
{
|
||||
Ok(suggestion) => ok(suggestion),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
// ── registration ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Register the companion-domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
// 1. List companions (read)
|
||||
out.push(Capability::new::<CompanionListParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_companion_list",
|
||||
"companion",
|
||||
"List all digital companions (id, name, character, model, appearance).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list(deps, p),
|
||||
));
|
||||
|
||||
// 2. Get companion (read)
|
||||
out.push(Capability::new::<CompanionGetParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_companion_get",
|
||||
"companion",
|
||||
"Get a single companion's full profile and configuration by id.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| get(deps, p),
|
||||
));
|
||||
|
||||
// 3. Create companion (write)
|
||||
out.push(Capability::new::<CompanionCreateParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_companion_create",
|
||||
"companion",
|
||||
"Create a new digital companion with a name and character description. The model must be configured separately via nomi_companion_update.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| create(deps, p),
|
||||
));
|
||||
|
||||
// 4. Update companion (write)
|
||||
out.push(Capability::new::<CompanionUpdateParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_companion_update",
|
||||
"companion",
|
||||
"Partially update a companion's profile (name, character, persona, model, appearance). Pass an RFC 7396 merge-patch object.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| update(deps, p),
|
||||
));
|
||||
|
||||
// 5. Delete companion (destructive, deny on channel)
|
||||
out.push(Capability::new::<CompanionDeleteParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_companion_delete",
|
||||
"companion",
|
||||
"Permanently delete a companion and all its associated data (thread, figure binding). Irreversible.",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, _ctx, p| delete(deps, p),
|
||||
));
|
||||
|
||||
// 6. Companion status (read)
|
||||
out.push(Capability::new::<CompanionStatusParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_companion_status",
|
||||
"companion",
|
||||
"Get a companion's runtime status (XP, level, mood, memory counts, suggestion counts, model readiness). Omit id for the default companion.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| status(deps, p),
|
||||
));
|
||||
|
||||
// 7. Get shared config (read)
|
||||
out.push(Capability::new::<CompanionGetConfigParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_companion_get_config",
|
||||
"companion",
|
||||
"Read the shared companion configuration (collection toggles, learning schedule, default companion id).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| get_config(deps, p),
|
||||
));
|
||||
|
||||
// 8. Update shared config (write)
|
||||
out.push(Capability::new::<CompanionUpdateConfigParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_companion_update_config",
|
||||
"companion",
|
||||
"Partially update the shared companion configuration (collection toggles, learning schedule, default companion, memory bridge). RFC 7396 merge-patch.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| update_config(deps, p),
|
||||
));
|
||||
|
||||
// 9. List suggestions (read)
|
||||
out.push(Capability::new::<CompanionListSuggestionsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_companion_list_suggestions",
|
||||
"companion",
|
||||
"List self-improvement suggestions generated by the companion's learning subsystem. Filter by status (new/accepted/dismissed).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list_suggestions(deps, p),
|
||||
));
|
||||
|
||||
// 10. Decide suggestion (write)
|
||||
out.push(Capability::new::<CompanionDecideSuggestionParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_companion_decide_suggestion",
|
||||
"companion",
|
||||
"Accept or dismiss a self-improvement suggestion. Accepting awards XP to all companions. The decision is idempotent.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| decide_suggestion(deps, p),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Computer-use domain capabilities (registry form, feature-gated). Lets a
|
||||
//! Remote/companion agent drive the local desktop — a thin facade over the
|
||||
//! in-tree `nomi_computer::ComputerTool`, mirroring the inward
|
||||
//! `mcp-computer-stdio` bridge (same 14 discrete tools, same action mapping,
|
||||
//! zero duplicated logic). Only compiled with the `computer-use` feature.
|
||||
//!
|
||||
//! DangerTier: observe/screenshot/cursor_position/list_windows/wait are `Read`;
|
||||
//! input-synthesis actions (click/type/key/scroll/launch/…) are `Write` — which
|
||||
//! is Allowed on every surface incl. Remote, so an external "外部伙伴" can drive
|
||||
//! the desktop (same posture as the browser `act` tools).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::computer_registry::{ComputerRegistry, tool_result_to_value};
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier};
|
||||
|
||||
fn registry(deps: &GatewayDeps) -> Result<&ComputerRegistry, Value> {
|
||||
deps.computer_registry
|
||||
.as_ref()
|
||||
.ok_or_else(|| json!({ "error": "computer-use is not available on this host" }))
|
||||
}
|
||||
|
||||
async fn run(deps: &GatewayDeps, input: Value) -> Value {
|
||||
match registry(deps) {
|
||||
Ok(reg) => tool_result_to_value(reg.execute(input).await),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parameter structs (lifted from the inward computer_stdio bridge) -------
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct NoParams {}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RefParams {
|
||||
/// Element number `[ref]` from the most recent `nomi_computer_snapshot`.
|
||||
r#ref: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct SetValueParams {
|
||||
/// Element number `[ref]` from the most recent snapshot.
|
||||
r#ref: u32,
|
||||
/// The text to set into the element.
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct XyParams {
|
||||
/// X coordinate in pixels of the most recent screenshot.
|
||||
x: i64,
|
||||
/// Y coordinate in pixels of the most recent screenshot.
|
||||
y: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct TypeParams {
|
||||
/// The text to type into the focused control.
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct KeyParams {
|
||||
/// Key or combo to press, e.g. "enter" or "ctrl+a".
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ScrollParams {
|
||||
/// Scroll direction: up, down, left, or right.
|
||||
direction: String,
|
||||
/// Wheel clicks (default 3).
|
||||
#[serde(default)]
|
||||
amount: Option<i64>,
|
||||
/// Optional X to scroll at (screenshot pixels).
|
||||
#[serde(default)]
|
||||
x: Option<i64>,
|
||||
/// Optional Y to scroll at (screenshot pixels).
|
||||
#[serde(default)]
|
||||
y: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct LaunchParams {
|
||||
/// What to open: a URL (https://…), a file/folder path, or an app name.
|
||||
target: String,
|
||||
/// Optional application to open the target WITH (e.g. app="msedge").
|
||||
#[serde(default)]
|
||||
app: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ScreenshotParams {
|
||||
/// Optional display index to capture (default: primary).
|
||||
#[serde(default)]
|
||||
display: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct WaitParams {
|
||||
/// Seconds to wait (max 5).
|
||||
#[serde(default)]
|
||||
seconds: Option<f64>,
|
||||
}
|
||||
|
||||
// ---- handlers (forward to the shared tool's action dispatcher) --------------
|
||||
|
||||
async fn snapshot(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: NoParams) -> Value {
|
||||
run(&deps, json!({ "action": "observe" })).await
|
||||
}
|
||||
async fn screenshot(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: ScreenshotParams) -> Value {
|
||||
run(&deps, json!({ "action": "screenshot", "display": p.display })).await
|
||||
}
|
||||
async fn click(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: RefParams) -> Value {
|
||||
run(&deps, json!({ "action": "click_element", "ref": p.r#ref })).await
|
||||
}
|
||||
async fn right_click(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: RefParams) -> Value {
|
||||
run(&deps, json!({ "action": "right_click_element", "ref": p.r#ref })).await
|
||||
}
|
||||
async fn double_click(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: RefParams) -> Value {
|
||||
run(&deps, json!({ "action": "double_click_element", "ref": p.r#ref })).await
|
||||
}
|
||||
async fn set_value(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: SetValueParams) -> Value {
|
||||
run(&deps, json!({ "action": "set_element_value", "ref": p.r#ref, "text": p.text })).await
|
||||
}
|
||||
async fn click_xy(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: XyParams) -> Value {
|
||||
run(&deps, json!({ "action": "left_click", "x": p.x, "y": p.y })).await
|
||||
}
|
||||
async fn type_text(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: TypeParams) -> Value {
|
||||
run(&deps, json!({ "action": "type", "text": p.text })).await
|
||||
}
|
||||
async fn key(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: KeyParams) -> Value {
|
||||
run(&deps, json!({ "action": "key", "key": p.key })).await
|
||||
}
|
||||
async fn scroll(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: ScrollParams) -> Value {
|
||||
run(
|
||||
&deps,
|
||||
json!({ "action": "scroll", "direction": p.direction, "amount": p.amount, "x": p.x, "y": p.y }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn launch(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: LaunchParams) -> Value {
|
||||
run(&deps, json!({ "action": "launch", "target": p.target, "app": p.app })).await
|
||||
}
|
||||
async fn list_windows(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: NoParams) -> Value {
|
||||
run(&deps, json!({ "action": "list_windows" })).await
|
||||
}
|
||||
async fn cursor_position(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: NoParams) -> Value {
|
||||
run(&deps, json!({ "action": "cursor_position" })).await
|
||||
}
|
||||
async fn wait(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: WaitParams) -> Value {
|
||||
run(&deps, json!({ "action": "wait", "seconds": p.seconds })).await
|
||||
}
|
||||
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<NoParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_snapshot",
|
||||
"computer",
|
||||
"Read the desktop accessibility tree (windows → controls, numbered [ref] + Set-of-Marks overlay). Do this first, then act on a [ref]. Re-run after any UI change. Read-only.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
snapshot,
|
||||
));
|
||||
out.push(Capability::new::<ScreenshotParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_screenshot",
|
||||
"computer",
|
||||
"Capture the screen as a PNG (optional `display` index).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
screenshot,
|
||||
));
|
||||
out.push(Capability::new::<RefParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_click",
|
||||
"computer",
|
||||
"Activate the element with the given `ref` from the latest snapshot.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
click,
|
||||
));
|
||||
out.push(Capability::new::<RefParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_right_click",
|
||||
"computer",
|
||||
"Right-click the element with the given `ref` (opens its context menu).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
right_click,
|
||||
));
|
||||
out.push(Capability::new::<RefParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_double_click",
|
||||
"computer",
|
||||
"Double-click the element with the given `ref`.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
double_click,
|
||||
));
|
||||
out.push(Capability::new::<SetValueParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_set_value",
|
||||
"computer",
|
||||
"Set the `text` value of the element with the given `ref` (good for text fields).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
set_value,
|
||||
));
|
||||
out.push(Capability::new::<XyParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_click_xy",
|
||||
"computer",
|
||||
"Left-click at pixel coordinates (`x`, `y`) of the most recent screenshot. Prefer click-by-ref when possible.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
click_xy,
|
||||
));
|
||||
out.push(Capability::new::<TypeParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_type",
|
||||
"computer",
|
||||
"Type the `text` string into the focused control.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
type_text,
|
||||
));
|
||||
out.push(Capability::new::<KeyParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_key",
|
||||
"computer",
|
||||
"Press a key or combo, e.g. \"enter\" or \"ctrl+a\".",
|
||||
DangerTier::Write,
|
||||
),
|
||||
key,
|
||||
));
|
||||
out.push(Capability::new::<ScrollParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_scroll",
|
||||
"computer",
|
||||
"Scroll in `direction` (up/down/left/right) by `amount` wheel clicks, optionally at (`x`, `y`).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
scroll,
|
||||
));
|
||||
out.push(Capability::new::<LaunchParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_launch",
|
||||
"computer",
|
||||
"Open an application, URL, file, or folder via the OS shell. Always use this instead of shell `start`/`Start-Process`.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
launch,
|
||||
));
|
||||
out.push(Capability::new::<NoParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_list_windows",
|
||||
"computer",
|
||||
"List open windows with ids, titles, positions and sizes.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
list_windows,
|
||||
));
|
||||
out.push(Capability::new::<NoParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_cursor_position",
|
||||
"computer",
|
||||
"Report the mouse cursor position in screenshot coordinates.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
cursor_position,
|
||||
));
|
||||
out.push(Capability::new::<WaitParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_computer_wait",
|
||||
"computer",
|
||||
"Pause for `seconds` (max 5) to let the UI settle.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
wait,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Confirmation-domain capabilities (registry form): list pending decisions of
|
||||
//! a driven conversation and resolve one by picking an option.
|
||||
//!
|
||||
//! These let a channel "master" agent relay a blocking decision in a worker
|
||||
//! conversation to the channel user as numbered text and submit the user's
|
||||
//! pick — the gateway otherwise only exposes a `pending_confirmations` count
|
||||
//! (`nomi_conversation_status`) with no way to read the options or answer.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::ConfirmRequest;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier};
|
||||
use crate::server::ok;
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListConfirmationsParams {
|
||||
/// The id of the conversation whose pending decisions to read.
|
||||
conversation_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ResolveConfirmationParams {
|
||||
/// The id of the conversation containing the pending decision.
|
||||
conversation_id: i64,
|
||||
/// The call_id of the specific pending decision to resolve (from nomi_list_confirmations).
|
||||
call_id: String,
|
||||
/// The chosen option's value (a bare option-id string for ACP).
|
||||
option: String,
|
||||
}
|
||||
|
||||
/// Build the `ConfirmRequest.data` for a resolved option, writing the chosen
|
||||
/// option under BOTH keys so either backend resolves it: the nomi agent reads
|
||||
/// `data.get("value")` (and defaults to "cancel" when the key is absent — a
|
||||
/// bare `Value::String` was therefore silently DENIED), while ACP's
|
||||
/// `confirm_option_id` reads `option_id` (falling back to `value`). Mirrors the
|
||||
/// double-key payload IDMM already uses in `nomifun-idmm` probe `inject`.
|
||||
fn confirm_data(option: &str) -> Value {
|
||||
json!({ "option_id": option, "value": option })
|
||||
}
|
||||
|
||||
async fn list(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ListConfirmationsParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({"error": "missing caller user identity"});
|
||||
}
|
||||
let id = p.conversation_id.to_string();
|
||||
let confs = match deps
|
||||
.conversation_service
|
||||
.list_confirmations(&ctx.user_id, &id, &deps.task_manager)
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => return json!({"error": e.to_string()}),
|
||||
};
|
||||
ok(json!({
|
||||
"confirmations": confs
|
||||
.iter()
|
||||
.map(|c| json!({
|
||||
"call_id": c.call_id,
|
||||
"title": c.title,
|
||||
"description": c.description,
|
||||
"options": c
|
||||
.options
|
||||
.iter()
|
||||
.map(|o| json!({"label": o.label, "value": o.value}))
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ResolveConfirmationParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({"error": "missing caller user identity"});
|
||||
}
|
||||
let id = p.conversation_id.to_string();
|
||||
|
||||
// Self-confirmation guard: an agent may not resolve a decision in its own
|
||||
// conversation (that would bypass the human-in-the-loop contract).
|
||||
if !ctx.conversation_id.is_empty() && id == ctx.conversation_id {
|
||||
return json!({
|
||||
"error": "self_confirmation_forbidden: you cannot resolve a confirmation in your own conversation"
|
||||
});
|
||||
}
|
||||
|
||||
let req = ConfirmRequest {
|
||||
msg_id: String::new(),
|
||||
data: confirm_data(&p.option),
|
||||
always_allow: false,
|
||||
};
|
||||
match deps
|
||||
.conversation_service
|
||||
.confirm(&ctx.user_id, &id, &p.call_id, req, &deps.task_manager)
|
||||
.await
|
||||
{
|
||||
Ok(()) => ok(json!({"resolved": p.call_id})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the confirmation-domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<ListConfirmationsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_list_confirmations",
|
||||
"confirmation",
|
||||
"List the pending decisions (permission / choice dialogs) of a conversation, each with its options. Returns an empty list when no active agent or no pending decisions.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| list(deps, ctx, p),
|
||||
));
|
||||
out.push(Capability::new::<ResolveConfirmationParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_resolve_confirmation",
|
||||
"confirmation",
|
||||
"Submit the user's pick for a pending decision in a driven conversation. Refused for the caller's own conversation (self-confirmation-forbidden).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, ctx, p| resolve(deps, ctx, p),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn confirm_data_carries_option_under_both_keys_so_nomi_does_not_read_cancel() {
|
||||
// REGRESSION: the gateway previously sent ConfirmRequest.data as a bare
|
||||
// Value::String(option). The nomi agent's confirm reads data.get("value")
|
||||
// and defaults to "cancel" when absent → every relayed approval on a Nomi
|
||||
// worker was silently DENIED. The payload must carry the option under
|
||||
// BOTH keys (nomi reads `value`; ACP's confirm_option_id reads
|
||||
// `option_id`, falling back to `value`).
|
||||
let d = confirm_data("proceed_once");
|
||||
assert_eq!(d.get("value").and_then(|v| v.as_str()), Some("proceed_once"));
|
||||
assert_eq!(d.get("option_id").and_then(|v| v.as_str()), Some("proceed_once"));
|
||||
// The nomi consumer's exact read must NOT collapse to cancel.
|
||||
assert_ne!(
|
||||
d.get("value").and_then(|v| v.as_str()).unwrap_or("cancel"),
|
||||
"cancel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_string_payload_is_read_as_cancel_by_nomi_consumer() {
|
||||
// Characterizes WHY the old shape was broken: a bare Value::String is
|
||||
// invisible to the nomi consumer's `data.get("value")`.
|
||||
let bare = Value::String("proceed_once".into());
|
||||
assert_eq!(bare.get("value").and_then(|v| v.as_str()).unwrap_or("cancel"), "cancel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_confirmation_guard_forbids_own_conversation() {
|
||||
let ctx = CallerCtx {
|
||||
conversation_id: "42".into(),
|
||||
user_id: "u1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let id = 42i64.to_string();
|
||||
let forbidden = !ctx.conversation_id.is_empty() && id == ctx.conversation_id;
|
||||
assert!(forbidden, "resolving own conversation must be forbidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_confirmation_guard_allows_different_conversation() {
|
||||
let ctx = CallerCtx {
|
||||
conversation_id: "42".into(),
|
||||
user_id: "u1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let id = 99i64.to_string();
|
||||
let allowed = ctx.conversation_id.is_empty() || id != ctx.conversation_id;
|
||||
assert!(allowed, "resolving a different conversation must be allowed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_confirmation_guard_allows_when_caller_has_no_conversation() {
|
||||
let ctx = CallerCtx {
|
||||
conversation_id: String::new(),
|
||||
user_id: "u1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let id = 42i64.to_string();
|
||||
let allowed = ctx.conversation_id.is_empty() || id != ctx.conversation_id;
|
||||
assert!(allowed, "empty caller conversation_id must bypass the guard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_user_identity_produces_error() {
|
||||
// The handlers check ctx.user_id.is_empty() before any deps access.
|
||||
let ctx = CallerCtx::default();
|
||||
assert!(ctx.user_id.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
//! Conversation-domain capabilities (registry form): list / status / send /
|
||||
//! create / update / delete. All self-protection guards from the legacy tool
|
||||
//! are preserved (no self-injection, no self-model-change, no self-deletion),
|
||||
//! and nomi sessions still get a model at creation via the shared resolution
|
||||
//! chain so downstream consumers never see a model-less nomi conversation.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use nomifun_api_types::{
|
||||
CreateConversationRequest, ListConversationsQuery, ListMessagesQuery, SendMessageRequest,
|
||||
UpdateConversationRequest,
|
||||
};
|
||||
use nomifun_common::{AgentType, AppError};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier, ProgressSink, Surface};
|
||||
use crate::server::ok;
|
||||
use crate::tools_provider;
|
||||
|
||||
const DEFAULT_LIST_LIMIT: u32 = 50;
|
||||
const DEFAULT_MESSAGE_LIMIT: u32 = 5;
|
||||
/// Per-message content budget in status output — keeps a busy transcript from
|
||||
/// blowing up the calling agent's context.
|
||||
const MESSAGE_SNIPPET_CHARS: usize = 500;
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListConversationsParams {
|
||||
/// Maximum number of conversations to return (default 50).
|
||||
#[serde(default)]
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ConversationStatusParams {
|
||||
/// The id of the conversation to inspect.
|
||||
conversation_id: i64,
|
||||
/// How many recent messages to include (default 5, max 50).
|
||||
#[serde(default)]
|
||||
message_limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct SendToConversationParams {
|
||||
/// The id of the TARGET conversation (not your own).
|
||||
conversation_id: i64,
|
||||
/// The message or task prompt to inject.
|
||||
content: String,
|
||||
/// When true the message is hidden from the visible history (use for
|
||||
/// background task prompts, like AutoWork does).
|
||||
#[serde(default)]
|
||||
hidden: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CreateConversationParams {
|
||||
/// Optional display name for the new conversation.
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// Agent type: "nomi" (default) or "acp". NOT for terminals — any
|
||||
/// terminal/shell intent must go through nomi_create_terminal instead.
|
||||
#[serde(default)]
|
||||
agent_type: Option<String>,
|
||||
/// ACP backend vendor when agent_type is "acp" (e.g. "claude", "codex", "gemini").
|
||||
#[serde(default)]
|
||||
backend: Option<String>,
|
||||
/// Provider id for nomi sessions (from nomi_list_providers). Omit to
|
||||
/// auto-resolve: your own companion model → first configured provider.
|
||||
#[serde(default)]
|
||||
provider_id: Option<String>,
|
||||
/// Model id for nomi sessions. Omit to auto-resolve (see provider_id).
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct UpdateConversationParams {
|
||||
/// The id of the conversation to update (from nomi_list_conversations).
|
||||
conversation_id: i64,
|
||||
/// New display name (omit to keep).
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// Pin (true) or unpin (false) the conversation in the sidebar.
|
||||
#[serde(default)]
|
||||
pinned: Option<bool>,
|
||||
/// New provider id (nomi conversations only; from nomi_list_providers).
|
||||
#[serde(default)]
|
||||
provider_id: Option<String>,
|
||||
/// New model id (nomi conversations only).
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct DeleteConversationParams {
|
||||
/// The id of the conversation to delete. Confirm the target with the user
|
||||
/// before calling — deletion also kills its agent and cron bindings.
|
||||
conversation_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct AgentRunParams {
|
||||
/// The goal / task to delegate. A fresh autonomous NomiFun (nomi) agent is
|
||||
/// spun up to accomplish it end-to-end.
|
||||
goal: String,
|
||||
/// Optional absolute workspace directory for the run. Omit for an
|
||||
/// auto-provisioned temp workspace.
|
||||
#[serde(default)]
|
||||
workspace: Option<String>,
|
||||
/// Optional model id for the agent (provider auto-resolved). Omit to use the
|
||||
/// default nomi model.
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
/// Max seconds to wait for completion before returning a `{status:"running"}`
|
||||
/// handle (default 300, clamped 5..1800). Poll nomi_agent_result afterwards.
|
||||
#[serde(default)]
|
||||
timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct AgentResultParams {
|
||||
/// The conversation id returned by nomi_agent_run.
|
||||
conversation_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct WhoamiParams {}
|
||||
|
||||
fn error_value(e: AppError) -> Value {
|
||||
json!({ "error": e.to_string() })
|
||||
}
|
||||
|
||||
fn require_user(ctx: &CallerCtx) -> Result<&str, Value> {
|
||||
if ctx.user_id.is_empty() {
|
||||
Err(json!({ "error": "missing caller user identity" }))
|
||||
} else {
|
||||
Ok(&ctx.user_id)
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ListConversationsParams) -> Value {
|
||||
let user_id = match require_user(&ctx) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let query = ListConversationsQuery {
|
||||
limit: Some(p.limit.unwrap_or(DEFAULT_LIST_LIMIT)),
|
||||
..Default::default()
|
||||
};
|
||||
// Exclude the companion's own work-partner single sessions from the page + total.
|
||||
let resp = match deps.conversation_service.list(user_id, query, true).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return error_value(e),
|
||||
};
|
||||
let mut items = Vec::with_capacity(resp.items.len());
|
||||
for conv in resp.items {
|
||||
let runtime = deps.conversation_service.runtime_summary_for(&conv.id.to_string()).await;
|
||||
items.push(json!({
|
||||
"id": conv.id,
|
||||
"name": conv.name,
|
||||
"agent_type": conv.r#type,
|
||||
"status": conv.status,
|
||||
"runtime_state": runtime.state,
|
||||
"pending_confirmations": runtime.pending_confirmations,
|
||||
"source": conv.source,
|
||||
"pinned": conv.pinned,
|
||||
"is_companion_companion": conv.extra.get("companionSession").and_then(Value::as_bool).unwrap_or(false),
|
||||
"companion_id": conv.extra.get("companionId").and_then(Value::as_str),
|
||||
"is_self": conv.id.to_string() == ctx.conversation_id,
|
||||
"modified_at": conv.modified_at,
|
||||
}));
|
||||
}
|
||||
ok(json!({ "total": resp.total, "conversations": items }))
|
||||
}
|
||||
|
||||
async fn status(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ConversationStatusParams) -> Value {
|
||||
let user_id = match require_user(&ctx) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let id = p.conversation_id.to_string();
|
||||
let id = id.as_str();
|
||||
let conv = match deps.conversation_service.get(user_id, id).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return error_value(e),
|
||||
};
|
||||
let runtime = deps.conversation_service.runtime_summary_for(id).await;
|
||||
let message_limit = p.message_limit.unwrap_or(DEFAULT_MESSAGE_LIMIT).clamp(1, 50);
|
||||
let messages = match deps
|
||||
.conversation_service
|
||||
.list_messages(
|
||||
user_id,
|
||||
id,
|
||||
ListMessagesQuery {
|
||||
page: Some(1),
|
||||
page_size: Some(message_limit),
|
||||
order: Some("desc".to_owned()),
|
||||
content_mode: None,
|
||||
cursor: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e) => return error_value(e),
|
||||
};
|
||||
let messages_json = match serde_json::to_value(&messages) {
|
||||
Ok(v) => truncate_message_contents(v),
|
||||
Err(e) => return json!({ "error": format!("failed to serialize messages: {e}") }),
|
||||
};
|
||||
ok(json!({
|
||||
"id": conv.id,
|
||||
"name": conv.name,
|
||||
"agent_type": conv.r#type,
|
||||
"status": conv.status,
|
||||
"runtime": runtime,
|
||||
"recent_messages": messages_json,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn send(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: SendToConversationParams) -> Value {
|
||||
let user_id = match require_user(&ctx) {
|
||||
Ok(u) => u.to_owned(),
|
||||
Err(e) => return e,
|
||||
};
|
||||
let id = p.conversation_id.to_string();
|
||||
if !ctx.conversation_id.is_empty() && id == ctx.conversation_id {
|
||||
return json!({ "error": "self_injection_forbidden: you cannot send a message into your own conversation" });
|
||||
}
|
||||
let req = SendMessageRequest {
|
||||
content: p.content,
|
||||
files: vec![],
|
||||
inject_skills: vec![],
|
||||
hidden: p.hidden.unwrap_or(false),
|
||||
origin: Some("companion".into()),
|
||||
channel_platform: None,
|
||||
};
|
||||
match deps.conversation_service.send_message(&user_id, &id, req, &deps.task_manager).await {
|
||||
Ok(msg_id) => ok(json!({
|
||||
"msg_id": msg_id,
|
||||
"note": "message accepted; the target session processes it asynchronously — use nomi_conversation_status to follow progress"
|
||||
})),
|
||||
Err(AppError::Conflict(m)) => json!({
|
||||
"error": format!("busy: the target conversation is already running a turn ({m}); check nomi_conversation_status and retry later")
|
||||
}),
|
||||
Err(e) => error_value(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: CreateConversationParams) -> Value {
|
||||
let user_id = match require_user(&ctx) {
|
||||
Ok(u) => u.to_owned(),
|
||||
Err(e) => return e,
|
||||
};
|
||||
let agent_type_str = p.agent_type.unwrap_or_else(|| "nomi".to_owned());
|
||||
if agent_type_str == "terminal" {
|
||||
return json!({
|
||||
"error": "terminal sessions are not conversations: use nomi_create_terminal (preset shell | claude | codex | gemini) for any terminal/shell intent"
|
||||
});
|
||||
}
|
||||
let agent_type: AgentType = match serde_json::from_value(json!(agent_type_str)) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return json!({ "error": format!("invalid agent_type '{agent_type_str}'") }),
|
||||
};
|
||||
let mut extra = json!({});
|
||||
if let Some(backend) = p.backend {
|
||||
extra["backend"] = json!(backend);
|
||||
}
|
||||
let mut model = None;
|
||||
let mut model_source = None;
|
||||
if agent_type == AgentType::Nomi {
|
||||
match tools_provider::resolve_nomi_model(&deps, &ctx, p.provider_id.as_deref(), p.model.as_deref()).await {
|
||||
Ok((m, source)) => {
|
||||
model = Some(m);
|
||||
model_source = Some(source);
|
||||
}
|
||||
Err(e) => return e,
|
||||
}
|
||||
}
|
||||
let req = CreateConversationRequest {
|
||||
r#type: agent_type,
|
||||
name: p.name,
|
||||
model,
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
extra,
|
||||
};
|
||||
match deps.conversation_service.create(&user_id, req).await {
|
||||
Ok(resp) => ok(json!({
|
||||
"id": resp.id,
|
||||
"name": resp.name,
|
||||
"agent_type": resp.r#type,
|
||||
"model": resp.model,
|
||||
"model_source": model_source,
|
||||
})),
|
||||
Err(e) => error_value(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Await an agent turn to completion (or until `timeout`), polling every `poll`.
|
||||
/// The turn is claimed synchronously inside `send_message` before it returns, so
|
||||
/// `is_processing` is reliably true on the first poll. Returns true if the turn
|
||||
/// finished, false on timeout (the run keeps going; poll nomi_agent_result). An
|
||||
/// already-finished turn returns immediately (the first check happens before any
|
||||
/// sleep). Used both to await an unsubscribable turn (coarse poll) and to let a
|
||||
/// just-finished turn settle before reading its final message (fine poll).
|
||||
async fn await_turn(deps: &GatewayDeps, conv_id: &str, timeout: Duration, poll: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let summary = deps.conversation_service.runtime_summary_for(conv_id).await;
|
||||
if !summary.is_processing {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(poll).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a serialized message list (desc-ordered) and return the newest assistant
|
||||
/// reply text — the first object with `position == "left"` and `type == "text"`,
|
||||
/// whose `content` is shaped `{"content": "<text>"}`.
|
||||
fn latest_assistant_text(v: &Value) -> Option<String> {
|
||||
match v {
|
||||
Value::Array(arr) => arr.iter().find_map(latest_assistant_text),
|
||||
Value::Object(map) => {
|
||||
let is_assistant_text = map.get("position").and_then(Value::as_str) == Some("left")
|
||||
&& map.get("type").and_then(Value::as_str) == Some("text");
|
||||
if is_assistant_text
|
||||
&& let Some(text) = map.get("content").and_then(|c| c.get("content")).and_then(Value::as_str)
|
||||
{
|
||||
return Some(text.to_owned());
|
||||
}
|
||||
map.values().find_map(latest_assistant_text)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the final assistant text of a (finished) conversation, if any.
|
||||
async fn read_final_text(deps: &GatewayDeps, user_id: &str, conv_id: &str) -> Option<String> {
|
||||
let messages = deps
|
||||
.conversation_service
|
||||
.list_messages(
|
||||
user_id,
|
||||
conv_id,
|
||||
ListMessagesQuery {
|
||||
page: Some(1),
|
||||
page_size: Some(10),
|
||||
order: Some("desc".to_owned()),
|
||||
content_mode: None,
|
||||
cursor: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let v = serde_json::to_value(&messages).ok()?;
|
||||
latest_assistant_text(&v)
|
||||
}
|
||||
|
||||
/// Subscribe to a turn's event stream. The agent instance is built inside
|
||||
/// `send_message`'s spawned task, so poll briefly for it. `None` if it never
|
||||
/// appears (caller falls back to polling completion).
|
||||
async fn subscribe_turn(
|
||||
deps: &GatewayDeps,
|
||||
conv_id: &str,
|
||||
wait: Duration,
|
||||
) -> Option<tokio::sync::broadcast::Receiver<nomifun_ai_agent::AgentStreamEvent>> {
|
||||
let deadline = Instant::now() + wait;
|
||||
loop {
|
||||
if let Some(agent) = deps.task_manager.get_task(conv_id) {
|
||||
return Some(agent.subscribe());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward the turn's events to `progress` until a terminal event
|
||||
/// (finish/turn_completed/error) or `deadline`. Events are forwarded as their
|
||||
/// serialized JSON (tagged `{"type": .., "data": ..}`); terminal detection uses
|
||||
/// the `type` tag so it never couples to the event struct internals. Returns
|
||||
/// true if the turn finished, false on timeout.
|
||||
async fn drain_stream(
|
||||
rx: &mut tokio::sync::broadcast::Receiver<nomifun_ai_agent::AgentStreamEvent>,
|
||||
progress: &ProgressSink,
|
||||
deadline: Instant,
|
||||
) -> bool {
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return false;
|
||||
}
|
||||
match tokio::time::timeout(remaining, rx.recv()).await {
|
||||
Ok(Ok(ev)) => {
|
||||
let v = serde_json::to_value(&ev).unwrap_or_else(|_| json!({}));
|
||||
let terminal = v
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|t| matches!(t, "finish" | "turn_completed" | "error"));
|
||||
let _ = progress.send(v).await;
|
||||
if terminal {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Ok(Err(RecvError::Lagged(_))) => continue,
|
||||
// Channel closed = the agent instance was dropped at turn end.
|
||||
Ok(Err(RecvError::Closed)) => return true,
|
||||
Err(_timeout) => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegate a goal to a fresh autonomous nomi agent. Streams the agent's
|
||||
/// events (text / tool-call deltas) through `progress` as they arrive (the
|
||||
/// streaming `/tool/stream` path); the buffered path returns only the final
|
||||
/// result. Either way: on completion returns the final assistant text; on
|
||||
/// timeout returns a `{status:"running"}` handle (poll `nomi_agent_result`).
|
||||
async fn agent_run(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: AgentRunParams, progress: ProgressSink) -> Value {
|
||||
let user_id = match require_user(&ctx) {
|
||||
Ok(u) => u.to_owned(),
|
||||
Err(e) => return e,
|
||||
};
|
||||
if p.goal.trim().is_empty() {
|
||||
return json!({ "error": "goal must not be empty" });
|
||||
}
|
||||
// A nomi conversation must get a model at creation.
|
||||
let model = match tools_provider::resolve_nomi_model(&deps, &ctx, None, p.model.as_deref()).await {
|
||||
Ok((m, _source)) => Some(m),
|
||||
Err(e) => return e,
|
||||
};
|
||||
// yolo: unattended Remote runs have no approval UI — without yolo a tool call
|
||||
// would park forever. desktopGateway: entitle the delegated agent to the full
|
||||
// platform tool set (the "外部伙伴" experience). We call create() directly
|
||||
// (not via the HTTP route), so these extra keys are honored, not stripped.
|
||||
let mut extra = json!({ "session_mode": "yolo", "desktopGateway": true });
|
||||
if let Some(ws) = p.workspace.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
extra["workspace"] = json!(ws);
|
||||
}
|
||||
let create_req = CreateConversationRequest {
|
||||
r#type: AgentType::Nomi,
|
||||
name: Some("Remote agent run".to_owned()),
|
||||
model,
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
extra,
|
||||
};
|
||||
let conv = match deps.conversation_service.create(&user_id, create_req).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return error_value(e),
|
||||
};
|
||||
let id = conv.id.to_string();
|
||||
let send_req = SendMessageRequest {
|
||||
content: p.goal,
|
||||
files: vec![],
|
||||
inject_skills: vec![],
|
||||
hidden: false,
|
||||
origin: Some("remote".into()),
|
||||
channel_platform: None,
|
||||
};
|
||||
if let Err(e) = deps
|
||||
.conversation_service
|
||||
.send_message(&user_id, &id, send_req, &deps.task_manager)
|
||||
.await
|
||||
{
|
||||
return json!({ "error": format!("failed to start agent run: {e}"), "conversation_id": conv.id });
|
||||
}
|
||||
let timeout = Duration::from_secs(p.timeout_secs.unwrap_or(300).clamp(5, 1800));
|
||||
let deadline = Instant::now() + timeout;
|
||||
// Prefer streaming the live event broadcast; fall back to polling completion
|
||||
// if the instance can't be subscribed in time. Final text is read from the
|
||||
// DB either way, so missed early deltas never corrupt the result.
|
||||
let finished = match subscribe_turn(&deps, &id, Duration::from_secs(5)).await {
|
||||
Some(mut rx) => drain_stream(&mut rx, &progress, deadline).await,
|
||||
None => await_turn(&deps, &id, timeout, Duration::from_millis(500)).await,
|
||||
};
|
||||
if finished {
|
||||
// The terminal broadcast event ("finish"/Closed) can fire a few ms before
|
||||
// the final assistant `text` message is committed: a reasoning model
|
||||
// persists its visible answer LAST (after the `thinking` message), right
|
||||
// at turn end, so reading immediately can miss it and return null.
|
||||
// `nomi_agent_result` never hits this because it gates on the runtime turn
|
||||
// having fully released (`is_processing == false`), by which point the
|
||||
// message is listable. Mirror that here — settle (bounded, fine-grained)
|
||||
// before reading. An already-settled turn returns at once (no added latency).
|
||||
let _ = await_turn(&deps, &id, Duration::from_secs(5), Duration::from_millis(25)).await;
|
||||
let text = read_final_text(&deps, &user_id, &id).await;
|
||||
ok(json!({ "conversation_id": conv.id, "status": "completed", "text": text }))
|
||||
} else {
|
||||
ok(json!({
|
||||
"conversation_id": conv.id,
|
||||
"status": "running",
|
||||
"note": "agent run still in progress after timeout; poll nomi_agent_result with this conversation_id"
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the result (or running status) of a delegated agent run.
|
||||
async fn agent_result(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: AgentResultParams) -> Value {
|
||||
let user_id = match require_user(&ctx) {
|
||||
Ok(u) => u.to_owned(),
|
||||
Err(e) => return e,
|
||||
};
|
||||
let id = p.conversation_id.to_string();
|
||||
let summary = deps.conversation_service.runtime_summary_for(&id).await;
|
||||
if summary.is_processing {
|
||||
return ok(json!({ "conversation_id": p.conversation_id, "status": "running" }));
|
||||
}
|
||||
let text = read_final_text(&deps, &user_id, &id).await;
|
||||
ok(json!({ "conversation_id": p.conversation_id, "status": "completed", "text": text }))
|
||||
}
|
||||
|
||||
/// Reflect the calling session's identity: which companion it is bound to, the
|
||||
/// surface it arrived on, and the user scope. For an external partner this
|
||||
/// answers "who am I acting as?"; for tests it proves companion binding reached
|
||||
/// dispatch.
|
||||
async fn whoami(_deps: Arc<GatewayDeps>, ctx: CallerCtx, _p: WhoamiParams) -> Value {
|
||||
ok(json!({
|
||||
"user_id": ctx.user_id,
|
||||
"companion_id": ctx.companion_id,
|
||||
"surface": format!("{:?}", ctx.surface()),
|
||||
"remote": ctx.remote,
|
||||
"channel_platform": ctx.channel_platform,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: UpdateConversationParams) -> Value {
|
||||
let user_id = match require_user(&ctx) {
|
||||
Ok(u) => u.to_owned(),
|
||||
Err(e) => return e,
|
||||
};
|
||||
let id = p.conversation_id.to_string();
|
||||
if p.name.is_none() && p.pinned.is_none() && p.provider_id.is_none() && p.model.is_none() {
|
||||
return json!({ "error": "nothing to update: provide at least one of name / pinned / provider_id+model" });
|
||||
}
|
||||
let mut model = None;
|
||||
if p.provider_id.is_some() || p.model.is_some() {
|
||||
if !ctx.conversation_id.is_empty() && id == ctx.conversation_id {
|
||||
return json!({
|
||||
"error": "self_model_change_forbidden: changing your own conversation's model would terminate your current turn; the owner can change it from the desktop UI"
|
||||
});
|
||||
}
|
||||
match tools_provider::resolve_explicit_model(&deps, p.provider_id.as_deref(), p.model.as_deref()).await {
|
||||
Ok(m) => model = Some(m),
|
||||
Err(e) => return e,
|
||||
}
|
||||
}
|
||||
let model_changed = model.is_some();
|
||||
let req = UpdateConversationRequest {
|
||||
name: p.name,
|
||||
pinned: p.pinned,
|
||||
model,
|
||||
extra: None,
|
||||
};
|
||||
match deps.conversation_service.update(&user_id, &id, req, &deps.task_manager).await {
|
||||
Ok(resp) => ok(json!({
|
||||
"id": resp.id,
|
||||
"name": resp.name,
|
||||
"pinned": resp.pinned,
|
||||
"model": resp.model,
|
||||
"note": model_changed.then_some(
|
||||
"model changed: any running task in that conversation was terminated; it restarts with the new model on the next message"
|
||||
),
|
||||
})),
|
||||
Err(e) => error_value(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: DeleteConversationParams) -> Value {
|
||||
let user_id = match require_user(&ctx) {
|
||||
Ok(u) => u.to_owned(),
|
||||
Err(e) => return e,
|
||||
};
|
||||
let id = p.conversation_id.to_string();
|
||||
if !ctx.conversation_id.is_empty() && id == ctx.conversation_id {
|
||||
return json!({ "error": "self_deletion_forbidden: you cannot delete your own conversation" });
|
||||
}
|
||||
match deps.conversation_service.delete(&user_id, &id).await {
|
||||
Ok(()) => ok(json!({ "deleted": id })),
|
||||
Err(e) => error_value(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap every `content` string inside the serialized message list so a long
|
||||
/// transcript cannot flood the calling agent.
|
||||
fn truncate_message_contents(mut value: Value) -> Value {
|
||||
fn walk(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
for (k, item) in map.iter_mut() {
|
||||
if k == "content" {
|
||||
if let Value::String(s) = item
|
||||
&& s.chars().count() > MESSAGE_SNIPPET_CHARS
|
||||
{
|
||||
let truncated: String = s.chars().take(MESSAGE_SNIPPET_CHARS).collect();
|
||||
*item = Value::String(format!("{truncated}…[truncated]"));
|
||||
} else {
|
||||
walk(item);
|
||||
}
|
||||
} else {
|
||||
walk(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for item in arr.iter_mut() {
|
||||
walk(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
walk(&mut value);
|
||||
value
|
||||
}
|
||||
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<ListConversationsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_list_conversations",
|
||||
"conversation",
|
||||
"List the desktop's conversations with their live runtime state.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
list,
|
||||
));
|
||||
out.push(Capability::new::<ConversationStatusParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_conversation_status",
|
||||
"conversation",
|
||||
"Runtime summary + the tail of a conversation's transcript (live progress snapshot).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
status,
|
||||
));
|
||||
out.push(Capability::new::<SendToConversationParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_send_to_conversation",
|
||||
"conversation",
|
||||
"Inject a message (or a hidden task prompt) into another session.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
send,
|
||||
));
|
||||
out.push(Capability::new::<CreateConversationParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_create_conversation",
|
||||
"conversation",
|
||||
"Open a fresh desktop session (nomi or acp). nomi sessions get a model at creation.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
create,
|
||||
));
|
||||
out.push(Capability::new::<UpdateConversationParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_update_conversation",
|
||||
"conversation",
|
||||
"Rename / pin / change model of a conversation (not your own model).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
update,
|
||||
));
|
||||
out.push(Capability::new::<DeleteConversationParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_delete_conversation",
|
||||
"conversation",
|
||||
"Delete a conversation (cascades: agent kill, cron unbind, knowledge unmount). Confirm first.",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
delete,
|
||||
));
|
||||
out.push(Capability::new_streaming::<AgentRunParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_agent_run",
|
||||
"agent",
|
||||
"Delegate a goal to a fresh autonomous NomiFun agent: spins up a nomi session (yolo, full platform tools), runs it to completion, and returns the final answer. Streams the agent's progress over /tool/stream (or the SSE REST endpoint); buffered callers get the final result. Long runs return a {status:\"running\"} handle — poll nomi_agent_result.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
agent_run,
|
||||
));
|
||||
out.push(Capability::new::<AgentResultParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_agent_result",
|
||||
"agent",
|
||||
"Fetch the result (or running status) of a goal delegated via nomi_agent_run, by conversation id.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
agent_result,
|
||||
));
|
||||
out.push(Capability::new::<WhoamiParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_whoami",
|
||||
"conversation",
|
||||
"Identity of the calling session: the bound companion id, surface (Desktop/Channel/Remote), and user. Lets an external partner confirm which companion it is acting as.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
whoami,
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncate_caps_long_content_strings() {
|
||||
let long = "x".repeat(2000);
|
||||
let v = json!({"items": [{"content": long, "other": "keep"}]});
|
||||
let out = truncate_message_contents(v);
|
||||
let content = out["items"][0]["content"].as_str().unwrap();
|
||||
assert!(content.chars().count() < 600);
|
||||
assert!(content.ends_with("…[truncated]"));
|
||||
assert_eq!(out["items"][0]["other"], "keep");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_keeps_short_content_untouched() {
|
||||
let v = json!({"content": "short"});
|
||||
let out = truncate_message_contents(v);
|
||||
assert_eq!(out["content"], "short");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Cron-domain capabilities (registry form). Create/update reuse the
|
||||
//! `ICronService` implementation behind the `[CRON_*]` text protocol, so a
|
||||
//! gateway session gets the same context derivation (agent type / model from
|
||||
//! the bound conversation) and bind-back behavior as the in-chat protocol.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{ListCronJobsQuery, UpdateConversationRequest};
|
||||
use nomifun_common::AgentType;
|
||||
use nomifun_conversation::response_middleware::{
|
||||
CronCreateParams as SvcCronCreate, CronUpdateParams as SvcCronUpdate, ICronService,
|
||||
};
|
||||
use nomifun_cron::types::cron_job_to_response;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier};
|
||||
use crate::server::ok;
|
||||
use crate::tools_provider;
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CronListParams {
|
||||
/// Restrict to jobs bound to one conversation (default: all jobs).
|
||||
#[serde(default)]
|
||||
conversation_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CronCreateParams {
|
||||
/// Short human-readable job name.
|
||||
name: String,
|
||||
/// Standard 5-field cron expression, e.g. "0 9 * * *" for daily 09:00.
|
||||
cron: String,
|
||||
/// Human-readable description of the schedule (e.g. "every day at 9am").
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
/// The prompt message sent to the agent on every trigger.
|
||||
message: String,
|
||||
/// Conversation to run the job in (default: the calling conversation).
|
||||
#[serde(default)]
|
||||
conversation_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CronUpdateParams {
|
||||
/// The id of the cron job to update (from nomi_cron_list).
|
||||
job_id: String,
|
||||
/// New job name (full replacement; pass the existing value to keep it).
|
||||
name: String,
|
||||
/// New cron expression (full replacement).
|
||||
cron: String,
|
||||
/// New human-readable schedule description.
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
/// New trigger message (full replacement).
|
||||
message: String,
|
||||
/// Conversation the job is bound to (default: the calling conversation).
|
||||
#[serde(default)]
|
||||
conversation_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CronDeleteParams {
|
||||
/// The id of the cron job to delete. Confirm the target with the user first.
|
||||
job_id: String,
|
||||
}
|
||||
|
||||
/// Duplicate-create guard: an ACTIVE job in the same conversation with the same
|
||||
/// (trimmed) name or the exact same (trimmed) message counts as a duplicate.
|
||||
fn is_duplicate_job(existing_name: &str, existing_message: &str, new_name: &str, new_message: &str) -> bool {
|
||||
existing_name.trim().eq_ignore_ascii_case(new_name.trim()) || existing_message.trim() == new_message.trim()
|
||||
}
|
||||
|
||||
async fn list(deps: Arc<GatewayDeps>, p: CronListParams) -> Value {
|
||||
let query = ListCronJobsQuery {
|
||||
conversation_id: p.conversation_id,
|
||||
};
|
||||
match deps.cron_service.list_jobs(&query).await {
|
||||
Ok(jobs) => ok(jobs.iter().map(cron_job_to_response).collect::<Vec<_>>()),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: CronCreateParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({ "error": "missing caller user identity" });
|
||||
}
|
||||
let target_conv_id = match p.conversation_id.or_else(|| ctx.conversation_id.parse::<i64>().ok()) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return json!({ "error": "missing required field: conversation_id (no calling conversation to bind to)" });
|
||||
}
|
||||
};
|
||||
let target_conversation = target_conv_id.to_string();
|
||||
|
||||
// ── duplicate guard ──────────────────────────────────────────────
|
||||
match deps
|
||||
.cron_service
|
||||
.list_jobs(&ListCronJobsQuery {
|
||||
conversation_id: Some(target_conv_id),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(jobs) => {
|
||||
if let Some(existing) = jobs
|
||||
.iter()
|
||||
.find(|j| j.enabled && is_duplicate_job(&j.name, &j.message, &p.name, &p.message))
|
||||
{
|
||||
return ok(json!({
|
||||
"duplicate": true,
|
||||
"existing_job": cron_job_to_response(existing),
|
||||
"note": "an ACTIVE cron job with the same name or message already exists in this conversation — nothing was created. Use nomi_cron_update to modify it; only create a second job if the owner explicitly asked for a duplicate this turn."
|
||||
}));
|
||||
}
|
||||
}
|
||||
Err(e) => return json!({ "error": e.to_string() }),
|
||||
}
|
||||
|
||||
// ── model guard (nomi conversations only) ────────────────────────
|
||||
let mut model_note: Option<String> = None;
|
||||
match deps.conversation_service.get(&ctx.user_id, &target_conversation).await {
|
||||
Ok(conv) => {
|
||||
let model_missing = conv.model.as_ref().is_none_or(|m| m.provider_id.trim().is_empty());
|
||||
if conv.r#type == AgentType::Nomi && model_missing {
|
||||
match tools_provider::resolve_nomi_model(&deps, &ctx, None, None).await {
|
||||
Ok((m, source)) => {
|
||||
let req = UpdateConversationRequest {
|
||||
name: None,
|
||||
pinned: None,
|
||||
model: Some(m.clone()),
|
||||
extra: None,
|
||||
};
|
||||
if let Err(e) = deps
|
||||
.conversation_service
|
||||
.update(&ctx.user_id, &target_conversation, req, &deps.task_manager)
|
||||
.await
|
||||
{
|
||||
return json!({ "error": format!("failed to persist auto-selected model onto the bound conversation: {e}") });
|
||||
}
|
||||
model_note = Some(format!(
|
||||
"the bound conversation had no model configured; auto-selected {}/{} (source: {source}) and saved it onto the conversation — mention this to the owner",
|
||||
m.provider_id, m.model
|
||||
));
|
||||
}
|
||||
Err(e) => return e,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return json!({
|
||||
"error": format!("cannot create the cron job: the bound conversation '{target_conversation}' is not accessible ({e}); a job bound to a missing conversation would never run")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let params = SvcCronCreate {
|
||||
name: p.name,
|
||||
schedule: p.cron,
|
||||
schedule_description: p.description.unwrap_or_default(),
|
||||
message: p.message,
|
||||
};
|
||||
let result = ICronService::create_job(deps.cron_service.as_ref(), &ctx.user_id, &target_conversation, ¶ms).await;
|
||||
if result.success {
|
||||
ok(json!({ "message": result.message, "model_note": model_note }))
|
||||
} else {
|
||||
json!({ "error": result.message })
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: CronUpdateParams) -> Value {
|
||||
let target_conversation = p
|
||||
.conversation_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| ctx.conversation_id.clone());
|
||||
let params = SvcCronUpdate {
|
||||
job_id: p.job_id,
|
||||
name: p.name,
|
||||
schedule: p.cron,
|
||||
schedule_description: p.description.unwrap_or_default(),
|
||||
message: p.message,
|
||||
};
|
||||
command_result(ICronService::update_job(deps.cron_service.as_ref(), &ctx.user_id, &target_conversation, ¶ms).await)
|
||||
}
|
||||
|
||||
async fn delete(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: CronDeleteParams) -> Value {
|
||||
command_result(ICronService::delete_job(deps.cron_service.as_ref(), &ctx.user_id, &p.job_id).await)
|
||||
}
|
||||
|
||||
fn command_result(result: nomifun_conversation::response_middleware::CronCommandResult) -> Value {
|
||||
if result.success {
|
||||
json!({ "result": result.message })
|
||||
} else {
|
||||
json!({ "error": result.message })
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<CronListParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_cron_list",
|
||||
"cron",
|
||||
"List scheduled cron jobs (all jobs by default; pass conversation_id to filter to one session).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<CronCreateParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_cron_create",
|
||||
"cron",
|
||||
"Schedule a recurring prompt (cron). Binds to conversation_id or the calling conversation; guards against duplicates and model-less nomi sessions.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
create,
|
||||
));
|
||||
out.push(Capability::new::<CronUpdateParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_cron_update",
|
||||
"cron",
|
||||
"Update a cron job (full replacement of name/cron/message).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
update,
|
||||
));
|
||||
out.push(Capability::new::<CronDeleteParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_cron_delete",
|
||||
"cron",
|
||||
"Delete a cron job. Confirm the target with the user first.",
|
||||
DangerTier::Destructive,
|
||||
),
|
||||
delete,
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duplicate_when_name_matches_ignoring_case_and_whitespace() {
|
||||
assert!(is_duplicate_job("Daily Report", "msg a", " daily report ", "msg b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_when_message_matches_exactly_after_trim() {
|
||||
assert!(is_duplicate_job("job a", " summarize inbox ", "job b", "summarize inbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_duplicate_when_both_differ() {
|
||||
assert!(!is_duplicate_job("job a", "message a", "job b", "message b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_comparison_is_case_sensitive() {
|
||||
assert!(!is_duplicate_job("job a", "Do The Thing", "job b", "do the thing"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Filesystem + shell-open domain capabilities (registry form): file read/write,
|
||||
//! directory browsing, metadata retrieval, entry removal/rename, and OS-level
|
||||
//! open (URLs, files, apps).
|
||||
//!
|
||||
//! The file service operates within a sandbox defined by `allowed_roots` — all
|
||||
//! paths are validated against these roots before any I/O. The gateway receives
|
||||
//! a `FileServiceRef` (= `Arc<dyn IFileService>`) which already performs this
|
||||
//! validation internally.
|
||||
//!
|
||||
//! The shell service wraps OS-native open commands with URL-scheme and path
|
||||
//! validation (only http/https/mailto, existing paths).
|
||||
//!
|
||||
//! PATH SCOPING: The `IFileService` methods that take `extra_root: Option<&Path>`
|
||||
//! allow callers to widen the sandbox per-request — we pass `None` here (strict
|
||||
//! mode: only `allowed_roots` configured at construction time apply). Write
|
||||
//! operations require a `workspace` parameter for event scoping.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
|
||||
use crate::server::ok;
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cap read_file output to avoid blowing up the LLM context.
|
||||
const READ_FILE_MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
// ── Param structs (single source: schema + runtime) ─────────────────────────
|
||||
|
||||
/// Read a file as UTF-8 text.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ReadFileParams {
|
||||
/// Absolute path to the file to read.
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Write (create or overwrite) a file with the given content.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct WriteFileParams {
|
||||
/// Absolute path to the file to write.
|
||||
path: String,
|
||||
/// UTF-8 text content to write.
|
||||
content: String,
|
||||
/// Workspace root directory (used for event scoping). Must be an
|
||||
/// allowed root or ancestor of `path`.
|
||||
workspace: String,
|
||||
}
|
||||
|
||||
/// List immediate children of a directory.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct BrowseParams {
|
||||
/// Absolute path to the directory to list.
|
||||
dir: String,
|
||||
/// Workspace root used to compute relative paths in the response.
|
||||
root: String,
|
||||
}
|
||||
|
||||
/// Recursively list all files under a workspace root as a flat list.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListWorkspaceFilesParams {
|
||||
/// Absolute path to the workspace root.
|
||||
root: String,
|
||||
}
|
||||
|
||||
/// Get metadata (name, size, mime, last_modified, is_directory) for a path.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct GetMetadataParams {
|
||||
/// Absolute path to the file or directory.
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Delete a file or directory (recursively).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RemoveParams {
|
||||
/// Absolute path to the file or directory to remove.
|
||||
path: String,
|
||||
/// Workspace root (used for event scoping).
|
||||
workspace: String,
|
||||
}
|
||||
|
||||
/// Rename a file or directory (same parent, new name).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RenameParams {
|
||||
/// Absolute path to the file or directory to rename.
|
||||
path: String,
|
||||
/// New name (just the filename, not a full path).
|
||||
new_name: String,
|
||||
}
|
||||
|
||||
/// Open a URL in the default browser (http/https/mailto only).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ShellOpenExternalParams {
|
||||
/// URL to open (must be http://, https://, or mailto:).
|
||||
url: String,
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn read_file(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: ReadFileParams) -> Value {
|
||||
match deps.file_service.read_file(&p.path, None).await {
|
||||
Ok(Some(content)) => {
|
||||
if content.len() > READ_FILE_MAX_BYTES {
|
||||
let truncated = &content[..content.floor_char_boundary(READ_FILE_MAX_BYTES)];
|
||||
ok(json!({
|
||||
"content": truncated,
|
||||
"truncated": true,
|
||||
"total_bytes": content.len(),
|
||||
"note": format!("output capped at ~{}KB; file is {} bytes total", READ_FILE_MAX_BYTES / 1024, content.len()),
|
||||
}))
|
||||
} else {
|
||||
ok(json!({ "content": content, "truncated": false }))
|
||||
}
|
||||
}
|
||||
Ok(None) => json!({ "error": format!("file not found: {}", p.path) }),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_file(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: WriteFileParams) -> Value {
|
||||
match deps
|
||||
.file_service
|
||||
.write_file(&p.path, p.content.as_bytes(), &p.workspace)
|
||||
.await
|
||||
{
|
||||
Ok(_) => ok(json!({ "written": true, "path": p.path })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn browse(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: BrowseParams) -> Value {
|
||||
match deps.file_service.get_files_by_dir(&p.dir, &p.root).await {
|
||||
Ok(entries) => {
|
||||
let items: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
json!({
|
||||
"name": e.name,
|
||||
"full_path": e.full_path,
|
||||
"relative_path": e.relative_path,
|
||||
"is_dir": e.is_dir,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ok(json!({ "entries": items, "count": items.len() }))
|
||||
}
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_workspace_files(
|
||||
deps: Arc<GatewayDeps>,
|
||||
_ctx: CallerCtx,
|
||||
p: ListWorkspaceFilesParams,
|
||||
) -> Value {
|
||||
match deps.file_service.list_workspace_files(&p.root).await {
|
||||
Ok(files) => {
|
||||
let items: Vec<Value> = files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
json!({
|
||||
"name": f.name,
|
||||
"full_path": f.full_path,
|
||||
"relative_path": f.relative_path,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ok(json!({ "files": items, "count": items.len() }))
|
||||
}
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_metadata(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: GetMetadataParams) -> Value {
|
||||
match deps.file_service.get_file_metadata(&p.path, None).await {
|
||||
Ok(meta) => ok(json!({
|
||||
"name": meta.name,
|
||||
"path": meta.path,
|
||||
"size": meta.size,
|
||||
"mime_type": meta.mime_type,
|
||||
"last_modified": meta.last_modified,
|
||||
"is_directory": meta.is_directory,
|
||||
})),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: RemoveParams) -> Value {
|
||||
match deps.file_service.remove_entry(&p.path, &p.workspace).await {
|
||||
Ok(()) => ok(json!({ "removed": true, "path": p.path })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: RenameParams) -> Value {
|
||||
match deps.file_service.rename_entry(&p.path, &p.new_name).await {
|
||||
Ok(new_path) => ok(json!({ "renamed": true, "new_path": new_path })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn shell_open_external(
|
||||
deps: Arc<GatewayDeps>,
|
||||
_ctx: CallerCtx,
|
||||
p: ShellOpenExternalParams,
|
||||
) -> Value {
|
||||
match deps.shell_service.open_external(&p.url).await {
|
||||
Ok(()) => ok(json!({ "opened": true, "url": p.url })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registration ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register the filesystem + shell-open domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
// 1. Read file (Read)
|
||||
out.push(Capability::new::<ReadFileParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_fs_read_file",
|
||||
"files",
|
||||
"Read a file as UTF-8 text (output capped at ~64KB). Returns the content or an error if the path is outside the sandbox or does not exist.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| read_file(deps, ctx, p),
|
||||
));
|
||||
|
||||
// 2. Write file (Write, deny_on Channel)
|
||||
out.push(Capability::new::<WriteFileParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_fs_write_file",
|
||||
"files",
|
||||
"Write (create or overwrite) a file with the given UTF-8 content. The path must be within an allowed workspace root.",
|
||||
DangerTier::Write,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, ctx, p| write_file(deps, ctx, p),
|
||||
));
|
||||
|
||||
// 3. Browse directory (Read)
|
||||
out.push(Capability::new::<BrowseParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_fs_browse",
|
||||
"files",
|
||||
"List immediate children of a directory (one level). Returns name, full_path, relative_path, and is_dir for each entry.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| browse(deps, ctx, p),
|
||||
));
|
||||
|
||||
// 4. List workspace files (Read)
|
||||
out.push(Capability::new::<ListWorkspaceFilesParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_fs_list_workspace_files",
|
||||
"files",
|
||||
"Recursively list all files under a workspace root as a flat list (up to 20,000 entries). Useful for discovering project structure.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| list_workspace_files(deps, ctx, p),
|
||||
));
|
||||
|
||||
// 5. Get metadata (Read)
|
||||
out.push(Capability::new::<GetMetadataParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_fs_get_metadata",
|
||||
"files",
|
||||
"Get metadata for a file or directory: name, path, size (bytes), MIME type, last_modified (unix timestamp), and whether it is a directory.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| get_metadata(deps, ctx, p),
|
||||
));
|
||||
|
||||
// 6. Remove entry (Destructive, deny_on Channel)
|
||||
out.push(Capability::new::<RemoveParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_fs_remove",
|
||||
"files",
|
||||
"Delete a file or directory (recursively). Irreversible — the snapshot system can restore if initialized, but the raw FS deletion cannot be undone.",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, ctx, p| remove(deps, ctx, p),
|
||||
));
|
||||
|
||||
// 7. Rename entry (Write, deny_on Channel)
|
||||
out.push(Capability::new::<RenameParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_fs_rename",
|
||||
"files",
|
||||
"Rename a file or directory (same parent, new name). Returns the new absolute path on success.",
|
||||
DangerTier::Write,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, ctx, p| rename(deps, ctx, p),
|
||||
));
|
||||
|
||||
// 8. Shell open external (Write, deny_on Channel)
|
||||
out.push(Capability::new::<ShellOpenExternalParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_shell_open_external",
|
||||
"files",
|
||||
"Open a URL in the default browser or mail client. Only http://, https://, and mailto: schemes are allowed.",
|
||||
DangerTier::Write,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, ctx, p| shell_open_external(deps, ctx, p),
|
||||
));
|
||||
}
|
||||
|
||||
// ── SKIPPED tools ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The following candidate tools were evaluated but NOT registered:
|
||||
//
|
||||
// - `nomi_fs_copy_files_to_workspace` — `copy_files_to_workspace` requires a
|
||||
// `source_root` and multiple file paths; more of a bulk UI operation than an
|
||||
// agent tool. Can be added later if needed.
|
||||
//
|
||||
// - `nomi_fs_create_temp_file` / `nomi_fs_create_upload_file` — temp/upload
|
||||
// helpers used by the UI upload flow and conversation attachments. The agent
|
||||
// can use `nomi_fs_write_file` for workspace files directly.
|
||||
//
|
||||
// - `nomi_fs_get_image_base64` / `nomi_fs_fetch_remote_image` — image
|
||||
// processing helpers. Could be added as a separate "media" domain if agents
|
||||
// need inline image data.
|
||||
//
|
||||
// - `nomi_fs_create_zip` / `nomi_fs_cancel_zip` — ZIP packaging operations.
|
||||
// Not a typical agent primitive; can be added if agent workflows need
|
||||
// archiving.
|
||||
//
|
||||
// - `nomi_shell_open_file` — `ShellService::open_file` opens a local file with
|
||||
// its default application. Could be added but overlaps with `open_external`
|
||||
// for most use cases and has less clear agent utility.
|
||||
//
|
||||
// - `nomi_shell_show_in_folder` — `ShellService::show_item_in_folder` reveals
|
||||
// an item in Finder/Explorer. Pure UI convenience; low agent utility.
|
||||
//
|
||||
// - `nomi_shell_launch` — `ShellService::launch` opens arbitrary targets
|
||||
// (apps, URLs, files) via the OS. Too broad for unsupervised agent use
|
||||
// (accepts any target string). If needed, can be added with Destructive tier.
|
||||
//
|
||||
// - `nomi_shell_open_folder_with` — `ShellService::open_folder_with` opens a
|
||||
// folder in VSCode/Terminal/Explorer. Agent-facing utility is limited; prefer
|
||||
// explicit terminal session creation via the terminal domain.
|
||||
//
|
||||
// - `nomi_shell_check_tool_installed` — `ShellService::check_tool_installed`
|
||||
// checks if VSCode/Terminal/Explorer is available. Informational but narrow.
|
||||
//
|
||||
// FileWatchService and SnapshotService methods are NOT included — they are
|
||||
// session-lifecycle services (start/stop watch, git-style staging) that belong
|
||||
// to the UI interaction layer, not agent tool primitives.
|
||||
@@ -0,0 +1,193 @@
|
||||
//! IDMM (Intelligent Decision-Making Mode) capabilities (registry form):
|
||||
//! read + set the per-session supervision config for a conversation or terminal
|
||||
//! target.
|
||||
//!
|
||||
//! Clean migration of `tools_idmm.rs` onto the capability registry. The typed
|
||||
//! params structs are now the single source (schema + runtime deserialization).
|
||||
//! Handler logic is identical to the legacy: overlay onto the previously
|
||||
//! persisted config so unexposed knobs (fault watch / strategy / budget) keep
|
||||
//! their values; the gateway exposes the DECISION watch's core knobs
|
||||
//! (enabled / tier / freeform policy).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{IdmmTargetKind, WatchTier};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier};
|
||||
use crate::server::{ok, require_user};
|
||||
|
||||
// ─── Params ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct SetIdmmParams {
|
||||
/// Target kind: "conversation" or "terminal".
|
||||
kind: String,
|
||||
/// The conversation id or terminal id to supervise.
|
||||
target_id: String,
|
||||
/// Enable (true) or disable (false) IDMM supervision.
|
||||
enabled: bool,
|
||||
/// Escalation tier: "rule" (no-LLM rules only, default) or
|
||||
/// "rule_plus_sidecar" (adds a backup-model sidecar; requires a
|
||||
/// steering_prompt and a configured backup provider).
|
||||
#[serde(default)]
|
||||
tier: Option<String>,
|
||||
/// Bounds what the sidecar may decide on the user's behalf. Required
|
||||
/// (non-empty) for the rule_plus_sidecar tier.
|
||||
#[serde(default)]
|
||||
steering_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct GetIdmmParams {
|
||||
/// Target kind: "conversation" or "terminal".
|
||||
kind: String,
|
||||
/// The conversation id or terminal id to inspect.
|
||||
target_id: String,
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_kind(raw: &str) -> Result<IdmmTargetKind, Value> {
|
||||
IdmmTargetKind::parse(raw)
|
||||
.ok_or_else(|| json!({"error": format!("unknown kind '{raw}' (expected conversation | terminal)")}))
|
||||
}
|
||||
|
||||
/// Terminal targets are ownership-checked (conversation ids are scoped by the
|
||||
/// conversation service itself; mirrors the REST routes' asymmetry).
|
||||
async fn verify_terminal(deps: &GatewayDeps, ctx: &CallerCtx, kind: IdmmTargetKind, target_id: &str) -> Option<Value> {
|
||||
if kind != IdmmTargetKind::Terminal {
|
||||
return None;
|
||||
}
|
||||
let user_id = match require_user(ctx) {
|
||||
Ok(u) => u.to_owned(),
|
||||
Err(e) => return Some(e),
|
||||
};
|
||||
match deps.idmm_service.verify_terminal_owner(target_id, &user_id).await {
|
||||
Ok(()) => None,
|
||||
Err(e) => Some(json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn set(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: SetIdmmParams) -> Value {
|
||||
let kind = match parse_kind(&p.kind) {
|
||||
Ok(k) => k,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Some(err) = verify_terminal(&deps, &ctx, kind, &p.target_id).await {
|
||||
return err;
|
||||
}
|
||||
|
||||
// Overlay onto the previously persisted config so unexposed knobs
|
||||
// (fault watch / strategy / budget details) keep their values. The gateway
|
||||
// exposes the DECISION watch (the agent-facing decision capability).
|
||||
let mut cfg = match deps.idmm_service.read_config_persisted(kind, &p.target_id).await {
|
||||
Ok(c) => c.unwrap_or_default(),
|
||||
Err(e) => return json!({"error": e.to_string()}),
|
||||
};
|
||||
cfg.decision_watch.base.enabled = p.enabled;
|
||||
if let Some(tier) = p.tier.as_deref() {
|
||||
cfg.decision_watch.base.tier = match tier {
|
||||
"rule" | "rule_only" => WatchTier::RuleOnly,
|
||||
"rule_plus_sidecar" | "rule_plus_model" => WatchTier::RulePlusModel,
|
||||
other => {
|
||||
return json!({"error": format!("unknown tier '{other}' (expected rule_only | rule_plus_model)")});
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(sp) = p.steering_prompt {
|
||||
cfg.decision_watch.strategy.freeform_policy = Some(sp);
|
||||
}
|
||||
|
||||
if let Err(e) = deps.idmm_service.save_config(kind, &p.target_id, &cfg).await {
|
||||
// Typical validation errors: sidecar tier without a steering prompt
|
||||
// or without a resolvable backup provider — relay them verbatim so
|
||||
// the agent can fix the call or ask the owner.
|
||||
return json!({"error": e.to_string()});
|
||||
}
|
||||
match deps.idmm_service.build_state(kind, &p.target_id).await {
|
||||
Ok(state) => ok(state),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: GetIdmmParams) -> Value {
|
||||
let kind = match parse_kind(&p.kind) {
|
||||
Ok(k) => k,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Some(err) = verify_terminal(&deps, &ctx, kind, &p.target_id).await {
|
||||
return err;
|
||||
}
|
||||
match deps.idmm_service.build_state(kind, &p.target_id).await {
|
||||
Ok(state) => ok(state),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Registration ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register the IDMM-domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<SetIdmmParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_set_idmm",
|
||||
"idmm",
|
||||
"Update IDMM supervision knobs (enabled / tier / steering prompt) and (re)arm the live supervisor for a conversation or terminal.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, ctx, p| set(deps, ctx, p),
|
||||
));
|
||||
out.push(Capability::new::<GetIdmmParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_get_idmm",
|
||||
"idmm",
|
||||
"Read the current IDMM config and live supervision state for a conversation or terminal.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| get(deps, ctx, p),
|
||||
));
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_kind_accepts_valid_values() {
|
||||
assert_eq!(parse_kind("conversation").unwrap(), IdmmTargetKind::Conversation);
|
||||
assert_eq!(parse_kind("terminal").unwrap(), IdmmTargetKind::Terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_kind_rejects_unknown() {
|
||||
let err = parse_kind("unknown").unwrap_err();
|
||||
let msg = err["error"].as_str().unwrap();
|
||||
assert!(msg.contains("unknown kind 'unknown'"));
|
||||
assert!(msg.contains("conversation | terminal"));
|
||||
}
|
||||
|
||||
/// Verify the tier mapping accepts all documented aliases and rejects unknowns.
|
||||
#[test]
|
||||
fn tier_mapping_coverage() {
|
||||
fn map_tier(s: &str) -> Result<WatchTier, String> {
|
||||
match s {
|
||||
"rule" | "rule_only" => Ok(WatchTier::RuleOnly),
|
||||
"rule_plus_sidecar" | "rule_plus_model" => Ok(WatchTier::RulePlusModel),
|
||||
other => Err(format!("unknown tier '{other}'")),
|
||||
}
|
||||
}
|
||||
assert_eq!(map_tier("rule").unwrap(), WatchTier::RuleOnly);
|
||||
assert_eq!(map_tier("rule_only").unwrap(), WatchTier::RuleOnly);
|
||||
assert_eq!(map_tier("rule_plus_sidecar").unwrap(), WatchTier::RulePlusModel);
|
||||
assert_eq!(map_tier("rule_plus_model").unwrap(), WatchTier::RulePlusModel);
|
||||
assert!(map_tier("bogus").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! Knowledge-base capabilities (registry form): catalog (list/create), markdown
|
||||
//! file writes, AI overview autogen, server-side URL fetch, and the per-target
|
||||
//! knowledge binding. The intricate per-surface write policy is preserved
|
||||
//! verbatim from the legacy tool. The `channel_write_enabled` field — previously
|
||||
//! readable at runtime but ABSENT from the MCP schema (a confirmed drift) — is
|
||||
//! now a declared param, so the single typed struct fixes the drift.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{KnowledgeSource, KnowledgeSourceEntry, KnowledgeSourceMode};
|
||||
use nomifun_knowledge::source_url::truncate_to_bytes;
|
||||
use nomifun_knowledge::{
|
||||
KnowledgeBinding, UrlFetcher, WriteRequest, WriteSurface, WriteTargetSpec, resolve_write_policy,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier};
|
||||
use crate::server::ok;
|
||||
|
||||
/// Response cap for `nomi_knowledge_fetch_url` markdown bodies.
|
||||
const FETCH_URL_MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
// ── param structs (single source: schema + runtime) ──────────────────────
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListBasesParams {
|
||||
/// Case-insensitive substring filter over base name/description.
|
||||
#[serde(default)]
|
||||
query: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CreateBaseParams {
|
||||
/// Display name for the new knowledge base.
|
||||
name: String,
|
||||
/// Optional description (also auto-generated later if URL sources are given).
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
/// Optional seed URLs to ingest.
|
||||
#[serde(default)]
|
||||
urls: Option<Vec<String>>,
|
||||
/// Ingestion mode for the seed URLs: "snapshot" (default, fetched once) or "live".
|
||||
#[serde(default)]
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct WriteFileParams {
|
||||
/// Target knowledge base id (from nomi_knowledge_list_bases).
|
||||
kb_id: String,
|
||||
/// Relative .md path inside the base (no traversal; .md only).
|
||||
rel_path: String,
|
||||
/// Markdown document content (written verbatim, not trimmed).
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct AutogenParams {
|
||||
/// Knowledge base id to (re)generate the AI overview for.
|
||||
kb_id: String,
|
||||
/// Overwrite the existing root README too (default false).
|
||||
#[serde(default)]
|
||||
overwrite_readme: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct FetchUrlParams {
|
||||
/// The URL to fetch + convert to markdown (SSRF-guarded; no private/loopback targets).
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct GetBindingParams {
|
||||
/// Target kind: "conversation" | "terminal" | "companion".
|
||||
kind: String,
|
||||
/// The target id whose binding to read.
|
||||
target_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct SetBindingParams {
|
||||
/// Target kind: "conversation" | "terminal" | "companion".
|
||||
kind: String,
|
||||
/// The target id whose binding to set.
|
||||
target_id: String,
|
||||
/// Enable (true) or disable (false) the binding.
|
||||
enabled: bool,
|
||||
/// Replacement list of bound base ids (omit to keep the current list).
|
||||
#[serde(default)]
|
||||
kb_ids: Option<Vec<String>>,
|
||||
/// Write-back ("回血") switch (omit to keep).
|
||||
#[serde(default)]
|
||||
writeback: Option<bool>,
|
||||
/// Write-back mode: "staged" | "direct" (omit to keep).
|
||||
#[serde(default)]
|
||||
writeback_mode: Option<String>,
|
||||
/// Write-back disposition: "conservative" | "aggressive" (omit to keep).
|
||||
#[serde(default)]
|
||||
writeback_eagerness: Option<String>,
|
||||
/// Allow write-back from external IM channel sessions (omit to keep).
|
||||
#[serde(default)]
|
||||
channel_write_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
// ── shared pure helpers (also used by caps_terminal's bind-on-create) ─────
|
||||
|
||||
/// Resolve the write surface for a gateway caller.
|
||||
pub(crate) fn gateway_surface(channel_platform: Option<&str>, companion_id: Option<&str>) -> WriteSurface {
|
||||
if channel_platform.is_some() {
|
||||
WriteSurface::ExternalChannel
|
||||
} else if companion_id.is_some() {
|
||||
WriteSurface::Companion
|
||||
} else {
|
||||
WriteSurface::RegularChat
|
||||
}
|
||||
}
|
||||
|
||||
fn first_unknown_id<'a>(requested: &'a [String], known: &HashSet<&str>) -> Option<&'a str> {
|
||||
requested.iter().find(|id| !known.contains(id.as_str())).map(String::as_str)
|
||||
}
|
||||
|
||||
fn unknown_kb_error(id: &str) -> Value {
|
||||
json!({ "error": format!("unknown knowledge base id '{id}'; call nomi_knowledge_list_bases for valid ids") })
|
||||
}
|
||||
|
||||
/// Reject unknown base ids up front. Shared with `caps_terminal`'s bind-on-create.
|
||||
pub(crate) async fn ensure_known_kb_ids(deps: &GatewayDeps, ids: &[String]) -> Result<(), Value> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let bases = match deps.knowledge_service.list_bases().await {
|
||||
Ok(bases) => bases,
|
||||
Err(e) => return Err(json!({ "error": e.to_string() })),
|
||||
};
|
||||
let known: HashSet<&str> = bases.iter().map(|b| b.id.as_str()).collect();
|
||||
match first_unknown_id(ids, &known) {
|
||||
Some(bad) => Err(unknown_kb_error(bad)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_base_note(snapshot_urls: usize) -> String {
|
||||
if snapshot_urls == 0 {
|
||||
return "base created; it only takes effect on a target after nomi_knowledge_set_binding binds it there"
|
||||
.to_owned();
|
||||
}
|
||||
format!(
|
||||
"库已创建;{snapshot_urls} 条 URL 正在后台抓取快照并自动生成梗概,请勿重复创建。\
|
||||
稍后可用 nomi_knowledge_list_bases 查看(description 出现即完成);\
|
||||
若较长时间后 description 仍未出现,说明抓取可能失败——可改用 nomi_knowledge_fetch_url \
|
||||
自行抓取内容、nomi_knowledge_write_file 落库,再 nomi_knowledge_autogen 生成梗概。\
|
||||
库仍需 nomi_knowledge_set_binding 绑定到目标后才会生效。"
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse the optional `urls` + `mode` into a URL [`KnowledgeSource`].
|
||||
fn parse_url_source(urls: Option<Vec<String>>, mode: Option<&str>) -> Result<Option<KnowledgeSource>, Value> {
|
||||
let urls: Vec<String> = urls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let mode = match mode {
|
||||
None | Some("snapshot") => KnowledgeSourceMode::Snapshot,
|
||||
Some("live") => KnowledgeSourceMode::Live,
|
||||
Some(other) => {
|
||||
return Err(json!({ "error": format!("unknown mode '{other}' (expected snapshot | live)") }));
|
||||
}
|
||||
};
|
||||
if urls.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(KnowledgeSource {
|
||||
kind: "url".into(),
|
||||
mode,
|
||||
entries: urls
|
||||
.into_iter()
|
||||
.map(|url| KnowledgeSourceEntry { url, title: None, ..Default::default() })
|
||||
.collect(),
|
||||
last_fetched_at: None,
|
||||
credential_ref: None,
|
||||
scope: None,
|
||||
sync: None,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn fetch_url_with(fetcher: &UrlFetcher, url: &str) -> Value {
|
||||
match fetcher.fetch_page(url).await {
|
||||
Ok(page) => {
|
||||
let capped = page.markdown.len() > FETCH_URL_MAX_BYTES;
|
||||
let markdown = truncate_to_bytes(&page.markdown, FETCH_URL_MAX_BYTES);
|
||||
ok(json!({
|
||||
"url": url,
|
||||
"final_url": page.final_url,
|
||||
"title": page.title,
|
||||
"markdown": markdown,
|
||||
"truncated": page.truncated || capped,
|
||||
}))
|
||||
}
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
// ── handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn list_bases(deps: Arc<GatewayDeps>, p: ListBasesParams) -> Value {
|
||||
let query = p.query.map(|q| q.to_lowercase());
|
||||
match deps.knowledge_service.list_bases().await {
|
||||
Ok(bases) => {
|
||||
let items: Vec<Value> = bases
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
query
|
||||
.as_deref()
|
||||
.is_none_or(|q| b.name.to_lowercase().contains(q) || b.description.to_lowercase().contains(q))
|
||||
})
|
||||
.map(|b| {
|
||||
json!({
|
||||
"id": b.id,
|
||||
"name": b.name,
|
||||
"description": b.description,
|
||||
"file_count": b.file_count,
|
||||
"root_exists": b.root_exists,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ok(json!({ "total": items.len(), "bases": items }))
|
||||
}
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_base(deps: Arc<GatewayDeps>, p: CreateBaseParams) -> Value {
|
||||
let name = p.name.trim().to_owned();
|
||||
if name.is_empty() {
|
||||
return json!({ "error": "missing required field: name" });
|
||||
}
|
||||
let description = p.description.unwrap_or_default();
|
||||
let source = match parse_url_source(p.urls, p.mode.as_deref()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let snapshot_urls = source
|
||||
.as_ref()
|
||||
.filter(|s| s.mode == KnowledgeSourceMode::Snapshot)
|
||||
.map(|s| s.entries.len())
|
||||
.unwrap_or(0);
|
||||
// `create_base_with_background_fetch` spawns a background fetch task and so
|
||||
// consumes an owned `Arc<Self>` — clone the service handle for it.
|
||||
match deps
|
||||
.knowledge_service
|
||||
.clone()
|
||||
.create_base_with_background_fetch(&name, &description, None, source)
|
||||
.await
|
||||
{
|
||||
Ok(info) => ok(json!({
|
||||
"id": info.id,
|
||||
"name": info.name,
|
||||
"description": info.description,
|
||||
"file_count": info.file_count,
|
||||
"note": create_base_note(snapshot_urls),
|
||||
})),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_file(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: WriteFileParams) -> Value {
|
||||
let surface = gateway_surface(ctx.channel_platform.as_deref(), ctx.companion_id.as_deref());
|
||||
let (scope, binding) = match (surface, ctx.companion_id.as_deref()) {
|
||||
(WriteSurface::Companion, Some(cid)) => (
|
||||
cid.to_owned(),
|
||||
deps.knowledge_service.get_binding("companion", cid).await.unwrap_or_default(),
|
||||
),
|
||||
_ => (
|
||||
ctx.conversation_id.clone(),
|
||||
KnowledgeBinding { enabled: true, writeback: true, ..Default::default() },
|
||||
),
|
||||
};
|
||||
let policy = resolve_write_policy(surface, &binding, &scope);
|
||||
let bound_kb_ids = deps.knowledge_service.resolve_kb_ids_for_cwd("").await;
|
||||
let req = WriteRequest {
|
||||
spec: WriteTargetSpec::Path { kb_id: p.kb_id, rel_path: p.rel_path },
|
||||
content: p.content,
|
||||
policy,
|
||||
bound_kb_ids,
|
||||
};
|
||||
match deps.knowledge_service.write_document(req).await {
|
||||
Ok(out) => ok(json!({
|
||||
"kb_id": out.kb_id,
|
||||
"rel_path": out.final_rel_path,
|
||||
"staged": out.staged,
|
||||
"updated": matches!(out.op, nomifun_knowledge::WriteOp::Update),
|
||||
"note": "written via the unified write path (placement enforced by session policy); after substantial additions refresh the overview via nomi_knowledge_autogen",
|
||||
})),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn autogen(deps: Arc<GatewayDeps>, p: AutogenParams) -> Value {
|
||||
match deps
|
||||
.knowledge_service
|
||||
.generate_overview(&p.kb_id, p.overwrite_readme.unwrap_or(false), None)
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => ok(outcome),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_url(_deps: Arc<GatewayDeps>, p: FetchUrlParams) -> Value {
|
||||
fetch_url_with(&UrlFetcher::default(), &p.url).await
|
||||
}
|
||||
|
||||
async fn get_binding(deps: Arc<GatewayDeps>, p: GetBindingParams) -> Value {
|
||||
match deps.knowledge_service.get_binding(&p.kind, &p.target_id).await {
|
||||
Ok(binding) => ok(binding),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_binding(deps: Arc<GatewayDeps>, p: SetBindingParams) -> Value {
|
||||
let mut binding = match deps.knowledge_service.get_binding(&p.kind, &p.target_id).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return json!({ "error": e.to_string() }),
|
||||
};
|
||||
binding.enabled = p.enabled;
|
||||
if let Some(ids) = p.kb_ids {
|
||||
binding.kb_ids = ids;
|
||||
}
|
||||
if let Some(wb) = p.writeback {
|
||||
binding.writeback = wb;
|
||||
}
|
||||
if let Some(mode) = p.writeback_mode {
|
||||
binding.writeback_mode = mode;
|
||||
}
|
||||
if let Some(eagerness) = p.writeback_eagerness {
|
||||
binding.writeback_eagerness = eagerness;
|
||||
}
|
||||
if let Some(channel_write) = p.channel_write_enabled {
|
||||
binding.channel_write_enabled = channel_write;
|
||||
}
|
||||
if p.enabled && binding.kb_ids.is_empty() {
|
||||
return json!({ "error": "kb_ids must not be empty when enabling a binding; call nomi_knowledge_list_bases for valid ids" });
|
||||
}
|
||||
if let Err(e) = ensure_known_kb_ids(&deps, &binding.kb_ids).await {
|
||||
return e;
|
||||
}
|
||||
match deps.knowledge_service.set_binding(&p.kind, &p.target_id, binding).await {
|
||||
Ok(binding) => ok(json!({
|
||||
"binding": binding,
|
||||
"note": "binding saved; bases are mounted into the target's workspace at its NEXT task start"
|
||||
})),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<ListBasesParams, _, _>(
|
||||
CapabilityMeta::new("nomi_knowledge_list_bases", "knowledge", "List knowledge bases (optionally filtered).", DangerTier::Read),
|
||||
|deps, _ctx, p| list_bases(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<CreateBaseParams, _, _>(
|
||||
CapabilityMeta::new("nomi_knowledge_create_base", "knowledge", "Create a new managed knowledge base, optionally seeded with URL sources (fetched in the background).", DangerTier::Write),
|
||||
|deps, _ctx, p| create_base(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<WriteFileParams, _, _>(
|
||||
CapabilityMeta::new("nomi_knowledge_write_file", "knowledge", "Create/update one markdown document in a base (placement enforced by per-surface policy).", DangerTier::Write),
|
||||
write_file,
|
||||
));
|
||||
out.push(Capability::new::<AutogenParams, _, _>(
|
||||
CapabilityMeta::new("nomi_knowledge_autogen", "knowledge", "Generate the AI overview (description + root README) for a base.", DangerTier::Write),
|
||||
|deps, _ctx, p| autogen(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<FetchUrlParams, _, _>(
|
||||
CapabilityMeta::new("nomi_knowledge_fetch_url", "knowledge", "Server-side fetch + HTML→markdown of a URL (SSRF-guarded).", DangerTier::Read),
|
||||
|deps, _ctx, p| fetch_url(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<GetBindingParams, _, _>(
|
||||
CapabilityMeta::new("nomi_knowledge_get_binding", "knowledge", "Read the knowledge binding for one target (conversation/terminal/companion).", DangerTier::Read),
|
||||
|deps, _ctx, p| get_binding(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<SetBindingParams, _, _>(
|
||||
CapabilityMeta::new("nomi_knowledge_set_binding", "knowledge", "Set the bound base list / toggle a target's knowledge binding and write-back knobs.", DangerTier::Write),
|
||||
|deps, _ctx, p| set_binding(deps, p),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gateway_surface_from_ctx() {
|
||||
assert!(matches!(gateway_surface(Some("lark"), Some("c1")), WriteSurface::ExternalChannel));
|
||||
assert!(matches!(gateway_surface(None, Some("c1")), WriteSurface::Companion));
|
||||
assert!(matches!(gateway_surface(None, None), WriteSurface::RegularChat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_source_defaults_to_snapshot_and_filters_blank_urls() {
|
||||
let src = parse_url_source(Some(vec!["https://e.com/a".into(), " ".into(), "https://e.com/b ".into()]), None)
|
||||
.unwrap()
|
||||
.expect("non-empty urls must yield a source");
|
||||
assert_eq!(src.kind, "url");
|
||||
assert_eq!(src.mode, KnowledgeSourceMode::Snapshot);
|
||||
let urls: Vec<&str> = src.entries.iter().map(|e| e.url.as_str()).collect();
|
||||
assert_eq!(urls, vec!["https://e.com/a", "https://e.com/b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_source_live_mode_and_unknown_mode() {
|
||||
let src = parse_url_source(Some(vec!["https://e.com".into()]), Some("live")).unwrap().unwrap();
|
||||
assert_eq!(src.mode, KnowledgeSourceMode::Live);
|
||||
let err = parse_url_source(Some(vec!["https://e.com".into()]), Some("weekly")).unwrap_err();
|
||||
assert!(err["error"].as_str().unwrap().contains("weekly"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_source_absent_or_empty_urls_is_none() {
|
||||
assert!(parse_url_source(None, None).unwrap().is_none());
|
||||
assert!(parse_url_source(Some(vec![]), None).unwrap().is_none());
|
||||
assert!(parse_url_source(Some(vec![" ".into()]), None).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_unknown_id_finds_in_request_order() {
|
||||
let known: HashSet<&str> = ["kb_a", "kb_b"].into();
|
||||
assert_eq!(first_unknown_id(&["kb_a".into(), "kb_b".into()], &known), None);
|
||||
assert_eq!(first_unknown_id(&["kb_a".into(), "kb_x".into()], &known), Some("kb_x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_base_note_signals_background_fetch() {
|
||||
let note = create_base_note(3);
|
||||
assert!(note.contains("3 条 URL") && note.contains("后台") && note.contains("请勿重复创建"));
|
||||
let plain = create_base_note(0);
|
||||
assert!(plain.contains("nomi_knowledge_set_binding") && !plain.contains("后台"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Extended knowledge-base capabilities (registry form): base detail / update /
|
||||
//! delete, file listing / read / delete, inbox review (list / merge / discard),
|
||||
//! full-text search, and user tag CRUD. Supplements `caps_knowledge.rs` which
|
||||
//! owns the core catalog + binding + write + autogen + fetch-url tools.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::GatewayDeps;
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
|
||||
use crate::server::ok;
|
||||
|
||||
/// Hard cap on `read_file` content returned to the model (256 KiB). Larger
|
||||
/// documents are truncated with a trailing note so the agent knows it is
|
||||
/// incomplete and can refine its query.
|
||||
const READ_FILE_MAX_BYTES: usize = 256 * 1024;
|
||||
|
||||
// ── param structs ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct GetBaseParams {
|
||||
/// Knowledge base id (from nomi_knowledge_list_bases).
|
||||
kb_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct UpdateBaseParams {
|
||||
/// Knowledge base id to update.
|
||||
kb_id: String,
|
||||
/// New display name (omit to keep).
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// New description (omit to keep; empty string clears it).
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
/// Replacement tag key list (omit to keep; empty array clears all tags).
|
||||
#[serde(default)]
|
||||
tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct DeleteBaseParams {
|
||||
/// Knowledge base id to delete.
|
||||
kb_id: String,
|
||||
/// Also remove the managed directory on disk (default false; only allowed
|
||||
/// for managed bases).
|
||||
#[serde(default)]
|
||||
purge: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListFilesParams {
|
||||
/// Knowledge base id whose files to list.
|
||||
kb_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ReadFileParams {
|
||||
/// Knowledge base id.
|
||||
kb_id: String,
|
||||
/// Relative .md path inside the base (forward slashes, no traversal).
|
||||
rel_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct DeleteFileParams {
|
||||
/// Knowledge base id.
|
||||
kb_id: String,
|
||||
/// Relative .md path of the file to delete (forward slashes, no traversal).
|
||||
rel_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListInboxParams {
|
||||
/// Knowledge base id whose staged inbox to list.
|
||||
kb_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct MergeInboxParams {
|
||||
/// Knowledge base id.
|
||||
kb_id: String,
|
||||
/// Scope (session id that staged the proposal).
|
||||
scope: String,
|
||||
/// Relative .md path of the staged proposal (mirrors the target base path).
|
||||
rel_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct DiscardInboxParams {
|
||||
/// Knowledge base id.
|
||||
kb_id: String,
|
||||
/// Scope (session id that staged the proposal).
|
||||
scope: String,
|
||||
/// Relative .md path of the staged proposal.
|
||||
rel_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct SearchParams {
|
||||
/// Knowledge base ids to search (at least one required).
|
||||
kb_ids: Vec<String>,
|
||||
/// Free-text query (matched against file paths, headings, and content).
|
||||
query: String,
|
||||
/// Maximum results to return (default 20, clamped to 1..=100).
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListTagsParams {
|
||||
// No parameters — lists all tags.
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CreateTagParams {
|
||||
/// Human-readable label for the tag (the key is auto-derived from it).
|
||||
label: String,
|
||||
/// Optional color string (hex or named; omit for no color).
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct DeleteTagParams {
|
||||
/// Tag key to delete (from nomi_knowledge_list_tags).
|
||||
key: String,
|
||||
}
|
||||
|
||||
// ── handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn get_base(deps: Arc<GatewayDeps>, p: GetBaseParams) -> Value {
|
||||
match deps.knowledge_service.get_base_info(&p.kb_id).await {
|
||||
Ok(info) => ok(info),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_base(deps: Arc<GatewayDeps>, p: UpdateBaseParams) -> Value {
|
||||
if p.name.is_none() && p.description.is_none() && p.tags.is_none() {
|
||||
return json!({"error": "nothing to update: provide at least one of name / description / tags"});
|
||||
}
|
||||
match deps
|
||||
.knowledge_service
|
||||
.update_base(&p.kb_id, p.name.as_deref(), p.description.as_deref(), p.tags)
|
||||
.await
|
||||
{
|
||||
Ok(info) => ok(json!({
|
||||
"id": info.id,
|
||||
"name": info.name,
|
||||
"description": info.description,
|
||||
"tags": info.tags,
|
||||
"file_count": info.file_count,
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_base(deps: Arc<GatewayDeps>, p: DeleteBaseParams) -> Value {
|
||||
let purge = p.purge.unwrap_or(false);
|
||||
match deps.knowledge_service.delete_base(&p.kb_id, purge).await {
|
||||
Ok(()) => ok(json!({
|
||||
"deleted": p.kb_id,
|
||||
"purged": purge,
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_files(deps: Arc<GatewayDeps>, p: ListFilesParams) -> Value {
|
||||
match deps.knowledge_service.list_files(&p.kb_id).await {
|
||||
Ok(files) => ok(json!({
|
||||
"kb_id": p.kb_id,
|
||||
"total": files.len(),
|
||||
"files": files,
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_file(deps: Arc<GatewayDeps>, p: ReadFileParams) -> Value {
|
||||
match deps.knowledge_service.read_file(&p.kb_id, &p.rel_path).await {
|
||||
Ok(file) => {
|
||||
let truncated = file.content.len() > READ_FILE_MAX_BYTES;
|
||||
let content = if truncated {
|
||||
let bytes = &file.content.as_bytes()[..READ_FILE_MAX_BYTES];
|
||||
let boundary = bytes.iter().rposition(|&b| b == b'\n').unwrap_or(READ_FILE_MAX_BYTES);
|
||||
let slice = &file.content[..boundary];
|
||||
format!("{slice}\n\n[…truncated — {total} bytes total; narrow your query or read in sections]", total = file.content.len())
|
||||
} else {
|
||||
file.content
|
||||
};
|
||||
ok(json!({
|
||||
"kb_id": p.kb_id,
|
||||
"rel_path": file.rel_path,
|
||||
"content": content,
|
||||
"size": file.size,
|
||||
"truncated": truncated,
|
||||
}))
|
||||
}
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_file(deps: Arc<GatewayDeps>, p: DeleteFileParams) -> Value {
|
||||
match deps.knowledge_service.delete_file(&p.kb_id, &p.rel_path).await {
|
||||
Ok(()) => ok(json!({
|
||||
"deleted": format!("{}/{}", p.kb_id, p.rel_path),
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_inbox(deps: Arc<GatewayDeps>, p: ListInboxParams) -> Value {
|
||||
match deps.knowledge_service.list_inbox(&p.kb_id).await {
|
||||
Ok(entries) => ok(json!({
|
||||
"kb_id": p.kb_id,
|
||||
"total": entries.len(),
|
||||
"entries": entries,
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn merge_inbox(deps: Arc<GatewayDeps>, p: MergeInboxParams) -> Value {
|
||||
match deps.knowledge_service.merge_inbox(&p.kb_id, &p.scope, &p.rel_path).await {
|
||||
Ok(result) => ok(json!({
|
||||
"merged_path": result.merged_path,
|
||||
"note": "inbox proposal accepted and merged into the base body",
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn discard_inbox(deps: Arc<GatewayDeps>, p: DiscardInboxParams) -> Value {
|
||||
match deps.knowledge_service.discard_inbox(&p.kb_id, &p.scope, &p.rel_path).await {
|
||||
Ok(()) => ok(json!({
|
||||
"discarded": format!("{}/{}/{}", p.kb_id, p.scope, p.rel_path),
|
||||
"note": "inbox proposal rejected and removed",
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(deps: Arc<GatewayDeps>, p: SearchParams) -> Value {
|
||||
if p.kb_ids.is_empty() {
|
||||
return json!({"error": "kb_ids must not be empty"});
|
||||
}
|
||||
let query = p.query.trim();
|
||||
if query.is_empty() {
|
||||
return json!({"error": "query must not be empty"});
|
||||
}
|
||||
let limit = p.limit.unwrap_or(20).clamp(1, 100);
|
||||
match deps.knowledge_service.search_bases(&p.kb_ids, query, limit).await {
|
||||
Ok(hits) => ok(json!({
|
||||
"total": hits.len(),
|
||||
"hits": hits,
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_tags(deps: Arc<GatewayDeps>, _p: ListTagsParams) -> Value {
|
||||
match deps.knowledge_service.list_tags().await {
|
||||
Ok(tags) => ok(json!({
|
||||
"total": tags.len(),
|
||||
"tags": tags,
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_tag(deps: Arc<GatewayDeps>, p: CreateTagParams) -> Value {
|
||||
let label = p.label.trim();
|
||||
if label.is_empty() {
|
||||
return json!({"error": "label must not be empty"});
|
||||
}
|
||||
match deps.knowledge_service.create_tag(label, p.color).await {
|
||||
Ok(tag) => ok(tag),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_tag(deps: Arc<GatewayDeps>, p: DeleteTagParams) -> Value {
|
||||
match deps.knowledge_service.delete_tag(&p.key).await {
|
||||
Ok(()) => ok(json!({
|
||||
"deleted": p.key,
|
||||
"note": "tag removed from all bases that referenced it",
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── registration ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
// ── base detail / mutation ────────────────────────────────────────────
|
||||
out.push(Capability::new::<GetBaseParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_get_base",
|
||||
"knowledge",
|
||||
"Get full detail of one knowledge base (id, name, description, source, tags, file count, etc.).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| get_base(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<UpdateBaseParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_update_base",
|
||||
"knowledge",
|
||||
"Update a knowledge base's name, description, or assigned tags.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| update_base(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<DeleteBaseParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_delete_base",
|
||||
"knowledge",
|
||||
"Delete a knowledge base registration (optionally purge its managed directory).",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, _ctx, p| delete_base(deps, p),
|
||||
));
|
||||
|
||||
// ── file access ──────────────────────────────────────────────────────
|
||||
out.push(Capability::new::<ListFilesParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_list_files",
|
||||
"knowledge",
|
||||
"List all markdown files in a knowledge base (paths, sizes, modification times).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list_files(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<ReadFileParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_read_file",
|
||||
"knowledge",
|
||||
"Read one markdown document from a knowledge base (truncated at 256 KiB).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| read_file(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<DeleteFileParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_delete_file",
|
||||
"knowledge",
|
||||
"Delete one markdown file from a knowledge base.",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, _ctx, p| delete_file(deps, p),
|
||||
));
|
||||
|
||||
// ── inbox (staged write-back review) ─────────────────────────────────
|
||||
out.push(Capability::new::<ListInboxParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_list_inbox",
|
||||
"knowledge",
|
||||
"List pending staged write-back proposals (inbox) for a knowledge base.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list_inbox(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<MergeInboxParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_merge_inbox",
|
||||
"knowledge",
|
||||
"Accept a staged inbox proposal: merge it into the base body and remove the staged copy.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| merge_inbox(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<DiscardInboxParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_discard_inbox",
|
||||
"knowledge",
|
||||
"Reject and remove a staged inbox proposal without merging.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| discard_inbox(deps, p),
|
||||
));
|
||||
|
||||
// ── search ───────────────────────────────────────────────────────────
|
||||
out.push(Capability::new::<SearchParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_search",
|
||||
"knowledge",
|
||||
"Full-text search across one or more knowledge bases (ranked by relevance).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| search(deps, p),
|
||||
));
|
||||
|
||||
// ── user tags ────────────────────────────────────────────────────────
|
||||
out.push(Capability::new::<ListTagsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_list_tags",
|
||||
"knowledge",
|
||||
"List all user-defined knowledge tags (key, label, color, sort order).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list_tags(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<CreateTagParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_create_tag",
|
||||
"knowledge",
|
||||
"Create a new user-defined tag for categorizing knowledge bases.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| create_tag(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<DeleteTagParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_knowledge_delete_tag",
|
||||
"knowledge",
|
||||
"Delete a user tag (also strips it from all bases that reference it).",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, _ctx, p| delete_tag(deps, p),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
//! MCP-servers, Extensions, Skills, and Hub management capabilities.
|
||||
//!
|
||||
//! Lets the LLM agent manage the desktop's MCP server registry, enable/disable
|
||||
//! extensions, import/delete skills, and install extensions from the Hub.
|
||||
//!
|
||||
//! ## Assumed GatewayDeps fields (parent must wire):
|
||||
//!
|
||||
//! - `mcp_config_service: McpConfigService`
|
||||
//! Clone of `states.mcp.config_service` (from `McpRouterState`).
|
||||
//! Crate: `nomifun-mcp`, type: `nomifun_mcp::McpConfigService`.
|
||||
//!
|
||||
//! - `extension_registry: ExtensionRegistry`
|
||||
//! Clone of `states.extension.registry` (from `ExtensionRouterState`).
|
||||
//! Crate: `nomifun-extension`, type: `nomifun_extension::ExtensionRegistry`.
|
||||
//!
|
||||
//! - `hub_installer: HubInstaller`
|
||||
//! Clone of `states.hub.installer` (from `HubRouterState`).
|
||||
//! Crate: `nomifun-extension`, type: `nomifun_extension::hub::installer::HubInstaller`.
|
||||
//!
|
||||
//! - `hub_index_manager: HubIndexManager`
|
||||
//! Clone of `states.hub.index_manager` (from `HubRouterState`).
|
||||
//! Crate: `nomifun-extension`, type: `nomifun_extension::hub::index_manager::HubIndexManager`.
|
||||
//!
|
||||
//! - `skill_paths: SkillPaths`
|
||||
//! Clone of `states.skill.skill_paths` (from `SkillRouterState`).
|
||||
//! Crate: `nomifun-extension`, type: `nomifun_extension::skill_service::SkillPaths`.
|
||||
//!
|
||||
//! ## SKIPPED tools (listed at the bottom of this file):
|
||||
//!
|
||||
//! - `nomi_mcp_test_connection` — requires building a `McpServerTransport`
|
||||
//! from the API `McpTransport` enum (tagged union with three variants), which
|
||||
//! is awkward to expose in a flat JSON schema for an LLM. The route handler
|
||||
//! also persists test results back to the config service by server id. Skipped
|
||||
//! until a clear agent use case emerges.
|
||||
//!
|
||||
//! - `nomi_skill_set_tags` — needs `skill_tag_repo` + `builtin_skill_tags`;
|
||||
//! low agent utility (user-facing tagging).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
|
||||
use crate::server::ok;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// MCP Server param structs
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct McpListServersParams {}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct McpAddServerParams {
|
||||
/// Human-readable name for the MCP server (must be unique; existing name = upsert).
|
||||
name: String,
|
||||
/// Optional description of the server's purpose.
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
/// Transport type: "stdio", "sse", or "http".
|
||||
transport_type: String,
|
||||
/// For stdio: the command to launch (e.g. "npx").
|
||||
#[serde(default)]
|
||||
command: Option<String>,
|
||||
/// For stdio: arguments to the command.
|
||||
#[serde(default)]
|
||||
args: Option<Vec<String>>,
|
||||
/// For stdio: environment variables as key-value pairs.
|
||||
#[serde(default)]
|
||||
env: Option<std::collections::HashMap<String, String>>,
|
||||
/// For sse/http: the endpoint URL.
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
/// For sse/http: extra headers as key-value pairs.
|
||||
#[serde(default)]
|
||||
headers: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct McpEditServerParams {
|
||||
/// The MCP server id (from nomi_mcp_list_servers).
|
||||
id: String,
|
||||
/// New description (pass null to clear, omit to keep).
|
||||
#[serde(default)]
|
||||
description: Option<Option<String>>,
|
||||
/// New transport type (omit to keep). Must provide matching fields.
|
||||
#[serde(default)]
|
||||
transport_type: Option<String>,
|
||||
/// For stdio: the command (omit to keep).
|
||||
#[serde(default)]
|
||||
command: Option<String>,
|
||||
/// For stdio: arguments (omit to keep).
|
||||
#[serde(default)]
|
||||
args: Option<Vec<String>>,
|
||||
/// For stdio: environment variables (omit to keep).
|
||||
#[serde(default)]
|
||||
env: Option<std::collections::HashMap<String, String>>,
|
||||
/// For sse/http: the endpoint URL (omit to keep).
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
/// For sse/http: extra headers (omit to keep).
|
||||
#[serde(default)]
|
||||
headers: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct McpDeleteServerParams {
|
||||
/// The MCP server id to permanently delete.
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct McpToggleServerParams {
|
||||
/// The MCP server id to toggle enabled/disabled.
|
||||
id: String,
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Extension param structs
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ExtensionListParams {}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ExtensionEnableParams {
|
||||
/// Extension name (from nomi_extension_list).
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ExtensionDisableParams {
|
||||
/// Extension name (from nomi_extension_list).
|
||||
name: String,
|
||||
/// Optional reason for disabling.
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Skill param structs
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct SkillListParams {}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct SkillImportParams {
|
||||
/// Absolute path to the skill directory to import (by copy).
|
||||
skill_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct SkillDeleteParams {
|
||||
/// Skill name to permanently delete (user-custom only).
|
||||
name: String,
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Hub param structs
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct HubListExtensionsParams {}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct HubInstallExtensionParams {
|
||||
/// Extension name from the Hub index (from nomi_hub_list_extensions).
|
||||
name: String,
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// MCP Server handlers
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async fn mcp_list_servers(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: McpListServersParams) -> Value {
|
||||
match deps.mcp_config_service.list_servers().await {
|
||||
Ok(servers) => ok(servers),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn mcp_add_server(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: McpAddServerParams) -> Value {
|
||||
use nomifun_api_types::McpTransport;
|
||||
|
||||
let transport = match p.transport_type.as_str() {
|
||||
"stdio" => {
|
||||
let Some(command) = p.command else {
|
||||
return json!({ "error": "field 'command' is required for stdio transport" });
|
||||
};
|
||||
McpTransport::Stdio {
|
||||
command,
|
||||
args: p.args.unwrap_or_default(),
|
||||
env: p.env.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
"sse" => {
|
||||
let Some(url) = p.url else {
|
||||
return json!({ "error": "field 'url' is required for sse transport" });
|
||||
};
|
||||
McpTransport::Sse {
|
||||
url,
|
||||
headers: p.headers.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
"http" => {
|
||||
let Some(url) = p.url else {
|
||||
return json!({ "error": "field 'url' is required for http transport" });
|
||||
};
|
||||
McpTransport::Http {
|
||||
url,
|
||||
headers: p.headers.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
other => {
|
||||
return json!({ "error": format!("unsupported transport_type: {other:?}; use \"stdio\", \"sse\", or \"http\"") });
|
||||
}
|
||||
};
|
||||
|
||||
let req = nomifun_api_types::CreateMcpServerRequest {
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
transport,
|
||||
original_json: None,
|
||||
builtin: false,
|
||||
};
|
||||
match deps.mcp_config_service.add_server(req).await {
|
||||
Ok(server) => ok(server),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn mcp_edit_server(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: McpEditServerParams) -> Value {
|
||||
use nomifun_api_types::McpTransport;
|
||||
|
||||
let transport = match p.transport_type.as_deref() {
|
||||
Some("stdio") => {
|
||||
let Some(command) = p.command else {
|
||||
return json!({ "error": "field 'command' is required for stdio transport" });
|
||||
};
|
||||
Some(McpTransport::Stdio {
|
||||
command,
|
||||
args: p.args.unwrap_or_default(),
|
||||
env: p.env.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
Some("sse") => {
|
||||
let Some(url) = p.url else {
|
||||
return json!({ "error": "field 'url' is required for sse transport" });
|
||||
};
|
||||
Some(McpTransport::Sse {
|
||||
url,
|
||||
headers: p.headers.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
Some("http") => {
|
||||
let Some(url) = p.url else {
|
||||
return json!({ "error": "field 'url' is required for http transport" });
|
||||
};
|
||||
Some(McpTransport::Http {
|
||||
url,
|
||||
headers: p.headers.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
Some(other) => {
|
||||
return json!({ "error": format!("unsupported transport_type: {other:?}; use \"stdio\", \"sse\", or \"http\"") });
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let req = nomifun_api_types::UpdateMcpServerRequest {
|
||||
name: None,
|
||||
description: p.description,
|
||||
transport,
|
||||
original_json: None,
|
||||
builtin: None,
|
||||
};
|
||||
match deps.mcp_config_service.edit_server(&p.id, req).await {
|
||||
Ok(server) => ok(server),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn mcp_delete_server(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: McpDeleteServerParams) -> Value {
|
||||
match deps.mcp_config_service.delete_server(&p.id).await {
|
||||
Ok(was_enabled) => ok(json!({
|
||||
"deleted": true,
|
||||
"was_enabled": was_enabled,
|
||||
})),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn mcp_toggle_server(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: McpToggleServerParams) -> Value {
|
||||
match deps.mcp_config_service.toggle_server(&p.id).await {
|
||||
Ok(server) => ok(server),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Extension handlers
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async fn extension_list(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: ExtensionListParams) -> Value {
|
||||
let summaries = deps.extension_registry.get_loaded_extensions().await;
|
||||
let items: Vec<Value> = summaries
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
json!({
|
||||
"name": s.name,
|
||||
"version": s.version,
|
||||
"display_name": s.display_name,
|
||||
"description": s.description,
|
||||
"enabled": s.enabled,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ok(items)
|
||||
}
|
||||
|
||||
async fn extension_enable(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: ExtensionEnableParams) -> Value {
|
||||
match deps.extension_registry.enable_extension(&p.name).await {
|
||||
Ok(()) => ok(json!({ "enabled": true, "name": p.name })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn extension_disable(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: ExtensionDisableParams) -> Value {
|
||||
match deps.extension_registry.disable_extension(&p.name, p.reason.as_deref()).await {
|
||||
Ok(()) => ok(json!({ "disabled": true, "name": p.name })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Skill handlers
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async fn skill_list(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: SkillListParams) -> Value {
|
||||
match nomifun_extension::skill_service::list_available_skills(&deps.skill_paths).await {
|
||||
Ok(items) => {
|
||||
let resp: Vec<Value> = items
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
json!({
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"is_custom": s.is_custom,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ok(resp)
|
||||
}
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn skill_import(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: SkillImportParams) -> Value {
|
||||
let path = std::path::Path::new(&p.skill_path);
|
||||
match nomifun_extension::skill_service::import_skill(&deps.skill_paths, path).await {
|
||||
Ok(name) => ok(json!({ "imported": true, "skill_name": name })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn skill_delete(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: SkillDeleteParams) -> Value {
|
||||
match nomifun_extension::skill_service::delete_skill(&deps.skill_paths, &p.name).await {
|
||||
Ok(()) => ok(json!({ "deleted": true, "name": p.name })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Hub handlers
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async fn hub_list_extensions(deps: Arc<GatewayDeps>, _ctx: CallerCtx, _p: HubListExtensionsParams) -> Value {
|
||||
let entries = deps.hub_index_manager.load_index().await;
|
||||
let items: Vec<Value> = entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let status_str = serde_json::to_value(&e.status)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_else(|| "notInstalled".to_string());
|
||||
json!({
|
||||
"name": e.name,
|
||||
"version": e.version,
|
||||
"display_name": e.display_name,
|
||||
"description": e.description,
|
||||
"author": e.author,
|
||||
"status": status_str,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ok(items)
|
||||
}
|
||||
|
||||
async fn hub_install_extension(deps: Arc<GatewayDeps>, _ctx: CallerCtx, p: HubInstallExtensionParams) -> Value {
|
||||
let result = deps.hub_installer.install(&p.name).await;
|
||||
ok(json!({
|
||||
"success": result.success,
|
||||
"msg": result.msg,
|
||||
}))
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Registration
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Register the MCP/Extension/Skill/Hub domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
// ── MCP Servers ──────────────────────────────────────────────────────
|
||||
|
||||
out.push(Capability::new::<McpListServersParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_mcp_list_servers",
|
||||
"mcp",
|
||||
"List all configured MCP servers (name, transport, enabled state, connection status).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| mcp_list_servers(deps, ctx, p),
|
||||
));
|
||||
|
||||
out.push(Capability::new::<McpAddServerParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_mcp_add_server",
|
||||
"mcp",
|
||||
"Add a new MCP server (stdio/sse/http). Upserts by name if one already exists. Headers may contain auth tokens.",
|
||||
DangerTier::Sensitive,
|
||||
),
|
||||
|deps, ctx, p| mcp_add_server(deps, ctx, p),
|
||||
));
|
||||
|
||||
out.push(Capability::new::<McpEditServerParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_mcp_edit_server",
|
||||
"mcp",
|
||||
"Edit an existing MCP server's transport or description (by id).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, ctx, p| mcp_edit_server(deps, ctx, p),
|
||||
));
|
||||
|
||||
out.push(Capability::new::<McpDeleteServerParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_mcp_delete_server",
|
||||
"mcp",
|
||||
"Permanently delete an MCP server configuration (by id).",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, ctx, p| mcp_delete_server(deps, ctx, p),
|
||||
));
|
||||
|
||||
out.push(Capability::new::<McpToggleServerParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_mcp_toggle_server",
|
||||
"mcp",
|
||||
"Toggle the enabled/disabled state of an MCP server (by id).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, ctx, p| mcp_toggle_server(deps, ctx, p),
|
||||
));
|
||||
|
||||
// ── Extensions ───────────────────────────────────────────────────────
|
||||
|
||||
out.push(Capability::new::<ExtensionListParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_extension_list",
|
||||
"extension",
|
||||
"List all loaded extensions (name, version, enabled state).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| extension_list(deps, ctx, p),
|
||||
));
|
||||
|
||||
out.push(Capability::new::<ExtensionEnableParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_extension_enable",
|
||||
"extension",
|
||||
"Enable a disabled extension by name.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, ctx, p| extension_enable(deps, ctx, p),
|
||||
));
|
||||
|
||||
out.push(Capability::new::<ExtensionDisableParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_extension_disable",
|
||||
"extension",
|
||||
"Disable an enabled extension by name (with optional reason).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, ctx, p| extension_disable(deps, ctx, p),
|
||||
));
|
||||
|
||||
// ── Skills ───────────────────────────────────────────────────────────
|
||||
|
||||
out.push(Capability::new::<SkillListParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_skill_list",
|
||||
"skill",
|
||||
"List all available skills (built-in and user-custom).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| skill_list(deps, ctx, p),
|
||||
));
|
||||
|
||||
out.push(Capability::new::<SkillImportParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_skill_import",
|
||||
"skill",
|
||||
"Import a skill from a local directory (by absolute path). Copies the skill into the user skills folder.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, ctx, p| skill_import(deps, ctx, p),
|
||||
));
|
||||
|
||||
out.push(Capability::new::<SkillDeleteParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_skill_delete",
|
||||
"skill",
|
||||
"Permanently delete a user-custom skill by name.",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, ctx, p| skill_delete(deps, ctx, p),
|
||||
));
|
||||
|
||||
// ── Hub ──────────────────────────────────────────────────────────────
|
||||
|
||||
out.push(Capability::new::<HubListExtensionsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_hub_list_extensions",
|
||||
"hub",
|
||||
"List extensions available in the Hub marketplace (name, version, install status).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| hub_list_extensions(deps, ctx, p),
|
||||
));
|
||||
|
||||
out.push(Capability::new::<HubInstallExtensionParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_hub_install_extension",
|
||||
"hub",
|
||||
"Install an extension from the Hub by name. Downloads and registers it locally.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, ctx, p| hub_install_extension(deps, ctx, p),
|
||||
));
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// SKIPPED tools
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// 1. `nomi_mcp_test_connection` (Read/Write)
|
||||
// Service: `McpConnectionTestService::test_connection(&self, name: &str, transport: &McpServerTransport)`
|
||||
// Issue: The `McpServerTransport` is a domain enum built from the tagged
|
||||
// `McpTransport` API type. Exposing a tagged-union transport in the flat
|
||||
// JSON schema would be confusing for an LLM (requires `type` + variant-
|
||||
// specific fields). The route handler also persists test results back.
|
||||
// Agent use case unclear — the user can trigger a test from the UI.
|
||||
//
|
||||
// 2. `nomi_skill_set_tags` (Write)
|
||||
// Service: `ISkillTagRepository::upsert(...)` + `builtin_skill_tags` map.
|
||||
// Issue: Tags are audience/scenario classifications for UI filtering, not
|
||||
// something an agent typically needs to set. Low priority.
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Memory-domain capabilities (registry form), backed by the companion memory
|
||||
//! store — the desktop's long-term memory, same data as `/api/companion/memories`.
|
||||
//!
|
||||
//! Reference migration of `tools_memory.rs` onto the capability registry: the
|
||||
//! `*Params` structs are now the SINGLE source (schema + runtime deserialization),
|
||||
//! so the historical drift — `offset` readable at runtime but absent from the MCP
|
||||
//! schema — is fixed by construction (it is a declared field here).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_companion::store::{MemoryFilter, MemoryScope};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::GatewayDeps;
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier};
|
||||
use crate::server::ok;
|
||||
|
||||
const DEFAULT_LIST_LIMIT: i64 = 50;
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct MemoryListParams {
|
||||
/// Filter by kind: profile / preference / knowledge / episode / task / affective.
|
||||
#[serde(default)]
|
||||
kind: Option<String>,
|
||||
/// Substring search over memory content.
|
||||
#[serde(default)]
|
||||
query: Option<String>,
|
||||
/// Include archived memories too (default false: active only).
|
||||
#[serde(default)]
|
||||
include_archived: Option<bool>,
|
||||
/// Maximum rows to return (default 50, clamped to 1..=200).
|
||||
#[serde(default)]
|
||||
limit: Option<i64>,
|
||||
/// Row offset for pagination (default 0).
|
||||
#[serde(default)]
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct MemorySaveParams {
|
||||
/// The memory content — one self-contained fact.
|
||||
content: String,
|
||||
/// Kind: profile / preference / knowledge / episode / task / affective (default knowledge).
|
||||
#[serde(default)]
|
||||
kind: Option<String>,
|
||||
/// Optional tags.
|
||||
#[serde(default)]
|
||||
tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct MemoryUpdateParams {
|
||||
/// The id of the memory to update (from nomi_memory_list).
|
||||
id: String,
|
||||
/// New content (omit to keep).
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
/// Pin (true) or unpin (false) the memory; pinned memories are always injected.
|
||||
#[serde(default)]
|
||||
pinned: Option<bool>,
|
||||
/// "active" or "archived".
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct MemoryDeleteParams {
|
||||
/// The id of the memory to permanently delete. Prefer archiving via
|
||||
/// nomi_memory_update unless the user explicitly asked to delete.
|
||||
id: String,
|
||||
}
|
||||
|
||||
async fn list(deps: Arc<GatewayDeps>, p: MemoryListParams) -> Value {
|
||||
let filter = MemoryFilter {
|
||||
kind: p.kind,
|
||||
q: p.query,
|
||||
status: if p.include_archived.unwrap_or(false) {
|
||||
None
|
||||
} else {
|
||||
Some("active".to_owned())
|
||||
},
|
||||
// Master-agent view spans every companion's memories.
|
||||
scope_companion_id: None,
|
||||
limit: p.limit.unwrap_or(DEFAULT_LIST_LIMIT).clamp(1, 200),
|
||||
offset: p.offset.unwrap_or(0).max(0),
|
||||
};
|
||||
match deps.companion_service.list_memories(&filter).await {
|
||||
Ok(memories) => ok(memories),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(deps: Arc<GatewayDeps>, p: MemorySaveParams) -> Value {
|
||||
let content = p.content.trim();
|
||||
if content.is_empty() {
|
||||
return json!({ "error": "missing required field: content" });
|
||||
}
|
||||
let kind = p.kind.unwrap_or_else(|| "knowledge".to_owned());
|
||||
let tags = p.tags.unwrap_or_default();
|
||||
match deps.companion_service.add_memory(&kind, content, &tags, MemoryScope::Shared).await {
|
||||
Ok(memory) => ok(memory),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(deps: Arc<GatewayDeps>, p: MemoryUpdateParams) -> Value {
|
||||
if p.content.is_none() && p.pinned.is_none() && p.status.is_none() {
|
||||
return json!({ "error": "nothing to update: provide at least one of content / pinned / status" });
|
||||
}
|
||||
match deps
|
||||
.companion_service
|
||||
.update_memory(&p.id, p.content.as_deref(), p.pinned, p.status.as_deref(), None)
|
||||
.await
|
||||
{
|
||||
Ok(()) => json!({ "result": format!("memory {} updated", p.id) }),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(deps: Arc<GatewayDeps>, p: MemoryDeleteParams) -> Value {
|
||||
match deps.companion_service.delete_memory(&p.id).await {
|
||||
Ok(()) => json!({ "result": format!("memory {} deleted", p.id) }),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the memory-domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<MemoryListParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_memory_list",
|
||||
"memory",
|
||||
"List the desktop's long-term memories (active by default; filter by kind/query; include_archived to see archived).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<MemorySaveParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_memory_save",
|
||||
"memory",
|
||||
"Persist a new long-term memory (one self-contained fact).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| save(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<MemoryUpdateParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_memory_update",
|
||||
"memory",
|
||||
"Edit a memory's content, pin/unpin it, or archive/reactivate it.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| update(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<MemoryDeleteParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_memory_delete",
|
||||
"memory",
|
||||
"Permanently delete a memory. Prefer archiving via nomi_memory_update unless the user asked to delete.",
|
||||
DangerTier::Destructive,
|
||||
),
|
||||
|deps, _ctx, p| delete(deps, p),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Provider-domain capability (registry form): the read-only model-provider
|
||||
//! catalog. The shared nomi model-resolution chain stays in `tools_provider`
|
||||
//! (used by the cron + conversation capabilities), this only exposes listing.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::GatewayDeps;
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier};
|
||||
use crate::server::ok;
|
||||
use crate::tools_provider::load_provider_summaries;
|
||||
|
||||
/// Cap on models listed per provider (keeps the tool result inside the calling
|
||||
/// agent's context budget; the full list lives in desktop Settings).
|
||||
const MAX_MODELS_PER_PROVIDER: usize = 20;
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListProvidersParams {
|
||||
/// Include disabled providers too (default false: enabled only).
|
||||
#[serde(default)]
|
||||
include_disabled: Option<bool>,
|
||||
}
|
||||
|
||||
async fn list(deps: Arc<GatewayDeps>, p: ListProvidersParams) -> Value {
|
||||
let include_disabled = p.include_disabled.unwrap_or(false);
|
||||
let summaries = match load_provider_summaries(&deps).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let items: Vec<Value> = summaries
|
||||
.iter()
|
||||
.filter(|p| include_disabled || p.enabled)
|
||||
.map(|p| {
|
||||
json!({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"platform": p.platform,
|
||||
"enabled": p.enabled,
|
||||
"models": p.models.iter().take(MAX_MODELS_PER_PROVIDER).collect::<Vec<_>>(),
|
||||
"model_count": p.models.len(),
|
||||
"models_truncated": p.models.len() > MAX_MODELS_PER_PROVIDER,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
return ok(json!({
|
||||
"providers": [],
|
||||
"note": "no model provider is configured/enabled on this desktop yet — add one in Settings → Providers (or via nomi_create_provider) before creating nomi conversations or cron jobs"
|
||||
}));
|
||||
}
|
||||
ok(json!({ "providers": items }))
|
||||
}
|
||||
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<ListProvidersParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_list_providers",
|
||||
"provider",
|
||||
"Read-only catalog of configured model providers and their enabled models (no API keys), for guiding a model choice before creating sessions / cron jobs.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list(deps, p),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! Requirement-domain capabilities (registry form), backed by the requirement
|
||||
//! service — the desktop's task/requirement board driving AutoWork scheduling.
|
||||
//!
|
||||
//! Migrated from `tools_requirement.rs` onto the capability registry: the
|
||||
//! `*Params` structs are now the single source (schema + runtime deserialization),
|
||||
//! eliminating hand-parsing via `require_str`/`opt_str`/`require_i64`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{
|
||||
CreateRequirementRequest, ListRequirementsQuery, RequirementStatus,
|
||||
UpdateRequirementRequest,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::GatewayDeps;
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier};
|
||||
use crate::server::ok;
|
||||
|
||||
/// How many requirements the duplicate guard inspects (same tag, newest pages
|
||||
/// first would be ideal; the repo orders deterministically and tags rarely
|
||||
/// exceed this).
|
||||
const DEDUP_SCAN_PAGE_SIZE: u32 = 200;
|
||||
|
||||
// ─── Params structs ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RequirementListParams {
|
||||
/// Filter by tag (the AutoWork grouping/scheduling dimension).
|
||||
#[serde(default)]
|
||||
tag: Option<String>,
|
||||
/// Filter by status: pending / in_progress / done / failed / cancelled / needs_review.
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
/// Full-text filter over title/content.
|
||||
#[serde(default)]
|
||||
query: Option<String>,
|
||||
/// Page number (default 1).
|
||||
#[serde(default)]
|
||||
page: Option<u32>,
|
||||
/// Page size (default 20, max 200).
|
||||
#[serde(default)]
|
||||
page_size: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RequirementCreateParams {
|
||||
/// Requirement title.
|
||||
title: String,
|
||||
/// Detailed requirement content (markdown).
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
/// Tag — the grouping AutoWork schedules by.
|
||||
tag: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RequirementUpdateParams {
|
||||
/// The id of the requirement to update (from nomi_requirement_list).
|
||||
id: i64,
|
||||
/// New title (omit to keep).
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
/// New content (omit to keep).
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
/// New tag (omit to keep).
|
||||
#[serde(default)]
|
||||
tag: Option<String>,
|
||||
/// New status: pending / in_progress / done / failed / cancelled.
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
/// Note recorded with a status change (recommended for done/failed).
|
||||
#[serde(default)]
|
||||
completion_note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RequirementDeleteParams {
|
||||
/// The id of the requirement to delete. Confirm the target with the user first.
|
||||
id: i64,
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse an optional status string into the typed enum, returning a structured
|
||||
/// error the LLM can self-correct from.
|
||||
fn parse_status(raw: Option<&str>) -> Result<Option<RequirementStatus>, Value> {
|
||||
match raw {
|
||||
None => Ok(None),
|
||||
Some(s) => serde_json::from_value::<RequirementStatus>(json!(s))
|
||||
.map(Some)
|
||||
.map_err(|_| json!({"error": format!("invalid status '{s}'")})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Duplicate-create guard: an OPEN requirement (pending / in_progress /
|
||||
/// needs_review) under the same tag with the same (trimmed,
|
||||
/// ASCII-case-insensitive) title counts as a duplicate. Done / failed /
|
||||
/// cancelled requirements never block a re-create.
|
||||
pub(crate) fn is_open_duplicate(
|
||||
status: &RequirementStatus,
|
||||
existing_title: &str,
|
||||
new_title: &str,
|
||||
) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
RequirementStatus::Pending | RequirementStatus::InProgress | RequirementStatus::NeedsReview
|
||||
) && existing_title.trim().eq_ignore_ascii_case(new_title.trim())
|
||||
}
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async fn list(deps: Arc<GatewayDeps>, p: RequirementListParams) -> Value {
|
||||
let status = match parse_status(p.status.as_deref()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let query = ListRequirementsQuery {
|
||||
tag: p.tag,
|
||||
status,
|
||||
conversation_id: None,
|
||||
q: p.query,
|
||||
order_by: None,
|
||||
order: None,
|
||||
page: p.page,
|
||||
page_size: p.page_size,
|
||||
};
|
||||
match deps.requirement_service.list(&query).await {
|
||||
Ok(result) => ok(result),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a requirement after checking the duplicate guard: an open
|
||||
/// requirement with the same tag + title (trimmed, case-insensitive) returns
|
||||
/// the existing one instead of creating a twin.
|
||||
async fn create(deps: Arc<GatewayDeps>, p: RequirementCreateParams) -> Value {
|
||||
let title = p.title;
|
||||
let tag = p.tag;
|
||||
|
||||
// ── T4 duplicate guard ───────────────────────────────────────────
|
||||
let dedup_query = ListRequirementsQuery {
|
||||
tag: Some(tag.clone()),
|
||||
status: None,
|
||||
conversation_id: None,
|
||||
q: None,
|
||||
order_by: None,
|
||||
order: None,
|
||||
page: Some(1),
|
||||
page_size: Some(DEDUP_SCAN_PAGE_SIZE),
|
||||
};
|
||||
match deps.requirement_service.list(&dedup_query).await {
|
||||
Ok(page) => {
|
||||
if let Some(existing) = page
|
||||
.items
|
||||
.iter()
|
||||
.find(|r| is_open_duplicate(&r.status, &r.title, &title))
|
||||
{
|
||||
return ok(json!({
|
||||
"duplicate": true,
|
||||
"existing_requirement": existing,
|
||||
"note": "an open requirement with this tag + title already exists — nothing was created. Use nomi_requirement_update to refine it; only create a second one if the owner explicitly asked for a duplicate this turn."
|
||||
}));
|
||||
}
|
||||
}
|
||||
Err(e) => return json!({"error": e.to_string()}),
|
||||
}
|
||||
|
||||
let req = CreateRequirementRequest {
|
||||
title,
|
||||
content: p.content.unwrap_or_default(),
|
||||
tag,
|
||||
order_key: None,
|
||||
status: None,
|
||||
created_by: Some("agent".to_owned()),
|
||||
attachments: vec![],
|
||||
};
|
||||
match deps.requirement_service.create(req).await {
|
||||
Ok(requirement) => ok(requirement),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(deps: Arc<GatewayDeps>, p: RequirementUpdateParams) -> Value {
|
||||
let status = match parse_status(p.status.as_deref()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let req = UpdateRequirementRequest {
|
||||
title: p.title,
|
||||
content: p.content,
|
||||
tag: p.tag,
|
||||
order_key: None,
|
||||
status,
|
||||
completion_note: p.completion_note,
|
||||
add_attachments: vec![],
|
||||
remove_attachment_ids: vec![],
|
||||
};
|
||||
match deps.requirement_service.update(p.id, req).await {
|
||||
Ok(requirement) => ok(requirement),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(deps: Arc<GatewayDeps>, p: RequirementDeleteParams) -> Value {
|
||||
match deps.requirement_service.delete(p.id).await {
|
||||
Ok(()) => json!({"result": format!("requirement {} deleted", p.id)}),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Registration ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Register the requirement-domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<RequirementListParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_requirement_list",
|
||||
"requirement",
|
||||
"List requirements (the desktop task board). Paginated; filter by tag / status / full-text query.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| list(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<RequirementCreateParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_requirement_create",
|
||||
"requirement",
|
||||
"Create a new requirement. Refuses to create a duplicate of an open requirement with the same tag + title (returns the existing one instead).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| create(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<RequirementUpdateParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_requirement_update",
|
||||
"requirement",
|
||||
"Update a requirement's title, content, tag, status, or completion note.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| update(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<RequirementDeleteParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_requirement_delete",
|
||||
"requirement",
|
||||
"Permanently delete a requirement. Confirm the target with the user first.",
|
||||
DangerTier::Destructive,
|
||||
),
|
||||
|deps, _ctx, p| delete(deps, p),
|
||||
));
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_statuses_with_same_title_are_duplicates() {
|
||||
for status in [
|
||||
RequirementStatus::Pending,
|
||||
RequirementStatus::InProgress,
|
||||
RequirementStatus::NeedsReview,
|
||||
] {
|
||||
assert!(
|
||||
is_open_duplicate(&status, "Fix login bug", " fix login BUG "),
|
||||
"{status:?} should block a same-title re-create"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_statuses_never_block_recreate() {
|
||||
for status in [
|
||||
RequirementStatus::Done,
|
||||
RequirementStatus::Failed,
|
||||
RequirementStatus::Cancelled,
|
||||
] {
|
||||
assert!(
|
||||
!is_open_duplicate(&status, "Fix login bug", "Fix login bug"),
|
||||
"{status:?} must not block a re-create"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_titles_are_not_duplicates() {
|
||||
assert!(!is_open_duplicate(
|
||||
&RequirementStatus::Pending,
|
||||
"Fix login bug",
|
||||
"Fix logout bug"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Extended SCHEDULING / AUTONOMY gateway tools — surface service methods that
|
||||
//! the base `caps_cron`, `caps_requirement`, `caps_autowork`, and `caps_idmm`
|
||||
//! files do NOT already register.
|
||||
//!
|
||||
//! Each handler follows the established pattern: typed `*Params` (single source
|
||||
//! of schema + deserialization), `(Arc<GatewayDeps>, CallerCtx, P) -> Value` or
|
||||
//! `(Arc<GatewayDeps>, P) -> Value`, `crate::server::ok` for success, structured
|
||||
//! `json!({"error":…})` on failure.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::IdmmTargetKind;
|
||||
use nomifun_cron::types::cron_job_to_response;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
|
||||
use crate::server::ok;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CRON DOMAIN (extensions)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CronGetJobParams {
|
||||
/// The id of the cron job to retrieve (from nomi_cron_list).
|
||||
job_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CronRunNowParams {
|
||||
/// The id of the cron job to trigger immediately.
|
||||
job_id: String,
|
||||
}
|
||||
|
||||
async fn cron_get_job(deps: Arc<GatewayDeps>, p: CronGetJobParams) -> Value {
|
||||
match deps.cron_service.get_job(&p.job_id).await {
|
||||
Ok(job) => ok(cron_job_to_response(&job)),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cron_run_now(deps: Arc<GatewayDeps>, p: CronRunNowParams) -> Value {
|
||||
match deps.cron_service.run_now(&p.job_id).await {
|
||||
Ok(resp) => ok(json!({
|
||||
"triggered": true,
|
||||
"conversation_id": resp.conversation_id,
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// REQUIREMENT DOMAIN (extensions)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RequirementGetParams {
|
||||
/// The id of the requirement to fetch.
|
||||
id: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RequirementListTagsParams {
|
||||
// Intentionally empty — tags() takes no arguments.
|
||||
// A unit struct would also work, but an empty object is friendlier to
|
||||
// schema consumers.
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RequirementGetBoardParams {
|
||||
/// The tag whose kanban board to retrieve.
|
||||
tag: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RequirementResumeTagParams {
|
||||
/// The tag to resume (un-pause).
|
||||
tag: String,
|
||||
/// Re-queue ALL failed requirements in the tag back to pending (default false).
|
||||
#[serde(default)]
|
||||
requeue_failed: bool,
|
||||
/// Re-queue these specific failed requirement ids back to pending.
|
||||
#[serde(default)]
|
||||
requeue_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
async fn requirement_get(deps: Arc<GatewayDeps>, p: RequirementGetParams) -> Value {
|
||||
match deps.requirement_service.get(p.id).await {
|
||||
Ok(req) => ok(req),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn requirement_list_tags(deps: Arc<GatewayDeps>, _p: RequirementListTagsParams) -> Value {
|
||||
match deps.requirement_service.tags().await {
|
||||
Ok(tags) => ok(tags),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn requirement_get_board(deps: Arc<GatewayDeps>, p: RequirementGetBoardParams) -> Value {
|
||||
match deps.requirement_service.board(&p.tag).await {
|
||||
Ok(board) => ok(board),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn requirement_resume_tag(deps: Arc<GatewayDeps>, p: RequirementResumeTagParams) -> Value {
|
||||
// Mirror the REST route: if `requeue_failed`, collect all failed ids from
|
||||
// the board and merge with explicit ids.
|
||||
let mut requeue_ids = p.requeue_ids;
|
||||
if p.requeue_failed {
|
||||
match deps.requirement_service.board(&p.tag).await {
|
||||
Ok(board) => {
|
||||
requeue_ids.extend(board.failed.into_iter().map(|r| r.id));
|
||||
}
|
||||
Err(e) => return json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
if let Err(e) = deps.requirement_service.resume_tag(&p.tag, &requeue_ids).await {
|
||||
return json!({"error": e.to_string()});
|
||||
}
|
||||
// Return the updated tag summary (same as the REST route).
|
||||
match deps.requirement_service.tags().await {
|
||||
Ok(tags) => {
|
||||
let summary = tags.into_iter().find(|t| t.tag == p.tag);
|
||||
ok(json!({
|
||||
"resumed": true,
|
||||
"requeued_count": requeue_ids.len(),
|
||||
"tag_summary": summary,
|
||||
}))
|
||||
}
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// IDMM DOMAIN (extensions)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct IdmmGetLogParams {
|
||||
/// Target kind: "conversation" or "terminal".
|
||||
kind: String,
|
||||
/// The conversation id or terminal id to inspect.
|
||||
target_id: String,
|
||||
/// Maximum rows to return (default 50, clamped to 1..=500).
|
||||
#[serde(default)]
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct IdmmGetActivityParams {
|
||||
/// Maximum rows to return (default 50, clamped to 1..=500).
|
||||
#[serde(default)]
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct IdmmInterveneParams {
|
||||
/// Target kind: "conversation" or "terminal".
|
||||
kind: String,
|
||||
/// The conversation id or terminal id to intervene on.
|
||||
target_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct IdmmGetSettingsParams {
|
||||
// No parameters — global settings.
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct IdmmSetSettingsParams {
|
||||
/// Backup provider id for sidecar model fallback (omit to clear).
|
||||
#[serde(default)]
|
||||
backup_provider_id: Option<String>,
|
||||
/// Backup model id for sidecar fallback (omit to clear).
|
||||
#[serde(default)]
|
||||
backup_model: Option<String>,
|
||||
/// Default steering prompt injected into new IDMM supervision configs.
|
||||
#[serde(default)]
|
||||
default_steering_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct IdmmClearLogParams {
|
||||
/// Target kind: "conversation" or "terminal".
|
||||
kind: String,
|
||||
/// The conversation id or terminal id whose log to clear.
|
||||
target_id: String,
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_idmm_kind(raw: &str) -> Result<IdmmTargetKind, Value> {
|
||||
IdmmTargetKind::parse(raw)
|
||||
.ok_or_else(|| json!({"error": format!("unknown kind '{raw}' (expected conversation | terminal)")}))
|
||||
}
|
||||
|
||||
/// Terminal targets are ownership-checked (same gate as caps_idmm).
|
||||
async fn verify_idmm_terminal(deps: &GatewayDeps, ctx: &CallerCtx, kind: IdmmTargetKind, target_id: &str) -> Option<Value> {
|
||||
if kind != IdmmTargetKind::Terminal {
|
||||
return None;
|
||||
}
|
||||
if ctx.user_id.is_empty() {
|
||||
return Some(json!({"error": "missing caller user identity"}));
|
||||
}
|
||||
match deps.idmm_service.verify_terminal_owner(target_id, &ctx.user_id).await {
|
||||
Ok(()) => None,
|
||||
Err(e) => Some(json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async fn idmm_get_log(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: IdmmGetLogParams) -> Value {
|
||||
let kind = match parse_idmm_kind(&p.kind) {
|
||||
Ok(k) => k,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Some(err) = verify_idmm_terminal(&deps, &ctx, kind, &p.target_id).await {
|
||||
return err;
|
||||
}
|
||||
let limit = p.limit.unwrap_or(50).clamp(1, 500);
|
||||
match deps.idmm_service.log(kind, &p.target_id, limit).await {
|
||||
Ok(records) => ok(records),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn idmm_get_activity(deps: Arc<GatewayDeps>, p: IdmmGetActivityParams) -> Value {
|
||||
let limit = p.limit.unwrap_or(50).clamp(1, 500);
|
||||
match deps.idmm_service.recent_activity(limit).await {
|
||||
Ok(records) => ok(records),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn idmm_intervene(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: IdmmInterveneParams) -> Value {
|
||||
let kind = match parse_idmm_kind(&p.kind) {
|
||||
Ok(k) => k,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Some(err) = verify_idmm_terminal(&deps, &ctx, kind, &p.target_id).await {
|
||||
return err;
|
||||
}
|
||||
match deps.idmm_service.intervene_now(kind, &p.target_id).await {
|
||||
Ok(()) => {
|
||||
// Return the updated state (same as the REST route).
|
||||
match deps.idmm_service.build_state(kind, &p.target_id).await {
|
||||
Ok(state) => ok(json!({
|
||||
"intervened": true,
|
||||
"state": state,
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn idmm_get_settings(deps: Arc<GatewayDeps>, _p: IdmmGetSettingsParams) -> Value {
|
||||
match deps.idmm_service.get_settings().await {
|
||||
Ok(settings) => ok(settings),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn idmm_set_settings(deps: Arc<GatewayDeps>, p: IdmmSetSettingsParams) -> Value {
|
||||
// Read current settings and overlay provided fields (same partial-update
|
||||
// semantics as the REST route).
|
||||
let mut settings = match deps.idmm_service.get_settings().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return json!({"error": e.to_string()}),
|
||||
};
|
||||
if p.backup_provider_id.is_some() {
|
||||
settings.backup_provider_id = p.backup_provider_id;
|
||||
}
|
||||
if p.backup_model.is_some() {
|
||||
settings.backup_model = p.backup_model;
|
||||
}
|
||||
if let Some(prompt) = p.default_steering_prompt {
|
||||
settings.default_steering_prompt = prompt;
|
||||
}
|
||||
|
||||
match deps.idmm_service.set_settings(&settings).await {
|
||||
Ok(()) => ok(settings),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn idmm_clear_log(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: IdmmClearLogParams) -> Value {
|
||||
let kind = match parse_idmm_kind(&p.kind) {
|
||||
Ok(k) => k,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if let Some(err) = verify_idmm_terminal(&deps, &ctx, kind, &p.target_id).await {
|
||||
return err;
|
||||
}
|
||||
match deps.idmm_service.clear_log(kind, &p.target_id).await {
|
||||
Ok(count) => json!({"result": format!("cleared {count} intervention records")}),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// REGISTRATION
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Register the scheduling/autonomy extension capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
// ── Cron extensions ─────────────────────────────────────────────────────
|
||||
out.push(Capability::new::<CronGetJobParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_cron_get_job",
|
||||
"cron",
|
||||
"Get a single cron job by id (full detail including schedule, next/last run, error).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| cron_get_job(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<CronRunNowParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_cron_run_now",
|
||||
"cron",
|
||||
"Trigger a cron job to execute immediately (out-of-schedule one-shot run).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| cron_run_now(deps, p),
|
||||
));
|
||||
|
||||
// ── Requirement extensions ──────────────────────────────────────────────
|
||||
out.push(Capability::new::<RequirementGetParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_requirement_get",
|
||||
"requirement",
|
||||
"Fetch a single requirement by id (full detail including attachments, timestamps, status).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| requirement_get(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<RequirementListTagsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_requirement_list_tags",
|
||||
"requirement",
|
||||
"List all AutoWork tags with per-status counts, paused state, and totals.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| requirement_list_tags(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<RequirementGetBoardParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_requirement_get_board",
|
||||
"requirement",
|
||||
"Get the kanban board view for a tag (requirements grouped by status column).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| requirement_get_board(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<RequirementResumeTagParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_requirement_resume_tag",
|
||||
"requirement",
|
||||
"Resume a paused AutoWork tag and optionally re-queue failed requirements back to pending.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| requirement_resume_tag(deps, p),
|
||||
));
|
||||
|
||||
// ── IDMM extensions ─────────────────────────────────────────────────────
|
||||
out.push(Capability::new::<IdmmGetLogParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_idmm_get_log",
|
||||
"idmm",
|
||||
"Read the persisted intervention log for a conversation or terminal (most-recent-first).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
idmm_get_log,
|
||||
));
|
||||
out.push(Capability::new::<IdmmGetActivityParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_idmm_get_activity",
|
||||
"idmm",
|
||||
"Read the cross-session recent intervention feed (all targets, most-recent-first).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| idmm_get_activity(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<IdmmInterveneParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_idmm_intervene",
|
||||
"idmm",
|
||||
"Force one IDMM supervision pass now (manual 'act now') and return the resulting state.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
idmm_intervene,
|
||||
));
|
||||
out.push(Capability::new::<IdmmGetSettingsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_idmm_get_settings",
|
||||
"idmm",
|
||||
"Read global IDMM settings (backup provider/model, default steering prompt).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| idmm_get_settings(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<IdmmSetSettingsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_idmm_set_settings",
|
||||
"idmm",
|
||||
"Update global IDMM settings (backup provider/model, default steering prompt). Partial update: omitted fields keep their current value.",
|
||||
DangerTier::Sensitive,
|
||||
),
|
||||
|deps, _ctx, p| idmm_set_settings(deps, p),
|
||||
));
|
||||
out.push(Capability::new::<IdmmClearLogParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_idmm_clear_log",
|
||||
"idmm",
|
||||
"Clear all persisted intervention records for a conversation or terminal. Irreversible.",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
idmm_clear_log,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
//! System-domain capabilities (registry form): desktop settings, client
|
||||
//! preferences (theme / zoom / keep-awake / feature toggles), model-provider
|
||||
//! CRUD, model fetching, and read-only system info.
|
||||
//!
|
||||
//! These tools let the LLM agent configure the desktop environment on behalf
|
||||
//! of the user — the headline use case is "set my theme to dark" / "add a
|
||||
//! new provider" / "change my zoom level" spoken to the companion.
|
||||
//!
|
||||
//! SKIPPED tools (listed at the bottom of this file) need extra GatewayDeps
|
||||
//! fields the parent has not yet wired:
|
||||
//! - `nomi_system_check_update` — needs `VersionCheckService`
|
||||
//! - `nomi_system_factory_reset` — needs `data_dir: PathBuf`
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{
|
||||
CreateProviderRequest, FetchModelsRequest, UpdateProviderRequest, UpdateSettingsRequest,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::GatewayDeps;
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier};
|
||||
use crate::server::ok;
|
||||
|
||||
// ── param structs (single source: schema + runtime) ──────────────────────
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct GetSettingsParams {}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct UpdateSettingsParams {
|
||||
/// System language code. Allowed: "en-US" or "zh-CN".
|
||||
#[serde(default)]
|
||||
language: Option<String>,
|
||||
/// Enable/disable desktop notifications globally.
|
||||
#[serde(default)]
|
||||
notification_enabled: Option<bool>,
|
||||
/// Enable/disable notifications specifically for cron-job results.
|
||||
#[serde(default)]
|
||||
cron_notification_enabled: Option<bool>,
|
||||
/// Enable/disable the command queue (batch-queued execution of LLM requests).
|
||||
#[serde(default)]
|
||||
command_queue_enabled: Option<bool>,
|
||||
/// Whether uploaded files should be saved to the current workspace.
|
||||
#[serde(default)]
|
||||
save_upload_to_workspace: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct GetPreferencesParams {
|
||||
/// Optional list of preference keys to fetch (omit to return all).
|
||||
/// Common keys: "theme", "ui.zoomFactor", "system.closeToTray",
|
||||
/// "companion.size", "system.keepAwake", "feature.*".
|
||||
#[serde(default)]
|
||||
keys: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct UpdatePreferencesParams {
|
||||
/// Map of key → JSON value to set. A `null` value deletes the key.
|
||||
/// Keys must be non-empty and at most 255 characters.
|
||||
///
|
||||
/// Common keys (non-exhaustive):
|
||||
/// "theme" (string: "light" | "dark" | "rhythm-dark" | …),
|
||||
/// "ui.zoomFactor" (number: 0.5–2.0),
|
||||
/// "system.closeToTray" (bool),
|
||||
/// "system.keepAwake" (bool),
|
||||
/// "companion.size" (number: px),
|
||||
/// "feature.<name>" (bool).
|
||||
preferences: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CreateProviderParams {
|
||||
/// Provider platform identifier (e.g. "openai", "anthropic", "gemini",
|
||||
/// "new-api", "bedrock", "vertex-ai", "minimax", "dashscope-coding", etc.).
|
||||
platform: String,
|
||||
/// Human-readable display name for this provider.
|
||||
name: String,
|
||||
/// API base URL (must start with http:// or https://). Empty string allowed
|
||||
/// only for bedrock platform.
|
||||
base_url: String,
|
||||
/// Plain-text API key (supports comma/newline-separated multi-keys for
|
||||
/// load balancing). Required for non-bedrock platforms.
|
||||
api_key: String,
|
||||
/// Initial model list. If omitted, use nomi_system_fetch_models after
|
||||
/// creation to populate.
|
||||
#[serde(default)]
|
||||
models: Option<Vec<String>>,
|
||||
/// Whether the provider is enabled (default true).
|
||||
#[serde(default)]
|
||||
enabled: Option<bool>,
|
||||
/// Optional context-window limit override (token count).
|
||||
#[serde(default)]
|
||||
context_limit: Option<i64>,
|
||||
/// Optional AWS Bedrock configuration (required when platform = "bedrock").
|
||||
/// Pass the full BedrockConfig object as JSON.
|
||||
#[serde(default)]
|
||||
bedrock_config: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct UpdateProviderParams {
|
||||
/// Provider id (from nomi_list_providers).
|
||||
id: String,
|
||||
/// New platform identifier (omit to keep).
|
||||
#[serde(default)]
|
||||
platform: Option<String>,
|
||||
/// New display name (omit to keep).
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// New API base URL (omit to keep).
|
||||
#[serde(default)]
|
||||
base_url: Option<String>,
|
||||
/// New API key in plain text (omit to keep).
|
||||
#[serde(default)]
|
||||
api_key: Option<String>,
|
||||
/// Replace model list (omit to keep).
|
||||
#[serde(default)]
|
||||
models: Option<Vec<String>>,
|
||||
/// Enable or disable (omit to keep).
|
||||
#[serde(default)]
|
||||
enabled: Option<bool>,
|
||||
/// Override context-window limit (omit to keep).
|
||||
#[serde(default)]
|
||||
context_limit: Option<i64>,
|
||||
/// AWS Bedrock configuration update (omit to keep).
|
||||
#[serde(default)]
|
||||
bedrock_config: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct DeleteProviderParams {
|
||||
/// Provider id to permanently delete.
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct FetchModelsParams {
|
||||
/// Provider id whose models to fetch from the remote API.
|
||||
id: String,
|
||||
/// If true, attempt automatic URL correction on failure for
|
||||
/// OpenAI-compatible providers (probes common URL suffixes).
|
||||
#[serde(default)]
|
||||
try_fix: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct GetInfoParams {}
|
||||
|
||||
// ── handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn get_settings(deps: Arc<GatewayDeps>, _p: GetSettingsParams) -> Value {
|
||||
match deps.settings_service.get_settings().await {
|
||||
Ok(settings) => ok(settings),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_settings(deps: Arc<GatewayDeps>, p: UpdateSettingsParams) -> Value {
|
||||
let req = UpdateSettingsRequest {
|
||||
language: p.language,
|
||||
notification_enabled: p.notification_enabled,
|
||||
cron_notification_enabled: p.cron_notification_enabled,
|
||||
command_queue_enabled: p.command_queue_enabled,
|
||||
save_upload_to_workspace: p.save_upload_to_workspace,
|
||||
};
|
||||
if req.is_empty() {
|
||||
return json!({ "error": "nothing to update: provide at least one field" });
|
||||
}
|
||||
match deps.settings_service.update_settings(req).await {
|
||||
Ok(settings) => ok(settings),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_preferences(deps: Arc<GatewayDeps>, p: GetPreferencesParams) -> Value {
|
||||
let keys_owned = p.keys.unwrap_or_default();
|
||||
let keys_ref: Vec<&str> = keys_owned.iter().map(String::as_str).collect();
|
||||
let filter = if keys_ref.is_empty() { None } else { Some(keys_ref.as_slice()) };
|
||||
match deps.client_pref_service.get_preferences(filter).await {
|
||||
Ok(prefs) => ok(prefs),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_preferences(deps: Arc<GatewayDeps>, p: UpdatePreferencesParams) -> Value {
|
||||
if p.preferences.is_empty() {
|
||||
return json!({ "error": "preferences map must not be empty" });
|
||||
}
|
||||
match deps.client_pref_service.update_preferences(p.preferences).await {
|
||||
Ok(()) => ok(json!({ "updated": true })),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_provider(deps: Arc<GatewayDeps>, p: CreateProviderParams) -> Value {
|
||||
// Map the bedrock_config Value passthrough into the typed struct.
|
||||
let bedrock_config = match p.bedrock_config {
|
||||
Some(val) => match serde_json::from_value(val) {
|
||||
Ok(cfg) => Some(cfg),
|
||||
Err(e) => return json!({ "error": format!("invalid bedrock_config: {e}") }),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let req = CreateProviderRequest {
|
||||
id: None,
|
||||
platform: p.platform,
|
||||
name: p.name,
|
||||
base_url: p.base_url,
|
||||
api_key: p.api_key,
|
||||
models: p.models.unwrap_or_default(),
|
||||
enabled: p.enabled.unwrap_or(true),
|
||||
capabilities: vec![],
|
||||
context_limit: p.context_limit,
|
||||
model_protocols: None,
|
||||
model_enabled: None,
|
||||
model_health: None,
|
||||
bedrock_config,
|
||||
is_full_url: false,
|
||||
};
|
||||
match deps.provider_service.create(req).await {
|
||||
Ok(resp) => ok(json!({
|
||||
"id": resp.id,
|
||||
"platform": resp.platform,
|
||||
"name": resp.name,
|
||||
"base_url": resp.base_url,
|
||||
"models": resp.models,
|
||||
"enabled": resp.enabled,
|
||||
"note": "provider created; use nomi_system_fetch_models to populate the model list from the remote API if models were not specified",
|
||||
})),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_provider(deps: Arc<GatewayDeps>, p: UpdateProviderParams) -> Value {
|
||||
let bedrock_config = match p.bedrock_config {
|
||||
Some(val) => match serde_json::from_value(val) {
|
||||
Ok(cfg) => Some(cfg),
|
||||
Err(e) => return json!({ "error": format!("invalid bedrock_config: {e}") }),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let req = UpdateProviderRequest {
|
||||
platform: p.platform,
|
||||
name: p.name,
|
||||
base_url: p.base_url,
|
||||
api_key: p.api_key,
|
||||
models: p.models,
|
||||
enabled: p.enabled,
|
||||
capabilities: None,
|
||||
context_limit: p.context_limit,
|
||||
model_protocols: None,
|
||||
model_enabled: None,
|
||||
model_health: None,
|
||||
bedrock_config,
|
||||
is_full_url: None,
|
||||
};
|
||||
match deps.provider_service.update(&p.id, req).await {
|
||||
Ok(resp) => ok(json!({
|
||||
"id": resp.id,
|
||||
"platform": resp.platform,
|
||||
"name": resp.name,
|
||||
"base_url": resp.base_url,
|
||||
"models": resp.models,
|
||||
"enabled": resp.enabled,
|
||||
})),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_provider(deps: Arc<GatewayDeps>, p: DeleteProviderParams) -> Value {
|
||||
match deps.provider_service.delete(&p.id).await {
|
||||
Ok(()) => json!({ "result": format!("provider {} deleted", p.id) }),
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_models(deps: Arc<GatewayDeps>, p: FetchModelsParams) -> Value {
|
||||
let req = FetchModelsRequest {
|
||||
try_fix: p.try_fix.unwrap_or(false),
|
||||
};
|
||||
match deps.model_fetch_service.fetch_models(&p.id, &req).await {
|
||||
Ok(resp) => {
|
||||
let mut result = json!({
|
||||
"models": resp.models,
|
||||
"count": resp.models.len(),
|
||||
});
|
||||
if let Some(fixed_url) = resp.fixed_base_url {
|
||||
result["fixed_base_url"] = json!(fixed_url);
|
||||
result["note"] = json!(
|
||||
"the provider's base URL was auto-corrected; the new URL has been applied"
|
||||
);
|
||||
}
|
||||
ok(result)
|
||||
}
|
||||
Err(e) => json!({ "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_info(_deps: Arc<GatewayDeps>, _p: GetInfoParams) -> Value {
|
||||
let info = nomifun_system::sysinfo::get_system_info();
|
||||
ok(info)
|
||||
}
|
||||
|
||||
// ── registration ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Register the system-domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
// 1. Settings (read)
|
||||
out.push(Capability::new::<GetSettingsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_system_get_settings",
|
||||
"system",
|
||||
"Read the desktop's system settings (language, notification toggles, etc.).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| get_settings(deps, p),
|
||||
));
|
||||
|
||||
// 2. Settings (write)
|
||||
out.push(Capability::new::<UpdateSettingsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_system_update_settings",
|
||||
"system",
|
||||
"Partially update system settings (language, notification toggles, command queue, workspace upload). Only provided fields are changed.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| update_settings(deps, p),
|
||||
));
|
||||
|
||||
// 3. Preferences (read)
|
||||
out.push(Capability::new::<GetPreferencesParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_system_get_preferences",
|
||||
"system",
|
||||
"Read client preferences (theme, zoom, keep-awake, companion size, feature toggles, etc.). Omit keys to get all.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| get_preferences(deps, p),
|
||||
));
|
||||
|
||||
// 4. Preferences (write) — the headline "set theme / zoom / keep-awake" tool
|
||||
out.push(Capability::new::<UpdatePreferencesParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_system_update_preferences",
|
||||
"system",
|
||||
"Batch set/delete client preferences (theme, ui.zoomFactor, system.closeToTray, system.keepAwake, companion.size, feature toggles). Pass null value to delete a key.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| update_preferences(deps, p),
|
||||
));
|
||||
|
||||
// 5. Create provider (sensitive — handles API keys)
|
||||
out.push(Capability::new::<CreateProviderParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_system_create_provider",
|
||||
"system",
|
||||
"Register a new model provider (platform + base URL + API key). The service validates credentials format and encrypts the key at rest.",
|
||||
DangerTier::Sensitive,
|
||||
),
|
||||
|deps, _ctx, p| create_provider(deps, p),
|
||||
));
|
||||
|
||||
// 6. Update provider (sensitive — may update API key)
|
||||
out.push(Capability::new::<UpdateProviderParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_system_update_provider",
|
||||
"system",
|
||||
"Partially update an existing model provider (name, URL, API key, models, enabled). Only provided fields are changed.",
|
||||
DangerTier::Sensitive,
|
||||
),
|
||||
|deps, _ctx, p| update_provider(deps, p),
|
||||
));
|
||||
|
||||
// 7. Delete provider (destructive)
|
||||
out.push(Capability::new::<DeleteProviderParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_system_delete_provider",
|
||||
"system",
|
||||
"Permanently delete a model provider and all its stored credentials.",
|
||||
DangerTier::Destructive,
|
||||
),
|
||||
|deps, _ctx, p| delete_provider(deps, p),
|
||||
));
|
||||
|
||||
// 8. Fetch models (write — triggers a network call and may auto-fix the URL)
|
||||
out.push(Capability::new::<FetchModelsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_system_fetch_models",
|
||||
"system",
|
||||
"Fetch the model list from a provider's remote API (by provider id). Use after creating a provider without specifying models.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, _ctx, p| fetch_models(deps, p),
|
||||
));
|
||||
|
||||
// 9. System info (read — pure, no service dependency beyond sysinfo)
|
||||
out.push(Capability::new::<GetInfoParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_system_get_info",
|
||||
"system",
|
||||
"Read system info: data/cache/log directories, OS platform, and CPU architecture.",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, _ctx, p| get_info(deps, p),
|
||||
));
|
||||
}
|
||||
|
||||
// ── SKIPPED tools ────────────────────────────────────────────────────────
|
||||
//
|
||||
// 10. `nomi_system_check_update` (Read)
|
||||
// Needs: `deps.version_check_service: nomifun_system::VersionCheckService`
|
||||
// Method: `version_check_service.check_update(&UpdateCheckRequest { .. })`
|
||||
// Not wired because VersionCheckService is not in the assumed GatewayDeps.
|
||||
//
|
||||
// 11. `nomi_system_factory_reset` (Destructive, deny_on Channel+Remote)
|
||||
// Needs: `deps.data_dir: PathBuf`
|
||||
// Method: `nomifun_common::factory_reset::write_marker(&data_dir, &ResetMarker::new(ResetScope::Full))`
|
||||
// Not wired because data_dir is not in the assumed GatewayDeps.
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Terminal-session capabilities (registry form): create / list.
|
||||
//!
|
||||
//! Terminals are a SEPARATE domain from conversations (PTY-backed processes
|
||||
//! in the `terminal_sessions` table, not `conversations`) — which is why the
|
||||
//! conversation tools refuse `agent_type = "terminal"` and point here.
|
||||
//!
|
||||
//! Migration of `tools_terminal.rs` onto the capability registry: the typed
|
||||
//! `*Params` structs are now the single source (schema + runtime
|
||||
//! deserialization). The `preset_launch` helper lives in `tools_terminal.rs`
|
||||
//! and is reused directly (pub(crate)).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::CreateTerminalRequest;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
|
||||
use crate::server::ok;
|
||||
use crate::tools_terminal::preset_launch;
|
||||
|
||||
/// Default PTY size for gateway-created terminals (no real viewport exists;
|
||||
/// wide enough that agent CLIs render sanely when the user attaches later).
|
||||
const DEFAULT_COLS: u16 = 120;
|
||||
const DEFAULT_ROWS: u16 = 30;
|
||||
|
||||
// ─── Params ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct CreateTerminalParams {
|
||||
/// Optional display name (defaults to the preset/backend name).
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// Launch preset: "shell" (default, the platform login shell) or an agent
|
||||
/// CLI "claude" | "codex" | "gemini".
|
||||
#[serde(default)]
|
||||
preset: Option<String>,
|
||||
/// Working directory (defaults to the user's home directory).
|
||||
#[serde(default)]
|
||||
cwd: Option<String>,
|
||||
/// Permission level for agent presets: "default" (interactive approvals)
|
||||
/// or "full-auto" (passes the CLI's skip-permissions flag — powerful,
|
||||
/// confirm with the user first). Ignored for the shell preset.
|
||||
#[serde(default)]
|
||||
mode: Option<String>,
|
||||
/// Advanced: explicit program to launch, overriding the preset's command.
|
||||
#[serde(default)]
|
||||
command: Option<String>,
|
||||
/// Advanced: explicit argument list for the program (overrides preset args).
|
||||
#[serde(default)]
|
||||
args: Option<Vec<String>>,
|
||||
/// Optional knowledge base ids to bind to this terminal at creation
|
||||
/// (bind-on-create); they are mounted into `.nomi/knowledge/` inside the
|
||||
/// cwd when the terminal starts. Use nomi_knowledge_list_bases for ids.
|
||||
#[serde(default)]
|
||||
knowledge_base_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ListTerminalsParams {
|
||||
/// Filter by status: "running" | "exited" (default: all).
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn create(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: CreateTerminalParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
|
||||
}
|
||||
let user_id = ctx.user_id;
|
||||
|
||||
let preset = p.preset.unwrap_or_else(|| "shell".to_owned());
|
||||
let mode = p.mode.unwrap_or_else(|| "default".to_owned());
|
||||
if mode != "default" && mode != "full-auto" {
|
||||
return json!({"error": format!("unknown mode '{mode}' (expected default | full-auto)")});
|
||||
}
|
||||
|
||||
let (mut command, mut cmd_args, backend) = match preset_launch(&preset, mode == "full-auto") {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json!({"error": e}),
|
||||
};
|
||||
|
||||
// Advanced overrides: an explicit command replaces the preset launch
|
||||
// entirely (args reset, then optionally replaced too).
|
||||
if let Some(custom) = p.command {
|
||||
command = custom;
|
||||
cmd_args = vec![];
|
||||
}
|
||||
if let Some(arr) = p.args {
|
||||
cmd_args = arr;
|
||||
}
|
||||
|
||||
let cwd = match p.cwd {
|
||||
Some(c) => c,
|
||||
None => match dirs::home_dir() {
|
||||
Some(h) => h.to_string_lossy().into_owned(),
|
||||
None => {
|
||||
return json!({"error": "no cwd given and the user home directory could not be determined"})
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Optional create-time knowledge binding: the bases get bound to this
|
||||
// terminal's WORKPATH (spec §7) and mounted into `{cwd}/.nomi/knowledge/`
|
||||
// when the PTY starts. The mount itself is best-effort downstream (never
|
||||
// blocks the launch), so the ids are validated HERE — a typo'd id would
|
||||
// otherwise be accepted and silently mount nothing.
|
||||
if let Some(ids) = &p.knowledge_base_ids {
|
||||
if let Err(e) = crate::caps_knowledge::ensure_known_kb_ids(&deps, ids).await {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
let knowledge_bases_bound = p.knowledge_base_ids.as_ref().map_or(0, Vec::len);
|
||||
|
||||
let req = CreateTerminalRequest {
|
||||
name: p.name,
|
||||
cwd,
|
||||
command,
|
||||
args: cmd_args,
|
||||
env: None,
|
||||
backend: backend.clone(),
|
||||
// Permission mode only applies to agent CLI presets.
|
||||
mode: backend.is_some().then(|| mode.clone()),
|
||||
cols: DEFAULT_COLS,
|
||||
rows: DEFAULT_ROWS,
|
||||
defer_spawn: false,
|
||||
knowledge_base_ids: p.knowledge_base_ids,
|
||||
};
|
||||
|
||||
match deps.terminal_service.create(&user_id, req).await {
|
||||
Ok(resp) => ok(json!({
|
||||
"id": resp.id,
|
||||
"name": resp.name,
|
||||
"status": resp.last_status,
|
||||
"cwd": resp.cwd,
|
||||
"command": resp.command,
|
||||
"args": resp.args,
|
||||
"backend": resp.backend,
|
||||
"mode": resp.mode,
|
||||
// Echo the validated bind-on-create request count (0 = none
|
||||
// requested); the mount itself remains best-effort.
|
||||
"knowledge_bases_bound": knowledge_bases_bound,
|
||||
"note": "terminal created, its process is running; use nomi_list_terminals to check status. Agent terminals (claude/codex/gemini) are eligible AutoWork targets via nomi_set_autowork."
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ListTerminalsParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
|
||||
}
|
||||
let user_id = &ctx.user_id;
|
||||
|
||||
match deps.terminal_service.list(user_id).await {
|
||||
Ok(rows) => {
|
||||
let items: Vec<Value> = rows
|
||||
.iter()
|
||||
.filter(|t| p.status.as_deref().is_none_or(|s| t.last_status == s))
|
||||
.map(|t| {
|
||||
json!({
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"status": t.last_status,
|
||||
"cwd": t.cwd,
|
||||
"command": t.command,
|
||||
"backend": t.backend,
|
||||
"mode": t.mode,
|
||||
"exit_code": t.exit_code,
|
||||
"created_at": t.created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ok(json!({"total": items.len(), "terminals": items}))
|
||||
}
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Registration ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Register the terminal-domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<CreateTerminalParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_create_terminal",
|
||||
"terminal",
|
||||
"Spawn a new PTY terminal session (shell or agent CLI). Use preset to pick the program; mode to enable full-auto permissions for agent CLIs.",
|
||||
DangerTier::Write,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, ctx, p| create(deps, ctx, p),
|
||||
));
|
||||
out.push(Capability::new::<ListTerminalsParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_list_terminals",
|
||||
"terminal",
|
||||
"List every terminal session of the calling user (filter by status: running | exited).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| list(deps, ctx, p),
|
||||
));
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Mirrors `ui/src/renderer/pages/terminal/launchPresets.ts` — the
|
||||
/// frontend and gateway presets must agree on commands and flags.
|
||||
#[test]
|
||||
fn presets_match_frontend_launch_presets() {
|
||||
assert_eq!(
|
||||
preset_launch("shell", false).unwrap(),
|
||||
("$SHELL".to_owned(), vec![], None)
|
||||
);
|
||||
// shell ignores full-auto (no permission concept).
|
||||
assert_eq!(preset_launch("shell", true).unwrap().1, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
preset_launch("claude", true).unwrap(),
|
||||
(
|
||||
"claude".to_owned(),
|
||||
vec!["--dangerously-skip-permissions".to_owned()],
|
||||
Some("claude".to_owned())
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
preset_launch("codex", true).unwrap(),
|
||||
(
|
||||
"codex".to_owned(),
|
||||
vec!["--dangerously-bypass-approvals-and-sandbox".to_owned()],
|
||||
Some("codex".to_owned())
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
preset_launch("gemini", true).unwrap(),
|
||||
("gemini".to_owned(), vec!["--yolo".to_owned()], Some("gemini".to_owned()))
|
||||
);
|
||||
// default mode = no extra flags for agent presets.
|
||||
assert_eq!(preset_launch("claude", false).unwrap().1, Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_preset_is_rejected() {
|
||||
let err = preset_launch("bash", false).unwrap_err();
|
||||
assert!(err.contains("bash"), "{err}");
|
||||
}
|
||||
|
||||
/// Mode validation rejects unknown strings.
|
||||
#[test]
|
||||
fn unknown_mode_is_rejected() {
|
||||
// Simulate the check that would happen inside `create` before
|
||||
// calling `preset_launch` — test the boundary inline since the
|
||||
// handler is async and the validation is trivial.
|
||||
let mode = "yolo";
|
||||
let valid = mode == "default" || mode == "full-auto";
|
||||
assert!(!valid);
|
||||
}
|
||||
|
||||
/// Knowledge base ids: serde correctly deserializes typed params.
|
||||
#[test]
|
||||
fn knowledge_base_ids_deserialization() {
|
||||
// Valid: present with string array
|
||||
let json_val = json!({"knowledge_base_ids": ["kb_a", "kb_b"]});
|
||||
let p: CreateTerminalParams = serde_json::from_value(json_val).unwrap();
|
||||
assert_eq!(p.knowledge_base_ids, Some(vec!["kb_a".to_owned(), "kb_b".to_owned()]));
|
||||
|
||||
// Valid: absent → None
|
||||
let json_val = json!({});
|
||||
let p: CreateTerminalParams = serde_json::from_value(json_val).unwrap();
|
||||
assert_eq!(p.knowledge_base_ids, None);
|
||||
|
||||
// Valid: explicit null → None
|
||||
let json_val = json!({"knowledge_base_ids": null});
|
||||
let p: CreateTerminalParams = serde_json::from_value(json_val).unwrap();
|
||||
assert_eq!(p.knowledge_base_ids, None);
|
||||
|
||||
// Invalid: non-string elements are rejected at deserialization
|
||||
let json_val = json!({"knowledge_base_ids": ["kb_a", 1]});
|
||||
let result = serde_json::from_value::<CreateTerminalParams>(json_val);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Extended terminal-session capabilities (registry form): get / write_input /
|
||||
//! kill / delete / resize / relaunch / update.
|
||||
//!
|
||||
//! Companion module to `caps_terminal.rs` (which covers create / list). These
|
||||
//! are the remaining mutation and query endpoints that a gateway-connected agent
|
||||
//! needs to fully manage PTY sessions.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::{Capability, CapabilityMeta, DangerTier, Surface};
|
||||
use crate::server::ok;
|
||||
|
||||
// ─── Params ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parameters for reading a single terminal session's detail/status.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct GetTerminalParams {
|
||||
/// The terminal session id (from nomi_list_terminals).
|
||||
id: i64,
|
||||
}
|
||||
|
||||
/// Parameters for writing bytes/keystrokes to a terminal's PTY.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct WriteInputParams {
|
||||
/// The terminal session id.
|
||||
id: i64,
|
||||
/// Base64-encoded bytes to write to the PTY stdin. Encode raw keystrokes
|
||||
/// (including control sequences like \r for Enter, \x03 for Ctrl-C) as
|
||||
/// base64 before passing here.
|
||||
data_b64: String,
|
||||
}
|
||||
|
||||
/// Parameters for terminating a terminal's running process (SIGKILL).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct KillTerminalParams {
|
||||
/// The terminal session id.
|
||||
id: i64,
|
||||
}
|
||||
|
||||
/// Parameters for permanently deleting a terminal session (kills process + removes row).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct DeleteTerminalParams {
|
||||
/// The terminal session id.
|
||||
id: i64,
|
||||
}
|
||||
|
||||
/// Parameters for resizing a terminal's PTY (cols x rows).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct ResizeTerminalParams {
|
||||
/// The terminal session id.
|
||||
id: i64,
|
||||
/// Number of columns (width in characters).
|
||||
cols: u16,
|
||||
/// Number of rows (height in characters).
|
||||
rows: u16,
|
||||
}
|
||||
|
||||
/// Parameters for relaunching a terminal's process in place (same session id,
|
||||
/// fresh child process).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct RelaunchTerminalParams {
|
||||
/// The terminal session id.
|
||||
id: i64,
|
||||
}
|
||||
|
||||
/// Parameters for updating a terminal session's metadata (rename / pin).
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
struct UpdateTerminalParams {
|
||||
/// The terminal session id.
|
||||
id: i64,
|
||||
/// New display name (omit to keep current).
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// Pin (true) or unpin (false) the terminal; pinned terminals persist in
|
||||
/// the sidebar. Omit to keep current.
|
||||
#[serde(default)]
|
||||
pinned: Option<bool>,
|
||||
}
|
||||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn get_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: GetTerminalParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
|
||||
}
|
||||
match deps.terminal_service.get(p.id).await {
|
||||
Ok(resp) => ok(json!({
|
||||
"id": resp.id,
|
||||
"name": resp.name,
|
||||
"status": resp.last_status,
|
||||
"cwd": resp.cwd,
|
||||
"command": resp.command,
|
||||
"args": resp.args,
|
||||
"backend": resp.backend,
|
||||
"mode": resp.mode,
|
||||
"cols": resp.cols,
|
||||
"rows": resp.rows,
|
||||
"exit_code": resp.exit_code,
|
||||
"pinned": resp.pinned,
|
||||
"created_at": resp.created_at,
|
||||
"updated_at": resp.updated_at,
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_input(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: WriteInputParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
|
||||
}
|
||||
match deps.terminal_service.input(p.id, &p.data_b64).await {
|
||||
Ok(()) => ok(json!({"written": true})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn kill_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: KillTerminalParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
|
||||
}
|
||||
match deps.terminal_service.kill(p.id).await {
|
||||
Ok(()) => ok(json!({"killed": true, "id": p.id})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: DeleteTerminalParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
|
||||
}
|
||||
match deps.terminal_service.delete(p.id).await {
|
||||
Ok(()) => ok(json!({"deleted": true, "id": p.id})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resize_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: ResizeTerminalParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
|
||||
}
|
||||
match deps.terminal_service.resize(p.id, p.cols, p.rows).await {
|
||||
Ok(()) => ok(json!({"resized": true, "id": p.id, "cols": p.cols, "rows": p.rows})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn relaunch_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: RelaunchTerminalParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
|
||||
}
|
||||
match deps.terminal_service.relaunch(p.id).await {
|
||||
Ok(resp) => ok(json!({
|
||||
"id": resp.id,
|
||||
"name": resp.name,
|
||||
"status": resp.last_status,
|
||||
"cwd": resp.cwd,
|
||||
"command": resp.command,
|
||||
"args": resp.args,
|
||||
"backend": resp.backend,
|
||||
"mode": resp.mode,
|
||||
"note": "process relaunched in place (same session id, fresh child)"
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_terminal(deps: Arc<GatewayDeps>, ctx: CallerCtx, p: UpdateTerminalParams) -> Value {
|
||||
if ctx.user_id.is_empty() {
|
||||
return json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"});
|
||||
}
|
||||
if p.name.is_none() && p.pinned.is_none() {
|
||||
return json!({"error": "nothing to update: provide at least one of name / pinned"});
|
||||
}
|
||||
match deps.terminal_service.update_meta(p.id, p.name, p.pinned).await {
|
||||
Ok(resp) => ok(json!({
|
||||
"id": resp.id,
|
||||
"name": resp.name,
|
||||
"pinned": resp.pinned,
|
||||
"status": resp.last_status,
|
||||
})),
|
||||
Err(e) => json!({"error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Registration ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Register the extended terminal-domain capabilities.
|
||||
pub(crate) fn register(out: &mut Vec<Capability>) {
|
||||
out.push(Capability::new::<GetTerminalParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_terminal_get",
|
||||
"terminal",
|
||||
"Get a single terminal session's detail and current status (running/exited, exit code, dimensions, etc.).",
|
||||
DangerTier::Read,
|
||||
),
|
||||
|deps, ctx, p| get_terminal(deps, ctx, p),
|
||||
));
|
||||
out.push(Capability::new::<WriteInputParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_terminal_write_input",
|
||||
"terminal",
|
||||
"Write base64-encoded bytes/keystrokes to a terminal's PTY stdin. Powerful: can execute arbitrary commands in the running shell.",
|
||||
DangerTier::Write,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, ctx, p| write_input(deps, ctx, p),
|
||||
));
|
||||
out.push(Capability::new::<KillTerminalParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_terminal_kill",
|
||||
"terminal",
|
||||
"Send SIGKILL to terminate the terminal's running process. The session remains (status becomes 'exited'); use relaunch to restart or delete to remove entirely.",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, ctx, p| kill_terminal(deps, ctx, p),
|
||||
));
|
||||
out.push(Capability::new::<DeleteTerminalParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_terminal_delete",
|
||||
"terminal",
|
||||
"Permanently delete a terminal session (kills the process if running, removes the row and all associated data).",
|
||||
DangerTier::Destructive,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, ctx, p| delete_terminal(deps, ctx, p),
|
||||
));
|
||||
out.push(Capability::new::<ResizeTerminalParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_terminal_resize",
|
||||
"terminal",
|
||||
"Resize a terminal's PTY to the given cols x rows (triggers deferred spawn if the session was created with defer_spawn).",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, ctx, p| resize_terminal(deps, ctx, p),
|
||||
));
|
||||
out.push(Capability::new::<RelaunchTerminalParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_terminal_relaunch",
|
||||
"terminal",
|
||||
"Relaunch a terminal's process in place: kills the old child and spawns a fresh one reusing the same session id, command, and cwd.",
|
||||
DangerTier::Write,
|
||||
)
|
||||
.deny_on(&[Surface::Channel]),
|
||||
|deps, ctx, p| relaunch_terminal(deps, ctx, p),
|
||||
));
|
||||
out.push(Capability::new::<UpdateTerminalParams, _, _>(
|
||||
CapabilityMeta::new(
|
||||
"nomi_terminal_update",
|
||||
"terminal",
|
||||
"Update a terminal session's metadata: rename it and/or pin/unpin it.",
|
||||
DangerTier::Write,
|
||||
),
|
||||
|deps, ctx, p| update_terminal(deps, ctx, p),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Single shared [`ComputerTool`] for the gateway's computer-use capabilities.
|
||||
//!
|
||||
//! Unlike the browser (one Chrome per companion), the desktop is one physical
|
||||
//! screen, so a single shared `ComputerTool` is the right model — exactly like
|
||||
//! the inward `mcp-computer-stdio` bridge. `ComputerTool` is stateful
|
||||
//! (`is_concurrency_safe == false`: shared observe/screenshot caches and `[ref]`
|
||||
//! resolution), so all calls are serialized behind one lock. Only compiled with
|
||||
//! the `computer-use` feature.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomi_computer::tool::ComputerTool;
|
||||
use nomi_config::config::ComputerConfig;
|
||||
use nomi_tools::Tool;
|
||||
use nomi_types::tool::ToolResult;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Owns the shared desktop `ComputerTool` and serializes calls to it.
|
||||
pub struct ComputerRegistry {
|
||||
tool: Arc<ComputerTool>,
|
||||
/// One global lock: the desktop is a single screen and `ComputerTool` keeps
|
||||
/// mutable observe/screenshot caches that concurrent callers would clobber
|
||||
/// (a stale `[ref]` resolves against the wrong snapshot).
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl ComputerRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tool: Arc::new(ComputerTool::new(&ComputerConfig::default())),
|
||||
lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a `{"action": ..}` payload to the shared tool, serialized.
|
||||
pub async fn execute(&self, input: Value) -> ToolResult {
|
||||
let _guard = self.lock.lock().await;
|
||||
self.tool.execute(input).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ComputerRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `ToolResult` onto the gateway result envelope: error → `{"error": ..}`;
|
||||
/// success → `{"result": {"text": .., "images": [{media_type, data}]}}` (base64
|
||||
/// screenshots / Set-of-Marks overlays flow straight through). Mirrors the
|
||||
/// browser registry's helper of the same name (kept separate so a computer-only
|
||||
/// build needs no browser-use feature).
|
||||
pub fn tool_result_to_value(result: ToolResult) -> Value {
|
||||
if result.is_error {
|
||||
return json!({ "error": result.content });
|
||||
}
|
||||
let mut payload = json!({ "text": result.content });
|
||||
if !result.images.is_empty() {
|
||||
let imgs: Vec<Value> = result
|
||||
.images
|
||||
.iter()
|
||||
.map(|img| json!({ "media_type": img.media_type, "data": img.data }))
|
||||
.collect();
|
||||
payload["images"] = Value::Array(imgs);
|
||||
}
|
||||
json!({ "result": payload })
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Late-bound dependency bundle for the gateway tool implementations.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_ai_agent::IWorkerTaskManager;
|
||||
use nomifun_companion::CompanionService;
|
||||
use nomifun_conversation::ConversationService;
|
||||
use nomifun_cron::service::CronService;
|
||||
use nomifun_db::IProviderRepository;
|
||||
use nomifun_idmm::IdmmService;
|
||||
use nomifun_knowledge::KnowledgeService;
|
||||
use nomifun_requirement::{Orchestrator, RequirementService};
|
||||
use nomifun_system::{ClientPrefService, ModelFetchService, ProviderService, SettingsService};
|
||||
use nomifun_terminal::TerminalService;
|
||||
|
||||
/// Everything the gateway tools need to operate the desktop.
|
||||
///
|
||||
/// Constructed by `nomifun-app` AFTER `build_module_states` (the
|
||||
/// `ConversationService` / `CronService` instances live there) and wired into
|
||||
/// the already-running [`crate::GatewayMcpServer`] via `set_deps` — the same
|
||||
/// late-wire choreography as the guide / requirement MCP servers, which is
|
||||
/// what lets the server start before the agent factory while the factory
|
||||
/// still receives the server's connection config.
|
||||
///
|
||||
/// NEW FIELD? A capability needing a new service adds it here, then wires it in
|
||||
/// `nomifun-app/src/router/routes.rs::inject_gateway_deps` (clone from the
|
||||
/// matching `states.*` / `services.*`). The struct is just an Arc bundle —
|
||||
/// growth is O(1) pointers, negligible.
|
||||
pub struct GatewayDeps {
|
||||
pub conversation_service: ConversationService,
|
||||
pub task_manager: Arc<dyn IWorkerTaskManager>,
|
||||
pub cron_service: Arc<CronService>,
|
||||
/// MUST be the router-state instance (the singleton clone that had
|
||||
/// `with_conversation_service` / `with_terminal_driver` attached in
|
||||
/// `build_requirement_state`) — the AutoWork config tools need those
|
||||
/// attachments; the bare singleton would error "not attached".
|
||||
pub requirement_service: Arc<RequirementService>,
|
||||
pub companion_service: Arc<CompanionService>,
|
||||
/// Singleton terminal service (owns the live PTY map shared with the
|
||||
/// terminal routes + AutoWork orchestrator).
|
||||
pub terminal_service: Arc<TerminalService>,
|
||||
/// Main-db provider rows: model listing + the nomi model resolution chain.
|
||||
pub provider_repo: Arc<dyn IProviderRepository>,
|
||||
/// IDMM supervision config (same instance as `/api/idmm` so save also
|
||||
/// arms/stops the live supervisor).
|
||||
pub idmm_service: Arc<IdmmService>,
|
||||
/// Knowledge base registry + bindings (same instance the conversation
|
||||
/// service mounts from at task start).
|
||||
pub knowledge_service: Arc<KnowledgeService>,
|
||||
/// AutoWork live-loop control. The REST `POST /api/requirements/autowork`
|
||||
/// starts/stops this orchestrator alongside persisting the config; the
|
||||
/// gateway autowork tools must mirror that or an "enabled" toggle would
|
||||
/// only take effect after the next desktop boot (boot-resume).
|
||||
pub autowork_orchestrator: Arc<Orchestrator>,
|
||||
/// System domain services (same instances the `/api/settings`,
|
||||
/// `/api/settings/client`, `/api/providers` routes use — so a gateway theme /
|
||||
/// toggle / provider change and a UI change act on identical state).
|
||||
pub settings_service: SettingsService,
|
||||
pub client_pref_service: ClientPrefService,
|
||||
pub provider_service: ProviderService,
|
||||
pub model_fetch_service: ModelFetchService,
|
||||
/// Channel domain state (plugin manager + pairing + sessions + settings),
|
||||
/// the same instances the `/api/channels` routes use. `Clone` (all Arc).
|
||||
pub channel_state: nomifun_channel::ChannelRouterState,
|
||||
/// Filesystem service (path-scoped to the configured allowed roots).
|
||||
pub file_service: nomifun_file::FileServiceRef,
|
||||
/// Shell-open service (OS ShellExecute / `open`).
|
||||
pub shell_service: std::sync::Arc<nomifun_shell::ShellService>,
|
||||
/// MCP server CRUD (same instance as the `/api/mcp` routes).
|
||||
pub mcp_config_service: nomifun_mcp::McpConfigService,
|
||||
/// Extension registry + hub + skills (same instances as the extension routes).
|
||||
pub extension_registry: nomifun_extension::ExtensionRegistry,
|
||||
pub hub_index_manager: nomifun_extension::HubIndexManager,
|
||||
pub hub_installer: nomifun_extension::HubInstaller,
|
||||
pub skill_paths: nomifun_extension::SkillPaths,
|
||||
/// Agent catalog + remote agents (same instances as the agent routes).
|
||||
pub agent_service: std::sync::Arc<nomifun_ai_agent::AgentService>,
|
||||
pub remote_agent_service: std::sync::Arc<nomifun_ai_agent::RemoteAgentService>,
|
||||
/// Client-preference repo backing the global model-failover config.
|
||||
pub client_pref_repo: std::sync::Arc<dyn nomifun_db::IClientPreferenceRepository>,
|
||||
/// **P3-GW1 (route A)**: per-companion browser tool registry, living in the
|
||||
/// main process. `Some` only when the `browser-use` feature is on and the
|
||||
/// app wired it; `None` (or the field absent without the feature) → the
|
||||
/// gateway exposes no `nomi_browser_*` tools. Each companion gets its own
|
||||
/// lazily-engined `BrowserTool` + a serialization mutex (X5). See
|
||||
/// [`crate::browser_registry`].
|
||||
#[cfg(feature = "browser-use")]
|
||||
pub browser_registry: Option<crate::browser_registry::BrowserRegistry>,
|
||||
/// Shared desktop `ComputerTool` (one screen → one serialized instance).
|
||||
/// `Some` only when the `computer-use` feature is on and the app wired it;
|
||||
/// otherwise the gateway exposes no `nomi_computer_*` tools. See
|
||||
/// [`crate::computer_registry`].
|
||||
#[cfg(feature = "computer-use")]
|
||||
pub computer_registry: Option<crate::computer_registry::ComputerRegistry>,
|
||||
}
|
||||
|
||||
/// Identity of the calling agent session, forwarded by the stdio bridge from
|
||||
/// the env the factory injected (`NOMI_GW_MCP_CONVERSATION_ID` /
|
||||
/// `NOMI_GW_MCP_USER_ID` / `NOMI_GW_MCP_COMPANION_ID`).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CallerCtx {
|
||||
/// The conversation the calling agent lives in. Used for self-protection
|
||||
/// (a session may not message or delete itself) and as the default cron
|
||||
/// binding target.
|
||||
pub conversation_id: String,
|
||||
/// The desktop user every tool scopes its data access to.
|
||||
pub user_id: String,
|
||||
/// The companion the calling session is bound to (multi-companion upgrade). `None`
|
||||
/// for sessions without a companion binding — memory/requirement tools are
|
||||
/// deliberately companion-agnostic (memory is shared), so this is attribution
|
||||
/// context, not an access scope.
|
||||
pub companion_id: Option<String>,
|
||||
/// IM platform when this is a channel master-agent session (e.g. "lark").
|
||||
/// `None` for plain companion/desktop sessions. Used to resolve the write
|
||||
/// surface (channel → write-disabled in P1).
|
||||
pub channel_platform: Option<String>,
|
||||
/// `true` when the caller is an external network consumer reaching the
|
||||
/// platform through the Remote front door (the "外部伙伴" surface). Takes
|
||||
/// precedence over `channel_platform` in [`CallerCtx::surface`]. Defaults
|
||||
/// `false` so every existing (desktop/channel) construction site is
|
||||
/// unaffected.
|
||||
pub remote: bool,
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! `nomifun-gateway` — the Desktop Gateway MCP: an in-process HTTP tool server
|
||||
//! that exposes the whole Nomi Desktop capability surface (conversations,
|
||||
//! terminals, cron jobs, global companion memory, requirements, AutoWork, IDMM,
|
||||
//! knowledge bases, model providers) to agent sessions that carry the
|
||||
//! backend-set `desktopGateway` extra flag.
|
||||
//!
|
||||
//! Governance principle: the companion IS the desktop's universal semantic control
|
||||
//! surface — every new desktop feature domain ships a companion-operable gateway
|
||||
//! tool by default (see the gateway design spec appendix).
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//!
|
||||
//! Remote IM (channel) sessions and companion companion threads act as the user's
|
||||
//! "master agent": one conversation through which the user can see and drive
|
||||
//! everything running on the desktop. Agents reach this server through the
|
||||
//! `nomicore mcp-gateway-stdio` bridge (claude / codex / gemini advertise
|
||||
//! stdio-only MCP capabilities; the nomi engine consumes the same bridge), and
|
||||
//! every tool call is forwarded back here as an authenticated `POST /tool`.
|
||||
//!
|
||||
//! ## Shape (third instance of the house pattern)
|
||||
//!
|
||||
//! Mirrors the requirement MCP server lifecycle: bind `127.0.0.1:0`, mint a
|
||||
//! per-process random bearer token, late-wire the service dependencies.
|
||||
|
||||
pub mod deps;
|
||||
pub mod registry;
|
||||
pub mod server;
|
||||
|
||||
#[cfg(feature = "browser-use")]
|
||||
pub mod browser_registry;
|
||||
|
||||
#[cfg(feature = "computer-use")]
|
||||
pub mod computer_registry;
|
||||
|
||||
// ── legacy helper modules retained for shared pure logic ─────────────────
|
||||
// `tools_provider` keeps the nomi model-resolution chain (used by the cron +
|
||||
// conversation capabilities); `tools_terminal` keeps `preset_launch` (used by
|
||||
// the terminal capabilities). `tools_browser` is the not-yet-migrated browser
|
||||
// domain, still dispatched by the legacy match in `server.rs` under coexistence.
|
||||
mod tools_provider;
|
||||
mod tools_terminal;
|
||||
|
||||
// ── capability domains (registry form) ───────────────────────────────────
|
||||
// NEW DOMAIN? Adding `mod caps_<x>;` here is step 2 of 3 — also add the
|
||||
// `crate::caps_<x>::register(&mut caps)` call in `registry/mod.rs::build()`.
|
||||
// The `all_caps_modules_are_mod_declared_and_registered` test fails CI if a
|
||||
// file here is missing its register() call (and vice-versa).
|
||||
mod caps_agent;
|
||||
mod caps_autowork;
|
||||
#[cfg(feature = "browser-use")]
|
||||
mod caps_browser;
|
||||
mod caps_channel;
|
||||
mod caps_companion;
|
||||
#[cfg(feature = "computer-use")]
|
||||
mod caps_computer;
|
||||
mod caps_confirmation;
|
||||
mod caps_conversation;
|
||||
mod caps_cron;
|
||||
mod caps_files;
|
||||
mod caps_idmm;
|
||||
mod caps_knowledge;
|
||||
mod caps_knowledge_ext;
|
||||
mod caps_mcp;
|
||||
mod caps_memory;
|
||||
mod caps_provider;
|
||||
mod caps_requirement;
|
||||
mod caps_scheduling_ext;
|
||||
mod caps_system;
|
||||
mod caps_terminal;
|
||||
mod caps_terminal_ext;
|
||||
|
||||
pub use deps::{CallerCtx, GatewayDeps};
|
||||
pub use registry::{Registry, Surface, ToolSpec};
|
||||
pub use server::GatewayMcpServer;
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Capability descriptor: the single source of truth for one operable platform
|
||||
//! capability — its MCP tool name, LLM-facing description, JSON Schema, danger
|
||||
//! tier, per-surface permission policy, and async handler.
|
||||
//!
|
||||
//! The design rule that kills the historical "three definitions per tool" drift
|
||||
//! (schemars Param struct in the bridge ↔ hand-parser in `tools_*.rs` ↔ service
|
||||
//! request type): a capability owns ONE typed `Request` struct `P`. Its JSON
|
||||
//! Schema is generated from `P` (`schemars`), its runtime arguments are
|
||||
//! deserialized into the SAME `P`, and the handler receives a typed `P`. Schema,
|
||||
//! validation, and execution can no longer disagree.
|
||||
//!
|
||||
//! The registry is **deps-free**: a handler receives `Arc<GatewayDeps>` as an
|
||||
//! argument at dispatch time, so `Registry::build()` constructs no services. The
|
||||
//! identical registry therefore serves both processes — the in-process server
|
||||
//! (which dispatches with real deps) and the `mcp-gateway-stdio` bridge (which
|
||||
//! only reads `tool_specs()` to answer `tools/list`).
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
|
||||
/// Boxed handler future. `Value` is the tool result (the `{"result": …}` /
|
||||
/// `{"error": …}` envelope the existing tools already produce).
|
||||
pub type BoxFut = Pin<Box<dyn Future<Output = Value> + Send>>;
|
||||
|
||||
/// Type-erased capability handler: `(deps, caller, raw_args) -> result`.
|
||||
pub type Handler = Arc<dyn Fn(Arc<GatewayDeps>, CallerCtx, Value) -> BoxFut + Send + Sync>;
|
||||
|
||||
/// A streaming capability emits intermediate progress `Value`s through this sink
|
||||
/// while it runs (e.g. a delegated agent's text/tool-call deltas), then returns
|
||||
/// its final `Value`. Adapters that don't stream (MCP `tools/call`, REST
|
||||
/// `/v1/tools/{name}`, CLI) just run the buffered [`Handler`] instead and get
|
||||
/// the final value only — so adding a streaming variant never breaks them.
|
||||
pub type ProgressSink = tokio::sync::mpsc::Sender<Value>;
|
||||
|
||||
/// Type-erased streaming handler: like [`Handler`] but also handed a
|
||||
/// [`ProgressSink`] for incremental output. Returns the final `Value`.
|
||||
pub type StreamingHandler =
|
||||
Arc<dyn Fn(Arc<GatewayDeps>, CallerCtx, Value, ProgressSink) -> BoxFut + Send + Sync>;
|
||||
|
||||
/// How dangerous an operation is. Drives the default per-surface permission
|
||||
/// decision (see [`default_decision`]). Promoted from IDMM's regex-on-command
|
||||
/// heuristic to a first-class, per-capability annotation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DangerTier {
|
||||
/// No side effects, no secrets. Always allowed.
|
||||
Read,
|
||||
/// Creates / modifies state, reversible.
|
||||
Write,
|
||||
/// Irreversible deletion / reset.
|
||||
Destructive,
|
||||
/// Reads or writes secrets / credentials.
|
||||
Sensitive,
|
||||
}
|
||||
|
||||
/// Which kind of session is calling. Derived from [`CallerCtx`]: a channel
|
||||
/// platform marks an external IM session; otherwise it is a local desktop
|
||||
/// session. `Remote` is reserved for future LAN/web/device sessions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Surface {
|
||||
/// Local desktop session (companion thread or a plain local conversation).
|
||||
Desktop,
|
||||
/// External IM channel master-agent session (telegram / lark / …).
|
||||
Channel,
|
||||
/// Future: remote LAN / web / external-device session.
|
||||
Remote,
|
||||
}
|
||||
|
||||
impl CallerCtx {
|
||||
/// The permission surface this caller acts on.
|
||||
pub fn surface(&self) -> Surface {
|
||||
if self.remote {
|
||||
Surface::Remote
|
||||
} else if self.channel_platform.is_some() {
|
||||
Surface::Channel
|
||||
} else {
|
||||
Surface::Desktop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-dispatch gate outcome.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
/// Execute the handler.
|
||||
Allow,
|
||||
/// Refuse until the agent restates the action and re-calls with `confirm=true`.
|
||||
Confirm,
|
||||
/// Hard-refuse on this surface regardless of confirmation.
|
||||
Deny,
|
||||
}
|
||||
|
||||
/// The default decision for a `(surface, danger)` pair — the policy matrix from
|
||||
/// the design spec §4. Capability-level `deny_on` / `confirm_on` overrides
|
||||
/// refine this in [`decide`].
|
||||
///
|
||||
/// | Surface | Read | Write | Destructive | Sensitive |
|
||||
/// |---------|------|-------|-------------|-----------|
|
||||
/// | Desktop | Allow | Allow | Confirm | Confirm |
|
||||
/// | Channel | Allow | Allow | Deny | Deny |
|
||||
/// | Remote | Allow | Allow | Confirm | Deny |
|
||||
pub fn default_decision(surface: Surface, danger: DangerTier) -> Decision {
|
||||
use DangerTier::*;
|
||||
use Surface::*;
|
||||
match (surface, danger) {
|
||||
(_, Read) | (_, Write) => Decision::Allow,
|
||||
(Desktop, Destructive) | (Desktop, Sensitive) => Decision::Confirm,
|
||||
(Channel, Destructive) | (Channel, Sensitive) => Decision::Deny,
|
||||
(Remote, Destructive) => Decision::Confirm,
|
||||
(Remote, Sensitive) => Decision::Deny,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the final gate decision for a capability on a surface, honoring the
|
||||
/// capability's explicit `deny_on` / `confirm_on` overrides and whether the
|
||||
/// caller already passed `confirm=true`.
|
||||
pub fn decide(meta: &CapabilityMeta, surface: Surface, confirmed: bool) -> Decision {
|
||||
if meta.deny_on.contains(&surface) {
|
||||
return Decision::Deny;
|
||||
}
|
||||
let base = default_decision(surface, meta.danger);
|
||||
if base == Decision::Deny {
|
||||
return Decision::Deny;
|
||||
}
|
||||
let needs_confirm = base == Decision::Confirm || meta.confirm_on.contains(&surface);
|
||||
if needs_confirm && !confirmed {
|
||||
Decision::Confirm
|
||||
} else {
|
||||
Decision::Allow
|
||||
}
|
||||
}
|
||||
|
||||
/// Static metadata for one capability. All `&'static` so the registry is cheap
|
||||
/// to build and the bridge can list tools with zero allocation beyond the schema.
|
||||
pub struct CapabilityMeta {
|
||||
/// MCP tool name. Convention: `nomi_<domain>_<verb_object>`, lower_snake,
|
||||
/// kept concise. The fully-namespaced wire name is `mcp__nomifun-desktop__<name>`
|
||||
/// (22-char prefix); Anthropic caps that at 64 chars, so the tool name has a
|
||||
/// 42-char hard budget. The registry self-test enforces both the hard limit
|
||||
/// and a tighter style budget so names cannot creep toward the ceiling.
|
||||
pub name: &'static str,
|
||||
/// Coarse domain label (for diagnostics / grouping).
|
||||
pub domain: &'static str,
|
||||
/// LLM-facing one-line description.
|
||||
pub summary: &'static str,
|
||||
/// Danger tier — drives the default permission decision.
|
||||
pub danger: DangerTier,
|
||||
/// Surfaces where this capability is hard-denied regardless of confirmation
|
||||
/// (escape hatch beyond the danger matrix, e.g. a `Write` too risky for IM).
|
||||
pub deny_on: &'static [Surface],
|
||||
/// Surfaces where this capability additionally requires confirmation
|
||||
/// (escape hatch to force confirm on an otherwise-allowed surface).
|
||||
pub confirm_on: &'static [Surface],
|
||||
}
|
||||
|
||||
impl CapabilityMeta {
|
||||
/// Construct metadata with no surface overrides (the danger matrix applies as-is).
|
||||
pub const fn new(name: &'static str, domain: &'static str, summary: &'static str, danger: DangerTier) -> Self {
|
||||
Self {
|
||||
name,
|
||||
domain,
|
||||
summary,
|
||||
danger,
|
||||
deny_on: &[],
|
||||
confirm_on: &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard-deny this capability on the given surfaces (beyond the danger matrix).
|
||||
pub const fn deny_on(mut self, surfaces: &'static [Surface]) -> Self {
|
||||
self.deny_on = surfaces;
|
||||
self
|
||||
}
|
||||
|
||||
/// Force confirmation for this capability on the given surfaces.
|
||||
pub const fn confirm_on(mut self, surfaces: &'static [Surface]) -> Self {
|
||||
self.confirm_on = surfaces;
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether this capability can require a `confirm=true` on ANY surface — used
|
||||
/// to decide whether to inject the `confirm` property into its schema.
|
||||
fn confirmable(&self) -> bool {
|
||||
matches!(self.danger, DangerTier::Destructive | DangerTier::Sensitive) || !self.confirm_on.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// One operable capability: metadata + generated schema + typed handler.
|
||||
pub struct Capability {
|
||||
pub meta: CapabilityMeta,
|
||||
/// JSON Schema object for the tool's arguments (MCP `inputSchema`).
|
||||
pub input_schema: Map<String, Value>,
|
||||
pub handler: Handler,
|
||||
/// Optional streaming handler. `Some` for capabilities that can emit
|
||||
/// incremental progress (e.g. `nomi_agent_run`); consumed by
|
||||
/// [`Registry::dispatch_stream`]. The buffered [`handler`](Self::handler) is
|
||||
/// always present, so non-streaming adapters are unaffected.
|
||||
pub stream: Option<StreamingHandler>,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// Build a capability from a typed request `P` and an async handler.
|
||||
///
|
||||
/// `P` is the single source: its `JsonSchema` becomes the MCP `inputSchema`,
|
||||
/// and incoming arguments are deserialized into `P` before the handler runs.
|
||||
/// A deserialization failure returns a structured `{"error": …}` the agent
|
||||
/// can self-correct from — it never reaches the handler.
|
||||
pub fn new<P, F, Fut>(meta: CapabilityMeta, f: F) -> Self
|
||||
where
|
||||
P: DeserializeOwned + JsonSchema + Send + 'static,
|
||||
F: Fn(Arc<GatewayDeps>, CallerCtx, P) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = Value> + Send + 'static,
|
||||
{
|
||||
let mut input_schema = schema_for_params::<P>();
|
||||
if meta.confirmable() {
|
||||
inject_confirm_property(&mut input_schema);
|
||||
}
|
||||
let f = Arc::new(f);
|
||||
let handler: Handler = Arc::new(move |deps, ctx, args: Value| {
|
||||
let f = f.clone();
|
||||
Box::pin(async move {
|
||||
// `confirm` is a cross-cutting gate field injected into the schema,
|
||||
// not part of `P`; drop it before typed deserialization so an
|
||||
// `deny_unknown_fields` request type would not choke on it.
|
||||
let args = strip_confirm(args);
|
||||
match serde_json::from_value::<P>(args) {
|
||||
Ok(p) => f(deps, ctx, p).await,
|
||||
Err(e) => json!({ "error": format!("invalid arguments for this tool: {e}") }),
|
||||
}
|
||||
})
|
||||
});
|
||||
Capability {
|
||||
meta,
|
||||
input_schema,
|
||||
handler,
|
||||
stream: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a STREAMING capability: `f` receives a [`ProgressSink`] for
|
||||
/// incremental output and returns the final `Value`. A buffered
|
||||
/// [`Handler`](Self::handler) is synthesized automatically (it runs `f` with
|
||||
/// a draining sink and returns only the final value), so MCP `tools/call`,
|
||||
/// REST `/v1/tools/{name}`, and CLI keep working unchanged; streaming
|
||||
/// adapters use [`Registry::dispatch_stream`] to see the progress events.
|
||||
pub fn new_streaming<P, F, Fut>(meta: CapabilityMeta, f: F) -> Self
|
||||
where
|
||||
P: DeserializeOwned + JsonSchema + Send + 'static,
|
||||
F: Fn(Arc<GatewayDeps>, CallerCtx, P, ProgressSink) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = Value> + Send + 'static,
|
||||
{
|
||||
let mut input_schema = schema_for_params::<P>();
|
||||
if meta.confirmable() {
|
||||
inject_confirm_property(&mut input_schema);
|
||||
}
|
||||
let f = Arc::new(f);
|
||||
|
||||
// Streaming path: deserialize P, run f feeding the caller's sink.
|
||||
let stream_f = f.clone();
|
||||
let stream: StreamingHandler = Arc::new(move |deps, ctx, args: Value, sink: ProgressSink| {
|
||||
let f = stream_f.clone();
|
||||
Box::pin(async move {
|
||||
let args = strip_confirm(args);
|
||||
match serde_json::from_value::<P>(args) {
|
||||
Ok(p) => f(deps, ctx, p, sink).await,
|
||||
Err(e) => json!({ "error": format!("invalid arguments for this tool: {e}") }),
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Buffered path: run the same handler with a sink whose receiver is
|
||||
// drained-and-discarded, returning only the final value.
|
||||
let handler: Handler = Arc::new(move |deps, ctx, args: Value| {
|
||||
let f = f.clone();
|
||||
Box::pin(async move {
|
||||
let args = strip_confirm(args);
|
||||
let p = match serde_json::from_value::<P>(args) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return json!({ "error": format!("invalid arguments for this tool: {e}") }),
|
||||
};
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<Value>(64);
|
||||
let drain = tokio::spawn(async move { while rx.recv().await.is_some() {} });
|
||||
let result = f(deps, ctx, p, tx).await;
|
||||
drain.abort();
|
||||
result
|
||||
})
|
||||
});
|
||||
|
||||
Capability {
|
||||
meta,
|
||||
input_schema,
|
||||
handler,
|
||||
stream: Some(stream),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the MCP-facing JSON Schema object for a request type `P`, stripped
|
||||
/// of the meta keys schemars adds (`$schema`, `title`) that MCP clients ignore.
|
||||
fn schema_for_params<P: JsonSchema>() -> Map<String, Value> {
|
||||
let schema = schemars::schema_for!(P);
|
||||
let value = serde_json::to_value(schema).unwrap_or_else(|_| json!({ "type": "object" }));
|
||||
let mut map = match value {
|
||||
Value::Object(m) => m,
|
||||
_ => Map::new(),
|
||||
};
|
||||
map.remove("$schema");
|
||||
map.remove("title");
|
||||
map.entry("type").or_insert_with(|| json!("object"));
|
||||
// Tools with no fields still need a `properties` object so clients render an
|
||||
// empty-args form rather than rejecting the schema.
|
||||
map.entry("properties").or_insert_with(|| json!({}));
|
||||
map
|
||||
}
|
||||
|
||||
/// Add the cross-cutting `confirm` argument to a confirm-gated tool's schema so
|
||||
/// the LLM can discover it.
|
||||
fn inject_confirm_property(schema: &mut Map<String, Value>) {
|
||||
let props = schema.entry("properties").or_insert_with(|| json!({}));
|
||||
if let Some(obj) = props.as_object_mut() {
|
||||
obj.insert(
|
||||
"confirm".into(),
|
||||
json!({
|
||||
"type": "boolean",
|
||||
"description": "Set true ONLY after restating the exact destructive/sensitive action and its target to the user and getting explicit agreement. Required to execute confirm-gated actions."
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the gate-only `confirm` key before typed deserialization.
|
||||
fn strip_confirm(mut args: Value) -> Value {
|
||||
if let Value::Object(ref mut m) = args {
|
||||
m.remove("confirm");
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matrix_allows_reads_and_writes_everywhere() {
|
||||
for s in [Surface::Desktop, Surface::Channel, Surface::Remote] {
|
||||
assert_eq!(default_decision(s, DangerTier::Read), Decision::Allow);
|
||||
assert_eq!(default_decision(s, DangerTier::Write), Decision::Allow);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_gates_destructive_and_sensitive() {
|
||||
assert_eq!(default_decision(Surface::Desktop, DangerTier::Destructive), Decision::Confirm);
|
||||
assert_eq!(default_decision(Surface::Desktop, DangerTier::Sensitive), Decision::Confirm);
|
||||
assert_eq!(default_decision(Surface::Channel, DangerTier::Destructive), Decision::Deny);
|
||||
assert_eq!(default_decision(Surface::Channel, DangerTier::Sensitive), Decision::Deny);
|
||||
assert_eq!(default_decision(Surface::Remote, DangerTier::Destructive), Decision::Confirm);
|
||||
assert_eq!(default_decision(Surface::Remote, DangerTier::Sensitive), Decision::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_caller_resolves_remote_surface() {
|
||||
// The Remote front door sets `remote: true`; surface() must yield Remote.
|
||||
let ctx = CallerCtx { remote: true, ..Default::default() };
|
||||
assert_eq!(ctx.surface(), Surface::Remote);
|
||||
// `remote` takes precedence over a stray channel_platform value.
|
||||
let ctx2 = CallerCtx {
|
||||
remote: true,
|
||||
channel_platform: Some("lark".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(ctx2.surface(), Surface::Remote);
|
||||
// Without the marker, behaviour is unchanged (desktop / channel).
|
||||
assert_eq!(CallerCtx::default().surface(), Surface::Desktop);
|
||||
assert_eq!(
|
||||
CallerCtx { channel_platform: Some("lark".into()), ..Default::default() }.surface(),
|
||||
Surface::Channel
|
||||
);
|
||||
}
|
||||
|
||||
const META_DESTRUCTIVE: CapabilityMeta = CapabilityMeta {
|
||||
name: "t_del",
|
||||
domain: "test",
|
||||
summary: "delete a thing",
|
||||
danger: DangerTier::Destructive,
|
||||
deny_on: &[],
|
||||
confirm_on: &[],
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn destructive_needs_confirm_on_desktop_until_confirmed() {
|
||||
assert_eq!(decide(&META_DESTRUCTIVE, Surface::Desktop, false), Decision::Confirm);
|
||||
assert_eq!(decide(&META_DESTRUCTIVE, Surface::Desktop, true), Decision::Allow);
|
||||
// External channels hard-deny destructive ops even with confirm=true.
|
||||
assert_eq!(decide(&META_DESTRUCTIVE, Surface::Channel, true), Decision::Deny);
|
||||
}
|
||||
|
||||
const META_WRITE_DENY_CHANNEL: CapabilityMeta = CapabilityMeta {
|
||||
name: "t_write",
|
||||
domain: "test",
|
||||
summary: "write a thing",
|
||||
danger: DangerTier::Write,
|
||||
deny_on: &[Surface::Channel],
|
||||
confirm_on: &[],
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn deny_on_override_hard_denies_even_writes() {
|
||||
assert_eq!(decide(&META_WRITE_DENY_CHANNEL, Surface::Channel, true), Decision::Deny);
|
||||
assert_eq!(decide(&META_WRITE_DENY_CHANNEL, Surface::Desktop, false), Decision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmable_drives_schema_injection() {
|
||||
assert!(META_DESTRUCTIVE.confirmable());
|
||||
assert!(!META_WRITE_DENY_CHANNEL.confirmable());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
//! The capability registry: a deps-free, compile-time-known collection of every
|
||||
//! operable platform capability, keyed by MCP tool name.
|
||||
//!
|
||||
//! - The in-process [`crate::server`] dispatches tool calls through
|
||||
//! [`Registry::dispatch_opt`] (with real `GatewayDeps`).
|
||||
//! - The `mcp-gateway-stdio` bridge answers `tools/list` from
|
||||
//! [`Registry::tool_specs`] (schema only, no deps).
|
||||
//!
|
||||
//! During migration the registry coexists with the legacy `tools_*.rs` dispatch
|
||||
//! match: `dispatch_opt` returns `None` for any tool not yet registered, letting
|
||||
//! the legacy match handle it. Once every tool is migrated the legacy match is
|
||||
//! deleted and the bridge flips to listing `tool_specs()` dynamically.
|
||||
|
||||
mod capability;
|
||||
|
||||
pub use capability::{
|
||||
Capability, CapabilityMeta, DangerTier, Decision, ProgressSink, StreamingHandler, Surface,
|
||||
decide, default_decision,
|
||||
};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
|
||||
/// A tool advertised to MCP clients via `tools/list`.
|
||||
pub struct ToolSpec {
|
||||
pub name: &'static str,
|
||||
pub domain: &'static str,
|
||||
pub description: &'static str,
|
||||
pub input_schema: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// The global capability set.
|
||||
pub struct Registry {
|
||||
by_name: BTreeMap<&'static str, Capability>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
/// The process-wide registry, built once. Construction allocates only the
|
||||
/// capability closures + their generated schemas — no services — so this is
|
||||
/// safe to call from the bridge process too.
|
||||
pub fn global() -> &'static Registry {
|
||||
static REG: OnceLock<Registry> = OnceLock::new();
|
||||
REG.get_or_init(Registry::build)
|
||||
}
|
||||
|
||||
fn build() -> Registry {
|
||||
let mut caps: Vec<Capability> = Vec::new();
|
||||
|
||||
// ── capability domains ───────────────────────────────────────────
|
||||
// NEW DOMAIN? Three steps (the `all_caps_modules_are_mod_declared_and_registered`
|
||||
// test fails CI if you miss 1–2; the compiler fails if you miss 4):
|
||||
// 1. create `caps_<domain>.rs` with `pub(crate) fn register(out: &mut Vec<Capability>)`
|
||||
// 2. add `mod caps_<domain>;` to lib.rs
|
||||
// 3. add `crate::caps_<domain>::register(&mut caps);` HERE
|
||||
// 4. if it needs a NEW service: add a field to deps.rs::GatewayDeps and
|
||||
// wire it in nomifun-app/src/router/routes.rs::inject_gateway_deps.
|
||||
// Adding a tool to an EXISTING domain is just one more `out.push(...)` — no wiring.
|
||||
crate::caps_memory::register(&mut caps);
|
||||
crate::caps_confirmation::register(&mut caps);
|
||||
crate::caps_conversation::register(&mut caps);
|
||||
crate::caps_provider::register(&mut caps);
|
||||
crate::caps_cron::register(&mut caps);
|
||||
crate::caps_requirement::register(&mut caps);
|
||||
crate::caps_autowork::register(&mut caps);
|
||||
crate::caps_idmm::register(&mut caps);
|
||||
crate::caps_terminal::register(&mut caps);
|
||||
crate::caps_knowledge::register(&mut caps);
|
||||
crate::caps_knowledge_ext::register(&mut caps);
|
||||
crate::caps_system::register(&mut caps);
|
||||
crate::caps_companion::register(&mut caps);
|
||||
crate::caps_channel::register(&mut caps);
|
||||
crate::caps_scheduling_ext::register(&mut caps);
|
||||
crate::caps_terminal_ext::register(&mut caps);
|
||||
crate::caps_files::register(&mut caps);
|
||||
crate::caps_mcp::register(&mut caps);
|
||||
crate::caps_agent::register(&mut caps);
|
||||
#[cfg(feature = "browser-use")]
|
||||
crate::caps_browser::register(&mut caps);
|
||||
#[cfg(feature = "computer-use")]
|
||||
crate::caps_computer::register(&mut caps);
|
||||
|
||||
// De-duplicate by name; a collision is a programmer error worth failing
|
||||
// fast on at first use (boot), not a silent last-writer-wins.
|
||||
let mut by_name = BTreeMap::new();
|
||||
for c in caps {
|
||||
let name = c.meta.name;
|
||||
if by_name.insert(name, c).is_some() {
|
||||
panic!("duplicate gateway capability name: {name}");
|
||||
}
|
||||
}
|
||||
Registry { by_name }
|
||||
}
|
||||
|
||||
/// Whether a tool name is handled by the registry (migration check).
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.by_name.contains_key(name)
|
||||
}
|
||||
|
||||
/// Total registered capabilities.
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_name.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_name.is_empty()
|
||||
}
|
||||
|
||||
/// The tools visible on a surface: everything except the hard-denied set.
|
||||
/// Confirm-gated tools ARE listed (they are usable with `confirm=true`);
|
||||
/// passing `confirmed = true` to [`decide`] collapses `Confirm → Allow`, so
|
||||
/// only `Deny` outcomes are filtered out.
|
||||
pub fn tool_specs(&self, surface: Surface) -> Vec<ToolSpec> {
|
||||
self.by_name
|
||||
.values()
|
||||
.filter(|c| decide(&c.meta, surface, true) != Decision::Deny)
|
||||
.map(|c| ToolSpec {
|
||||
name: c.meta.name,
|
||||
domain: c.meta.domain,
|
||||
description: c.meta.summary,
|
||||
input_schema: c.input_schema.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Like [`tool_specs`](Self::tool_specs) but restricted to the given
|
||||
/// capability domains (`CapabilityMeta::domain`). Powers curated external
|
||||
/// "profiles" (e.g. an `agent` profile = do-work domains only), so a remote
|
||||
/// MCP client gets a tight, intent-focused tool list instead of all ~150.
|
||||
/// An empty `domains` slice yields an empty result (callers pass the full
|
||||
/// set or use [`tool_specs`](Self::tool_specs) for "everything").
|
||||
pub fn tool_specs_for(&self, surface: Surface, domains: &[&str]) -> Vec<ToolSpec> {
|
||||
self.by_name
|
||||
.values()
|
||||
.filter(|c| domains.contains(&c.meta.domain))
|
||||
.filter(|c| decide(&c.meta, surface, true) != Decision::Deny)
|
||||
.map(|c| ToolSpec {
|
||||
name: c.meta.name,
|
||||
domain: c.meta.domain,
|
||||
description: c.meta.summary,
|
||||
input_schema: c.input_schema.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn tool_visible(&self, surface: Surface, name: &str) -> bool {
|
||||
self.by_name
|
||||
.get(name)
|
||||
.is_some_and(|c| decide(&c.meta, surface, true) != Decision::Deny)
|
||||
}
|
||||
|
||||
pub fn tool_visible_for(&self, surface: Surface, domains: &[&str], name: &str) -> bool {
|
||||
self.by_name.get(name).is_some_and(|c| {
|
||||
domains.contains(&c.meta.domain) && decide(&c.meta, surface, true) != Decision::Deny
|
||||
})
|
||||
}
|
||||
|
||||
/// Dispatch a tool call if the registry owns the tool; `None` means "not a
|
||||
/// registry tool — let the legacy match handle it".
|
||||
pub async fn dispatch_opt(
|
||||
&self,
|
||||
deps: Arc<GatewayDeps>,
|
||||
ctx: CallerCtx,
|
||||
name: &str,
|
||||
args: &Value,
|
||||
) -> Option<Value> {
|
||||
let cap = self.by_name.get(name)?;
|
||||
let surface = ctx.surface();
|
||||
let confirmed = args
|
||||
.get("confirm")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let result = match decide(&cap.meta, surface, confirmed) {
|
||||
Decision::Deny => json!({
|
||||
"error": format!("'{name}' is not permitted on the {surface:?} surface")
|
||||
}),
|
||||
Decision::Confirm => json!({
|
||||
"needs_confirmation": true,
|
||||
"tool": name,
|
||||
"danger": format!("{:?}", cap.meta.danger),
|
||||
"note": "This action is destructive or sensitive. Restate the exact action and its target to the user, get explicit agreement, then call again with confirm=true."
|
||||
}),
|
||||
Decision::Allow => (cap.handler)(deps, ctx, args.clone()).await,
|
||||
};
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Streaming dispatch: like [`dispatch_opt`](Self::dispatch_opt) but a
|
||||
/// streaming-capable tool emits intermediate progress through `progress`
|
||||
/// while it runs, and the returned `Value` is the final result. A
|
||||
/// non-streaming tool emits nothing on `progress` and returns its single
|
||||
/// value (so the streaming endpoint works uniformly for every tool).
|
||||
/// `None` means the tool name is unknown.
|
||||
pub async fn dispatch_stream(
|
||||
&self,
|
||||
deps: Arc<GatewayDeps>,
|
||||
ctx: CallerCtx,
|
||||
name: &str,
|
||||
args: &Value,
|
||||
progress: ProgressSink,
|
||||
) -> Option<Value> {
|
||||
let cap = self.by_name.get(name)?;
|
||||
let surface = ctx.surface();
|
||||
let confirmed = args
|
||||
.get("confirm")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let result = match decide(&cap.meta, surface, confirmed) {
|
||||
Decision::Deny => json!({
|
||||
"error": format!("'{name}' is not permitted on the {surface:?} surface")
|
||||
}),
|
||||
Decision::Confirm => json!({
|
||||
"needs_confirmation": true,
|
||||
"tool": name,
|
||||
"danger": format!("{:?}", cap.meta.danger),
|
||||
"note": "This action is destructive or sensitive. Restate the exact action and its target to the user, get explicit agreement, then call again with confirm=true."
|
||||
}),
|
||||
Decision::Allow => match &cap.stream {
|
||||
Some(stream) => stream(deps, ctx, args.clone(), progress).await,
|
||||
None => (cap.handler)(deps, ctx, args.clone()).await,
|
||||
},
|
||||
};
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_api_types::GatewayMcpConfig;
|
||||
|
||||
/// Boot-time invariants for every registered capability: unique names
|
||||
/// (panics in `build` otherwise), `nomi_`-prefixed, a non-empty summary, a
|
||||
/// well-formed object schema, and a fully-namespaced MCP wire name within
|
||||
/// the Anthropic 64-char limit — with a tighter style budget on the tool
|
||||
/// name itself so length cannot creep up to the hard ceiling unnoticed.
|
||||
#[test]
|
||||
fn registry_builds_and_names_fit_mcp_limit() {
|
||||
let reg = Registry::global();
|
||||
// Wire name = `mcp__<server>__<tool>`; derive the prefix from the REAL
|
||||
// server-name constant so a rename can never silently invalidate this.
|
||||
let prefix = format!("mcp__{}__", GatewayMcpConfig::SERVER_NAME).len();
|
||||
// Hard ceiling Anthropic enforces on the wire name.
|
||||
const HARD_WIRE_LIMIT: usize = 64;
|
||||
// Style budget for the tool name alone (see CapabilityMeta::name doc):
|
||||
// keeps a comfortable margin under the ceiling as domains grow.
|
||||
const TOOL_NAME_BUDGET: usize = 42;
|
||||
|
||||
for (name, cap) in reg.by_name.iter() {
|
||||
assert!(
|
||||
name.starts_with("nomi_"),
|
||||
"gateway tool names are nomi_-prefixed: {name}"
|
||||
);
|
||||
assert!(
|
||||
prefix + name.len() <= HARD_WIRE_LIMIT,
|
||||
"tool name breaks the MCP 64-char wire limit: {name} ({prefix} + {} > {HARD_WIRE_LIMIT})",
|
||||
name.len()
|
||||
);
|
||||
assert!(
|
||||
name.len() <= TOOL_NAME_BUDGET,
|
||||
"tool name exceeds the {TOOL_NAME_BUDGET}-char style budget (keep `nomi_<domain>_<verb_object>` concise): {name} ({} chars)",
|
||||
name.len()
|
||||
);
|
||||
assert!(
|
||||
!cap.meta.summary.trim().is_empty(),
|
||||
"capability {name} has an empty summary (LLMs need it)"
|
||||
);
|
||||
assert!(
|
||||
cap.input_schema.contains_key("properties"),
|
||||
"capability {name} schema missing `properties` (MCP/OpenAI clients reject such schemas)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Floor on the registered-capability count. A drop below this almost always
|
||||
/// means a `caps_*` module's `register()` call was accidentally removed from
|
||||
/// `build()` (or a domain module deleted). Bump the floor when capabilities
|
||||
/// are intentionally removed. Default build (no `browser-use`) sits just
|
||||
/// below the feature-on count, so the floor allows for the gated module.
|
||||
#[test]
|
||||
fn registry_capability_count_floor() {
|
||||
let n = Registry::global().len();
|
||||
assert!(
|
||||
n >= 132,
|
||||
"capability count fell to {n} (floor 132) — a caps_* module may have lost its \
|
||||
register() call in Registry::build(), or a domain was removed. If intentional, lower the floor."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_surfaces_do_not_advertise_team_tools() {
|
||||
let reg = Registry::global();
|
||||
for surface in [Surface::Desktop, Surface::Remote, Surface::Channel] {
|
||||
let team_tools: Vec<&str> = reg
|
||||
.tool_specs(surface)
|
||||
.iter()
|
||||
.map(|s| s.name)
|
||||
.filter(|name| name.starts_with("nomi_team_"))
|
||||
.collect();
|
||||
assert!(
|
||||
team_tools.is_empty(),
|
||||
"team tools must not be advertised on {surface:?}: {team_tools:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_specs_for_filters_to_domains() {
|
||||
let reg = Registry::global();
|
||||
let agentish = reg.tool_specs_for(Surface::Remote, &["agent", "conversation"]);
|
||||
assert!(
|
||||
!agentish.is_empty(),
|
||||
"agent/conversation domains must expose tools"
|
||||
);
|
||||
// strict subset of the full Remote surface
|
||||
let all: std::collections::BTreeSet<&str> = reg
|
||||
.tool_specs(Surface::Remote)
|
||||
.iter()
|
||||
.map(|s| s.name)
|
||||
.collect();
|
||||
assert!(agentish.iter().all(|s| all.contains(s.name)));
|
||||
assert!(
|
||||
agentish.len() < all.len(),
|
||||
"a profile must be narrower than the full surface"
|
||||
);
|
||||
// contains the agent-delegation cap, excludes a system-management cap
|
||||
let names: Vec<&str> = agentish.iter().map(|s| s.name).collect();
|
||||
assert!(names.contains(&"nomi_agent_run"));
|
||||
assert!(
|
||||
!names.contains(&"nomi_system_update_settings"),
|
||||
"system domain must be excluded"
|
||||
);
|
||||
// unknown domain yields nothing
|
||||
assert!(
|
||||
reg.tool_specs_for(Surface::Remote, &["does_not_exist"])
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
/// **Anti-drift guard (the structural fix for the historical ~10% coverage gap).**
|
||||
///
|
||||
/// Every `caps_*.rs` file on disk MUST be both `mod`-declared in `lib.rs` and
|
||||
/// have its `register()` called in `Registry::build()`. A new domain file that
|
||||
/// forgets either step compiles silently and contributes ZERO tools with no
|
||||
/// other test failure — exactly the silent non-exposure that let coverage rot
|
||||
/// before. This test makes that mistake a hard CI failure. Pure source-text
|
||||
/// scanning (no proc-macro / inventory / linkme), so it also covers
|
||||
/// feature-gated modules whose `cfg` lines keep them out of a default build.
|
||||
#[test]
|
||||
fn all_caps_modules_are_mod_declared_and_registered() {
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
let src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
|
||||
// 1. Ground truth: caps_*.rs files on disk.
|
||||
let mut on_disk: Vec<String> = fs::read_dir(&src_dir)
|
||||
.expect("read gateway src dir")
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| {
|
||||
let n = e.file_name().to_string_lossy().into_owned();
|
||||
(n.starts_with("caps_") && n.ends_with(".rs"))
|
||||
.then(|| n.trim_end_matches(".rs").to_owned())
|
||||
})
|
||||
.collect();
|
||||
on_disk.sort();
|
||||
assert!(
|
||||
!on_disk.is_empty(),
|
||||
"no caps_*.rs files found — test misconfigured?"
|
||||
);
|
||||
|
||||
// 2. `mod caps_*;` declarations in lib.rs (ignores the cfg line above them).
|
||||
let lib_rs = fs::read_to_string(src_dir.join("lib.rs")).expect("read lib.rs");
|
||||
let modded: Vec<String> = lib_rs
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let t = l.trim();
|
||||
t.strip_prefix("mod ")
|
||||
.or_else(|| t.strip_prefix("pub mod "))
|
||||
.and_then(|r| r.strip_suffix(';'))
|
||||
.filter(|n| n.starts_with("caps_"))
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3. `crate::caps_*::register(&mut caps);` call sites in build().
|
||||
let reg_rs =
|
||||
fs::read_to_string(src_dir.join("registry/mod.rs")).expect("read registry/mod.rs");
|
||||
let registered: Vec<String> = reg_rs
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
l.trim()
|
||||
.strip_prefix("crate::")
|
||||
.and_then(|r| r.strip_suffix("::register(&mut caps);"))
|
||||
.filter(|n| n.starts_with("caps_"))
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let not_modded: Vec<&String> = on_disk.iter().filter(|f| !modded.contains(f)).collect();
|
||||
assert!(
|
||||
not_modded.is_empty(),
|
||||
"caps_*.rs on disk but NOT `mod`-declared in lib.rs (dead, never compiled): {not_modded:?} — add `mod <name>;`"
|
||||
);
|
||||
let not_registered: Vec<&String> =
|
||||
on_disk.iter().filter(|f| !registered.contains(f)).collect();
|
||||
assert!(
|
||||
not_registered.is_empty(),
|
||||
"caps_*.rs NOT registered in Registry::build(): {not_registered:?} — add `crate::<name>::register(&mut caps);` (silently contributes ZERO tools otherwise)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! In-process HTTP half of the Desktop Gateway MCP.
|
||||
//!
|
||||
//! ACP CLIs and the nomi engine spawn a SEPARATE stdio process
|
||||
//! (`nomicore mcp-gateway-stdio`) that cannot share this process's services;
|
||||
//! it forwards each tool call back here as an authenticated `POST /tool`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::IntoResponse;
|
||||
use nomifun_common::generate_id;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
use crate::registry::Registry;
|
||||
|
||||
/// Late-bound handle to the gateway dependencies. Unlike the guide /
|
||||
/// requirement servers (which hold a `Weak` to a singleton that outlives
|
||||
/// them elsewhere), this slot OWNS the deps bundle: `GatewayDeps` is
|
||||
/// assembled specifically for this server during router construction and has
|
||||
/// no other owner. Nothing inside the bundle references the server back, so
|
||||
/// there is no Arc cycle.
|
||||
type DepsSlot = Arc<RwLock<Option<Arc<GatewayDeps>>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct GatewayState {
|
||||
auth_token: String,
|
||||
deps: DepsSlot,
|
||||
}
|
||||
|
||||
/// In-process HTTP MCP server for the desktop gateway tools.
|
||||
pub struct GatewayMcpServer {
|
||||
http_addr: SocketAddr,
|
||||
auth_token: String,
|
||||
shutdown_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
deps_slot: DepsSlot,
|
||||
}
|
||||
|
||||
impl GatewayMcpServer {
|
||||
/// Bind a fresh `127.0.0.1:0` listener, mint a random bearer token, and
|
||||
/// start serving `POST /tool`. Deps must be wired separately via
|
||||
/// [`set_deps`](Self::set_deps) before the first tool call arrives.
|
||||
pub async fn start() -> Result<Self, String> {
|
||||
let auth_token = generate_id();
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind gateway MCP HTTP listener: {e}"))?;
|
||||
let http_addr = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to read gateway MCP local addr: {e}"))?;
|
||||
|
||||
let deps_slot: DepsSlot = Arc::new(RwLock::new(None));
|
||||
|
||||
let state = GatewayState {
|
||||
auth_token: auth_token.clone(),
|
||||
deps: deps_slot.clone(),
|
||||
};
|
||||
|
||||
let app = axum::Router::new()
|
||||
.route("/tool", axum::routing::post(handle_tool_request))
|
||||
.with_state(state);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
warn!(error = %e, "Gateway MCP axum server exited with error");
|
||||
}
|
||||
});
|
||||
|
||||
debug!(
|
||||
http_port = http_addr.port(),
|
||||
"Gateway MCP Server started (axum)"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
http_addr,
|
||||
auth_token,
|
||||
shutdown_handle: Some(handle),
|
||||
deps_slot,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wire the dependency bundle after router construction. Must be called
|
||||
/// once before the first tool request arrives.
|
||||
pub async fn set_deps(&self, deps: Arc<GatewayDeps>) {
|
||||
*self.deps_slot.write().await = Some(deps);
|
||||
}
|
||||
|
||||
pub fn http_port(&self) -> u16 {
|
||||
self.http_addr.port()
|
||||
}
|
||||
|
||||
pub fn auth_token(&self) -> &str {
|
||||
&self.auth_token
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(handle) = self.shutdown_handle.take() {
|
||||
handle.abort();
|
||||
debug!(
|
||||
http_port = self.http_addr.port(),
|
||||
"Gateway MCP Server stop requested"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GatewayMcpServer {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axum handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn handle_tool_request(
|
||||
State(state): State<GatewayState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> impl IntoResponse {
|
||||
let provided_token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
|
||||
if provided_token != state.auth_token {
|
||||
warn!("Gateway MCP: unauthorized request");
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "unauthorized"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let tool = body
|
||||
.get("tool")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let args = body.get("args").cloned().unwrap_or(Value::Null);
|
||||
let ctx = CallerCtx {
|
||||
conversation_id: body
|
||||
.get("conversation_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
user_id: body
|
||||
.get("user_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
// Optional: only master-agent / companion sessions with a companion binding
|
||||
// carry it; empty is normalized to None.
|
||||
companion_id: body
|
||||
.get("companion_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned),
|
||||
// Optional: only channel master-agent sessions carry it.
|
||||
channel_platform: body
|
||||
.get("channel_platform")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned),
|
||||
// This in-process server is the INWARD path (bundled agents on loopback);
|
||||
// never the external Remote surface.
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let deps = match state.deps.read().await.clone() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
warn!(tool, "Gateway MCP: deps not available");
|
||||
return finish(json!({"error": "service_unavailable"}));
|
||||
}
|
||||
};
|
||||
|
||||
info!(tool, caller = %ctx.conversation_id, "Gateway MCP: dispatching tool");
|
||||
|
||||
// The capability registry is the single authority: it owns every tool,
|
||||
// generates its schema, and enforces the danger-tier × surface permission
|
||||
// gate. An unknown name returns a structured error the agent can recover from.
|
||||
let response_body = match Registry::global()
|
||||
.dispatch_opt(deps.clone(), ctx.clone(), &tool, &args)
|
||||
.await
|
||||
{
|
||||
Some(v) => v,
|
||||
None => {
|
||||
warn!(tool, "Gateway MCP: unknown tool");
|
||||
json!({ "error": format!("Unknown tool: {tool}") })
|
||||
}
|
||||
};
|
||||
|
||||
finish(response_body)
|
||||
}
|
||||
|
||||
/// Wrap a JSON body as a response and ask the client to close the connection
|
||||
/// (the stdio bridge runs with `pool_max_idle_per_host(0)` and does not reuse).
|
||||
fn finish(body: Value) -> axum::response::Response {
|
||||
let mut resp = Json(body).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::CONNECTION, HeaderValue::from_static("close"));
|
||||
resp
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers for the capability handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every conversation-domain tool needs the calling user's identity to scope
|
||||
/// data access; refuse to operate without one.
|
||||
pub(crate) fn require_user(ctx: &CallerCtx) -> Result<&str, Value> {
|
||||
if ctx.user_id.is_empty() {
|
||||
Err(json!({"error": "missing caller user identity (NOMI_GW_MCP_USER_ID)"}))
|
||||
} else {
|
||||
Ok(&ctx.user_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a serializable payload as a successful tool result.
|
||||
pub(crate) fn ok<T: serde::Serialize>(payload: T) -> Value {
|
||||
match serde_json::to_value(payload) {
|
||||
Ok(v) => json!({"result": v}),
|
||||
Err(e) => json!({"error": format!("failed to serialize result: {e}")}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn post_tool(port: u16, token: Option<&str>, body: Value) -> (u16, Value) {
|
||||
let client = reqwest::Client::builder().no_proxy().build().unwrap();
|
||||
let mut req = client
|
||||
.post(format!("http://127.0.0.1:{port}/tool"))
|
||||
.json(&body);
|
||||
if let Some(t) = token {
|
||||
req = req.header("Authorization", format!("Bearer {t}"));
|
||||
}
|
||||
let resp = req.send().await.unwrap();
|
||||
let status = resp.status().as_u16();
|
||||
let json: Value = resp.json().await.unwrap_or(Value::Null);
|
||||
(status, json)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_returns_positive_port_and_token() {
|
||||
let server = GatewayMcpServer::start().await.unwrap();
|
||||
assert!(server.http_port() > 0);
|
||||
assert!(!server.auth_token().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn each_start_uses_a_fresh_auth_token() {
|
||||
let a = GatewayMcpServer::start().await.unwrap();
|
||||
let b = GatewayMcpServer::start().await.unwrap();
|
||||
assert_ne!(a.auth_token(), b.auth_token());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_call_requires_auth() {
|
||||
let server = GatewayMcpServer::start().await.unwrap();
|
||||
let (status, _) = post_tool(
|
||||
server.http_port(),
|
||||
None,
|
||||
json!({"tool": "nomi_list_conversations", "args": {}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_deps_returns_unavailable() {
|
||||
// Server started but set_deps never called.
|
||||
let server = GatewayMcpServer::start().await.unwrap();
|
||||
let (status, body) = post_tool(
|
||||
server.http_port(),
|
||||
Some(server.auth_token()),
|
||||
json!({"tool": "nomi_list_conversations", "args": {}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(
|
||||
body.get("error").and_then(Value::as_str),
|
||||
Some("service_unavailable")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_user_rejects_empty_identity() {
|
||||
let ctx = CallerCtx::default();
|
||||
assert!(require_user(&ctx).is_err());
|
||||
let ctx = CallerCtx {
|
||||
user_id: "u1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(require_user(&ctx).unwrap(), "u1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Provider/model gateway tools + the shared nomi model resolution chain.
|
||||
//!
|
||||
//! The chain exists to kill the "cron job silently bound to a model-less
|
||||
//! conversation, blows up at execution time with Provider '' not found"
|
||||
//! class of bug: nomi sessions get a model AT CREATION, resolved as
|
||||
//! explicit args → calling companion's own profile model → first configured
|
||||
//! provider's first model → hard error with guidance.
|
||||
|
||||
use nomifun_common::ProviderWithModel;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::deps::{CallerCtx, GatewayDeps};
|
||||
|
||||
/// A provider row reduced to what the listing tool + resolution chain need.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ProviderSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub platform: String,
|
||||
pub enabled: bool,
|
||||
/// Effective model ids: the `models` JSON array filtered by the
|
||||
/// per-model `model_enabled` map (absent entry = enabled).
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn summarize_provider(row: &nomifun_db::models::Provider) -> ProviderSummary {
|
||||
let all_models: Vec<String> = serde_json::from_str(&row.models).unwrap_or_default();
|
||||
let enabled_map: serde_json::Map<String, Value> = row
|
||||
.model_enabled
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
let models = all_models
|
||||
.into_iter()
|
||||
.filter(|m| enabled_map.get(m).and_then(Value::as_bool).unwrap_or(true))
|
||||
.collect();
|
||||
ProviderSummary {
|
||||
id: row.id.clone(),
|
||||
name: row.name.clone(),
|
||||
platform: row.platform.clone(),
|
||||
enabled: row.enabled,
|
||||
models,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_provider_summaries(deps: &GatewayDeps) -> Result<Vec<ProviderSummary>, Value> {
|
||||
let rows = deps
|
||||
.provider_repo
|
||||
.list()
|
||||
.await
|
||||
.map_err(|e| json!({"error": format!("failed to list providers: {e}")}))?;
|
||||
Ok(rows.iter().map(summarize_provider).collect())
|
||||
}
|
||||
|
||||
/// `nomi_list_providers` lives in `caps_provider`; this module retains only the
|
||||
/// shared provider summaries + the nomi model-resolution chain.
|
||||
|
||||
/// Outcome of the model resolution chain, with the step that produced it
|
||||
/// (surfaced to the calling agent so it can tell the owner what was picked).
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) struct ResolvedModel {
|
||||
pub provider_id: String,
|
||||
pub model: String,
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
/// The pure nomi model resolution chain:
|
||||
/// 1. explicit provider+model → as given (provider must exist and be enabled)
|
||||
/// 2. explicit provider only → that provider's first available model
|
||||
/// 3. explicit model only → first enabled provider offering it
|
||||
/// 4. calling companion's profile model (only when its provider still exists+enabled)
|
||||
/// 5. first enabled provider's first model
|
||||
/// 6. error with configuration guidance
|
||||
pub(crate) fn resolve_model_chain(
|
||||
explicit_provider: Option<&str>,
|
||||
explicit_model: Option<&str>,
|
||||
companion_model: Option<(&str, &str)>,
|
||||
providers: &[ProviderSummary],
|
||||
) -> Result<ResolvedModel, String> {
|
||||
let find = |id: &str| providers.iter().find(|p| p.id == id);
|
||||
let require_enabled = |pid: &str| -> Result<&ProviderSummary, String> {
|
||||
let p = find(pid)
|
||||
.ok_or_else(|| format!("provider '{pid}' not found; call nomi_list_providers for valid ids"))?;
|
||||
if !p.enabled {
|
||||
return Err(format!(
|
||||
"provider '{}' ({}) is disabled; pick another via nomi_list_providers",
|
||||
p.name, p.id
|
||||
));
|
||||
}
|
||||
Ok(p)
|
||||
};
|
||||
|
||||
match (explicit_provider, explicit_model) {
|
||||
(Some(pid), Some(model)) => {
|
||||
require_enabled(pid)?;
|
||||
return Ok(ResolvedModel {
|
||||
provider_id: pid.to_owned(),
|
||||
model: model.to_owned(),
|
||||
source: "explicit",
|
||||
});
|
||||
}
|
||||
(Some(pid), None) => {
|
||||
let p = require_enabled(pid)?;
|
||||
let model = p
|
||||
.models
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("provider '{}' ({}) has no available models", p.name, p.id))?;
|
||||
return Ok(ResolvedModel {
|
||||
provider_id: pid.to_owned(),
|
||||
model,
|
||||
source: "explicit_provider_first_model",
|
||||
});
|
||||
}
|
||||
(None, Some(model)) => {
|
||||
let p = providers
|
||||
.iter()
|
||||
.find(|p| p.enabled && p.models.iter().any(|m| m == model))
|
||||
.ok_or_else(|| {
|
||||
format!("no enabled provider offers model '{model}'; call nomi_list_providers for valid combinations")
|
||||
})?;
|
||||
return Ok(ResolvedModel {
|
||||
provider_id: p.id.clone(),
|
||||
model: model.to_owned(),
|
||||
source: "explicit_model",
|
||||
});
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
if let Some((pid, model)) = companion_model
|
||||
&& !pid.is_empty()
|
||||
&& !model.is_empty()
|
||||
&& find(pid).map(|p| p.enabled).unwrap_or(false)
|
||||
{
|
||||
return Ok(ResolvedModel {
|
||||
provider_id: pid.to_owned(),
|
||||
model: model.to_owned(),
|
||||
source: "companion_profile",
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(p) = providers.iter().find(|p| p.enabled && !p.models.is_empty()) {
|
||||
return Ok(ResolvedModel {
|
||||
provider_id: p.id.clone(),
|
||||
model: p.models[0].clone(),
|
||||
source: "first_available_provider",
|
||||
});
|
||||
}
|
||||
|
||||
Err("no model available: no provider is configured/enabled on this desktop. Call nomi_list_providers to confirm, then ask the owner to configure one in Settings → Providers — do NOT create nomi sessions or cron jobs without a model.".to_owned())
|
||||
}
|
||||
|
||||
/// Async wrapper around [`resolve_model_chain`]: loads the provider rows and
|
||||
/// the calling companion's profile model, returns a ready-to-persist
|
||||
/// `ProviderWithModel` plus the resolution source.
|
||||
pub(crate) async fn resolve_nomi_model(
|
||||
deps: &GatewayDeps,
|
||||
ctx: &CallerCtx,
|
||||
explicit_provider: Option<&str>,
|
||||
explicit_model: Option<&str>,
|
||||
) -> Result<(ProviderWithModel, &'static str), Value> {
|
||||
let providers = load_provider_summaries(deps).await?;
|
||||
let companion_model = companion_profile_model(deps, ctx).await;
|
||||
match resolve_model_chain(
|
||||
explicit_provider,
|
||||
explicit_model,
|
||||
companion_model.as_ref().map(|(p, m)| (p.as_str(), m.as_str())),
|
||||
&providers,
|
||||
) {
|
||||
Ok(r) => {
|
||||
let model = r.model;
|
||||
Ok((
|
||||
ProviderWithModel {
|
||||
provider_id: r.provider_id,
|
||||
model: model.clone(),
|
||||
use_model: Some(model),
|
||||
},
|
||||
r.source,
|
||||
))
|
||||
}
|
||||
Err(msg) => Err(json!({"error": msg})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit-args-only resolution (no companion / first-provider fallback): used by
|
||||
/// `nomi_update_conversation`, where a model change is an explicit owner
|
||||
/// instruction that must not be silently substituted.
|
||||
pub(crate) async fn resolve_explicit_model(
|
||||
deps: &GatewayDeps,
|
||||
explicit_provider: Option<&str>,
|
||||
explicit_model: Option<&str>,
|
||||
) -> Result<ProviderWithModel, Value> {
|
||||
let providers = load_provider_summaries(deps).await?;
|
||||
match resolve_model_chain(explicit_provider, explicit_model, None, &providers) {
|
||||
Ok(r) => {
|
||||
let model = r.model;
|
||||
Ok(ProviderWithModel {
|
||||
provider_id: r.provider_id,
|
||||
model: model.clone(),
|
||||
use_model: Some(model),
|
||||
})
|
||||
}
|
||||
Err(msg) => Err(json!({"error": msg})),
|
||||
}
|
||||
}
|
||||
|
||||
/// The calling companion's configured profile model `(provider_id, model)`.
|
||||
/// `ctx.companion_id` first; a missing/unconfigured bound companion degrades to the
|
||||
/// default companion (mirrors `CompanionMasterAgentProfile`).
|
||||
async fn companion_profile_model(deps: &GatewayDeps, ctx: &CallerCtx) -> Option<(String, String)> {
|
||||
if let Some(id) = &ctx.companion_id
|
||||
&& let Ok(p) = deps.companion_service.get_companion(id).await
|
||||
&& p.model.is_configured()
|
||||
{
|
||||
return Some((p.model.provider_id, p.model.model));
|
||||
}
|
||||
let default_id = deps.companion_service.default_companion_id().await?;
|
||||
let p = deps.companion_service.get_companion(&default_id).await.ok()?;
|
||||
p.model
|
||||
.is_configured()
|
||||
.then(|| (p.model.provider_id, p.model.model))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn provider(id: &str, enabled: bool, models: &[&str]) -> ProviderSummary {
|
||||
ProviderSummary {
|
||||
id: id.to_owned(),
|
||||
name: format!("name-{id}"),
|
||||
platform: "openai".to_owned(),
|
||||
enabled,
|
||||
models: models.iter().map(|m| m.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_provider_and_model_win() {
|
||||
let providers = vec![provider("p1", true, &["m1"]), provider("p2", true, &["m2"])];
|
||||
let r = resolve_model_chain(Some("p2"), Some("custom-model"), Some(("p1", "m1")), &providers).unwrap();
|
||||
assert_eq!(r.provider_id, "p2");
|
||||
assert_eq!(r.model, "custom-model");
|
||||
assert_eq!(r.source, "explicit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_unknown_provider_errors_instead_of_falling_back() {
|
||||
let providers = vec![provider("p1", true, &["m1"])];
|
||||
let err = resolve_model_chain(Some("ghost"), Some("m"), Some(("p1", "m1")), &providers).unwrap_err();
|
||||
assert!(err.contains("ghost"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_disabled_provider_errors() {
|
||||
let providers = vec![provider("p1", false, &["m1"])];
|
||||
let err = resolve_model_chain(Some("p1"), None, None, &providers).unwrap_err();
|
||||
assert!(err.contains("disabled"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_provider_only_takes_its_first_model() {
|
||||
let providers = vec![provider("p1", true, &["a", "b"])];
|
||||
let r = resolve_model_chain(Some("p1"), None, None, &providers).unwrap();
|
||||
assert_eq!((r.provider_id.as_str(), r.model.as_str()), ("p1", "a"));
|
||||
assert_eq!(r.source, "explicit_provider_first_model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_model_only_scans_enabled_providers() {
|
||||
let providers = vec![
|
||||
provider("p0", false, &["target"]),
|
||||
provider("p1", true, &["other"]),
|
||||
provider("p2", true, &["target"]),
|
||||
];
|
||||
let r = resolve_model_chain(None, Some("target"), None, &providers).unwrap();
|
||||
assert_eq!(r.provider_id, "p2");
|
||||
assert_eq!(r.source, "explicit_model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_profile_used_when_no_explicit_args() {
|
||||
let providers = vec![provider("p1", true, &["m1"]), provider("p2", true, &["m2"])];
|
||||
let r = resolve_model_chain(None, None, Some(("p2", "m2")), &providers).unwrap();
|
||||
assert_eq!((r.provider_id.as_str(), r.model.as_str()), ("p2", "m2"));
|
||||
assert_eq!(r.source, "companion_profile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_profile_with_deleted_provider_falls_through_to_first_available() {
|
||||
let providers = vec![provider("p1", true, &["m1"])];
|
||||
let r = resolve_model_chain(None, None, Some(("gone", "mx")), &providers).unwrap();
|
||||
assert_eq!((r.provider_id.as_str(), r.model.as_str()), ("p1", "m1"));
|
||||
assert_eq!(r.source, "first_available_provider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_available_skips_disabled_and_empty_providers() {
|
||||
let providers = vec![
|
||||
provider("off", false, &["m"]),
|
||||
provider("empty", true, &[]),
|
||||
provider("good", true, &["pick-me"]),
|
||||
];
|
||||
let r = resolve_model_chain(None, None, None, &providers).unwrap();
|
||||
assert_eq!((r.provider_id.as_str(), r.model.as_str()), ("good", "pick-me"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_resolvable_returns_guidance_error() {
|
||||
let err = resolve_model_chain(None, None, None, &[]).unwrap_err();
|
||||
assert!(err.contains("nomi_list_providers"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_filters_per_model_enabled_map() {
|
||||
let row = nomifun_db::models::Provider {
|
||||
id: "p1".into(),
|
||||
platform: "openai".into(),
|
||||
name: "P1".into(),
|
||||
base_url: String::new(),
|
||||
api_key_encrypted: String::new(),
|
||||
models: r#"["a","b","c"]"#.into(),
|
||||
enabled: true,
|
||||
capabilities: "[]".into(),
|
||||
context_limit: None,
|
||||
model_protocols: None,
|
||||
model_enabled: Some(r#"{"b": false}"#.into()),
|
||||
model_health: None,
|
||||
bedrock_config: None,
|
||||
is_full_url: false,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
let s = summarize_provider(&row);
|
||||
assert_eq!(s.models, vec!["a".to_owned(), "c".to_owned()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Terminal-domain shared helper: the launch-preset resolver.
|
||||
//!
|
||||
//! The terminal CAPABILITIES live in `caps_terminal`; this module retains only
|
||||
//! `preset_launch`, the backend mirror of the frontend launch presets, reused
|
||||
//! by the terminal capability handlers (and unit-tested there).
|
||||
|
||||
/// Backend mirror of the frontend launch presets
|
||||
/// (`ui/src/renderer/pages/terminal/launchPresets.ts`) — keep the two in sync.
|
||||
/// Returns `(command, args, backend)`; the `$SHELL` sentinel is resolved to the
|
||||
/// platform shell by `TerminalService`.
|
||||
pub(crate) fn preset_launch(preset: &str, full_auto: bool) -> Result<(String, Vec<String>, Option<String>), String> {
|
||||
let flag = |f: &str| {
|
||||
if full_auto {
|
||||
vec![f.to_owned()]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
match preset {
|
||||
"shell" => Ok((nomifun_terminal::types::SHELL_SENTINEL.to_owned(), vec![], None)),
|
||||
"claude" => Ok((
|
||||
"claude".to_owned(),
|
||||
flag("--dangerously-skip-permissions"),
|
||||
Some("claude".to_owned()),
|
||||
)),
|
||||
"codex" => Ok((
|
||||
"codex".to_owned(),
|
||||
flag("--dangerously-bypass-approvals-and-sandbox"),
|
||||
Some("codex".to_owned()),
|
||||
)),
|
||||
"gemini" => Ok(("gemini".to_owned(), flag("--yolo"), Some("gemini".to_owned()))),
|
||||
other => Err(format!("unknown preset '{other}' (expected shell | claude | codex | gemini)")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! 网关并行执行面:`BrowserRegistry::execute_parallel` 异 key 并发、同 key 串行,结果保输入序。
|
||||
//!
|
||||
//! **Hermetic(不启动 Chrome)**:用**未知动作**——facade 在 dispatch 即返 `Unknown action {name}`
|
||||
//! 错误,**不调 `engine()`**(不解析/下载 Chrome),但仍走完 `execute` 的 per-key 锁 + 派发路径。
|
||||
//! 错误消息含动作名,故可据此断言结果**按输入序**返回。
|
||||
//!
|
||||
//! 跑:`cargo nextest run -p nomifun-gateway --features browser-use -E 'test(execute_parallel)'`
|
||||
|
||||
#![cfg(feature = "browser-use")]
|
||||
|
||||
use nomifun_gateway::browser_registry::{tool_result_to_value, BrowserRegistry};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_parallel_distinct_keys_results_in_input_order() {
|
||||
let reg = BrowserRegistry::default_for_browser_use();
|
||||
// 两个不同 key(异 key 并发)。未知动作 → 各自快速返错,不启动 Chrome。
|
||||
let batch = vec![
|
||||
("companion-a".to_string(), json!({"action": "zzz_marker_alpha"})),
|
||||
("companion-b".to_string(), json!({"action": "zzz_marker_beta"})),
|
||||
];
|
||||
let started = std::time::Instant::now();
|
||||
let results = reg.execute_parallel(batch).await;
|
||||
assert_eq!(results.len(), 2, "一输入一结果");
|
||||
// 保序:结果[0] 对应 alpha、结果[1] 对应 beta(错误消息回带动作名)。
|
||||
let strs: Vec<String> = results
|
||||
.into_iter()
|
||||
.map(|r| tool_result_to_value(r).to_string())
|
||||
.collect();
|
||||
assert!(strs[0].contains("zzz_marker_alpha"), "结果须按输入序(idx0=alpha): {}", strs[0]);
|
||||
assert!(strs[1].contains("zzz_marker_beta"), "结果须按输入序(idx1=beta): {}", strs[1]);
|
||||
// 不得死锁/卡住(未知动作不启动 Chrome,应近乎瞬时)。
|
||||
assert!(started.elapsed().as_secs() < 20, "execute_parallel 不得死锁/卡住");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_parallel_same_key_serializes_and_returns_all() {
|
||||
let reg = BrowserRegistry::default_for_browser_use();
|
||||
// 同一 key 两次:必须经该 key 的 CompanionBrowser 锁**串行**(不并发撞同一引擎),但两结果都返回。
|
||||
let batch = vec![
|
||||
("companion-a".to_string(), json!({"action": "zzz_one"})),
|
||||
("companion-a".to_string(), json!({"action": "zzz_two"})),
|
||||
];
|
||||
let results = reg.execute_parallel(batch).await;
|
||||
assert_eq!(results.len(), 2, "同 key 两调用都须返回(串行,无丢失)");
|
||||
}
|
||||
Reference in New Issue
Block a user