diff --git a/src/openhuman/agent_workflows/mod.rs b/src/openhuman/agent_workflows/mod.rs index 2b617e943..bb183bc3a 100644 --- a/src/openhuman/agent_workflows/mod.rs +++ b/src/openhuman/agent_workflows/mod.rs @@ -14,6 +14,7 @@ pub mod ops; pub mod parse; pub mod schemas; pub mod select; +pub mod tools; pub mod types; pub mod workdir; diff --git a/src/openhuman/agent_workflows/tools.rs b/src/openhuman/agent_workflows/tools.rs new file mode 100644 index 000000000..61a9a5832 --- /dev/null +++ b/src/openhuman/agent_workflows/tools.rs @@ -0,0 +1,334 @@ +//! LLM-callable wrappers over the agent-workflows domain. +//! +//! These tools let the agent discover, read, scaffold, and inspect the +//! lifecycle phases of installed WORKFLOW.md bundles (under +//! `~/.openhuman/workflows/` and the workspace). They are thin shims over +//! the free functions re-exported from +//! [`crate::openhuman::agent_workflows`]. +//! +//! `agent_workflow_list` / `read` / `phase_info` are read-only and +//! default-enabled; `agent_workflow_create` is a bounded `Write` +//! (scaffolds a user-scope dir) and default-enabled. `agent_workflow_uninstall` +//! recursively deletes a workflow directory — it is `Dangerous` and ships +//! default-OFF via `tools/user_filter.rs`. + +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::agent_workflows::{ + create_workflow, discover_workflows, effective_tool_scope, is_workspace_trusted, + phase_guidance, read_workflow, uninstall_workflow, +}; +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`")) +} + +fn read_optional_str(args: &serde_json::Value, key: &str) -> String { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .unwrap_or_default() + .to_string() +} + +/// List installed agent workflows (user + project scope). +pub struct AgentWorkflowListTool { + workspace_dir: PathBuf, +} + +impl AgentWorkflowListTool { + pub fn new(config: Arc) -> Self { + Self { + workspace_dir: config.workspace_dir.clone(), + } + } +} + +#[async_trait] +impl Tool for AgentWorkflowListTool { + fn name(&self) -> &str { + "agent_workflow_list" + } + + fn description(&self) -> &str { + "List installed agent workflows (reusable, phased task playbooks \ + defined as WORKFLOW.md bundles). Returns each workflow's `name`, \ + `description`, `when_to_use`, `tags`, `scope`, and phase names. Use \ + this to find a workflow whose `when_to_use` matches the user's \ + request, then `agent_workflow_read` it for the full body and \ + `agent_workflow_phase_info` to resolve a specific phase's guidance." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][agent_workflows] list invoked"); + let trusted = is_workspace_trusted(&self.workspace_dir); + let workflows = discover_workflows(None, Some(&self.workspace_dir), trusted); + let body = serde_json::to_string(&json!({ + "count": workflows.len(), + "workflows": workflows, + }))?; + Ok(ToolResult::success(body)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Read a single workflow by id (directory name). +pub struct AgentWorkflowReadTool; + +#[async_trait] +impl Tool for AgentWorkflowReadTool { + fn name(&self) -> &str { + "agent_workflow_read" + } + + fn description(&self) -> &str { + "Read one agent workflow by `id` (its directory name), returning the \ + full parsed WORKFLOW.md: description, when-to-use, tags, tool scope, \ + and the per-phase rules/scripts/context. Call `agent_workflow_list` \ + first to discover ids." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Workflow id (directory name)." } + }, + "required": ["id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][agent_workflows] read invoked"); + let id = read_required_str(&args, "id")?; + let workflow = + read_workflow(&id).map_err(|e| anyhow::anyhow!("agent_workflow_read: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&workflow)?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Resolve a single phase's guidance and effective tool scope. +pub struct AgentWorkflowPhaseInfoTool; + +#[async_trait] +impl Tool for AgentWorkflowPhaseInfoTool { + fn name(&self) -> &str { + "agent_workflow_phase_info" + } + + fn description(&self) -> &str { + "For a given workflow `id` and `phase`, return the rendered phase \ + guidance (rules + context as markdown) and the effective tool scope \ + (allow/deny) the agent should honor while in that phase. Use before \ + executing a workflow phase so you follow its rules and stay within \ + its tool allowlist." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Workflow id (directory name)." }, + "phase": { "type": "string", "description": "Phase name to resolve." } + }, + "required": ["id", "phase"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][agent_workflows] phase_info invoked"); + let id = read_required_str(&args, "id")?; + let phase = read_required_str(&args, "phase")?; + let workflow = + read_workflow(&id).map_err(|e| anyhow::anyhow!("agent_workflow_phase_info: {e}"))?; + let guidance = phase_guidance(&workflow, &phase); + let tool_scope = effective_tool_scope(&workflow, &phase); + let body = serde_json::to_string(&json!({ + "id": id, + "phase": phase, + "guidance": guidance, + "tool_scope": tool_scope, + }))?; + Ok(ToolResult::success(body)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Scaffold a new user-scope workflow. +pub struct AgentWorkflowCreateTool; + +#[async_trait] +impl Tool for AgentWorkflowCreateTool { + fn name(&self) -> &str { + "agent_workflow_create" + } + + fn description(&self) -> &str { + "Scaffold a new user-scope agent workflow: creates \ + `~/.openhuman/workflows//WORKFLOW.md` from `name`, with an \ + optional `description` and `when_to_use`. Returns the created \ + workflow. Use when the user wants to capture a repeatable, phased \ + procedure as a reusable workflow." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "Human-readable workflow name." }, + "description": { "type": "string", "description": "One-line summary." }, + "when_to_use": { "type": "string", "description": "When the agent should reach for this workflow." } + }, + "required": ["name"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][agent_workflows] create invoked"); + let name = read_required_str(&args, "name")?; + let description = read_optional_str(&args, "description"); + let when_to_use = read_optional_str(&args, "when_to_use"); + let workflow = create_workflow(&name, &description, &when_to_use) + .map_err(|e| anyhow::anyhow!("agent_workflow_create: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&workflow)?)) + } +} + +/// Permanently delete an installed workflow directory. Default-OFF. +pub struct AgentWorkflowUninstallTool; + +#[async_trait] +impl Tool for AgentWorkflowUninstallTool { + fn name(&self) -> &str { + "agent_workflow_uninstall" + } + + fn description(&self) -> &str { + "Permanently delete an installed agent workflow by `id`, removing its \ + entire directory under `~/.openhuman/workflows/`. This is \ + IRREVERSIBLE. Only use when the user has explicitly asked to remove a \ + specific workflow." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Workflow id (directory name) to delete." } + }, + "required": ["id"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][agent_workflows] uninstall invoked"); + let id = read_required_str(&args, "id")?; + let removed = uninstall_workflow(&id) + .map_err(|e| anyhow::anyhow!("agent_workflow_uninstall: {e}"))?; + Ok(ToolResult::success(serde_json::to_string( + &json!({ "id": id, "removed": removed }), + )?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + #[test] + fn metadata_is_stable() { + let cfg = Arc::new(Config::default()); + assert_eq!( + AgentWorkflowListTool::new(cfg).name(), + "agent_workflow_list" + ); + assert_eq!(AgentWorkflowReadTool.name(), "agent_workflow_read"); + assert_eq!( + AgentWorkflowPhaseInfoTool.name(), + "agent_workflow_phase_info" + ); + assert_eq!(AgentWorkflowCreateTool.name(), "agent_workflow_create"); + assert_eq!( + AgentWorkflowUninstallTool.name(), + "agent_workflow_uninstall" + ); + } + + #[test] + fn permission_levels_match_risk() { + assert_eq!( + AgentWorkflowReadTool.permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + AgentWorkflowCreateTool.permission_level(), + PermissionLevel::Write + ); + assert_eq!( + AgentWorkflowUninstallTool.permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!(AgentWorkflowReadTool.scope(), ToolScope::All); + } + + #[tokio::test] + async fn read_requires_id() { + let err = AgentWorkflowReadTool + .execute(json!({})) + .await + .expect_err("expected missing-arg error"); + assert!(err.to_string().contains("id")); + } + + #[tokio::test] + async fn phase_info_requires_id_and_phase() { + let err = AgentWorkflowPhaseInfoTool + .execute(json!({ "id": "x" })) + .await + .expect_err("expected missing-arg error"); + assert!(err.to_string().contains("phase")); + } + + #[tokio::test] + async fn uninstall_requires_id() { + let err = AgentWorkflowUninstallTool + .execute(json!({ "id": "" })) + .await + .expect_err("expected missing-arg error"); + assert!(err.to_string().contains("id")); + } +} diff --git a/src/openhuman/artifacts/mod.rs b/src/openhuman/artifacts/mod.rs index bb9ab4f88..7131ed2e7 100644 --- a/src/openhuman/artifacts/mod.rs +++ b/src/openhuman/artifacts/mod.rs @@ -1,6 +1,7 @@ pub mod ops; pub mod schemas; pub mod store; +pub mod tools; pub mod types; pub use schemas::{ diff --git a/src/openhuman/artifacts/tools.rs b/src/openhuman/artifacts/tools.rs new file mode 100644 index 000000000..27451537f --- /dev/null +++ b/src/openhuman/artifacts/tools.rs @@ -0,0 +1,263 @@ +//! LLM-callable wrappers over the artifacts metadata domain. +//! +//! Each tool is a thin shim over a read/delete handler in +//! [`crate::openhuman::artifacts::ops`], unwrapping the `RpcOutcome` +//! envelope and emitting the inner JSON value. The artifacts domain owns +//! agent-generated files (presentations/documents/images) under +//! `/artifacts/`; these tools let the agent enumerate and +//! inspect what it has produced. +//! +//! `artifact_list` / `artifact_get` are read-only and default-enabled. +//! `artifact_delete` is `Dangerous` (irreversible directory removal) and +//! ships default-OFF — it must be opted in via the tool toggle +//! (`TOOL_ID_TO_RUST_NAMES` in `tools/user_filter.rs`). + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::artifacts::ops; +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +/// Read `offset` / `limit` as optional `usize` from tool args. +fn read_opt_usize(args: &serde_json::Value, key: &str) -> Option { + args.get(key) + .and_then(serde_json::Value::as_u64) + .map(|v| v as usize) +} + +/// Read a required, non-empty string arg. +fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + let raw = args + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); + match raw { + Some(s) => Ok(s.to_string()), + None => Err(anyhow::anyhow!("missing required string argument `{key}`")), + } +} + +/// List artifacts the agent has produced, newest first. +pub struct ArtifactListTool { + config: Arc, +} + +impl ArtifactListTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for ArtifactListTool { + fn name(&self) -> &str { + "artifact_list" + } + + fn description(&self) -> &str { + "List agent-generated artifacts (presentations, documents, images, \ + and other files this assistant has produced), sorted by creation \ + time descending. Each entry carries `id`, `kind`, `title`, `path`, \ + `size_bytes`, `status`, and `created_at`. Use this to recall what \ + you have already generated for the user before regenerating, or to \ + find an artifact `id` to pass to `artifact_get`. Supports `offset` \ + and `limit` pagination." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "offset": { "type": "integer", "minimum": 0, "description": "Pagination offset (default 0)." }, + "limit": { "type": "integer", "minimum": 1, "description": "Max artifacts to return (default 50, cap 200)." } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][artifacts] list invoked"); + let offset = read_opt_usize(&args, "offset"); + let limit = read_opt_usize(&args, "limit"); + let outcome = ops::ai_list_artifacts(&self.config, offset, limit) + .await + .map_err(|e| anyhow::anyhow!("artifact_list: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Retrieve a single artifact's metadata plus its absolute on-disk path. +pub struct ArtifactGetTool { + config: Arc, +} + +impl ArtifactGetTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for ArtifactGetTool { + fn name(&self) -> &str { + "artifact_get" + } + + fn description(&self) -> &str { + "Get one agent-generated artifact by `id`, returning its metadata \ + (`kind`, `title`, `path`, `size_bytes`, `status`, `created_at`) plus \ + a computed `absolute_path` you can read from. Call `artifact_list` \ + first to discover ids." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "artifact_id": { "type": "string", "description": "The artifact id (UUID) to fetch." } + }, + "required": ["artifact_id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][artifacts] get invoked"); + let id = read_required_str(&args, "artifact_id")?; + let outcome = ops::ai_get_artifact(&self.config, &id) + .await + .map_err(|e| anyhow::anyhow!("artifact_get: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Delete an artifact directory and all its contents. **Irreversible** — +/// ships default-OFF (`Dangerous`). +pub struct ArtifactDeleteTool { + config: Arc, +} + +impl ArtifactDeleteTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for ArtifactDeleteTool { + fn name(&self) -> &str { + "artifact_delete" + } + + fn description(&self) -> &str { + "Permanently delete an agent-generated artifact and all of its files \ + by `id`. This is IRREVERSIBLE — the artifact directory is removed \ + from disk. Only use when the user has clearly asked to discard a \ + specific artifact." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "artifact_id": { "type": "string", "description": "The artifact id (UUID) to delete." } + }, + "required": ["artifact_id"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][artifacts] delete invoked"); + let id = read_required_str(&args, "artifact_id")?; + let outcome = ops::ai_delete_artifact(&self.config, &id) + .await + .map_err(|e| anyhow::anyhow!("artifact_delete: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + fn test_config() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn metadata_is_stable() { + let cfg = test_config(); + assert_eq!(ArtifactListTool::new(cfg.clone()).name(), "artifact_list"); + assert_eq!(ArtifactGetTool::new(cfg.clone()).name(), "artifact_get"); + assert_eq!( + ArtifactDeleteTool::new(cfg.clone()).name(), + "artifact_delete" + ); + assert_eq!( + ArtifactListTool::new(cfg.clone()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + ArtifactDeleteTool::new(cfg.clone()).permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!(ArtifactListTool::new(cfg).scope(), ToolScope::All); + } + + #[test] + fn read_tools_are_concurrency_safe() { + let cfg = test_config(); + assert!(ArtifactListTool::new(cfg.clone()).is_concurrency_safe(&serde_json::Value::Null)); + assert!(ArtifactGetTool::new(cfg).is_concurrency_safe(&serde_json::Value::Null)); + } + + #[tokio::test] + async fn get_requires_artifact_id() { + let tool = ArtifactGetTool::new(test_config()); + let err = tool + .execute(json!({})) + .await + .expect_err("expected missing-arg error"); + assert!(err.to_string().contains("artifact_id")); + } + + #[tokio::test] + async fn delete_requires_artifact_id() { + let tool = ArtifactDeleteTool::new(test_config()); + let err = tool + .execute(json!({ "artifact_id": " " })) + .await + .expect_err("expected missing-arg error"); + assert!(err.to_string().contains("artifact_id")); + } + + #[tokio::test] + async fn list_returns_artifacts_envelope() { + // Config::default() points at a workspace dir; listing an empty/missing + // artifacts root yields an empty list, not an error. + let tool = ArtifactListTool::new(test_config()); + let result = tool.execute(json!({ "limit": 5 })).await; + // Either a clean empty listing or a benign error if the workspace is + // unwritable in the sandbox — assert it does not panic and, when Ok, + // carries the expected shape. + if let Ok(res) = result { + let body = res.output_for_llm(false); + assert!(body.contains("artifacts"), "body was: {body}"); + } + } +} diff --git a/src/openhuman/billing/mod.rs b/src/openhuman/billing/mod.rs index b058a8a6f..6cf4d4085 100644 --- a/src/openhuman/billing/mod.rs +++ b/src/openhuman/billing/mod.rs @@ -5,6 +5,7 @@ mod ops; mod schemas; +pub mod tools; pub use ops::*; pub use schemas::{ diff --git a/src/openhuman/billing/tools.rs b/src/openhuman/billing/tools.rs new file mode 100644 index 000000000..0026d506b --- /dev/null +++ b/src/openhuman/billing/tools.rs @@ -0,0 +1,516 @@ +//! LLM-callable wrappers over the `billing` domain. +//! +//! Reads (plan/balance/transactions/cards/coupons/auto-recharge + the Stripe +//! portal link) are default-ON. Every money-moving or payment-method mutator +//! ships default-OFF via `tools/user_filter.rs` (`billing_writes` toggle); +//! `billing_delete_card` is `Dangerous`. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::openhuman::billing; +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +fn req_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`")) +} + +/// Read tool over a `&Config`-only `async fn -> Result, String>`. +macro_rules! cfg_read { + ($ty:ident, $name:literal, $fn:ident, $desc:literal) => { + pub struct $ty { + config: Arc, + } + impl $ty { + pub fn new(config: Arc) -> Self { + Self { config } + } + } + #[async_trait] + impl Tool for $ty { + fn name(&self) -> &str { + $name + } + fn description(&self) -> &str { + $desc + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!(concat!("[tool][billing] ", $name, " invoked")); + emit!(billing::$fn(&self.config).await, $name) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + } + }; +} + +cfg_read!( + BillingPlanTool, + "billing_get_plan", + get_current_plan, + "Return the user's current billing plan." +); +cfg_read!( + BillingBalanceTool, + "billing_get_balance", + get_balance, + "Return the user's current credit balance." +); +cfg_read!( + BillingAutoRechargeTool, + "billing_get_auto_recharge", + get_auto_recharge, + "Return the auto-recharge configuration." +); +cfg_read!( + BillingCardsTool, + "billing_list_cards", + get_cards, + "List the user's saved payment cards (masked)." +); +cfg_read!( + BillingCouponsTool, + "billing_list_coupons", + get_user_coupons, + "List the user's coupons / redemption history." +); +cfg_read!( + BillingPortalTool, + "billing_create_stripe_portal", + create_portal_session, + "Create a Stripe customer-portal session and return its URL (read-only; no charge)." +); + +/// Transaction history (paginated). +pub struct BillingTransactionsTool { + config: Arc, +} + +impl BillingTransactionsTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for BillingTransactionsTool { + fn name(&self) -> &str { + "billing_list_transactions" + } + + fn description(&self) -> &str { + "List billing transactions, optionally paginated by `limit` / `offset`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "limit": { "type": "integer", "minimum": 1 }, + "offset": { "type": "integer", "minimum": 0 } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][billing] transactions invoked"); + let limit = args.get("limit").and_then(Value::as_u64); + let offset = args.get("offset").and_then(Value::as_u64); + emit!( + billing::get_transactions(&self.config, limit, offset).await, + "billing_list_transactions" + ) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +// ── Mutators (default-OFF) ────────────────────────────────────────────────── + +/// Purchase a plan. +pub struct BillingPurchasePlanTool { + config: Arc, +} +impl BillingPurchasePlanTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for BillingPurchasePlanTool { + fn name(&self) -> &str { + "billing_purchase_plan" + } + fn description(&self) -> &str { + "Start a checkout to purchase a billing `plan`. Initiates a payment \ + flow. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": { "plan": { "type": "string" } }, "required": ["plan"] }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + fn external_effect(&self) -> bool { + true + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let plan = req_str(&args, "plan")?; + emit!( + billing::purchase_plan(&self.config, &plan).await, + "billing_purchase_plan" + ) + } +} + +/// Top up credits. +pub struct BillingTopUpTool { + config: Arc, +} +impl BillingTopUpTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for BillingTopUpTool { + fn name(&self) -> &str { + "billing_top_up_credits" + } + fn description(&self) -> &str { + "Top up account credits by `amount_usd`, optionally via a specific \ + `gateway`. Charges the user. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "amount_usd": { "type": "number", "minimum": 0 }, + "gateway": { "type": "string" } + }, + "required": ["amount_usd"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + fn external_effect(&self) -> bool { + true + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let amount = args + .get("amount_usd") + .and_then(Value::as_f64) + .ok_or_else(|| anyhow::anyhow!("missing required number argument `amount_usd`"))?; + let gateway = args + .get("gateway") + .and_then(Value::as_str) + .map(str::to_string); + emit!( + billing::top_up_credits(&self.config, amount, gateway).await, + "billing_top_up_credits" + ) + } +} + +/// Create a Coinbase crypto charge. +pub struct BillingCoinbaseChargeTool { + config: Arc, +} +impl BillingCoinbaseChargeTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for BillingCoinbaseChargeTool { + fn name(&self) -> &str { + "billing_create_coinbase_charge" + } + fn description(&self) -> &str { + "Create a Coinbase crypto charge for a `plan`, optional `interval`. \ + Initiates a payment. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "plan": { "type": "string" }, "interval": { "type": "string" } }, + "required": ["plan"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + fn external_effect(&self) -> bool { + true + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let plan = req_str(&args, "plan")?; + let interval = args + .get("interval") + .and_then(Value::as_str) + .map(str::to_string); + emit!( + billing::create_coinbase_charge(&self.config, &plan, interval).await, + "billing_create_coinbase_charge" + ) + } +} + +/// Create a Stripe setup intent (add payment method). +pub struct BillingSetupIntentTool { + config: Arc, +} +impl BillingSetupIntentTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for BillingSetupIntentTool { + fn name(&self) -> &str { + "billing_create_setup_intent" + } + fn description(&self) -> &str { + "Create a Stripe setup intent to add a payment method. Default-OFF \ + (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + fn external_effect(&self) -> bool { + true + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + emit!( + billing::create_setup_intent(&self.config).await, + "billing_create_setup_intent" + ) + } +} + +/// Update a saved card. +pub struct BillingUpdateCardTool { + config: Arc, +} +impl BillingUpdateCardTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for BillingUpdateCardTool { + fn name(&self) -> &str { + "billing_update_card" + } + fn description(&self) -> &str { + "Update a saved card (`payment_method_id`) with a `payload` of fields. \ + Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "payment_method_id": { "type": "string" }, + "payload": { "type": "object" } + }, + "required": ["payment_method_id", "payload"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + fn external_effect(&self) -> bool { + true + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let pm = req_str(&args, "payment_method_id")?; + let payload = args.get("payload").cloned().unwrap_or(Value::Null); + emit!( + billing::update_card(&self.config, &pm, payload).await, + "billing_update_card" + ) + } +} + +/// Delete a saved card. Dangerous. +pub struct BillingDeleteCardTool { + config: Arc, +} +impl BillingDeleteCardTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for BillingDeleteCardTool { + fn name(&self) -> &str { + "billing_delete_card" + } + fn description(&self) -> &str { + "Delete a saved payment card by `payment_method_id`. Default-OFF \ + (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "payment_method_id": { "type": "string" } }, + "required": ["payment_method_id"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + fn external_effect(&self) -> bool { + true + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let pm = req_str(&args, "payment_method_id")?; + emit!( + billing::delete_card(&self.config, &pm).await, + "billing_delete_card" + ) + } +} + +/// Redeem a coupon. +pub struct BillingRedeemCouponTool { + config: Arc, +} +impl BillingRedeemCouponTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for BillingRedeemCouponTool { + fn name(&self) -> &str { + "billing_redeem_coupon" + } + fn description(&self) -> &str { + "Redeem a coupon `code`. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": { "code": { "type": "string" } }, "required": ["code"] }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + fn external_effect(&self) -> bool { + true + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let code = req_str(&args, "code")?; + emit!( + billing::redeem_coupon(&self.config, &code).await, + "billing_redeem_coupon" + ) + } +} + +/// Update auto-recharge policy. +pub struct BillingUpdateAutoRechargeTool { + config: Arc, +} +impl BillingUpdateAutoRechargeTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for BillingUpdateAutoRechargeTool { + fn name(&self) -> &str { + "billing_update_auto_recharge" + } + fn description(&self) -> &str { + "Update the auto-recharge policy with a `payload` of settings. \ + Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "payload": { "type": "object" } }, + "required": ["payload"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + fn external_effect(&self) -> bool { + true + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let payload = args + .get("payload") + .cloned() + .ok_or_else(|| anyhow::anyhow!("missing required object argument `payload`"))?; + emit!( + billing::update_auto_recharge(&self.config, payload).await, + "billing_update_auto_recharge" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn names_and_levels() { + assert_eq!(BillingPlanTool::new(cfg()).name(), "billing_get_plan"); + assert_eq!( + BillingPlanTool::new(cfg()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + BillingPurchasePlanTool::new(cfg()).permission_level(), + PermissionLevel::Write + ); + assert!( + BillingPurchasePlanTool::new(cfg()).external_effect_with_args(&serde_json::Value::Null) + ); + assert_eq!( + BillingDeleteCardTool::new(cfg()).permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!(BillingPlanTool::new(cfg()).scope(), ToolScope::All); + } + + #[tokio::test] + async fn purchase_requires_plan() { + let err = BillingPurchasePlanTool::new(cfg()) + .execute(json!({})) + .await + .expect_err("missing plan"); + assert!(err.to_string().contains("plan")); + } +} diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index 649caad59..ceb4ca86d 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -12,6 +12,7 @@ pub mod ops; pub mod schema; mod schemas; pub mod settings_cli; +pub mod tools; #[allow(unused_imports)] pub use daemon::DaemonConfig; diff --git a/src/openhuman/config/tools.rs b/src/openhuman/config/tools.rs new file mode 100644 index 000000000..4242865ff --- /dev/null +++ b/src/openhuman/config/tools.rs @@ -0,0 +1,174 @@ +//! LLM-callable wrappers over the `config` domain (reads only). +//! +//! Config reads (snapshot, client config, autonomy, search, runtime flags, +//! resolved API URL, data paths) are default-ON and let the agent explain how +//! it is configured. +//! +//! The `config_update_*` mutators are intentionally NOT exposed here yet: the +//! domain's apply functions take hand-built `*SettingsPatch` structs (which are +//! not `Deserialize`) mapped field-by-field from separate wire types in +//! `config::schemas`. Exposing them as agent tools needs either those wire +//! types made public or a small `config::ops` patch-from-json helper — tracked +//! as a follow-up. They would all ship default-OFF (and autonomy is privilege +//! escalation), so leaving them out keeps this PR's surface read-only and safe. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{Tool, ToolResult}; + +use super::ops; + +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +/// Read tool over an arg-less `async fn() -> Result, String>`. +macro_rules! read_tool { + ($ty:ident, $name:literal, $fn:ident, $desc:literal) => { + pub struct $ty; + #[async_trait] + impl Tool for $ty { + fn name(&self) -> &str { + $name + } + fn description(&self) -> &str { + $desc + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!(concat!("[tool][config] ", $name, " invoked")); + emit!(ops::$fn().await, $name) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + } + }; +} + +/// Full config snapshot (needs the in-scope config). +pub struct ConfigSnapshotTool { + config: Arc, +} + +impl ConfigSnapshotTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for ConfigSnapshotTool { + fn name(&self) -> &str { + "config_snapshot" + } + + fn description(&self) -> &str { + "Return a full snapshot of the effective core configuration. Use to \ + inspect how the assistant is configured." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][config] snapshot invoked"); + emit!( + ops::get_config_snapshot(&self.config).await, + "config_snapshot" + ) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Runtime flags (sync, returns RpcOutcome directly). +pub struct ConfigRuntimeFlagsTool; + +#[async_trait] +impl Tool for ConfigRuntimeFlagsTool { + fn name(&self) -> &str { + "config_get_runtime_flags" + } + + fn description(&self) -> &str { + "Return the effective runtime feature flags (env-derived toggles)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][config] runtime_flags invoked"); + let outcome = ops::get_runtime_flags(); + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +read_tool!( + ConfigClientConfigTool, + "config_get_client_config", + load_and_get_client_config_snapshot, + "Return the redacted client-facing config snapshot." +); +read_tool!( + ConfigAutonomyTool, + "config_get_autonomy", + get_autonomy_settings, + "Return the current agent autonomy/access settings." +); +read_tool!( + ConfigSearchTool, + "config_get_search", + get_search_settings, + "Return the current search settings (API keys redacted)." +); +read_tool!( + ConfigResolveApiUrlTool, + "config_resolve_api_url", + load_and_resolve_api_url, + "Return the effective backend API URL after resolution." +); +read_tool!( + ConfigDataPathsTool, + "config_get_data_paths", + get_data_paths, + "Return the resolved on-disk data directories and workspace marker." +); + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::{PermissionLevel, ToolScope}; + + #[test] + fn read_metadata() { + assert_eq!( + ConfigSnapshotTool::new(Arc::new(Config::default())).name(), + "config_snapshot" + ); + assert_eq!(ConfigAutonomyTool.name(), "config_get_autonomy"); + assert_eq!( + ConfigAutonomyTool.permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!(ConfigRuntimeFlagsTool.scope(), ToolScope::All); + } +} diff --git a/src/openhuman/cost/mod.rs b/src/openhuman/cost/mod.rs index 346a5a908..df659e346 100644 --- a/src/openhuman/cost/mod.rs +++ b/src/openhuman/cost/mod.rs @@ -1,6 +1,7 @@ mod global; mod rpc; mod schemas; +pub mod tools; pub mod tracker; pub mod types; diff --git a/src/openhuman/cost/tools.rs b/src/openhuman/cost/tools.rs new file mode 100644 index 000000000..679573496 --- /dev/null +++ b/src/openhuman/cost/tools.rs @@ -0,0 +1,164 @@ +//! LLM-callable wrappers over the `cost` domain. Read-only, default-ON. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{Tool, ToolResult}; + +use super::rpc; + +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +/// 7-day cost dashboard. +pub struct CostDashboardTool { + config: Arc, +} + +impl CostDashboardTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for CostDashboardTool { + fn name(&self) -> &str { + "cost_get_dashboard" + } + + fn description(&self) -> &str { + "Return the cost dashboard: recent AI spend broken down for an at-a-glance \ + view. Use when the user asks how much they've spent recently." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][cost] dashboard invoked"); + emit!(rpc::dashboard(&self.config), "cost_get_dashboard") + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Per-day cost history. +pub struct CostDailyHistoryTool { + config: Arc, +} + +impl CostDailyHistoryTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for CostDailyHistoryTool { + fn name(&self) -> &str { + "cost_get_daily_history" + } + + fn description(&self) -> &str { + "Return per-day AI cost history for the last `days` days (default 30). \ + Use for trend analysis of spend over time." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "days": { "type": "integer", "minimum": 1, "description": "How many days back (default 30)." } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][cost] daily_history invoked"); + let days = args + .get("days") + .and_then(serde_json::Value::as_u64) + .map(|v| v as u32) + .unwrap_or(30); + emit!( + rpc::daily_history(&self.config, days), + "cost_get_daily_history" + ) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Session/daily/monthly cost summary. +pub struct CostSummaryTool { + config: Arc, +} + +impl CostSummaryTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for CostSummaryTool { + fn name(&self) -> &str { + "cost_get_summary" + } + + fn description(&self) -> &str { + "Return aggregate cost totals (session / daily / monthly). Use for a \ + quick spend summary." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][cost] summary invoked"); + emit!(rpc::summary(&self.config), "cost_get_summary") + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::{PermissionLevel, ToolScope}; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn metadata() { + assert_eq!(CostDashboardTool::new(cfg()).name(), "cost_get_dashboard"); + assert_eq!( + CostDailyHistoryTool::new(cfg()).name(), + "cost_get_daily_history" + ); + assert_eq!(CostSummaryTool::new(cfg()).name(), "cost_get_summary"); + assert_eq!( + CostDashboardTool::new(cfg()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!(CostSummaryTool::new(cfg()).scope(), ToolScope::All); + } +} diff --git a/src/openhuman/credentials/mod.rs b/src/openhuman/credentials/mod.rs index 468aad623..bc21b7acd 100644 --- a/src/openhuman/credentials/mod.rs +++ b/src/openhuman/credentials/mod.rs @@ -8,6 +8,7 @@ pub mod profiles; pub mod responses; mod schemas; pub mod session_support; +pub mod tools; pub use crate::api::rest::{ decrypt_handoff_blob, user_id_from_auth_me_payload, user_id_from_profile_payload, diff --git a/src/openhuman/credentials/tools.rs b/src/openhuman/credentials/tools.rs new file mode 100644 index 000000000..27327360b --- /dev/null +++ b/src/openhuman/credentials/tools.rs @@ -0,0 +1,237 @@ +//! LLM-callable wrappers over the `credentials` domain — READ-ONLY surface. +//! +//! These expose non-secret reads only: the list of stored credential profiles +//! (no token material), the session/auth state, the current user profile, the +//! OAuth connect URL, and the list of available integrations. All default-ON. +//! +//! The sensitive surface — storing/removing credentials, switching the active +//! profile, reading bearer/JWT plaintext, OAuth token handoff/revoke, Composio +//! key storage — is intentionally NOT exposed as agent tools (exfiltration / +//! auth-mutation risk). Those stay RPC-only. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::openhuman::config::Config; +use crate::openhuman::credentials; +use crate::openhuman::tools::traits::{Tool, ToolResult}; + +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +/// List stored credential profiles (no secrets). +pub struct CredentialListTool { + config: Arc, +} +impl CredentialListTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for CredentialListTool { + fn name(&self) -> &str { + "credential_list" + } + fn description(&self) -> &str { + "List stored credential profiles (provider + profile names only — no \ + secret/token material). Optional `provider` filter." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": { "provider": { "type": "string" } } }) + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][credentials] list invoked"); + let provider = args + .get("provider") + .and_then(Value::as_str) + .map(str::to_string); + emit!( + credentials::list_provider_credentials(&self.config, provider).await, + "credential_list" + ) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Auth/session state (no tokens). +pub struct SessionStateTool { + config: Arc, +} +impl SessionStateTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for SessionStateTool { + fn name(&self) -> &str { + "session_state" + } + fn description(&self) -> &str { + "Return the current auth/session state (signed-in flag, profile info — \ + no token material)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][credentials] session_state invoked"); + emit!( + credentials::auth_get_state(&self.config).await, + "session_state" + ) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Current user profile. +pub struct SessionGetUserTool { + config: Arc, +} +impl SessionGetUserTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for SessionGetUserTool { + fn name(&self) -> &str { + "session_get_user" + } + fn description(&self) -> &str { + "Return the current signed-in user's profile (name/email/plan). Does not \ + expose the session token." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][credentials] get_user invoked"); + emit!( + credentials::auth_get_me(&self.config).await, + "session_get_user" + ) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// OAuth authorize URL for a provider. +pub struct OAuthConnectUrlTool { + config: Arc, +} +impl OAuthConnectUrlTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for OAuthConnectUrlTool { + fn name(&self) -> &str { + "oauth_connect_url" + } + fn description(&self) -> &str { + "Return an OAuth authorize URL for a `provider` (and optional `skill_id`) \ + the user can open to connect an integration. Returns a URL + state; \ + does not complete the connection." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "provider": { "type": "string" }, + "skill_id": { "type": "string" } + }, + "required": ["provider"] + }) + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][credentials] oauth_connect invoked"); + let provider = args + .get("provider") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `provider`"))?; + let skill_id = args.get("skill_id").and_then(Value::as_str); + emit!( + credentials::oauth_connect(&self.config, provider, skill_id, None, None).await, + "oauth_connect_url" + ) + } +} + +/// List available OAuth integrations. +pub struct OAuthListTool { + config: Arc, +} +impl OAuthListTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for OAuthListTool { + fn name(&self) -> &str { + "oauth_list" + } + fn description(&self) -> &str { + "List the user's available/connected OAuth integrations." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][credentials] oauth_list invoked"); + emit!( + credentials::oauth_list_integrations(&self.config).await, + "oauth_list" + ) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::{PermissionLevel, ToolScope}; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn metadata() { + assert_eq!(CredentialListTool::new(cfg()).name(), "credential_list"); + assert_eq!(SessionStateTool::new(cfg()).name(), "session_state"); + assert_eq!(OAuthConnectUrlTool::new(cfg()).name(), "oauth_connect_url"); + assert_eq!( + CredentialListTool::new(cfg()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!(SessionStateTool::new(cfg()).scope(), ToolScope::All); + } + + #[tokio::test] + async fn oauth_connect_requires_provider() { + let err = OAuthConnectUrlTool::new(cfg()) + .execute(json!({})) + .await + .expect_err("missing provider"); + assert!(err.to_string().contains("provider")); + } +} diff --git a/src/openhuman/dashboard/mod.rs b/src/openhuman/dashboard/mod.rs index 1d2f723ba..18c55bbe4 100644 --- a/src/openhuman/dashboard/mod.rs +++ b/src/openhuman/dashboard/mod.rs @@ -11,6 +11,7 @@ mod ops; mod schemas; +pub mod tools; mod types; pub use ops::model_health; diff --git a/src/openhuman/dashboard/tools.rs b/src/openhuman/dashboard/tools.rs new file mode 100644 index 000000000..ea1f99a29 --- /dev/null +++ b/src/openhuman/dashboard/tools.rs @@ -0,0 +1,64 @@ +//! LLM-callable wrapper over the `dashboard` domain. Read-only, default-ON. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{Tool, ToolResult}; + +use super::ops; + +/// Per-model health table. +pub struct DashboardModelHealthTool { + config: Arc, +} + +impl DashboardModelHealthTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for DashboardModelHealthTool { + fn name(&self) -> &str { + "dashboard_model_health" + } + + fn description(&self) -> &str { + "Return the model-health table the dashboard shows: per-model status and \ + the active routing configuration. Use to advise on which models are \ + healthy/available." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][dashboard] model_health invoked"); + let outcome = ops::model_health(&self.config) + .map_err(|e| anyhow::anyhow!("dashboard_model_health: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::{PermissionLevel, ToolScope}; + + #[test] + fn metadata() { + let t = DashboardModelHealthTool::new(Arc::new(Config::default())); + assert_eq!(t.name(), "dashboard_model_health"); + assert_eq!(t.permission_level(), PermissionLevel::ReadOnly); + assert_eq!(t.scope(), ToolScope::All); + } +} diff --git a/src/openhuman/doctor/mod.rs b/src/openhuman/doctor/mod.rs index d4e458f1b..3bc8afafb 100644 --- a/src/openhuman/doctor/mod.rs +++ b/src/openhuman/doctor/mod.rs @@ -3,6 +3,7 @@ mod core; pub mod ops; mod schemas; +pub mod tools; pub use core::*; pub use ops as rpc; diff --git a/src/openhuman/doctor/tools.rs b/src/openhuman/doctor/tools.rs new file mode 100644 index 000000000..b1d1b105d --- /dev/null +++ b/src/openhuman/doctor/tools.rs @@ -0,0 +1,127 @@ +//! LLM-callable wrappers over the `doctor` diagnostics domain. +//! +//! Read-only health diagnostics; both tools delegate to +//! [`crate::openhuman::doctor::ops`] and ship default-ON. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{Tool, ToolResult}; + +use super::ops; + +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +/// Run the full diagnostic battery. +pub struct DoctorHealthTool { + config: Arc, +} + +impl DoctorHealthTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for DoctorHealthTool { + fn name(&self) -> &str { + "doctor_health" + } + + fn description(&self) -> &str { + "Run the full diagnostic battery (config, connectivity, dependencies, \ + runtime) and return a structured report of checks with pass/warn/fail \ + status. Use to diagnose why the assistant or a subsystem isn't working." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][doctor] health invoked"); + emit!(ops::doctor_report(&self.config).await, "doctor_health") + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Probe model availability. +pub struct DoctorModelsTool { + config: Arc, +} + +impl DoctorModelsTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for DoctorModelsTool { + fn name(&self) -> &str { + "doctor_models" + } + + fn description(&self) -> &str { + "Probe configured AI models for availability and return a per-model \ + report. Set `use_cache` false to force a fresh probe (default true)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "use_cache": { "type": "boolean", "description": "Use cached probe results (default true)." } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][doctor] models invoked"); + let use_cache = args + .get("use_cache") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + emit!( + ops::doctor_models(&self.config, use_cache).await, + "doctor_models" + ) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::{PermissionLevel, ToolScope}; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn metadata() { + assert_eq!(DoctorHealthTool::new(cfg()).name(), "doctor_health"); + assert_eq!(DoctorModelsTool::new(cfg()).name(), "doctor_models"); + assert_eq!( + DoctorHealthTool::new(cfg()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!(DoctorHealthTool::new(cfg()).scope(), ToolScope::All); + } +} diff --git a/src/openhuman/health/mod.rs b/src/openhuman/health/mod.rs index 3990cce26..aa7bc9805 100644 --- a/src/openhuman/health/mod.rs +++ b/src/openhuman/health/mod.rs @@ -2,6 +2,7 @@ pub mod bus; mod core; pub mod ops; mod schemas; +pub mod tools; pub use core::*; pub use ops as rpc; diff --git a/src/openhuman/health/tools.rs b/src/openhuman/health/tools.rs new file mode 100644 index 000000000..87e652ec1 --- /dev/null +++ b/src/openhuman/health/tools.rs @@ -0,0 +1,92 @@ +//! LLM-callable wrappers over the `health` domain. Read-only, default-ON. + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::tools::traits::{Tool, ToolResult}; + +use super::ops; + +/// Component health snapshot. +pub struct HealthSnapshotTool; + +#[async_trait] +impl Tool for HealthSnapshotTool { + fn name(&self) -> &str { + "health_snapshot" + } + + fn description(&self) -> &str { + "Return a snapshot of core component health (subsystem readiness flags). \ + Use for a quick liveness/readiness check of the running core." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][health] snapshot invoked"); + let outcome = ops::health_snapshot(); + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Version / OS / arch / pid. +pub struct HealthSystemInfoTool; + +#[async_trait] +impl Tool for HealthSystemInfoTool { + fn name(&self) -> &str { + "health_system_info" + } + + fn description(&self) -> &str { + "Return basic system info for the running core: version, OS, arch, and \ + process id." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][health] system_info invoked"); + let outcome = ops::system_info(); + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::{PermissionLevel, ToolScope}; + + #[test] + fn metadata() { + assert_eq!(HealthSnapshotTool.name(), "health_snapshot"); + assert_eq!(HealthSystemInfoTool.name(), "health_system_info"); + assert_eq!( + HealthSnapshotTool.permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!(HealthSnapshotTool.scope(), ToolScope::All); + } + + #[tokio::test] + async fn system_info_executes() { + let out = HealthSystemInfoTool + .execute(json!({})) + .await + .expect("system_info"); + assert!(out.output_for_llm(false).contains("os")); + } +} diff --git a/src/openhuman/learning/mod.rs b/src/openhuman/learning/mod.rs index 2329a2984..be6174000 100644 --- a/src/openhuman/learning/mod.rs +++ b/src/openhuman/learning/mod.rs @@ -32,6 +32,7 @@ pub mod scheduler; pub mod schemas; pub mod stability_detector; pub mod tool_tracker; +pub mod tools; pub mod transcript_ingest; pub mod user_profile; diff --git a/src/openhuman/learning/tools.rs b/src/openhuman/learning/tools.rs new file mode 100644 index 000000000..a0a202fee --- /dev/null +++ b/src/openhuman/learning/tools.rs @@ -0,0 +1,662 @@ +//! LLM-callable wrappers over the `learning` domain (the user-profile facet +//! cache). +//! +//! Learning has no public `ops` layer — its RPC handlers are private, +//! `ControllerFuture`-boxed functions in `schemas.rs` operating on a +//! [`FacetCache`] obtained via `memory::global`. These tools mirror that exact +//! handler logic (same `class/key` composition, same `FacetState` / +//! `UserState` transitions) so the agent-tool surface stays behaviourally +//! identical to the RPC surface. If a public `learning::ops` layer is added +//! later, both should delegate to it. +//! +//! The three reads (`learning_list_facets` / `learning_get_facet` / +//! `learning_cache_stats`) are default-enabled. Every mutator — pinning, +//! forgetting, rebuilding, resetting, and the profile writers — ships +//! default-OFF via `tools/user_filter.rs` (`learning_manage` toggle), because +//! they persistently rewrite the assistant's model of the user. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::config::rpc as config_rpc; +use crate::openhuman::learning::cache::FacetCache; +use crate::openhuman::learning::stability_detector::StabilityDetector; +use crate::openhuman::memory_store::profile::{FacetState, ProfileFacet, UserState}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +/// Acquire the profile facet cache, mirroring `learning::schemas::get_cache`. +fn get_cache() -> anyhow::Result { + let client = crate::openhuman::memory::global::client_if_ready() + .ok_or_else(|| anyhow::anyhow!("memory client not ready"))?; + Ok(FacetCache::new(client.profile_conn())) +} + +/// Compose the full facet key from a class string + key suffix. +fn full_key(class_str: &str, key_suffix: &str) -> String { + format!("{class_str}/{key_suffix}") +} + +fn facet_to_json(f: &ProfileFacet) -> serde_json::Value { + serde_json::to_value(f).unwrap_or(serde_json::Value::Null) +} + +fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`")) +} + +/// List learned facets (active + provisional), optionally filtered by class. +pub struct LearningListFacetsTool; + +#[async_trait] +impl Tool for LearningListFacetsTool { + fn name(&self) -> &str { + "learning_list_facets" + } + + fn description(&self) -> &str { + "List the assistant's learned facets about the user (active and \ + provisional states only), optionally filtered by `class` (e.g. \ + style, identity, tooling, goal). Each facet has a key, value, \ + confidence, and state. Use to see what the assistant has inferred." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "class": { "type": "string", "description": "Optional class filter (style|identity|tooling|veto|goal|channel)." } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][learning] list_facets invoked"); + let class_filter = args + .get("class") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let cache = get_cache()?; + let all = cache + .list_all() + .map_err(|e| anyhow::anyhow!("learning_list_facets: {e:#}"))?; + let facets: Vec = all + .iter() + .filter(|f| f.state == FacetState::Active || f.state == FacetState::Provisional) + .filter(|f| match &class_filter { + Some(cls) => { + f.class.as_deref() == Some(cls.as_str()) + || f.key.starts_with(&format!("{cls}/")) + } + None => true, + }) + .map(facet_to_json) + .collect(); + Ok(ToolResult::success(serde_json::to_string(&json!({ + "count": facets.len(), + "facets": facets, + }))?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Read a single facet by class + key. +pub struct LearningGetFacetTool; + +#[async_trait] +impl Tool for LearningGetFacetTool { + fn name(&self) -> &str { + "learning_get_facet" + } + + fn description(&self) -> &str { + "Read one learned facet by `class` + `key`, returning the facet (or \ + `found: false`)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "class": { "type": "string" }, + "key": { "type": "string", "description": "Key suffix within the class." } + }, + "required": ["class", "key"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][learning] get_facet invoked"); + let class_str = read_required_str(&args, "class")?; + let key_suffix = read_required_str(&args, "key")?; + let fk = full_key(&class_str, &key_suffix); + let cache = get_cache()?; + let facet = cache + .get(&fk) + .map_err(|e| anyhow::anyhow!("learning_get_facet: {e:#}"))?; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "found": facet.is_some(), + "facet": facet.as_ref().map(facet_to_json), + }))?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Aggregate facet-cache statistics. +pub struct LearningCacheStatsTool; + +#[async_trait] +impl Tool for LearningCacheStatsTool { + fn name(&self) -> &str { + "learning_cache_stats" + } + + fn description(&self) -> &str { + "Report facet-cache health: total facets, counts by state \ + (active/provisional/candidate/dropped), and a per-class breakdown of \ + active facets." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][learning] cache_stats invoked"); + let cache = get_cache()?; + let all = cache + .list_all() + .map_err(|e| anyhow::anyhow!("learning_cache_stats: {e:#}"))?; + let count_state = |s: FacetState| all.iter().filter(|f| f.state == s).count(); + let mut by_class: std::collections::HashMap = + std::collections::HashMap::new(); + for f in all.iter().filter(|f| f.state == FacetState::Active) { + let cls = f + .class + .clone() + .or_else(|| f.key.split('/').next().map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()); + *by_class.entry(cls).or_insert(0) += 1; + } + Ok(ToolResult::success(serde_json::to_string(&json!({ + "total": all.len(), + "active": count_state(FacetState::Active), + "provisional": count_state(FacetState::Provisional), + "candidate": count_state(FacetState::Candidate), + "dropped": count_state(FacetState::Dropped), + "by_class": by_class, + }))?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Update a facet's value and pin it. Default-OFF. +pub struct LearningUpdateFacetTool; + +#[async_trait] +impl Tool for LearningUpdateFacetTool { + fn name(&self) -> &str { + "learning_update_facet" + } + + fn description(&self) -> &str { + "Set the `value` of a learned facet (`class` + `key`) and pin it so the \ + stability detector won't override it. Use to correct what the \ + assistant believes about the user." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "class": { "type": "string" }, + "key": { "type": "string" }, + "value": { "type": "string" } + }, + "required": ["class", "key", "value"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][learning] update_facet invoked"); + let class_str = read_required_str(&args, "class")?; + let key_suffix = read_required_str(&args, "key")?; + let value = read_required_str(&args, "value")?; + let fk = full_key(&class_str, &key_suffix); + let cache = get_cache()?; + let mut facet = cache + .get(&fk) + .map_err(|e| anyhow::anyhow!("learning_update_facet: {e:#}"))? + .ok_or_else(|| anyhow::anyhow!("learning_update_facet: facet not found: {fk}"))?; + facet.value = value; + facet.user_state = UserState::Pinned; + cache + .upsert(&facet) + .map_err(|e| anyhow::anyhow!("learning_update_facet: upsert failed: {e:#}"))?; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "facet": facet_to_json(&facet), + }))?)) + } +} + +/// Set/clear a facet's pin via `set_user_state`. Shared by pin/unpin. +async fn set_pin( + args: serde_json::Value, + tool: &str, + state: UserState, +) -> anyhow::Result { + let class_str = read_required_str(&args, "class")?; + let key_suffix = read_required_str(&args, "key")?; + let fk = full_key(&class_str, &key_suffix); + let cache = get_cache()?; + let updated = cache + .set_user_state(&fk, state) + .map_err(|e| anyhow::anyhow!("{tool}: set_user_state failed: {e:#}"))?; + if !updated { + return Err(anyhow::anyhow!("{tool}: facet not found: {fk}")); + } + let facet = cache + .get(&fk) + .map_err(|e| anyhow::anyhow!("{tool}: re-read failed: {e:#}"))?; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "facet": facet.as_ref().map(facet_to_json), + }))?)) +} + +/// Pin a facet. Default-OFF. +pub struct LearningPinFacetTool; + +#[async_trait] +impl Tool for LearningPinFacetTool { + fn name(&self) -> &str { + "learning_pin_facet" + } + + fn description(&self) -> &str { + "Pin a learned facet (`class` + `key`) so it stays active regardless of \ + the stability detector." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "class": { "type": "string" }, "key": { "type": "string" } }, + "required": ["class", "key"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][learning] pin_facet invoked"); + set_pin(args, "learning_pin_facet", UserState::Pinned).await + } +} + +/// Unpin a facet (return to auto management). Default-OFF. +pub struct LearningUnpinFacetTool; + +#[async_trait] +impl Tool for LearningUnpinFacetTool { + fn name(&self) -> &str { + "learning_unpin_facet" + } + + fn description(&self) -> &str { + "Unpin a learned facet (`class` + `key`), returning it to automatic \ + stability management." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "class": { "type": "string" }, "key": { "type": "string" } }, + "required": ["class", "key"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][learning] unpin_facet invoked"); + set_pin(args, "learning_unpin_facet", UserState::Auto).await + } +} + +/// Forget a facet (semantic delete). Default-OFF. +pub struct LearningForgetFacetTool; + +#[async_trait] +impl Tool for LearningForgetFacetTool { + fn name(&self) -> &str { + "learning_forget_facet" + } + + fn description(&self) -> &str { + "Forget a learned facet (`class` + `key`): marks it dropped and \ + forgotten so it won't resurface. Use when the assistant should \ + permanently unlearn something about the user." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "class": { "type": "string" }, "key": { "type": "string" } }, + "required": ["class", "key"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][learning] forget_facet invoked"); + let class_str = read_required_str(&args, "class")?; + let key_suffix = read_required_str(&args, "key")?; + let fk = full_key(&class_str, &key_suffix); + let cache = get_cache()?; + let facet_json = match cache + .get(&fk) + .map_err(|e| anyhow::anyhow!("learning_forget_facet: {e:#}"))? + { + Some(mut f) => { + f.user_state = UserState::Forgotten; + f.state = FacetState::Dropped; + cache + .upsert(&f) + .map_err(|e| anyhow::anyhow!("learning_forget_facet: upsert failed: {e:#}"))?; + facet_to_json(&f) + } + None => serde_json::Value::Null, + }; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "facet": facet_json, + }))?)) + } +} + +/// Rebuild the facet cache (heavyweight stability cycle). Default-OFF. +pub struct LearningRebuildCacheTool; + +#[async_trait] +impl Tool for LearningRebuildCacheTool { + fn name(&self) -> &str { + "learning_rebuild_cache" + } + + fn description(&self) -> &str { + "Run a full stability-detector rebuild cycle over the facet cache \ + (re-scores and prunes facets). Heavyweight; returns added/evicted/kept \ + counts." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][learning] rebuild_cache invoked"); + let cache = get_cache()?; + let detector = StabilityDetector::new(cache); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0); + let outcome = detector + .rebuild(now) + .map_err(|e| anyhow::anyhow!("learning_rebuild_cache: rebuild failed: {e:#}"))?; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "added": outcome.added, + "evicted": outcome.evicted, + "kept": outcome.kept, + "total_size": outcome.total_size, + }))?)) + } +} + +/// Reset the facet cache (delete all auto facets, keep pinned). Default-OFF. +pub struct LearningResetCacheTool; + +#[async_trait] +impl Tool for LearningResetCacheTool { + fn name(&self) -> &str { + "learning_reset_cache" + } + + fn description(&self) -> &str { + "Delete every automatically-managed facet from the cache, preserving \ + only user-pinned facets. Irreversible. Only use when the user wants to \ + wipe the assistant's learned model of them." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][learning] reset_cache invoked"); + let cache = get_cache()?; + let all = cache + .list_all() + .map_err(|e| anyhow::anyhow!("learning_reset_cache: {e:#}"))?; + let pinned_preserved = all + .iter() + .filter(|f| f.user_state == UserState::Pinned) + .count(); + let mut deleted = 0usize; + for f in &all { + if f.user_state != UserState::Pinned && cache.delete(&f.key).unwrap_or(false) { + deleted += 1; + } + } + Ok(ToolResult::success(serde_json::to_string(&json!({ + "deleted": deleted, + "pinned_preserved": pinned_preserved, + }))?)) + } +} + +/// Write PROFILE.md from supplied markdown. Default-OFF. +pub struct LearningSaveProfileTool; + +#[async_trait] +impl Tool for LearningSaveProfileTool { + fn name(&self) -> &str { + "learning_save_profile" + } + + fn description(&self) -> &str { + "Write the supplied `markdown` to PROFILE.md in the workspace (the \ + assistant's durable profile of the user). When `summarize` is true, \ + compress it with the model first." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "markdown": { "type": "string", "description": "Profile markdown body (required)." }, + "summarize": { "type": "boolean", "description": "Compress with the model first (default false)." } + }, + "required": ["markdown"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][learning] save_profile invoked"); + let markdown = read_required_str(&args, "markdown")?; + let summarize = args + .get("summarize") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let config = config_rpc::load_config_with_timeout() + .await + .map_err(|e| anyhow::anyhow!("learning_save_profile: {e}"))?; + let body = if summarize { + crate::openhuman::learning::linkedin_enrichment::summarise_profile_with_llm( + &config, &markdown, + ) + .await + .map_err(|e| anyhow::anyhow!("learning_save_profile: summarisation failed: {e:#}"))? + } else { + markdown + }; + let path = config.workspace_dir.join("PROFILE.md"); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| anyhow::anyhow!("learning_save_profile: create dir failed: {e}"))?; + } + tokio::fs::write(&path, &body) + .await + .map_err(|e| anyhow::anyhow!("learning_save_profile: write failed: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "path": path.display().to_string(), + "bytes": body.len(), + }))?)) + } +} + +/// Enrich the profile via LinkedIn (external scrape). Default-OFF. +pub struct LearningEnrichProfileTool; + +#[async_trait] +impl Tool for LearningEnrichProfileTool { + fn name(&self) -> &str { + "learning_enrich_profile" + } + + fn description(&self) -> &str { + "Run the LinkedIn profile-enrichment pipeline: optionally with a preset \ + `profile_url`, scrape the profile (Apify) and build/persist a profile. \ + Reaches external services. Only use when the user asks to enrich their \ + profile." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "profile_url": { "type": "string", "description": "Optional preset LinkedIn profile URL." } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + fn external_effect(&self) -> bool { + true + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][learning] enrich_profile invoked"); + let preset = args + .get("profile_url") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let config = config_rpc::load_config_with_timeout() + .await + .map_err(|e| anyhow::anyhow!("learning_enrich_profile: {e}"))?; + let result = crate::openhuman::learning::linkedin_enrichment::run_linkedin_enrichment( + &config, preset, + ) + .await + .map_err(|e| anyhow::anyhow!("learning_enrich_profile: {e:#}"))?; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "profile_url": result.profile_url, + "profile_data": result.profile_data, + "stages": result.stages, + "log": result.log, + }))?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + #[test] + fn names_and_levels() { + assert_eq!(LearningListFacetsTool.name(), "learning_list_facets"); + assert_eq!( + LearningListFacetsTool.permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + LearningUpdateFacetTool.permission_level(), + PermissionLevel::Write + ); + assert_eq!( + LearningRebuildCacheTool.permission_level(), + PermissionLevel::Execute + ); + assert_eq!( + LearningResetCacheTool.permission_level(), + PermissionLevel::Dangerous + ); + assert!(LearningEnrichProfileTool.external_effect_with_args(&serde_json::Value::Null)); + assert_eq!(LearningListFacetsTool.scope(), ToolScope::All); + } + + #[test] + fn full_key_composes_class_and_suffix() { + assert_eq!(full_key("style", "verbosity"), "style/verbosity"); + } + + #[tokio::test] + async fn get_facet_requires_class_and_key() { + let err = LearningGetFacetTool + .execute(json!({ "class": "style" })) + .await + .expect_err("missing key"); + assert!(err.to_string().contains("key")); + } + + #[tokio::test] + async fn update_facet_requires_value() { + let err = LearningUpdateFacetTool + .execute(json!({ "class": "style", "key": "verbosity" })) + .await + .expect_err("missing value"); + assert!(err.to_string().contains("value")); + } +} diff --git a/src/openhuman/mcp_registry/mod.rs b/src/openhuman/mcp_registry/mod.rs index 5652bb744..eed836c67 100644 --- a/src/openhuman/mcp_registry/mod.rs +++ b/src/openhuman/mcp_registry/mod.rs @@ -60,6 +60,7 @@ mod schemas; pub mod setup; pub mod setup_ops; pub mod store; +pub mod tools; pub mod types; pub use schemas::{ diff --git a/src/openhuman/mcp_registry/tools.rs b/src/openhuman/mcp_registry/tools.rs new file mode 100644 index 000000000..5808b262f --- /dev/null +++ b/src/openhuman/mcp_registry/tools.rs @@ -0,0 +1,460 @@ +//! LLM-callable wrappers over the `mcp_registry` client surface. +//! +//! These expose the installed-MCP-servers registry to the agent: search the +//! catalog, inspect a server, list installed servers and their connection +//! status, connect/disconnect, call a tool on a connected server, and get +//! AI config help. Thin shims over [`crate::openhuman::mcp_registry::ops`]. +//! +//! Discovery/observe/connect/call tools are default-ON. The persistent +//! `mcp_registry_install` / `mcp_registry_uninstall` mutators (write installed +//! state + secrets) ship default-OFF via `tools/user_filter.rs` +//! (`mcp_manage` toggle). +//! +//! NOTE: the `mcp_setup_*` setup-agent tools and the generic `mcp_list_servers` +//! / `mcp_call_tool` bridge tools already exist elsewhere; these `mcp_registry_*` +//! tools are the distinct installed-registry surface and do not duplicate them. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +use super::ops; + +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +fn req_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`")) +} + +/// Search the MCP registry catalog. +pub struct McpRegistrySearchTool { + config: Arc, +} +impl McpRegistrySearchTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for McpRegistrySearchTool { + fn name(&self) -> &str { + "mcp_registry_search" + } + fn description(&self) -> &str { + "Search the MCP server registry catalog by `query`, paginated by `page` \ + / `page_size`. Use to discover installable MCP servers." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "query": { "type": "string" }, + "page": { "type": "integer", "minimum": 1 }, + "page_size": { "type": "integer", "minimum": 1 } + } + }) + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let query = args + .get("query") + .and_then(Value::as_str) + .map(str::to_string); + let page = args.get("page").and_then(Value::as_u64).map(|v| v as u32); + let page_size = args + .get("page_size") + .and_then(Value::as_u64) + .map(|v| v as u32); + emit!( + ops::mcp_clients_registry_search(&self.config, query, page, page_size).await, + "mcp_registry_search" + ) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Get one registry server by qualified name. +pub struct McpRegistryGetTool { + config: Arc, +} +impl McpRegistryGetTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for McpRegistryGetTool { + fn name(&self) -> &str { + "mcp_registry_get" + } + fn description(&self) -> &str { + "Get one MCP registry server's detail by `qualified_name`." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "qualified_name": { "type": "string" } }, + "required": ["qualified_name"] + }) + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let qn = req_str(&args, "qualified_name")?; + emit!( + ops::mcp_clients_registry_get(&self.config, qn).await, + "mcp_registry_get" + ) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// List installed MCP servers. +pub struct McpRegistryInstalledListTool { + config: Arc, +} +impl McpRegistryInstalledListTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for McpRegistryInstalledListTool { + fn name(&self) -> &str { + "mcp_registry_installed_list" + } + fn description(&self) -> &str { + "List the MCP servers currently installed for this user." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + emit!( + ops::mcp_clients_installed_list(&self.config).await, + "mcp_registry_installed_list" + ) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Connection status of installed MCP servers. +pub struct McpRegistryStatusTool { + config: Arc, +} +impl McpRegistryStatusTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for McpRegistryStatusTool { + fn name(&self) -> &str { + "mcp_registry_status" + } + fn description(&self) -> &str { + "Report the connection status of installed MCP servers." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + emit!( + ops::mcp_clients_status(&self.config).await, + "mcp_registry_status" + ) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Connect an installed MCP server. +pub struct McpRegistryConnectTool { + config: Arc, +} +impl McpRegistryConnectTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for McpRegistryConnectTool { + fn name(&self) -> &str { + "mcp_registry_connect" + } + fn description(&self) -> &str { + "Connect (spawn + handshake) an installed MCP server by `server_id`, \ + returning its tools." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "server_id": { "type": "string" } }, + "required": ["server_id"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let sid = req_str(&args, "server_id")?; + emit!( + ops::mcp_clients_connect(&self.config, sid).await, + "mcp_registry_connect" + ) + } +} + +/// Disconnect an MCP server. +pub struct McpRegistryDisconnectTool; +#[async_trait] +impl Tool for McpRegistryDisconnectTool { + fn name(&self) -> &str { + "mcp_registry_disconnect" + } + fn description(&self) -> &str { + "Disconnect (stop) a connected MCP server by `server_id`." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "server_id": { "type": "string" } }, + "required": ["server_id"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let sid = req_str(&args, "server_id")?; + emit!( + ops::mcp_clients_disconnect(sid).await, + "mcp_registry_disconnect" + ) + } +} + +/// Call a tool on a connected MCP server. +pub struct McpRegistryToolCallTool; +#[async_trait] +impl Tool for McpRegistryToolCallTool { + fn name(&self) -> &str { + "mcp_registry_tool_call" + } + fn description(&self) -> &str { + "Invoke a tool on a connected MCP server: `server_id` + `tool_name` + \ + `arguments` object." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "server_id": { "type": "string" }, + "tool_name": { "type": "string" }, + "arguments": { "type": "object" } + }, + "required": ["server_id", "tool_name"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let sid = req_str(&args, "server_id")?; + let tool_name = req_str(&args, "tool_name")?; + let arguments = args.get("arguments").cloned().unwrap_or(json!({})); + emit!( + ops::mcp_clients_tool_call(sid, tool_name, arguments).await, + "mcp_registry_tool_call" + ) + } +} + +/// AI config assistance for an MCP server. +pub struct McpRegistryConfigAssistTool { + config: Arc, +} +impl McpRegistryConfigAssistTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for McpRegistryConfigAssistTool { + fn name(&self) -> &str { + "mcp_registry_config_assist" + } + fn description(&self) -> &str { + "Get AI guidance for configuring an MCP server (`qualified_name`) given a \ + `user_message`; returns a reply and suggested env vars." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "qualified_name": { "type": "string" }, + "user_message": { "type": "string" } + }, + "required": ["qualified_name", "user_message"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let qn = req_str(&args, "qualified_name")?; + let msg = req_str(&args, "user_message")?; + emit!( + ops::mcp_clients_config_assist(&self.config, qn, msg, None).await, + "mcp_registry_config_assist" + ) + } +} + +/// Install an MCP server (persists install + env). Default-OFF. +pub struct McpRegistryInstallTool { + config: Arc, +} +impl McpRegistryInstallTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for McpRegistryInstallTool { + fn name(&self) -> &str { + "mcp_registry_install" + } + fn description(&self) -> &str { + "Install an MCP server (`qualified_name`) with an `env` map and optional \ + `config`. Persists the install + secrets. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "qualified_name": { "type": "string" }, + "env": { "type": "object", "additionalProperties": { "type": "string" } }, + "config": { "type": "object" } + }, + "required": ["qualified_name"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let qn = req_str(&args, "qualified_name")?; + let env: HashMap = args + .get("env") + .cloned() + .map(serde_json::from_value) + .transpose() + .map_err(|e| anyhow::anyhow!("mcp_registry_install: invalid env: {e}"))? + .unwrap_or_default(); + let config_value = args.get("config").cloned(); + emit!( + ops::mcp_clients_install(&self.config, qn, env, config_value).await, + "mcp_registry_install" + ) + } +} + +/// Uninstall an MCP server. Default-OFF. +pub struct McpRegistryUninstallTool { + config: Arc, +} +impl McpRegistryUninstallTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for McpRegistryUninstallTool { + fn name(&self) -> &str { + "mcp_registry_uninstall" + } + fn description(&self) -> &str { + "Uninstall an installed MCP server by `server_id`. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "server_id": { "type": "string" } }, + "required": ["server_id"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let sid = req_str(&args, "server_id")?; + emit!( + ops::mcp_clients_uninstall(&self.config, sid).await, + "mcp_registry_uninstall" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn names_and_levels() { + assert_eq!( + McpRegistrySearchTool::new(cfg()).name(), + "mcp_registry_search" + ); + assert_eq!( + McpRegistrySearchTool::new(cfg()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + McpRegistryConnectTool::new(cfg()).permission_level(), + PermissionLevel::Execute + ); + assert_eq!( + McpRegistryToolCallTool.permission_level(), + PermissionLevel::Execute + ); + assert_eq!( + McpRegistryInstallTool::new(cfg()).permission_level(), + PermissionLevel::Write + ); + assert_eq!(McpRegistrySearchTool::new(cfg()).scope(), ToolScope::All); + } + + #[tokio::test] + async fn get_requires_qualified_name() { + let err = McpRegistryGetTool::new(cfg()) + .execute(json!({})) + .await + .expect_err("missing qualified_name"); + assert!(err.to_string().contains("qualified_name")); + } +} diff --git a/src/openhuman/people/mod.rs b/src/openhuman/people/mod.rs index aa18c2f19..e937a6c52 100644 --- a/src/openhuman/people/mod.rs +++ b/src/openhuman/people/mod.rs @@ -14,6 +14,7 @@ pub mod rpc; pub mod schemas; pub mod scorer; pub mod store; +pub mod tools; pub mod types; pub use schemas::{ diff --git a/src/openhuman/people/tools.rs b/src/openhuman/people/tools.rs new file mode 100644 index 000000000..f540b7cd3 --- /dev/null +++ b/src/openhuman/people/tools.rs @@ -0,0 +1,417 @@ +//! LLM-callable wrappers over the `people` domain (local relationship graph). +//! +//! These tools let the agent rank known contacts, resolve handles to stable +//! person ids, inspect closeness scores, attach aliases, log interactions, +//! and read a person record. Read + bounded-write tools delegate to +//! [`crate::openhuman::people::rpc`] (which returns `RpcOutcome`) or to +//! `PeopleStore` methods; results are emitted as JSON. +//! +//! All tools here are device-local and default-enabled EXCEPT +//! `people_refresh_address_book`, which performs a bulk OS address-book +//! ingest (and can trigger a Contacts permission prompt) — it is `Execute` +//! and ships default-OFF via `tools/user_filter.rs`. + +use async_trait::async_trait; +use chrono::Utc; +use serde_json::json; + +use crate::openhuman::people::rpc; +use crate::openhuman::people::store::{self, PeopleStore}; +use crate::openhuman::people::types::{Handle, Interaction, PersonId}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +/// Acquire the global people store or surface a uniform error. +fn people_store() -> anyhow::Result> { + store::get().map_err(|e| anyhow::anyhow!("people store unavailable: {e}")) +} + +fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`")) +} + +fn parse_person_id(args: &serde_json::Value) -> anyhow::Result { + let raw = read_required_str(args, "person_id")?; + serde_json::from_value(json!(raw)).map_err(|e| anyhow::anyhow!("invalid person_id: {e}")) +} + +/// Build a [`Handle`] from `kind` + `value` args. +fn parse_handle(args: &serde_json::Value) -> anyhow::Result { + let kind = read_required_str(args, "kind")?; + let value = read_required_str(args, "value")?; + serde_json::from_value(json!({ "kind": kind, "value": value })).map_err(|e| { + anyhow::anyhow!("invalid handle (kind must be imessage|email|display_name): {e}") + }) +} + +fn handle_schema_props() -> serde_json::Value { + json!({ + "kind": { "type": "string", "enum": ["imessage", "email", "display_name"], "description": "Handle kind." }, + "value": { "type": "string", "description": "Handle value (phone / email / display name)." } + }) +} + +/// List ranked contacts. +pub struct PeopleListTool; + +#[async_trait] +impl Tool for PeopleListTool { + fn name(&self) -> &str { + "people_list" + } + + fn description(&self) -> &str { + "List the user's known contacts ranked by a closeness score (recency × \ + frequency × reciprocity × depth). Each entry carries `person_id`, \ + names, handles, the score and its components, and interaction count. \ + Use to find who the user is closest to or to resolve a name to a \ + `person_id`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "limit": { "type": "integer", "minimum": 1, "description": "Max contacts (default 100, cap 500)." } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][people] list invoked"); + let limit = args + .get("limit") + .and_then(serde_json::Value::as_u64) + .map(|v| v as usize) + .unwrap_or(100); + let store = people_store()?; + let outcome = rpc::handle_list(&store, limit) + .await + .map_err(|e| anyhow::anyhow!("people_list: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Resolve a handle to a person id. +pub struct PeopleResolveTool; + +#[async_trait] +impl Tool for PeopleResolveTool { + fn name(&self) -> &str { + "people_resolve" + } + + fn description(&self) -> &str { + "Resolve a contact handle (kind = imessage | email | display_name) to a \ + stable `person_id`. When `create_if_missing` is true, mints a new \ + person for an unknown handle. Returns `{ person_id, created }`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "kind": handle_schema_props()["kind"], + "value": handle_schema_props()["value"], + "create_if_missing": { "type": "boolean", "description": "Mint a person if the handle is unknown (default false)." } + }, + "required": ["kind", "value"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + // May mint a new person record when create_if_missing is set. + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][people] resolve invoked"); + let handle = parse_handle(&args)?; + let create = args + .get("create_if_missing") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let store = people_store()?; + let outcome = rpc::handle_resolve(&store, handle, create) + .await + .map_err(|e| anyhow::anyhow!("people_resolve: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } +} + +/// Score breakdown for a person. +pub struct PeopleScoreTool; + +#[async_trait] +impl Tool for PeopleScoreTool { + fn name(&self) -> &str { + "people_score" + } + + fn description(&self) -> &str { + "Return the closeness score and its components (recency, frequency, \ + reciprocity, depth) plus interaction count for one `person_id`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "person_id": { "type": "string", "description": "Person id (UUID)." } }, + "required": ["person_id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][people] score invoked"); + let person_id = parse_person_id(&args)?; + let store = people_store()?; + let outcome = rpc::handle_score(&store, person_id) + .await + .map_err(|e| anyhow::anyhow!("people_score: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Read a full person record. +pub struct PeopleGetTool; + +#[async_trait] +impl Tool for PeopleGetTool { + fn name(&self) -> &str { + "people_get" + } + + fn description(&self) -> &str { + "Load the full record for one `person_id`: display name, primary email \ + / phone, and every attached handle/alias." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "person_id": { "type": "string", "description": "Person id (UUID)." } }, + "required": ["person_id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][people] get invoked"); + let person_id = parse_person_id(&args)?; + let store = people_store()?; + let person = store + .get(person_id) + .await + .map_err(|e| anyhow::anyhow!("people_get: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "person": person, + }))?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Attach a handle alias to a person. +pub struct PeopleAddAliasTool; + +#[async_trait] +impl Tool for PeopleAddAliasTool { + fn name(&self) -> &str { + "people_add_alias" + } + + fn description(&self) -> &str { + "Attach an additional handle (kind = imessage | email | display_name) \ + to an existing `person_id` so future messages from that handle map to \ + the same person. Idempotent." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "person_id": { "type": "string", "description": "Person id (UUID)." }, + "kind": handle_schema_props()["kind"], + "value": handle_schema_props()["value"] + }, + "required": ["person_id", "kind", "value"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][people] add_alias invoked"); + let person_id = parse_person_id(&args)?; + let handle = parse_handle(&args)?; + let store = people_store()?; + store + .add_alias(person_id, handle) + .await + .map_err(|e| anyhow::anyhow!("people_add_alias: {e}"))?; + Ok(ToolResult::success(serde_json::to_string( + &json!({ "ok": true }), + )?)) + } +} + +/// Record an interaction (append-only, feeds scoring). +pub struct PeopleRecordInteractionTool; + +#[async_trait] +impl Tool for PeopleRecordInteractionTool { + fn name(&self) -> &str { + "people_record_interaction" + } + + fn description(&self) -> &str { + "Log an interaction with a `person_id` to feed the closeness score. \ + `is_outbound` marks who initiated; `length` is a depth proxy (e.g. \ + message length). Timestamp defaults to now. Append-only." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "person_id": { "type": "string", "description": "Person id (UUID)." }, + "is_outbound": { "type": "boolean", "description": "True if the user sent it (required)." }, + "length": { "type": "integer", "minimum": 0, "description": "Depth proxy (default 0)." } + }, + "required": ["person_id", "is_outbound"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][people] record_interaction invoked"); + let person_id = parse_person_id(&args)?; + let is_outbound = args + .get("is_outbound") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| anyhow::anyhow!("missing required boolean argument `is_outbound`"))?; + let length = args + .get("length") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) as u32; + let interaction = Interaction { + person_id, + ts: Utc::now(), + is_outbound, + length, + }; + let store = people_store()?; + store + .record_interaction(interaction) + .await + .map_err(|e| anyhow::anyhow!("people_record_interaction: {e}"))?; + Ok(ToolResult::success(serde_json::to_string( + &json!({ "ok": true }), + )?)) + } +} + +/// Bulk-ingest the OS address book. **Triggers a permission prompt** — +/// default-OFF. +pub struct PeopleRefreshAddressBookTool; + +#[async_trait] +impl Tool for PeopleRefreshAddressBookTool { + fn name(&self) -> &str { + "people_refresh_address_book" + } + + fn description(&self) -> &str { + "Bulk-import the operating system address book into the people store, \ + seeding contacts and their handles. On macOS this may trigger a \ + Contacts (TCC) permission prompt. Returns counts of seeded / skipped \ + contacts. Only use when the user explicitly asks to import contacts." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][people] refresh_address_book invoked"); + let store = people_store()?; + let outcome = rpc::handle_refresh_address_book(&store) + .await + .map_err(|e| anyhow::anyhow!("people_refresh_address_book: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + #[test] + fn names_and_levels() { + assert_eq!(PeopleListTool.name(), "people_list"); + assert_eq!(PeopleListTool.permission_level(), PermissionLevel::ReadOnly); + assert_eq!(PeopleResolveTool.permission_level(), PermissionLevel::Write); + assert_eq!( + PeopleRecordInteractionTool.permission_level(), + PermissionLevel::Write + ); + assert_eq!( + PeopleRefreshAddressBookTool.permission_level(), + PermissionLevel::Execute + ); + assert_eq!(PeopleListTool.scope(), ToolScope::All); + } + + #[test] + fn parse_handle_accepts_known_kinds() { + let h = parse_handle(&json!({ "kind": "email", "value": "a@b.com" })).expect("email"); + assert!(matches!(h, Handle::Email(_))); + let d = + parse_handle(&json!({ "kind": "display_name", "value": "Alice" })).expect("display"); + assert!(matches!(d, Handle::DisplayName(_))); + } + + #[test] + fn parse_handle_rejects_unknown_kind() { + let err = parse_handle(&json!({ "kind": "fax", "value": "x" })).expect_err("bad kind"); + assert!(err.to_string().contains("handle")); + } + + #[test] + fn parse_person_id_rejects_non_uuid() { + let err = parse_person_id(&json!({ "person_id": "not-a-uuid" })).expect_err("bad uuid"); + assert!(err.to_string().contains("person_id")); + } + + #[tokio::test] + async fn score_requires_person_id() { + let err = PeopleScoreTool + .execute(json!({})) + .await + .expect_err("missing person_id"); + assert!(err.to_string().contains("person_id")); + } +} diff --git a/src/openhuman/referral/mod.rs b/src/openhuman/referral/mod.rs index 1da5415d3..2643903f7 100644 --- a/src/openhuman/referral/mod.rs +++ b/src/openhuman/referral/mod.rs @@ -2,6 +2,7 @@ mod ops; mod schemas; +pub mod tools; pub use ops::*; pub use schemas::{ diff --git a/src/openhuman/referral/tools.rs b/src/openhuman/referral/tools.rs new file mode 100644 index 000000000..e0fa2802e --- /dev/null +++ b/src/openhuman/referral/tools.rs @@ -0,0 +1,142 @@ +//! LLM-callable wrappers over the `referral` domain. Both default-ON +//! (claim is bounded and self-service). + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::openhuman::config::Config; +use crate::openhuman::referral; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +/// Referral stats. +pub struct ReferralStatsTool { + config: Arc, +} + +impl ReferralStatsTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for ReferralStatsTool { + fn name(&self) -> &str { + "referral_get_stats" + } + + fn description(&self) -> &str { + "Return the user's referral stats (code, referrals, rewards)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][referral] stats invoked"); + emit!( + referral::get_stats(&self.config).await, + "referral_get_stats" + ) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Claim a referral code. +pub struct ReferralClaimTool { + config: Arc, +} + +impl ReferralClaimTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for ReferralClaimTool { + fn name(&self) -> &str { + "referral_claim" + } + + fn description(&self) -> &str { + "Claim a referral `code` (optionally with a `device_fingerprint`). \ + Bounded, self-service." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "code": { "type": "string" }, + "device_fingerprint": { "type": "string" } + }, + "required": ["code"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][referral] claim invoked"); + let code = args + .get("code") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `code`"))?; + let fp = args.get("device_fingerprint").and_then(Value::as_str); + emit!( + referral::claim_referral(&self.config, code, fp).await, + "referral_claim" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn metadata() { + assert_eq!(ReferralStatsTool::new(cfg()).name(), "referral_get_stats"); + assert_eq!( + ReferralStatsTool::new(cfg()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + ReferralClaimTool::new(cfg()).permission_level(), + PermissionLevel::Write + ); + assert_eq!(ReferralStatsTool::new(cfg()).scope(), ToolScope::All); + } + + #[tokio::test] + async fn claim_requires_code() { + let err = ReferralClaimTool::new(cfg()) + .execute(json!({})) + .await + .expect_err("missing code"); + assert!(err.to_string().contains("code")); + } +} diff --git a/src/openhuman/screen_intelligence/mod.rs b/src/openhuman/screen_intelligence/mod.rs index 040d07f5d..5fabf0ae3 100644 --- a/src/openhuman/screen_intelligence/mod.rs +++ b/src/openhuman/screen_intelligence/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod cli; pub mod ops; mod schemas; pub mod server; +pub mod tools; mod capture; mod capture_worker; diff --git a/src/openhuman/screen_intelligence/tools.rs b/src/openhuman/screen_intelligence/tools.rs new file mode 100644 index 000000000..21476fb35 --- /dev/null +++ b/src/openhuman/screen_intelligence/tools.rs @@ -0,0 +1,391 @@ +//! LLM-callable wrappers over the `screen_intelligence` (accessibility) domain. +//! +//! These let the agent observe and drive the desktop: status, capture sessions, +//! single captures, input automation, recent vision summaries, and the +//! Globe/Fn hotkey listener. All delegate to the arg-less / payload functions +//! in [`crate::openhuman::screen_intelligence::ops`] (which read the global +//! engine + load config internally). +//! +//! Observation + capture/input tools are default-ON (capture requires a session +//! the user started with consent). The OS permission-request tools — +//! `screen_intelligence_request_permissions` / `_request_permission` — trigger +//! system permission dialogs, so they are `Dangerous` and ship default-OFF via +//! `tools/user_filter.rs` (`screen_permissions` toggle). + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::screen_intelligence::ops; +use crate::openhuman::screen_intelligence::types::{ + InputActionParams, PermissionRequestParams, StartSessionParams, StopSessionParams, +}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +/// Arg-less SI tool at a given permission level. +macro_rules! argless_tool { + ($ty:ident, $name:literal, $fn:ident, $perm:expr, $conc:expr, $desc:literal) => { + pub struct $ty; + #[async_trait] + impl Tool for $ty { + fn name(&self) -> &str { + $name + } + fn description(&self) -> &str { + $desc + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + fn permission_level(&self) -> PermissionLevel { + $perm + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!(concat!("[tool][screen_intelligence] ", $name, " invoked")); + emit!(ops::$fn().await, $name) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + $conc + } + } + }; +} + +argless_tool!( + ScreenStatusTool, + "screen_intelligence_status", + accessibility_status, + PermissionLevel::ReadOnly, + true, + "Report screen-intelligence status: granted permissions, active session, and platform support." +); +argless_tool!( + ScreenCaptureImageRefTool, + "screen_intelligence_capture_image_ref", + accessibility_capture_image_ref, + PermissionLevel::ReadOnly, + false, + "Capture the current screen and return an image reference (no inline bytes)." +); +argless_tool!( + ScreenVisionFlushTool, + "screen_intelligence_vision_flush", + accessibility_vision_flush, + PermissionLevel::Execute, + false, + "Clear the cached recent-vision summaries." +); +argless_tool!( + ScreenRefreshPermissionsTool, + "screen_intelligence_refresh_permissions", + accessibility_refresh_permissions, + PermissionLevel::ReadOnly, + false, + "Re-detect current OS permission grants (does not prompt)." +); +argless_tool!( + ScreenCaptureNowTool, + "screen_intelligence_capture_now", + accessibility_capture_now, + PermissionLevel::Execute, + false, + "Capture a frame now within the active session and return its image reference." +); +argless_tool!( + ScreenCaptureTestTool, + "screen_intelligence_capture_test", + accessibility_capture_test, + PermissionLevel::Execute, + false, + "Run a standalone capture diagnostic (no session required)." +); +argless_tool!( + ScreenGlobeStartTool, + "screen_intelligence_globe_listener_start", + accessibility_globe_listener_start, + PermissionLevel::Execute, + false, + "Start the Globe/Fn hotkey listener." +); +argless_tool!( + ScreenGlobePollTool, + "screen_intelligence_globe_listener_poll", + accessibility_globe_listener_poll, + PermissionLevel::ReadOnly, + true, + "Poll for Globe/Fn hotkey events since the last poll." +); +argless_tool!( + ScreenGlobeStopTool, + "screen_intelligence_globe_listener_stop", + accessibility_globe_listener_stop, + PermissionLevel::Execute, + false, + "Stop the Globe/Fn hotkey listener." +); + +/// Start a capture session (requires explicit consent). +pub struct ScreenSessionStartTool; + +#[async_trait] +impl Tool for ScreenSessionStartTool { + fn name(&self) -> &str { + "screen_intelligence_session_start" + } + + fn description(&self) -> &str { + "Start a screen-capture session. Requires `consent: true`; optional \ + `ttl_secs` and `screen_monitoring`. Capture tools only work while a \ + session is active." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "consent": { "type": "boolean", "description": "Explicit user consent to capture (required true)." }, + "ttl_secs": { "type": "integer", "minimum": 1 }, + "screen_monitoring": { "type": "boolean" } + }, + "required": ["consent"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][screen_intelligence] session_start invoked"); + let payload: StartSessionParams = serde_json::from_value(args) + .map_err(|e| anyhow::anyhow!("screen_intelligence_session_start: invalid args: {e}"))?; + emit!( + ops::accessibility_start_session(payload).await, + "screen_intelligence_session_start" + ) + } +} + +/// Stop the capture session. +pub struct ScreenSessionStopTool; + +#[async_trait] +impl Tool for ScreenSessionStopTool { + fn name(&self) -> &str { + "screen_intelligence_session_stop" + } + + fn description(&self) -> &str { + "Stop the active screen-capture session, with an optional `reason`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": { "reason": { "type": "string" } } }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][screen_intelligence] session_stop invoked"); + let payload: StopSessionParams = serde_json::from_value(args) + .map_err(|e| anyhow::anyhow!("screen_intelligence_session_stop: invalid args: {e}"))?; + emit!( + ops::accessibility_stop_session(payload).await, + "screen_intelligence_session_stop" + ) + } +} + +/// Drive a click/type/key input action. +pub struct ScreenInputActionTool; + +#[async_trait] +impl Tool for ScreenInputActionTool { + fn name(&self) -> &str { + "screen_intelligence_input_action" + } + + fn description(&self) -> &str { + "Perform a desktop input action (click / type / key) via the \ + accessibility engine. Takes an `InputActionParams` object." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "description": "InputActionParams: the action kind plus its coordinates/text/keys.", + "additionalProperties": true + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][screen_intelligence] input_action invoked"); + let payload: InputActionParams = serde_json::from_value(args) + .map_err(|e| anyhow::anyhow!("screen_intelligence_input_action: invalid args: {e}"))?; + emit!( + ops::accessibility_input_action(payload).await, + "screen_intelligence_input_action" + ) + } +} + +/// Recent vision summaries. +pub struct ScreenVisionRecentTool; + +#[async_trait] +impl Tool for ScreenVisionRecentTool { + fn name(&self) -> &str { + "screen_intelligence_vision_recent" + } + + fn description(&self) -> &str { + "Return recent vision summaries from captured frames, optionally capped \ + by `limit`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "limit": { "type": "integer", "minimum": 1 } } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][screen_intelligence] vision_recent invoked"); + let limit = args + .get("limit") + .and_then(serde_json::Value::as_u64) + .map(|v| v as usize); + emit!( + ops::accessibility_vision_recent(limit).await, + "screen_intelligence_vision_recent" + ) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Request all OS permissions. Default-OFF, Dangerous. +pub struct ScreenRequestPermissionsTool; + +#[async_trait] +impl Tool for ScreenRequestPermissionsTool { + fn name(&self) -> &str { + "screen_intelligence_request_permissions" + } + + fn description(&self) -> &str { + "Trigger the OS permission prompts needed for screen intelligence \ + (accessibility / input monitoring). Shows system dialogs. Default-OFF \ + (opt-in)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][screen_intelligence] request_permissions invoked"); + emit!( + ops::accessibility_request_permissions().await, + "screen_intelligence_request_permissions" + ) + } +} + +/// Request a single OS permission. Default-OFF, Dangerous. +pub struct ScreenRequestPermissionTool; + +#[async_trait] +impl Tool for ScreenRequestPermissionTool { + fn name(&self) -> &str { + "screen_intelligence_request_permission" + } + + fn description(&self) -> &str { + "Trigger the OS permission prompt for a single `permission` kind. Shows \ + a system dialog. Default-OFF (opt-in)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "permission": { "type": "string", "description": "Permission kind." } }, + "required": ["permission"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][screen_intelligence] request_permission invoked"); + let payload: PermissionRequestParams = serde_json::from_value(args).map_err(|e| { + anyhow::anyhow!("screen_intelligence_request_permission: invalid args: {e}") + })?; + emit!( + ops::accessibility_request_permission(payload).await, + "screen_intelligence_request_permission" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + #[test] + fn names_and_levels() { + assert_eq!(ScreenStatusTool.name(), "screen_intelligence_status"); + assert_eq!( + ScreenStatusTool.permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + ScreenSessionStartTool.permission_level(), + PermissionLevel::Execute + ); + assert_eq!( + ScreenInputActionTool.permission_level(), + PermissionLevel::Execute + ); + assert_eq!( + ScreenRequestPermissionsTool.permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!( + ScreenRequestPermissionTool.permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!(ScreenStatusTool.scope(), ToolScope::All); + } + + #[tokio::test] + async fn session_start_requires_consent() { + let err = ScreenSessionStartTool + .execute(json!({})) + .await + .expect_err("missing consent"); + assert!(err.to_string().contains("session_start")); + } +} diff --git a/src/openhuman/security/mod.rs b/src/openhuman/security/mod.rs index 6cb2fb251..1db86bcc3 100644 --- a/src/openhuman/security/mod.rs +++ b/src/openhuman/security/mod.rs @@ -1,6 +1,7 @@ mod core; pub mod ops; mod schemas; +pub mod tools; pub mod audit; pub mod bubblewrap; diff --git a/src/openhuman/security/tools.rs b/src/openhuman/security/tools.rs new file mode 100644 index 000000000..7367d6d9f --- /dev/null +++ b/src/openhuman/security/tools.rs @@ -0,0 +1,68 @@ +//! LLM-callable wrapper over the `security` domain. Read-only, default-ON. +//! +//! Only the policy-info read is exposed as an agent tool; command/path gating +//! is enforced in-engine, not as an agent-callable surface. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{Tool, ToolResult}; + +use super::ops; + +/// Report the current security/autonomy policy. +pub struct SecurityPolicyInfoTool { + config: Arc, +} + +impl SecurityPolicyInfoTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for SecurityPolicyInfoTool { + fn name(&self) -> &str { + "security_policy_info" + } + + fn description(&self) -> &str { + "Report the effective security/autonomy policy: access level, \ + workspace-only flag, allowed commands, rate limits, and approval \ + requirements. Use to explain what the agent is/aren't permitted to do." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][security] policy_info invoked"); + let outcome = ops::security_policy_info_for_config(&self.config); + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::{PermissionLevel, ToolScope}; + + #[tokio::test] + async fn metadata_and_execute() { + let t = SecurityPolicyInfoTool::new(Arc::new(Config::default())); + assert_eq!(t.name(), "security_policy_info"); + assert_eq!(t.permission_level(), PermissionLevel::ReadOnly); + assert_eq!(t.scope(), ToolScope::All); + let out = t.execute(json!({})).await.expect("policy_info"); + assert!(!out.output_for_llm(false).is_empty()); + } +} diff --git a/src/openhuman/service/mod.rs b/src/openhuman/service/mod.rs index acfefea63..19b6cd830 100644 --- a/src/openhuman/service/mod.rs +++ b/src/openhuman/service/mod.rs @@ -8,6 +8,7 @@ pub mod ops; mod restart; mod schemas; mod shutdown; +pub mod tools; mod common; #[cfg(target_os = "linux")] diff --git a/src/openhuman/service/tools.rs b/src/openhuman/service/tools.rs new file mode 100644 index 000000000..952c988a7 --- /dev/null +++ b/src/openhuman/service/tools.rs @@ -0,0 +1,278 @@ +//! LLM-callable wrappers over the `service` (daemon lifecycle) domain. +//! +//! `service_status` and `daemon_host_prefs_get` are read-only and default-ON. +//! Every lifecycle mutator — start/stop/restart/shutdown/install/uninstall and +//! the tray-prefs setter — changes the running process or the installed system +//! service, so they ship default-OFF via `tools/user_filter.rs` +//! (`service_lifecycle` toggle). shutdown/install/uninstall are `Dangerous`. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +use super::ops; + +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +fn opt_str(args: &serde_json::Value, key: &str) -> Option { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// Macro to define a simple `&Config`-only service tool. +macro_rules! config_tool { + ($ty:ident, $name:literal, $fn:ident, $perm:expr, $desc:literal) => { + pub struct $ty { + config: Arc, + } + impl $ty { + pub fn new(config: Arc) -> Self { + Self { config } + } + } + #[async_trait] + impl Tool for $ty { + fn name(&self) -> &str { + $name + } + fn description(&self) -> &str { + $desc + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + fn permission_level(&self) -> PermissionLevel { + $perm + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!(concat!("[tool][service] ", $name, " invoked")); + emit!(ops::$fn(&self.config).await, $name) + } + } + }; +} + +config_tool!( + ServiceStatusTool, + "service_status", + service_status, + PermissionLevel::ReadOnly, + "Report the daemon/service status (installed, running, version)." +); +config_tool!( + DaemonHostPrefsGetTool, + "daemon_host_prefs_get", + daemon_host_get, + PermissionLevel::ReadOnly, + "Read the daemon host UI preferences (e.g. show-tray flag)." +); +config_tool!( + ServiceStartTool, + "service_start", + service_start, + PermissionLevel::Execute, + "Start the OpenHuman daemon service. Default-OFF (opt-in)." +); +config_tool!( + ServiceStopTool, + "service_stop", + service_stop, + PermissionLevel::Execute, + "Stop the OpenHuman daemon service. Default-OFF (opt-in)." +); +config_tool!( + ServiceInstallTool, + "service_install", + service_install, + PermissionLevel::Dangerous, + "Install the OpenHuman daemon as a system service. Default-OFF (opt-in)." +); +config_tool!( + ServiceUninstallTool, + "service_uninstall", + service_uninstall, + PermissionLevel::Dangerous, + "Remove the OpenHuman daemon system service. Default-OFF (opt-in)." +); + +/// Set daemon host tray preference. Default-OFF. +pub struct DaemonHostPrefsSetTool { + config: Arc, +} + +impl DaemonHostPrefsSetTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for DaemonHostPrefsSetTool { + fn name(&self) -> &str { + "daemon_host_prefs_set" + } + + fn description(&self) -> &str { + "Set the daemon host UI preference `show_tray`. Default-OFF (opt-in)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "show_tray": { "type": "boolean", "description": "Show the tray icon." } }, + "required": ["show_tray"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][service] daemon_host_prefs_set invoked"); + let show_tray = args + .get("show_tray") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| anyhow::anyhow!("missing required boolean argument `show_tray`"))?; + emit!( + ops::daemon_host_set(&self.config, show_tray).await, + "daemon_host_prefs_set" + ) + } +} + +/// Restart the core process. Default-OFF. +pub struct ServiceRestartTool; + +#[async_trait] +impl Tool for ServiceRestartTool { + fn name(&self) -> &str { + "service_restart" + } + + fn description(&self) -> &str { + "Request an asynchronous restart of the core process, with optional \ + `source` and `reason` labels. Default-OFF (opt-in)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "source": { "type": "string" }, + "reason": { "type": "string" } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][service] restart invoked"); + emit!( + ops::service_restart(opt_str(&args, "source"), opt_str(&args, "reason")).await, + "service_restart" + ) + } +} + +/// Gracefully shut down the core process. Default-OFF, Dangerous. +pub struct ServiceShutdownTool; + +#[async_trait] +impl Tool for ServiceShutdownTool { + fn name(&self) -> &str { + "service_shutdown" + } + + fn description(&self) -> &str { + "Request a graceful shutdown of the core process, with optional \ + `source` and `reason` labels. Terminates the running assistant. \ + Default-OFF (opt-in)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "source": { "type": "string" }, + "reason": { "type": "string" } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][service] shutdown invoked"); + emit!( + ops::service_shutdown(opt_str(&args, "source"), opt_str(&args, "reason")).await, + "service_shutdown" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn names_and_levels() { + assert_eq!(ServiceStatusTool::new(cfg()).name(), "service_status"); + assert_eq!( + ServiceStatusTool::new(cfg()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + ServiceStartTool::new(cfg()).permission_level(), + PermissionLevel::Execute + ); + assert_eq!( + ServiceInstallTool::new(cfg()).permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!( + ServiceShutdownTool.permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!( + ServiceRestartTool.permission_level(), + PermissionLevel::Execute + ); + assert_eq!( + DaemonHostPrefsSetTool::new(cfg()).permission_level(), + PermissionLevel::Write + ); + assert_eq!(ServiceStatusTool::new(cfg()).scope(), ToolScope::All); + } + + #[tokio::test] + async fn daemon_host_set_requires_flag() { + let err = DaemonHostPrefsSetTool::new(cfg()) + .execute(json!({})) + .await + .expect_err("missing show_tray"); + assert!(err.to_string().contains("show_tray")); + } +} diff --git a/src/openhuman/skills/mod.rs b/src/openhuman/skills/mod.rs index 16ab53d51..4593ecb69 100644 --- a/src/openhuman/skills/mod.rs +++ b/src/openhuman/skills/mod.rs @@ -12,6 +12,7 @@ pub mod preflight; pub mod registry; pub mod run_log; pub mod schemas; +pub mod tools; pub mod types; pub use ops::*; diff --git a/src/openhuman/skills/tools.rs b/src/openhuman/skills/tools.rs new file mode 100644 index 000000000..3b9f60cea --- /dev/null +++ b/src/openhuman/skills/tools.rs @@ -0,0 +1,539 @@ +//! LLM-callable wrappers over the `skills` metadata domain. +//! +//! These tools let the agent discover installed skills, inspect a skill's +//! definition and bundled resources, review recent runs and their logs, and +//! (opt-in) scaffold / install / uninstall user skills. Thin shims over the +//! free functions in the `skills::ops_*` / `registry` / `run_log` modules. +//! +//! NOTE: launching a skill run is already exposed by `RunSkillTool` +//! (`skills.run`), so it is intentionally not duplicated here. +//! +//! Read tools are default-enabled. The write/install/uninstall tools +//! (`skill_create`, `skill_install_from_url`, `skill_uninstall`) mutate the +//! on-disk skill set (and install fetches remote content), so they ship +//! default-OFF via `tools/user_filter.rs`. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +use super::ops_create::{create_skill, CreateSkillParams}; +use super::ops_discover::{discover_skills, is_workspace_trusted, read_skill_resource}; +use super::ops_install::{ + install_skill_from_url, uninstall_skill, InstallSkillFromUrlParams, UninstallSkillParams, +}; +use super::registry::get_skill; +use super::run_log::{find_run_log_path, read_run_log_slice, scan_runs}; + +fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`")) +} + +/// List installed skills. +pub struct SkillListTool { + workspace_dir: PathBuf, +} + +impl SkillListTool { + pub fn new(config: Arc) -> Self { + Self { + workspace_dir: config.workspace_dir.clone(), + } + } +} + +#[async_trait] +impl Tool for SkillListTool { + fn name(&self) -> &str { + "skill_list" + } + + fn description(&self) -> &str { + "List installed skills (reusable, packaged agent procedures defined as \ + SKILL.md bundles). Returns each skill's name, dir, description, tags, \ + tool hints, scope, and any warnings. Use to find a skill to inspect \ + (`skill_describe`) or run." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][skills] list invoked"); + let home = dirs::home_dir(); + let trusted = is_workspace_trusted(&self.workspace_dir); + let skills = discover_skills(home.as_deref(), Some(&self.workspace_dir), trusted); + Ok(ToolResult::success(serde_json::to_string(&json!({ + "count": skills.len(), + "skills": skills, + }))?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Describe one skill (definition + declared inputs). +pub struct SkillDescribeTool { + workspace_dir: PathBuf, +} + +impl SkillDescribeTool { + pub fn new(config: Arc) -> Self { + Self { + workspace_dir: config.workspace_dir.clone(), + } + } +} + +#[async_trait] +impl Tool for SkillDescribeTool { + fn name(&self) -> &str { + "skill_describe" + } + + fn description(&self) -> &str { + "Describe one skill by `skill_id`: its agent definition (id, \ + display name, when-to-use) and the inputs it declares (name, \ + description, required, type). Use before running a skill to learn \ + which inputs to supply." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "skill_id": { "type": "string", "description": "Skill id (directory name)." } }, + "required": ["skill_id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][skills] describe invoked"); + let skill_id = read_required_str(&args, "skill_id")?; + let def = get_skill(&self.workspace_dir, &skill_id) + .ok_or_else(|| anyhow::anyhow!("skill_describe: skill `{skill_id}` not found"))?; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "definition": def.definition, + "inputs": def.inputs, + "github_gated": def.github.is_some(), + }))?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Read a bundled resource file from a skill. +pub struct SkillReadResourceTool { + workspace_dir: PathBuf, +} + +impl SkillReadResourceTool { + pub fn new(config: Arc) -> Self { + Self { + workspace_dir: config.workspace_dir.clone(), + } + } +} + +#[async_trait] +impl Tool for SkillReadResourceTool { + fn name(&self) -> &str { + "skill_read_resource" + } + + fn description(&self) -> &str { + "Read a bundled resource file from a skill (`skill_id` + `relative_path` \ + under the skill directory, e.g. `scripts/run.sh` or \ + `references/spec.md`). Path-hardened and size-capped. Use to inspect a \ + skill's helper scripts or reference docs." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "skill_id": { "type": "string", "description": "Skill id (directory name)." }, + "relative_path": { "type": "string", "description": "Path relative to the skill directory." } + }, + "required": ["skill_id", "relative_path"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][skills] read_resource invoked"); + let skill_id = read_required_str(&args, "skill_id")?; + let relative_path = read_required_str(&args, "relative_path")?; + let content = + read_skill_resource(&self.workspace_dir, &skill_id, Path::new(&relative_path)) + .map_err(|e| anyhow::anyhow!("skill_read_resource: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "skill_id": skill_id, + "relative_path": relative_path, + "content": content, + }))?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// List recent skill runs. +pub struct SkillRecentRunsTool { + workspace_dir: PathBuf, +} + +impl SkillRecentRunsTool { + pub fn new(config: Arc) -> Self { + Self { + workspace_dir: config.workspace_dir.clone(), + } + } +} + +#[async_trait] +impl Tool for SkillRecentRunsTool { + fn name(&self) -> &str { + "skill_recent_runs" + } + + fn description(&self) -> &str { + "List recent skill runs (optionally filtered by `skill_id`), newest \ + first. Each carries `run_id`, `skill_id`, start time, status, and \ + duration. Use to find a `run_id` for `skill_read_run_log`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "skill_id": { "type": "string", "description": "Filter to one skill (optional)." }, + "limit": { "type": "integer", "minimum": 1, "description": "Max runs (default 20)." } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][skills] recent_runs invoked"); + let skill_id = args + .get("skill_id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); + let limit = args + .get("limit") + .and_then(serde_json::Value::as_u64) + .map(|v| v as usize) + .unwrap_or(20); + let runs = scan_runs(&self.workspace_dir, skill_id, limit); + Ok(ToolResult::success(serde_json::to_string(&json!({ + "count": runs.len(), + "runs": runs, + }))?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Read a slice of a run log. +pub struct SkillReadRunLogTool { + workspace_dir: PathBuf, +} + +impl SkillReadRunLogTool { + pub fn new(config: Arc) -> Self { + Self { + workspace_dir: config.workspace_dir.clone(), + } + } +} + +#[async_trait] +impl Tool for SkillReadRunLogTool { + fn name(&self) -> &str { + "skill_read_run_log" + } + + fn description(&self) -> &str { + "Read a slice of a skill run's log by `run_id`, from `offset` bytes up \ + to `max_bytes`. Returns the content plus the next offset and an `eof` \ + flag so you can stream a long log. Use `skill_recent_runs` to find a \ + run id." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "run_id": { "type": "string", "description": "Skill run id." }, + "offset": { "type": "integer", "minimum": 0, "description": "Byte offset to start at (default 0)." }, + "max_bytes": { "type": "integer", "minimum": 1, "description": "Max bytes to read (default 65536)." } + }, + "required": ["run_id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][skills] read_run_log invoked"); + let run_id = read_required_str(&args, "run_id")?; + let offset = args + .get("offset") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let max_bytes = args + .get("max_bytes") + .and_then(serde_json::Value::as_u64) + .map(|v| v as usize) + .unwrap_or(65536); + let path = find_run_log_path(&self.workspace_dir, &run_id) + .ok_or_else(|| anyhow::anyhow!("skill_read_run_log: run `{run_id}` not found"))?; + let slice = read_run_log_slice(&path, offset, max_bytes) + .map_err(|e| anyhow::anyhow!("skill_read_run_log: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&slice)?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Scaffold a new user skill. **Writes to disk** — default-OFF. +pub struct SkillCreateTool { + workspace_dir: PathBuf, +} + +impl SkillCreateTool { + pub fn new(config: Arc) -> Self { + Self { + workspace_dir: config.workspace_dir.clone(), + } + } +} + +#[async_trait] +impl Tool for SkillCreateTool { + fn name(&self) -> &str { + "skill_create" + } + + fn description(&self) -> &str { + "Scaffold a new skill (SKILL.md, plus skill.toml when inputs are \ + declared). Requires `name` and `description`; optional `scope` \ + (user|project), `tags`, `allowed_tools`, and `inputs`. Use when the \ + user wants to capture a repeatable procedure as a packaged skill." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "Skill name (required)." }, + "description": { "type": "string", "description": "One-line summary (required)." }, + "scope": { "type": "string", "enum": ["user", "project"], "description": "Install scope (default user)." }, + "license": { "type": "string" }, + "author": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "allowed_tools": { "type": "array", "items": { "type": "string" } }, + "inputs": { "type": "array", "items": { "type": "object" } } + }, + "required": ["name", "description"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][skills] create invoked"); + let params: CreateSkillParams = serde_json::from_value(args) + .map_err(|e| anyhow::anyhow!("skill_create: invalid params: {e}"))?; + let skill = create_skill(&self.workspace_dir, params) + .map_err(|e| anyhow::anyhow!("skill_create: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&skill)?)) + } +} + +/// Install a skill from a remote URL. **Fetches + writes** — default-OFF. +pub struct SkillInstallFromUrlTool { + workspace_dir: PathBuf, +} + +impl SkillInstallFromUrlTool { + pub fn new(config: Arc) -> Self { + Self { + workspace_dir: config.workspace_dir.clone(), + } + } +} + +#[async_trait] +impl Tool for SkillInstallFromUrlTool { + fn name(&self) -> &str { + "skill_install_from_url" + } + + fn description(&self) -> &str { + "Install a user skill from a remote `url` (https, must point at a \ + SKILL.md). Fetches and writes it under `~/.openhuman/skills/`. \ + Optional `timeout_secs`. Collisions are rejected. Only use when the \ + user explicitly asks to install a skill from a URL." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "url": { "type": "string", "description": "https URL ending in .md (required)." }, + "timeout_secs": { "type": "integer", "minimum": 1, "description": "Fetch timeout (default 60, max 600)." } + }, + "required": ["url"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + fn external_effect(&self) -> bool { + // Fetches remote content over the network. + true + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][skills] install_from_url invoked"); + let params: InstallSkillFromUrlParams = serde_json::from_value(args) + .map_err(|e| anyhow::anyhow!("skill_install_from_url: invalid params: {e}"))?; + let outcome = install_skill_from_url(&self.workspace_dir, params) + .await + .map_err(|e| anyhow::anyhow!("skill_install_from_url: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome)?)) + } +} + +/// Uninstall a user skill. **Deletes from disk** — default-OFF. +pub struct SkillUninstallTool; + +#[async_trait] +impl Tool for SkillUninstallTool { + fn name(&self) -> &str { + "skill_uninstall" + } + + fn description(&self) -> &str { + "Uninstall a user-scope skill by `name`, deleting its directory under \ + `~/.openhuman/skills/`. Irreversible; project/legacy skills are \ + read-only and cannot be removed. Only use when the user asks to remove \ + a specific skill." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "name": { "type": "string", "description": "Skill name (directory) to remove." } }, + "required": ["name"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][skills] uninstall invoked"); + let name = read_required_str(&args, "name")?; + let outcome = uninstall_skill(UninstallSkillParams { name }, None) + .map_err(|e| anyhow::anyhow!("skill_uninstall: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome)?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn names_and_levels() { + let c = cfg(); + assert_eq!(SkillListTool::new(c.clone()).name(), "skill_list"); + assert_eq!( + SkillListTool::new(c.clone()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + SkillCreateTool::new(c.clone()).permission_level(), + PermissionLevel::Write + ); + assert_eq!( + SkillInstallFromUrlTool::new(c.clone()).permission_level(), + PermissionLevel::Write + ); + assert!(SkillInstallFromUrlTool::new(c.clone()) + .external_effect_with_args(&serde_json::Value::Null)); + assert_eq!( + SkillUninstallTool.permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!(SkillListTool::new(c).scope(), ToolScope::All); + } + + #[tokio::test] + async fn describe_requires_skill_id() { + let err = SkillDescribeTool::new(cfg()) + .execute(json!({})) + .await + .expect_err("missing skill_id"); + assert!(err.to_string().contains("skill_id")); + } + + #[tokio::test] + async fn read_resource_requires_both_args() { + let err = SkillReadResourceTool::new(cfg()) + .execute(json!({ "skill_id": "x" })) + .await + .expect_err("missing relative_path"); + assert!(err.to_string().contains("relative_path")); + } + + #[tokio::test] + async fn uninstall_requires_name() { + let err = SkillUninstallTool + .execute(json!({})) + .await + .expect_err("missing name"); + assert!(err.to_string().contains("name")); + } + + #[tokio::test] + async fn list_returns_envelope() { + // A fresh workspace has no project skills, but the user-home scan may + // surface bundled skills; either way the call succeeds and returns the + // envelope shape. + let out = SkillListTool::new(cfg()) + .execute(json!({})) + .await + .expect("list"); + assert!(out.output_for_llm(false).contains("skills")); + } +} diff --git a/src/openhuman/task_sources/mod.rs b/src/openhuman/task_sources/mod.rs index dcdb95847..8dc0f7bab 100644 --- a/src/openhuman/task_sources/mod.rs +++ b/src/openhuman/task_sources/mod.rs @@ -24,6 +24,7 @@ pub mod pipeline; pub mod route; mod schemas; pub mod store; +pub mod tools; pub mod types; pub use crate::openhuman::memory_sync::composio::providers::{NormalizedTask, TaskFetchFilter}; diff --git a/src/openhuman/task_sources/tools.rs b/src/openhuman/task_sources/tools.rs new file mode 100644 index 000000000..4a733ceb5 --- /dev/null +++ b/src/openhuman/task_sources/tools.rs @@ -0,0 +1,570 @@ +//! LLM-callable wrappers over the `task_sources` domain. +//! +//! These tools let the agent inspect external task sources (GitHub / Notion +//! / Linear / ClickUp issue & task feeds), trigger an on-demand fetch, list +//! ingested tasks, and dry-run a filter. Each tool is a thin shim over the +//! async functions in [`crate::openhuman::task_sources::ops`], which return +//! `RpcOutcome`; the wrapper emits the inner value as JSON. +//! +//! The read/observe tools (`list` / `get` / `fetch` / `list_tasks` / +//! `preview_filter` / `status`) are default-enabled. The persistent-config +//! mutators — `add`, `update`, `remove` — change ingestion behaviour and +//! cascade history, so they ship default-OFF via `tools/user_filter.rs`. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::config::Config; +use crate::openhuman::task_sources::{FilterSpec, ProviderSlug, TaskSourcePatch}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +use super::ops; + +fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`")) +} + +fn opt_u64(args: &serde_json::Value, key: &str) -> Option { + args.get(key).and_then(serde_json::Value::as_u64) +} + +fn opt_str(args: &serde_json::Value, key: &str) -> Option { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn parse_provider(args: &serde_json::Value) -> anyhow::Result { + let raw = read_required_str(args, "provider")?; + ProviderSlug::parse(&raw).map_err(|e| anyhow::anyhow!("invalid provider: {e}")) +} + +fn parse_filter(args: &serde_json::Value) -> anyhow::Result { + let val = args + .get("filter") + .cloned() + .ok_or_else(|| anyhow::anyhow!("missing required object argument `filter`"))?; + serde_json::from_value(val).map_err(|e| anyhow::anyhow!("invalid filter: {e}")) +} + +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +/// List configured external task sources. +pub struct TaskSourceListTool { + config: Arc, +} + +impl TaskSourceListTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TaskSourceListTool { + fn name(&self) -> &str { + "task_source_list" + } + + fn description(&self) -> &str { + "List configured external task sources (GitHub / Notion / Linear / \ + ClickUp feeds that ingest issues and tasks). Each entry carries `id`, \ + `provider`, `name`, `enabled`, `filter`, `interval_secs`, and \ + last-fetch metadata. Use to see what feeds exist before fetching or \ + editing one." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][task_sources] list invoked"); + emit!(ops::list(&self.config).await, "task_source_list") + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Read one task source by id. +pub struct TaskSourceGetTool { + config: Arc, +} + +impl TaskSourceGetTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TaskSourceGetTool { + fn name(&self) -> &str { + "task_source_get" + } + + fn description(&self) -> &str { + "Get one external task source by `id`, returning its full config \ + (provider, filter, interval, target, last fetch status)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "id": { "type": "string", "description": "Task-source id." } }, + "required": ["id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][task_sources] get invoked"); + let id = read_required_str(&args, "id")?; + emit!(ops::get(&self.config, &id).await, "task_source_get") + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Trigger an on-demand fetch of one task source. +pub struct TaskSourceFetchTool { + config: Arc, +} + +impl TaskSourceFetchTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TaskSourceFetchTool { + fn name(&self) -> &str { + "task_source_fetch" + } + + fn description(&self) -> &str { + "Fetch one task source now (by `id`) instead of waiting for its poll \ + interval. Returns counts of tasks fetched, newly routed, and skipped \ + as duplicates. Use when the user wants the latest issues/tasks pulled \ + immediately." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "id": { "type": "string", "description": "Task-source id to fetch." } }, + "required": ["id"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][task_sources] fetch invoked"); + let id = read_required_str(&args, "id")?; + emit!(ops::fetch(&self.config, &id).await, "task_source_fetch") + } +} + +/// List ingested tasks for one source. +pub struct TaskSourceListTasksTool { + config: Arc, +} + +impl TaskSourceListTasksTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TaskSourceListTasksTool { + fn name(&self) -> &str { + "task_source_list_tasks" + } + + fn description(&self) -> &str { + "List the tasks already ingested from one task source (by `id`), most \ + recent first, optionally capped by `limit`. Use to see what was \ + pulled from a given feed." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Task-source id." }, + "limit": { "type": "integer", "minimum": 1, "description": "Max tasks to return." } + }, + "required": ["id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][task_sources] list_tasks invoked"); + let id = read_required_str(&args, "id")?; + let limit = opt_u64(&args, "limit").map(|v| v as usize); + emit!( + ops::list_tasks(&self.config, &id, limit).await, + "task_source_list_tasks" + ) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Dry-run a provider filter without persisting a source. +pub struct TaskSourcePreviewFilterTool { + config: Arc, +} + +impl TaskSourcePreviewFilterTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TaskSourcePreviewFilterTool { + fn name(&self) -> &str { + "task_source_preview_filter" + } + + fn description(&self) -> &str { + "Dry-run a task-source `filter` for a `provider` and return the tasks \ + it would match, WITHOUT creating a persistent source or ingesting \ + anything. Use to validate a filter before `task_source_add`. The \ + `filter` object is provider-tagged (e.g. \ + `{ \"provider\": \"github\", \"repo\": \"owner/name\", \"labels\": [\"bug\"] }`)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "provider": { "type": "string", "enum": ["github", "notion", "linear", "clickup"] }, + "filter": { "type": "object", "description": "Provider-tagged filter spec." }, + "connection_id": { "type": "string", "description": "Optional Composio connection id." }, + "max": { "type": "integer", "minimum": 1, "description": "Max tasks to preview." } + }, + "required": ["provider", "filter"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][task_sources] preview_filter invoked"); + let provider = parse_provider(&args)?; + let filter = parse_filter(&args)?; + let connection_id = opt_str(&args, "connection_id"); + let max = opt_u64(&args, "max").map(|v| v as u32); + emit!( + ops::preview_filter(&self.config, provider, filter, connection_id, max).await, + "task_source_preview_filter" + ) + } +} + +/// Report task-source subsystem status. +pub struct TaskSourceStatusTool { + config: Arc, +} + +impl TaskSourceStatusTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TaskSourceStatusTool { + fn name(&self) -> &str { + "task_source_status" + } + + fn description(&self) -> &str { + "Report task-source subsystem health: whether ingestion is enabled, \ + the default poll interval, and the total/enabled source counts." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][task_sources] status invoked"); + emit!(ops::status(&self.config).await, "task_source_status") + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Create a persistent task source. **Mutates config + spawns polling** — +/// default-OFF. +pub struct TaskSourceAddTool { + config: Arc, +} + +impl TaskSourceAddTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TaskSourceAddTool { + fn name(&self) -> &str { + "task_source_add" + } + + fn description(&self) -> &str { + "Create a persistent external task source that will be polled on an \ + interval. Requires `provider` and a provider-tagged `filter`; \ + optional `name`, `connection_id`, `interval_secs`, `target` \ + (agent_todo_proactive|todo_only), `max_tasks_per_fetch`, and \ + `assigned_executor`. Validate the filter with \ + `task_source_preview_filter` first." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "provider": { "type": "string", "enum": ["github", "notion", "linear", "clickup"] }, + "filter": { "type": "object", "description": "Provider-tagged filter spec." }, + "name": { "type": "string" }, + "connection_id": { "type": "string" }, + "interval_secs": { "type": "integer", "minimum": 1 }, + "target": { "type": "string", "enum": ["agent_todo_proactive", "todo_only"] }, + "max_tasks_per_fetch": { "type": "integer", "minimum": 1 }, + "assigned_executor": { "type": "string" } + }, + "required": ["provider", "filter"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][task_sources] add invoked"); + let provider = parse_provider(&args)?; + let filter = parse_filter(&args)?; + let target = match args.get("target") { + Some(v) => Some( + serde_json::from_value(v.clone()) + .map_err(|e| anyhow::anyhow!("invalid target: {e}"))?, + ), + None => None, + }; + let max_tasks = opt_u64(&args, "max_tasks_per_fetch").map(|v| v as u32); + emit!( + ops::add( + &self.config, + provider, + opt_str(&args, "connection_id"), + opt_str(&args, "name"), + filter, + opt_u64(&args, "interval_secs"), + target, + max_tasks, + opt_str(&args, "assigned_executor"), + ) + .await, + "task_source_add" + ) + } +} + +/// Patch an existing task source. **Mutates config** — default-OFF. +pub struct TaskSourceUpdateTool { + config: Arc, +} + +impl TaskSourceUpdateTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TaskSourceUpdateTool { + fn name(&self) -> &str { + "task_source_update" + } + + fn description(&self) -> &str { + "Patch an existing task source by `id`. Supply a `patch` object with \ + only the fields to change (name, enabled, filter, intervalSecs, \ + target, maxTasksPerFetch, connectionId, assignedExecutor). Omitted \ + fields are left untouched." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Task-source id to update." }, + "patch": { "type": "object", "description": "Partial TaskSourcePatch (camelCase fields)." } + }, + "required": ["id", "patch"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][task_sources] update invoked"); + let id = read_required_str(&args, "id")?; + let patch_val = args + .get("patch") + .cloned() + .ok_or_else(|| anyhow::anyhow!("missing required object argument `patch`"))?; + let patch: TaskSourcePatch = + serde_json::from_value(patch_val).map_err(|e| anyhow::anyhow!("invalid patch: {e}"))?; + emit!( + ops::update(&self.config, &id, patch).await, + "task_source_update" + ) + } +} + +/// Delete a task source and its ingested history. **Destructive** — +/// default-OFF. +pub struct TaskSourceRemoveTool { + config: Arc, +} + +impl TaskSourceRemoveTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TaskSourceRemoveTool { + fn name(&self) -> &str { + "task_source_remove" + } + + fn description(&self) -> &str { + "Delete a task source by `id`, also removing all of its ingested-task \ + history (cascade). Irreversible. Only use when the user wants the \ + feed gone." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "id": { "type": "string", "description": "Task-source id to remove." } }, + "required": ["id"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][task_sources] remove invoked"); + let id = read_required_str(&args, "id")?; + emit!(ops::remove(&self.config, &id).await, "task_source_remove") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn names_and_levels() { + let c = cfg(); + assert_eq!( + TaskSourceListTool::new(c.clone()).name(), + "task_source_list" + ); + assert_eq!( + TaskSourceListTool::new(c.clone()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + TaskSourceFetchTool::new(c.clone()).permission_level(), + PermissionLevel::Execute + ); + assert_eq!( + TaskSourceAddTool::new(c.clone()).permission_level(), + PermissionLevel::Write + ); + assert_eq!( + TaskSourceRemoveTool::new(c.clone()).permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!(TaskSourceListTool::new(c).scope(), ToolScope::All); + } + + #[test] + fn read_tools_concurrency_safe() { + let c = cfg(); + assert!(TaskSourceListTool::new(c.clone()).is_concurrency_safe(&serde_json::Value::Null)); + assert!(TaskSourceGetTool::new(c).is_concurrency_safe(&serde_json::Value::Null)); + } + + #[tokio::test] + async fn get_requires_id() { + let err = TaskSourceGetTool::new(cfg()) + .execute(json!({})) + .await + .expect_err("missing id"); + assert!(err.to_string().contains("id")); + } + + #[tokio::test] + async fn add_requires_provider_and_filter() { + let err = TaskSourceAddTool::new(cfg()) + .execute(json!({ "filter": { "provider": "github" } })) + .await + .expect_err("missing provider"); + assert!(err.to_string().contains("provider")); + } + + #[test] + fn parse_provider_rejects_unknown() { + let err = parse_provider(&json!({ "provider": "jira" })).expect_err("unknown provider"); + assert!(err.to_string().contains("provider")); + } +} diff --git a/src/openhuman/team/mod.rs b/src/openhuman/team/mod.rs index 3da32b83c..3d5a17be2 100644 --- a/src/openhuman/team/mod.rs +++ b/src/openhuman/team/mod.rs @@ -2,6 +2,7 @@ mod ops; mod schemas; +pub mod tools; pub use ops::*; pub use schemas::{all_team_controller_schemas, all_team_registered_controllers, team_schemas}; diff --git a/src/openhuman/team/tools.rs b/src/openhuman/team/tools.rs new file mode 100644 index 000000000..6869d324e --- /dev/null +++ b/src/openhuman/team/tools.rs @@ -0,0 +1,486 @@ +//! LLM-callable wrappers over the `team` domain. +//! +//! Reads (list teams/members/invites, get team, usage) are default-ON. Every +//! membership/org mutator ships default-OFF via `tools/user_filter.rs` +//! (`team_admin` toggle); delete_team and remove_member are `Dangerous`. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::openhuman::config::Config; +use crate::openhuman::team; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +fn req_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`")) +} + +fn team_id_schema() -> serde_json::Value { + json!({ + "type": "object", + "properties": { "team_id": { "type": "string" } }, + "required": ["team_id"] + }) +} + +/// `&Config`-only read. +macro_rules! cfg_read { + ($ty:ident, $name:literal, $fn:ident, $desc:literal) => { + pub struct $ty { + config: Arc, + } + impl $ty { + pub fn new(config: Arc) -> Self { + Self { config } + } + } + #[async_trait] + impl Tool for $ty { + fn name(&self) -> &str { + $name + } + fn description(&self) -> &str { + $desc + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + emit!(team::$fn(&self.config).await, $name) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + } + }; +} + +/// `(config, team_id)` read. +macro_rules! team_id_read { + ($ty:ident, $name:literal, $fn:ident, $desc:literal) => { + pub struct $ty { + config: Arc, + } + impl $ty { + pub fn new(config: Arc) -> Self { + Self { config } + } + } + #[async_trait] + impl Tool for $ty { + fn name(&self) -> &str { + $name + } + fn description(&self) -> &str { + $desc + } + fn parameters_schema(&self) -> serde_json::Value { + team_id_schema() + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let team_id = req_str(&args, "team_id")?; + emit!(team::$fn(&self.config, &team_id).await, $name) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + } + }; +} + +cfg_read!( + TeamListTool, + "team_list", + list_teams, + "List the teams the user belongs to." +); +cfg_read!( + TeamUsageTool, + "team_get_usage", + get_usage, + "Return usage metrics for the active team." +); +team_id_read!( + TeamGetTool, + "team_get", + get_team, + "Get one team by `team_id`." +); +team_id_read!( + TeamListMembersTool, + "team_list_members", + list_members, + "List the members of a team by `team_id`." +); +team_id_read!( + TeamListInvitesTool, + "team_list_invites", + list_invites, + "List the outstanding invites for a team by `team_id`." +); + +/// `(config, team_id)` mutator at a given permission level. +macro_rules! team_id_mutator { + ($ty:ident, $name:literal, $fn:ident, $perm:expr, $desc:literal) => { + pub struct $ty { + config: Arc, + } + impl $ty { + pub fn new(config: Arc) -> Self { + Self { config } + } + } + #[async_trait] + impl Tool for $ty { + fn name(&self) -> &str { + $name + } + fn description(&self) -> &str { + $desc + } + fn parameters_schema(&self) -> serde_json::Value { + team_id_schema() + } + fn permission_level(&self) -> PermissionLevel { + $perm + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let team_id = req_str(&args, "team_id")?; + emit!(team::$fn(&self.config, &team_id).await, $name) + } + } + }; +} + +team_id_mutator!( + TeamDeleteTool, + "team_delete", + delete_team, + PermissionLevel::Dangerous, + "Delete a team by `team_id`. Default-OFF (opt-in)." +); +team_id_mutator!( + TeamSwitchTool, + "team_switch", + switch_team, + PermissionLevel::Write, + "Switch the active team to `team_id`. Default-OFF (opt-in)." +); +team_id_mutator!( + TeamLeaveTool, + "team_leave", + leave_team, + PermissionLevel::Write, + "Leave a team by `team_id`. Default-OFF (opt-in)." +); + +/// Create a team. +pub struct TeamCreateTool { + config: Arc, +} +impl TeamCreateTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for TeamCreateTool { + fn name(&self) -> &str { + "team_create" + } + fn description(&self) -> &str { + "Create a new team with `name`. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let name = req_str(&args, "name")?; + emit!(team::create_team(&self.config, &name).await, "team_create") + } +} + +/// Update a team's name. +pub struct TeamUpdateTool { + config: Arc, +} +impl TeamUpdateTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for TeamUpdateTool { + fn name(&self) -> &str { + "team_update" + } + fn description(&self) -> &str { + "Update a team (`team_id`) name. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "team_id": { "type": "string" }, "name": { "type": "string" } }, + "required": ["team_id"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let team_id = req_str(&args, "team_id")?; + let name = args.get("name").and_then(Value::as_str); + emit!( + team::update_team(&self.config, &team_id, name).await, + "team_update" + ) + } +} + +/// Join a team via invite code. +pub struct TeamJoinTool { + config: Arc, +} +impl TeamJoinTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for TeamJoinTool { + fn name(&self) -> &str { + "team_join" + } + fn description(&self) -> &str { + "Join a team via an invite `code`. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": { "code": { "type": "string" } }, "required": ["code"] }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let code = req_str(&args, "code")?; + emit!(team::join_team(&self.config, &code).await, "team_join") + } +} + +/// Create a team invite. +pub struct TeamCreateInviteTool { + config: Arc, +} +impl TeamCreateInviteTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for TeamCreateInviteTool { + fn name(&self) -> &str { + "team_create_invite" + } + fn description(&self) -> &str { + "Create an invite for a team (`team_id`), optional `max_uses` and \ + `expires_in_days`. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "team_id": { "type": "string" }, + "max_uses": { "type": "integer", "minimum": 1 }, + "expires_in_days": { "type": "integer", "minimum": 1 } + }, + "required": ["team_id"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let team_id = req_str(&args, "team_id")?; + let max_uses = args.get("max_uses").and_then(Value::as_u64); + let expires = args.get("expires_in_days").and_then(Value::as_u64); + emit!( + team::create_invite(&self.config, &team_id, max_uses, expires).await, + "team_create_invite" + ) + } +} + +/// Revoke a team invite. +pub struct TeamRevokeInviteTool { + config: Arc, +} +impl TeamRevokeInviteTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for TeamRevokeInviteTool { + fn name(&self) -> &str { + "team_revoke_invite" + } + fn description(&self) -> &str { + "Revoke a team invite (`team_id` + `invite_id`). Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "team_id": { "type": "string" }, "invite_id": { "type": "string" } }, + "required": ["team_id", "invite_id"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let team_id = req_str(&args, "team_id")?; + let invite_id = req_str(&args, "invite_id")?; + emit!( + team::revoke_invite(&self.config, &team_id, &invite_id).await, + "team_revoke_invite" + ) + } +} + +/// Remove a team member. +pub struct TeamRemoveMemberTool { + config: Arc, +} +impl TeamRemoveMemberTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for TeamRemoveMemberTool { + fn name(&self) -> &str { + "team_remove_member" + } + fn description(&self) -> &str { + "Remove a member (`user_id`) from a team (`team_id`). Default-OFF \ + (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "team_id": { "type": "string" }, "user_id": { "type": "string" } }, + "required": ["team_id", "user_id"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let team_id = req_str(&args, "team_id")?; + let user_id = req_str(&args, "user_id")?; + emit!( + team::remove_member(&self.config, &team_id, &user_id).await, + "team_remove_member" + ) + } +} + +/// Change a team member's role. +pub struct TeamChangeMemberRoleTool { + config: Arc, +} +impl TeamChangeMemberRoleTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for TeamChangeMemberRoleTool { + fn name(&self) -> &str { + "team_change_member_role" + } + fn description(&self) -> &str { + "Change a team member's `role` (`team_id` + `user_id` + `role`). \ + Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "team_id": { "type": "string" }, + "user_id": { "type": "string" }, + "role": { "type": "string" } + }, + "required": ["team_id", "user_id", "role"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let team_id = req_str(&args, "team_id")?; + let user_id = req_str(&args, "user_id")?; + let role = req_str(&args, "role")?; + emit!( + team::change_member_role(&self.config, &team_id, &user_id, &role).await, + "team_change_member_role" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn names_and_levels() { + assert_eq!(TeamListTool::new(cfg()).name(), "team_list"); + assert_eq!( + TeamListTool::new(cfg()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + TeamCreateTool::new(cfg()).permission_level(), + PermissionLevel::Write + ); + assert_eq!( + TeamDeleteTool::new(cfg()).permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!( + TeamRemoveMemberTool::new(cfg()).permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!(TeamListTool::new(cfg()).scope(), ToolScope::All); + } + + #[tokio::test] + async fn get_requires_team_id() { + let err = TeamGetTool::new(cfg()) + .execute(json!({})) + .await + .expect_err("missing team_id"); + assert!(err.to_string().contains("team_id")); + } +} diff --git a/src/openhuman/threads/mod.rs b/src/openhuman/threads/mod.rs index 84aca8dc6..d4ac7d9f6 100644 --- a/src/openhuman/threads/mod.rs +++ b/src/openhuman/threads/mod.rs @@ -8,6 +8,7 @@ pub mod error; pub mod ops; pub mod schemas; pub mod title; +pub mod tools; pub mod turn_state; pub mod welcome_migration; diff --git a/src/openhuman/threads/tools.rs b/src/openhuman/threads/tools.rs new file mode 100644 index 000000000..9f2a1205a --- /dev/null +++ b/src/openhuman/threads/tools.rs @@ -0,0 +1,746 @@ +//! LLM-callable wrappers over the `threads` domain (conversation threads). +//! +//! These tools let the agent enumerate, create, retitle, relabel, and read +//! conversation threads and their messages, inspect/clear in-flight turn +//! state, and read/write a thread's kanban task board. Most tools deserialize +//! their args into the same request struct the RPC layer uses and delegate to +//! [`crate::openhuman::threads::ops`] (which returns +//! `RpcOutcome>`); the wrapper emits the inner envelope as JSON. +//! +//! Read/bounded-write tools are default-enabled. The destructive ones — +//! `thread_delete` (one thread) and `thread_purge_all` (every thread) — ship +//! default-OFF via `tools/user_filter.rs`. + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use serde_json::json; + +use crate::openhuman::agent::task_board::{ + board_for_thread, TaskBoard, TaskBoardCard, TaskBoardStore, +}; +use crate::openhuman::config::Config; +use crate::openhuman::memory::{ + AppendConversationMessageRequest, ConversationMessagesRequest, CreateConversationThreadRequest, + DeleteConversationThreadRequest, EmptyRequest, GenerateConversationThreadTitleRequest, + UpdateConversationMessageRequest, UpdateConversationThreadLabelsRequest, + UpdateConversationThreadTitleRequest, +}; +use crate::openhuman::threads::ops; +use crate::openhuman::threads::turn_state::{ClearTurnStateRequest, GetTurnStateRequest}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`")) +} + +/// Deserialize tool args into a request struct with a uniform error. +fn parse_req( + args: serde_json::Value, + tool: &str, +) -> anyhow::Result { + serde_json::from_value(args).map_err(|e| anyhow::anyhow!("{tool}: invalid arguments: {e}")) +} + +/// Emit a thread ops `RpcOutcome` envelope as a JSON tool result. +macro_rules! emit { + ($outcome:expr, $name:literal) => {{ + let outcome = $outcome.map_err(|e| anyhow::anyhow!(concat!($name, ": {}"), e))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + }}; +} + +/// List conversation threads. +pub struct ThreadListTool; + +#[async_trait] +impl Tool for ThreadListTool { + fn name(&self) -> &str { + "thread_list" + } + + fn description(&self) -> &str { + "List conversation threads (id, title, labels, timestamps). Use to find \ + a thread id before reading its messages, retitling, or relabeling." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] list invoked"); + emit!(ops::threads_list(EmptyRequest {}).await, "thread_list") + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Read one thread's metadata (filtered from the list). +pub struct ThreadReadTool; + +/// Recursively find the first JSON object whose id field matches `target`. +fn find_thread_obj(value: &serde_json::Value, target: &str) -> Option { + match value { + serde_json::Value::Object(map) => { + for key in ["id", "thread_id", "threadId"] { + if map.get(key).and_then(|v| v.as_str()) == Some(target) { + return Some(value.clone()); + } + } + map.values().find_map(|v| find_thread_obj(v, target)) + } + serde_json::Value::Array(items) => items.iter().find_map(|v| find_thread_obj(v, target)), + _ => None, + } +} + +#[async_trait] +impl Tool for ThreadReadTool { + fn name(&self) -> &str { + "thread_read" + } + + fn description(&self) -> &str { + "Read one conversation thread's metadata by `thread_id` (title, labels, \ + timestamps). For the messages themselves, use `thread_message_list`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "thread_id": { "type": "string", "description": "Thread id." } }, + "required": ["thread_id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] read invoked"); + let thread_id = read_required_str(&args, "thread_id")?; + let outcome = ops::threads_list(EmptyRequest {}) + .await + .map_err(|e| anyhow::anyhow!("thread_read: {e}"))?; + let value = serde_json::to_value(&outcome.value)?; + match find_thread_obj(&value, &thread_id) { + Some(found) => Ok(ToolResult::success(serde_json::to_string(&found)?)), + None => Err(anyhow::anyhow!( + "thread_read: thread `{thread_id}` not found" + )), + } + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Create a new thread. +pub struct ThreadCreateTool; + +#[async_trait] +impl Tool for ThreadCreateTool { + fn name(&self) -> &str { + "thread_create" + } + + fn description(&self) -> &str { + "Create a new conversation thread, optionally with `labels` and a \ + `personality_id`. Returns the new thread summary (including its id)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "labels": { "type": "array", "items": { "type": "string" } }, + "personality_id": { "type": "string" } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] create invoked"); + let req: CreateConversationThreadRequest = parse_req(args, "thread_create")?; + emit!(ops::thread_create_new(req).await, "thread_create") + } +} + +/// Update a thread's title. +pub struct ThreadUpdateTitleTool; + +#[async_trait] +impl Tool for ThreadUpdateTitleTool { + fn name(&self) -> &str { + "thread_update_title" + } + + fn description(&self) -> &str { + "Set a conversation thread's title. Requires `thread_id` and a \ + non-empty `title`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": { "type": "string" }, + "title": { "type": "string" } + }, + "required": ["thread_id", "title"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] update_title invoked"); + let req: UpdateConversationThreadTitleRequest = parse_req(args, "thread_update_title")?; + emit!(ops::thread_update_title(req).await, "thread_update_title") + } +} + +/// Update a thread's labels. +pub struct ThreadUpdateLabelsTool; + +#[async_trait] +impl Tool for ThreadUpdateLabelsTool { + fn name(&self) -> &str { + "thread_update_labels" + } + + fn description(&self) -> &str { + "Replace a conversation thread's labels with the supplied `labels` \ + array (empty clears all labels). Requires `thread_id`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": { "type": "string" }, + "labels": { "type": "array", "items": { "type": "string" } } + }, + "required": ["thread_id", "labels"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] update_labels invoked"); + let req: UpdateConversationThreadLabelsRequest = parse_req(args, "thread_update_labels")?; + emit!(ops::thread_update_labels(req).await, "thread_update_labels") + } +} + +/// List a thread's messages. +pub struct ThreadMessageListTool; + +#[async_trait] +impl Tool for ThreadMessageListTool { + fn name(&self) -> &str { + "thread_message_list" + } + + fn description(&self) -> &str { + "List the messages in a conversation thread by `thread_id`, in order." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "thread_id": { "type": "string" } }, + "required": ["thread_id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] message_list invoked"); + let req: ConversationMessagesRequest = parse_req(args, "thread_message_list")?; + emit!(ops::messages_list(req).await, "thread_message_list") + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Append a message to a thread. +pub struct ThreadMessageAppendTool; + +#[async_trait] +impl Tool for ThreadMessageAppendTool { + fn name(&self) -> &str { + "thread_message_append" + } + + fn description(&self) -> &str { + "Append a message record to a conversation thread. Requires `thread_id` \ + and a `message` object (id, content, type, sender, created_at)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": { "type": "string" }, + "message": { "type": "object", "description": "ConversationMessageRecord." } + }, + "required": ["thread_id", "message"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] message_append invoked"); + let req: AppendConversationMessageRequest = parse_req(args, "thread_message_append")?; + let outcome = ops::message_append(req) + .await + .map_err(|e| anyhow::anyhow!("thread_message_append: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } +} + +/// Patch a message's metadata. +pub struct ThreadMessageUpdateTool; + +#[async_trait] +impl Tool for ThreadMessageUpdateTool { + fn name(&self) -> &str { + "thread_message_update" + } + + fn description(&self) -> &str { + "Patch a message's `extra_metadata` in a thread. Requires `thread_id` \ + and `message_id`; `extra_metadata` is the new metadata object." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": { "type": "string" }, + "message_id": { "type": "string" }, + "extra_metadata": { "type": "object" } + }, + "required": ["thread_id", "message_id"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] message_update invoked"); + let req: UpdateConversationMessageRequest = parse_req(args, "thread_message_update")?; + emit!(ops::message_update(req).await, "thread_message_update") + } +} + +/// AI-generate a thread title. +pub struct ThreadTitleGenerateTool; + +#[async_trait] +impl Tool for ThreadTitleGenerateTool { + fn name(&self) -> &str { + "thread_title_generate" + } + + fn description(&self) -> &str { + "Generate a concise title for a conversation thread using the model, \ + based on its messages. Requires `thread_id`; optional \ + `assistant_message` to bias the title. Skips if a non-placeholder \ + title already exists." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": { "type": "string" }, + "assistant_message": { "type": "string" } + }, + "required": ["thread_id"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + // Runs an inference call. + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] title_generate invoked"); + let req: GenerateConversationThreadTitleRequest = parse_req(args, "thread_title_generate")?; + let outcome = ops::thread_generate_title(req) + .await + .map_err(|e| anyhow::anyhow!("thread_title_generate: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } +} + +/// Read in-flight turn state for one thread. +pub struct ThreadTurnStateGetTool; + +#[async_trait] +impl Tool for ThreadTurnStateGetTool { + fn name(&self) -> &str { + "thread_turn_state_get" + } + + fn description(&self) -> &str { + "Read the saved in-flight turn state for a thread by `thread_id` (the \ + snapshot of an interrupted/streaming agent turn), or null if none." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "thread_id": { "type": "string" } }, + "required": ["thread_id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] turn_state_get invoked"); + let req: GetTurnStateRequest = parse_req(args, "thread_turn_state_get")?; + emit!(ops::turn_state_get(req).await, "thread_turn_state_get") + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// List all saved turn-state snapshots. +pub struct ThreadTurnStateListTool; + +#[async_trait] +impl Tool for ThreadTurnStateListTool { + fn name(&self) -> &str { + "thread_turn_state_list" + } + + fn description(&self) -> &str { + "List all saved in-flight turn-state snapshots across threads." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] turn_state_list invoked"); + emit!( + ops::turn_state_list(EmptyRequest {}).await, + "thread_turn_state_list" + ) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Clear a thread's turn-state snapshot. +pub struct ThreadTurnStateClearTool; + +#[async_trait] +impl Tool for ThreadTurnStateClearTool { + fn name(&self) -> &str { + "thread_turn_state_clear" + } + + fn description(&self) -> &str { + "Clear (delete) the saved in-flight turn-state snapshot for a thread by \ + `thread_id`. Returns whether a snapshot existed." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "thread_id": { "type": "string" } }, + "required": ["thread_id"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] turn_state_clear invoked"); + let req: ClearTurnStateRequest = parse_req(args, "thread_turn_state_clear")?; + emit!(ops::turn_state_clear(req).await, "thread_turn_state_clear") + } +} + +/// Read a thread's kanban task board. +pub struct ThreadTaskBoardReadTool { + config: Arc, +} + +impl ThreadTaskBoardReadTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for ThreadTaskBoardReadTool { + fn name(&self) -> &str { + "thread_task_board_read" + } + + fn description(&self) -> &str { + "Read a thread's kanban task board by `thread_id`, returning its cards \ + (empty board if none exists yet)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "thread_id": { "type": "string" } }, + "required": ["thread_id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] task_board_read invoked"); + let thread_id = read_required_str(&args, "thread_id")?; + let board = board_for_thread(&self.config.workspace_dir, &thread_id) + .map_err(|e| anyhow::anyhow!("thread_task_board_read: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "task_board": board, + }))?)) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Replace a thread's kanban task board. +pub struct ThreadTaskBoardWriteTool { + config: Arc, +} + +impl ThreadTaskBoardWriteTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for ThreadTaskBoardWriteTool { + fn name(&self) -> &str { + "thread_task_board_write" + } + + fn description(&self) -> &str { + "Replace a thread's kanban task board with the supplied `cards` array \ + (TaskBoardCard objects). Requires `thread_id`. The board is normalized \ + and persisted; returns the saved board." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": { "type": "string" }, + "cards": { "type": "array", "items": { "type": "object" } } + }, + "required": ["thread_id", "cards"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] task_board_write invoked"); + let thread_id = read_required_str(&args, "thread_id")?; + let cards_val = args + .get("cards") + .cloned() + .ok_or_else(|| anyhow::anyhow!("missing required array argument `cards`"))?; + let cards: Vec = serde_json::from_value(cards_val) + .map_err(|e| anyhow::anyhow!("thread_task_board_write: invalid cards: {e}"))?; + let board = TaskBoard { + thread_id, + cards, + updated_at: Utc::now().to_rfc3339(), + }; + let saved = TaskBoardStore::new(self.config.workspace_dir.clone()) + .put(board) + .map_err(|e| anyhow::anyhow!("thread_task_board_write: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&json!({ + "task_board": saved, + }))?)) + } +} + +/// Delete one thread. **Irreversible** — default-OFF. +pub struct ThreadDeleteTool; + +#[async_trait] +impl Tool for ThreadDeleteTool { + fn name(&self) -> &str { + "thread_delete" + } + + fn description(&self) -> &str { + "Permanently delete a conversation thread (and its messages + turn \ + state) by `thread_id`. Requires `deleted_at` (RFC3339 timestamp). \ + Irreversible. Only use when the user asks to delete a specific thread." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": { "type": "string" }, + "deleted_at": { "type": "string", "description": "RFC3339 deletion timestamp." } + }, + "required": ["thread_id", "deleted_at"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] delete invoked"); + let req: DeleteConversationThreadRequest = parse_req(args, "thread_delete")?; + emit!(ops::thread_delete(req).await, "thread_delete") + } +} + +/// Purge every thread. **Irreversible** — default-OFF. +pub struct ThreadPurgeAllTool; + +#[async_trait] +impl Tool for ThreadPurgeAllTool { + fn name(&self) -> &str { + "thread_purge_all" + } + + fn description(&self) -> &str { + "Delete EVERY conversation thread and message, and clear all turn-state \ + snapshots. Irreversible and total. Only use when the user explicitly \ + asks to wipe all conversation history." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": {} }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Dangerous + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][threads] purge_all invoked"); + emit!( + ops::threads_purge(EmptyRequest {}).await, + "thread_purge_all" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn names_and_levels() { + assert_eq!(ThreadListTool.name(), "thread_list"); + assert_eq!(ThreadListTool.permission_level(), PermissionLevel::ReadOnly); + assert_eq!(ThreadCreateTool.permission_level(), PermissionLevel::Write); + assert_eq!( + ThreadTitleGenerateTool.permission_level(), + PermissionLevel::Execute + ); + assert_eq!( + ThreadDeleteTool.permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!( + ThreadPurgeAllTool.permission_level(), + PermissionLevel::Dangerous + ); + assert_eq!( + ThreadTaskBoardReadTool::new(cfg()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + ThreadTaskBoardWriteTool::new(cfg()).permission_level(), + PermissionLevel::Write + ); + assert_eq!(ThreadListTool.scope(), ToolScope::All); + } + + #[test] + fn find_thread_obj_matches_nested_id() { + let blob = json!({ + "data": { "threads": [ { "id": "t1", "title": "A" }, { "id": "t2" } ] } + }); + let found = find_thread_obj(&blob, "t2").expect("found t2"); + assert_eq!(found["id"], "t2"); + assert!(find_thread_obj(&blob, "nope").is_none()); + } + + #[tokio::test] + async fn update_title_requires_args() { + let err = ThreadUpdateTitleTool + .execute(json!({ "thread_id": "t1" })) + .await + .expect_err("missing title"); + assert!(err.to_string().contains("thread_update_title")); + } + + #[tokio::test] + async fn delete_requires_deleted_at() { + let err = ThreadDeleteTool + .execute(json!({ "thread_id": "t1" })) + .await + .expect_err("missing deleted_at"); + assert!(err.to_string().contains("thread_delete")); + } + + #[tokio::test] + async fn task_board_read_requires_thread_id() { + let err = ThreadTaskBoardReadTool::new(cfg()) + .execute(json!({})) + .await + .expect_err("missing thread_id"); + assert!(err.to_string().contains("thread_id")); + } +} diff --git a/src/openhuman/todos/mod.rs b/src/openhuman/todos/mod.rs index 63a2f60db..ab6aaefc6 100644 --- a/src/openhuman/todos/mod.rs +++ b/src/openhuman/todos/mod.rs @@ -16,6 +16,7 @@ pub mod ops; pub mod schemas; pub mod store; +pub mod tools; pub use schemas::{ all_controller_schemas as all_todos_controller_schemas, diff --git a/src/openhuman/todos/tools.rs b/src/openhuman/todos/tools.rs new file mode 100644 index 000000000..a9e820c5c --- /dev/null +++ b/src/openhuman/todos/tools.rs @@ -0,0 +1,601 @@ +//! LLM-callable wrappers over the per-thread todo board (`todos` domain). +//! +//! These tools let the agent read and mutate the kanban-style task board +//! that scopes per conversation thread. Each tool is a thin shim over the +//! free functions in [`crate::openhuman::todos::ops`], constructing a +//! [`BoardLocation`] from the optional `thread_id` argument plus the +//! configured workspace dir (falling back to the in-memory scratch board +//! when no thread is supplied). +//! +//! `todo_list` (ReadOnly) and the bounded, reversible writers +//! (`todo_add` / `todo_edit` / `todo_update_status` / `todo_decide_plan`) +//! are default-enabled. The destructive writers — `todo_remove`, +//! `todo_replace`, `todo_clear` — ship default-OFF via +//! `tools/user_filter.rs` because they discard board state. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +use super::ops::{self, BoardLocation, CardPatch, TodosSnapshot}; + +/// Build a [`BoardLocation`] for a tool call: a thread-scoped board when +/// `thread_id` is present, else the process-global scratch board. +fn board_location(config: &Config, args: &serde_json::Value) -> BoardLocation { + match args + .get("thread_id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + Some(thread_id) => BoardLocation::Thread { + workspace_dir: config.workspace_dir.clone(), + thread_id: thread_id.to_string(), + }, + None => BoardLocation::Scratch, + } +} + +fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`")) +} + +fn opt_str(args: &serde_json::Value, key: &str) -> Option { + args.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn opt_str_vec(args: &serde_json::Value, key: &str) -> Option> { + args.get(key) + .and_then(serde_json::Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) +} + +/// Build a [`CardPatch`] from the common optional args shared by add/edit. +fn card_patch(args: &serde_json::Value) -> anyhow::Result { + let status = match opt_str(args, "status") { + Some(raw) => Some(ops::parse_status(&raw).map_err(|e| anyhow::anyhow!(e))?), + None => None, + }; + Ok(CardPatch { + content: opt_str(args, "content"), + status, + objective: opt_str(args, "objective"), + plan: opt_str_vec(args, "plan"), + assigned_agent: opt_str(args, "assigned_agent"), + allowed_tools: opt_str_vec(args, "allowed_tools"), + approval_mode: None, + acceptance_criteria: opt_str_vec(args, "acceptance_criteria"), + evidence: opt_str_vec(args, "evidence"), + notes: opt_str(args, "notes"), + blocker: opt_str(args, "blocker"), + source_metadata: None, + }) +} + +fn snapshot_to_result(snapshot: TodosSnapshot) -> anyhow::Result { + Ok(ToolResult::success(serde_json::to_string(&snapshot)?)) +} + +/// The optional thread-scoping arg, shared by every todo tool. +fn thread_id_prop() -> serde_json::Value { + json!({ + "type": "string", + "description": "Thread id scoping the board. Omit to use the in-memory scratch board for the current session." + }) +} + +/// List the cards on a thread's todo board. +pub struct TodoListTool { + config: Arc, +} + +impl TodoListTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TodoListTool { + fn name(&self) -> &str { + "todo_list" + } + + fn description(&self) -> &str { + "List the todo cards on a thread's task board, with a markdown \ + rendering. Use to review outstanding/completed work before adding or \ + updating tasks. Each card has an `id`, `content`, `status`, and \ + optional objective/plan/notes/blocker fields." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": { "thread_id": thread_id_prop() } }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][todos] list invoked"); + let location = board_location(&self.config, &args); + let snapshot = ops::list(&location).map_err(|e| anyhow::anyhow!("todo_list: {e}"))?; + snapshot_to_result(snapshot) + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Add a new card to a thread's todo board. +pub struct TodoAddTool { + config: Arc, +} + +impl TodoAddTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TodoAddTool { + fn name(&self) -> &str { + "todo_add" + } + + fn description(&self) -> &str { + "Add a todo card to a thread's task board. `content` is the task \ + summary; optional fields capture an `objective`, an ordered `plan`, \ + `acceptance_criteria`, an `assigned_agent`, `allowed_tools`, free-form \ + `notes`, a `blocker`, and an initial `status` \ + (todo|awaiting_approval|ready|in_progress|blocked|done|rejected)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": thread_id_prop(), + "content": { "type": "string", "description": "Task summary (required)." }, + "status": { "type": "string", "description": "Initial status (default todo)." }, + "objective": { "type": "string" }, + "plan": { "type": "array", "items": { "type": "string" } }, + "acceptance_criteria": { "type": "array", "items": { "type": "string" } }, + "assigned_agent": { "type": "string" }, + "allowed_tools": { "type": "array", "items": { "type": "string" } }, + "evidence": { "type": "array", "items": { "type": "string" } }, + "notes": { "type": "string" }, + "blocker": { "type": "string" } + }, + "required": ["content"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][todos] add invoked"); + let content = read_required_str(&args, "content")?; + let location = board_location(&self.config, &args); + let patch = card_patch(&args)?; + let snapshot = + ops::add(&location, &content, patch).map_err(|e| anyhow::anyhow!("todo_add: {e}"))?; + snapshot_to_result(snapshot) + } +} + +/// Edit an existing card (partial update). +pub struct TodoEditTool { + config: Arc, +} + +impl TodoEditTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TodoEditTool { + fn name(&self) -> &str { + "todo_edit" + } + + fn description(&self) -> &str { + "Edit fields on an existing todo card by `id`. Only the fields you \ + supply are changed; omitted fields are left untouched. Same field set \ + as `todo_add` (content/status/objective/plan/notes/blocker/…)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": thread_id_prop(), + "id": { "type": "string", "description": "Card id to edit (required)." }, + "content": { "type": "string" }, + "status": { "type": "string" }, + "objective": { "type": "string" }, + "plan": { "type": "array", "items": { "type": "string" } }, + "acceptance_criteria": { "type": "array", "items": { "type": "string" } }, + "assigned_agent": { "type": "string" }, + "allowed_tools": { "type": "array", "items": { "type": "string" } }, + "evidence": { "type": "array", "items": { "type": "string" } }, + "notes": { "type": "string" }, + "blocker": { "type": "string" } + }, + "required": ["id"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][todos] edit invoked"); + let id = read_required_str(&args, "id")?; + let location = board_location(&self.config, &args); + let patch = card_patch(&args)?; + let snapshot = + ops::edit(&location, &id, patch).map_err(|e| anyhow::anyhow!("todo_edit: {e}"))?; + snapshot_to_result(snapshot) + } +} + +/// Transition a card's status. +pub struct TodoUpdateStatusTool { + config: Arc, +} + +impl TodoUpdateStatusTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TodoUpdateStatusTool { + fn name(&self) -> &str { + "todo_update_status" + } + + fn description(&self) -> &str { + "Transition a todo card to a new `status` \ + (todo|awaiting_approval|ready|in_progress|blocked|done|rejected). Use \ + to mark work started, blocked, or completed." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": thread_id_prop(), + "id": { "type": "string", "description": "Card id (required)." }, + "status": { "type": "string", "description": "New status (required)." } + }, + "required": ["id", "status"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][todos] update_status invoked"); + let id = read_required_str(&args, "id")?; + let status_raw = read_required_str(&args, "status")?; + let status = ops::parse_status(&status_raw).map_err(|e| anyhow::anyhow!(e))?; + let location = board_location(&self.config, &args); + let snapshot = ops::update_status(&location, &id, status) + .map_err(|e| anyhow::anyhow!("todo_update_status: {e}"))?; + snapshot_to_result(snapshot) + } +} + +/// Approve or reject a gated plan card. +pub struct TodoDecidePlanTool { + config: Arc, +} + +impl TodoDecidePlanTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TodoDecidePlanTool { + fn name(&self) -> &str { + "todo_decide_plan" + } + + fn description(&self) -> &str { + "Approve (`approve: true`) or reject (`approve: false`) a card that is \ + awaiting plan approval. Approving moves it to ready; rejecting moves \ + it to rejected." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": thread_id_prop(), + "id": { "type": "string", "description": "Card id (required)." }, + "approve": { "type": "boolean", "description": "Approve or reject the plan (required)." } + }, + "required": ["id", "approve"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][todos] decide_plan invoked"); + let id = read_required_str(&args, "id")?; + let approve = args + .get("approve") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| anyhow::anyhow!("missing required boolean argument `approve`"))?; + let location = board_location(&self.config, &args); + let snapshot = ops::decide_plan(&location, &id, approve) + .map_err(|e| anyhow::anyhow!("todo_decide_plan: {e}"))?; + snapshot_to_result(snapshot) + } +} + +/// Remove a single card. **Destructive** — default-OFF. +pub struct TodoRemoveTool { + config: Arc, +} + +impl TodoRemoveTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TodoRemoveTool { + fn name(&self) -> &str { + "todo_remove" + } + + fn description(&self) -> &str { + "Permanently remove a single todo card by `id` from a thread's board. \ + This is irreversible. Prefer `todo_update_status` to `done`/`rejected` \ + over deleting, unless the user wants the card gone entirely." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": thread_id_prop(), + "id": { "type": "string", "description": "Card id to remove (required)." } + }, + "required": ["id"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][todos] remove invoked"); + let id = read_required_str(&args, "id")?; + let location = board_location(&self.config, &args); + let snapshot = + ops::remove(&location, &id).map_err(|e| anyhow::anyhow!("todo_remove: {e}"))?; + snapshot_to_result(snapshot) + } +} + +/// Replace the entire board with a supplied card set. **Destructive** — +/// default-OFF. +pub struct TodoReplaceTool { + config: Arc, +} + +impl TodoReplaceTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TodoReplaceTool { + fn name(&self) -> &str { + "todo_replace" + } + + fn description(&self) -> &str { + "Wholesale-replace a thread's todo board with the supplied `cards` \ + array, discarding the previous contents. Irreversible. Use only when \ + rebuilding a board from scratch." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "thread_id": thread_id_prop(), + "cards": { + "type": "array", + "description": "Full replacement card set (TaskBoardCard objects).", + "items": { "type": "object" } + } + }, + "required": ["cards"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][todos] replace invoked"); + let cards_val = args + .get("cards") + .cloned() + .ok_or_else(|| anyhow::anyhow!("missing required array argument `cards`"))?; + let cards = serde_json::from_value(cards_val) + .map_err(|e| anyhow::anyhow!("todo_replace: invalid cards: {e}"))?; + let location = board_location(&self.config, &args); + let snapshot = + ops::replace(&location, cards).map_err(|e| anyhow::anyhow!("todo_replace: {e}"))?; + snapshot_to_result(snapshot) + } +} + +/// Empty the board. **Destructive** — default-OFF. +pub struct TodoClearTool { + config: Arc, +} + +impl TodoClearTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[async_trait] +impl Tool for TodoClearTool { + fn name(&self) -> &str { + "todo_clear" + } + + fn description(&self) -> &str { + "Remove every card from a thread's todo board, leaving it empty. \ + Irreversible. Only use when the user wants to clear the whole board." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object", "properties": { "thread_id": thread_id_prop() } }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][todos] clear invoked"); + let location = board_location(&self.config, &args); + let snapshot = ops::clear(&location).map_err(|e| anyhow::anyhow!("todo_clear: {e}"))?; + snapshot_to_result(snapshot) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn names_and_levels() { + let c = cfg(); + assert_eq!(TodoListTool::new(c.clone()).name(), "todo_list"); + assert_eq!( + TodoListTool::new(c.clone()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + TodoAddTool::new(c.clone()).permission_level(), + PermissionLevel::Write + ); + assert_eq!( + TodoRemoveTool::new(c.clone()).permission_level(), + PermissionLevel::Write + ); + assert_eq!(TodoListTool::new(c).scope(), ToolScope::All); + } + + #[test] + fn board_location_prefers_thread_then_scratch() { + let c = cfg(); + let with_thread = board_location(&c, &json!({ "thread_id": "abc" })); + assert_eq!(with_thread.thread_id(), Some("abc")); + let scratch = board_location(&c, &json!({ "thread_id": " " })); + assert!(matches!(scratch, BoardLocation::Scratch)); + let absent = board_location(&c, &json!({})); + assert!(matches!(absent, BoardLocation::Scratch)); + } + + #[test] + fn card_patch_parses_fields() { + let patch = card_patch(&json!({ + "content": "do it", + "status": "in_progress", + "plan": ["a", "b"], + "notes": "n" + })) + .expect("patch"); + assert_eq!(patch.content.as_deref(), Some("do it")); + assert!(patch.status.is_some()); + assert_eq!(patch.plan.as_ref().map(|p| p.len()), Some(2)); + } + + #[test] + fn card_patch_rejects_bad_status() { + let err = card_patch(&json!({ "status": "nope" })).expect_err("bad status"); + assert!(err.to_string().contains("invalid status")); + } + + #[tokio::test] + async fn add_requires_content() { + let err = TodoAddTool::new(cfg()) + .execute(json!({})) + .await + .expect_err("missing content"); + assert!(err.to_string().contains("content")); + } + + #[tokio::test] + async fn scratch_board_add_then_list_roundtrips() { + // Using the scratch board (no thread_id) avoids any filesystem + // dependency, exercising the full add → list path deterministically. + let c = cfg(); + let added = TodoAddTool::new(c.clone()) + .execute(json!({ "content": "scratch task" })) + .await + .expect("add"); + assert!(added.output_for_llm(false).contains("scratch task")); + let listed = TodoListTool::new(c).execute(json!({})).await.expect("list"); + assert!(listed.output_for_llm(false).contains("scratch task")); + } + + #[tokio::test] + async fn decide_plan_requires_approve_bool() { + let err = TodoDecidePlanTool::new(cfg()) + .execute(json!({ "id": "x" })) + .await + .expect_err("missing approve"); + assert!(err.to_string().contains("approve")); + } +} diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 506f7658f..0f05d760f 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -13,15 +13,37 @@ pub(crate) mod implementations; pub use crate::openhuman::agent::tools::*; pub use crate::openhuman::agent_orchestration::tools::*; +pub use crate::openhuman::agent_workflows::tools::*; +pub use crate::openhuman::artifacts::tools::*; pub use crate::openhuman::audio_toolkit::tools::*; +pub use crate::openhuman::billing::tools::*; pub use crate::openhuman::codegraph::tools::*; pub use crate::openhuman::composio::tools::*; +pub use crate::openhuman::config::tools::*; +pub use crate::openhuman::cost::tools::*; +pub use crate::openhuman::credentials::tools::*; pub use crate::openhuman::cron::tools::*; +pub use crate::openhuman::dashboard::tools::*; +pub use crate::openhuman::doctor::tools::*; +pub use crate::openhuman::health::tools::*; pub use crate::openhuman::integrations::tools::*; +pub use crate::openhuman::learning::tools::*; +pub use crate::openhuman::mcp_registry::tools::*; pub use crate::openhuman::memory::tools::*; +pub use crate::openhuman::people::tools::*; +pub use crate::openhuman::referral::tools::*; +pub use crate::openhuman::screen_intelligence::tools::*; pub use crate::openhuman::search::tools::*; +pub use crate::openhuman::security::tools::*; +pub use crate::openhuman::service::tools::*; +pub use crate::openhuman::skills::tools::*; +pub use crate::openhuman::task_sources::tools::*; +pub use crate::openhuman::team::tools::*; +pub use crate::openhuman::threads::tools::*; +pub use crate::openhuman::todos::tools::*; pub use crate::openhuman::wallet::tools::*; pub use crate::openhuman::whatsapp_data::tools::*; +pub use crate::openhuman::workspace::tools::*; pub use implementations::*; pub use ops::*; pub use policy::{DefaultToolPolicy, PolicyDecision, ToolPolicy}; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index cc6d6400f..43c5da7d5 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -249,6 +249,200 @@ pub fn all_tools_with_runtime( Arc::clone(&runtime), Arc::clone(&audit), )), + // Knowledge & memory tools (agent-tool expansion). Read/bounded-write + // ship default-ON; the overextending siblings (people_refresh_address_book — + // bulk OS contacts ingest with a permission prompt) ship default-OFF via + // `tools::user_filter`. (The vault domain was removed upstream in #3040.) + Box::new(PeopleListTool), + Box::new(PeopleResolveTool), + Box::new(PeopleScoreTool), + Box::new(PeopleGetTool), + Box::new(PeopleAddAliasTool), + Box::new(PeopleRecordInteractionTool), + Box::new(PeopleRefreshAddressBookTool), + // Skills metadata tools. `skill_run` is already exposed by RunSkillTool + // above, so it is not duplicated. Reads ship default-ON; the + // create/install/uninstall mutators ship default-OFF via + // `tools::user_filter` (install also fetches remote content). + Box::new(SkillListTool::new(config.clone())), + Box::new(SkillDescribeTool::new(config.clone())), + Box::new(SkillReadResourceTool::new(config.clone())), + Box::new(SkillRecentRunsTool::new(config.clone())), + Box::new(SkillReadRunLogTool::new(config.clone())), + Box::new(SkillCreateTool::new(config.clone())), + Box::new(SkillInstallFromUrlTool::new(config.clone())), + Box::new(SkillUninstallTool), + // Threads (conversation) tools. Read/bounded-write ship default-ON; + // the destructive thread_delete / thread_purge_all ship default-OFF + // via `tools::user_filter` (thread_destructive toggle). + Box::new(ThreadListTool), + Box::new(ThreadReadTool), + Box::new(ThreadCreateTool), + Box::new(ThreadUpdateTitleTool), + Box::new(ThreadUpdateLabelsTool), + Box::new(ThreadMessageListTool), + Box::new(ThreadMessageAppendTool), + Box::new(ThreadMessageUpdateTool), + Box::new(ThreadTitleGenerateTool), + Box::new(ThreadTurnStateGetTool), + Box::new(ThreadTurnStateListTool), + Box::new(ThreadTurnStateClearTool), + Box::new(ThreadTaskBoardReadTool::new(config.clone())), + Box::new(ThreadTaskBoardWriteTool::new(config.clone())), + Box::new(ThreadDeleteTool), + Box::new(ThreadPurgeAllTool), + // Learning (user-profile facet cache) tools. Reads ship default-ON; + // every mutator ships default-OFF via `tools::user_filter` + // (learning_manage toggle) — they persistently rewrite the assistant's + // model of the user. enrich_profile also flags external_effect. + Box::new(LearningListFacetsTool), + Box::new(LearningGetFacetTool), + Box::new(LearningCacheStatsTool), + Box::new(LearningUpdateFacetTool), + Box::new(LearningPinFacetTool), + Box::new(LearningUnpinFacetTool), + Box::new(LearningForgetFacetTool), + Box::new(LearningRebuildCacheTool), + Box::new(LearningResetCacheTool), + Box::new(LearningSaveProfileTool), + Box::new(LearningEnrichProfileTool), + // Task & workflow productivity tools (issue: agent-tool expansion). + // Read/observe + bounded-write tools are registered here; the + // destructive/overextending siblings (artifact_delete, todo_remove/ + // replace/clear, task_source_add/update/remove, + // agent_workflow_uninstall) are registered too but ship default-OFF + // via `tools::user_filter` (their toggle IDs default off in + // onboarding). The per-call permission ladder still gates them. + Box::new(AgentWorkflowListTool::new(config.clone())), + Box::new(AgentWorkflowReadTool), + Box::new(AgentWorkflowPhaseInfoTool), + Box::new(AgentWorkflowCreateTool), + Box::new(AgentWorkflowUninstallTool), + Box::new(ArtifactListTool::new(config.clone())), + Box::new(ArtifactGetTool::new(config.clone())), + Box::new(ArtifactDeleteTool::new(config.clone())), + Box::new(TodoListTool::new(config.clone())), + Box::new(TodoAddTool::new(config.clone())), + Box::new(TodoEditTool::new(config.clone())), + Box::new(TodoUpdateStatusTool::new(config.clone())), + Box::new(TodoDecidePlanTool::new(config.clone())), + Box::new(TodoRemoveTool::new(config.clone())), + Box::new(TodoReplaceTool::new(config.clone())), + Box::new(TodoClearTool::new(config.clone())), + Box::new(TaskSourceListTool::new(config.clone())), + Box::new(TaskSourceGetTool::new(config.clone())), + Box::new(TaskSourceFetchTool::new(config.clone())), + Box::new(TaskSourceListTasksTool::new(config.clone())), + Box::new(TaskSourcePreviewFilterTool::new(config.clone())), + Box::new(TaskSourceStatusTool::new(config.clone())), + Box::new(TaskSourceAddTool::new(config.clone())), + Box::new(TaskSourceUpdateTool::new(config.clone())), + Box::new(TaskSourceRemoveTool::new(config.clone())), + // System & self-management: observability (default-ON) + service + // lifecycle. doctor/health/cost/dashboard/security reads are default-ON. + // service_status / daemon_host_prefs_get default-ON; the lifecycle + // mutators ship default-OFF via `tools::user_filter` (service_lifecycle). + Box::new(DoctorHealthTool::new(config.clone())), + Box::new(DoctorModelsTool::new(config.clone())), + Box::new(HealthSnapshotTool), + Box::new(HealthSystemInfoTool), + Box::new(CostDashboardTool::new(config.clone())), + Box::new(CostDailyHistoryTool::new(config.clone())), + Box::new(CostSummaryTool::new(config.clone())), + Box::new(DashboardModelHealthTool::new(config.clone())), + Box::new(SecurityPolicyInfoTool::new(config.clone())), + Box::new(ServiceStatusTool::new(config.clone())), + Box::new(DaemonHostPrefsGetTool::new(config.clone())), + Box::new(ServiceStartTool::new(config.clone())), + Box::new(ServiceStopTool::new(config.clone())), + Box::new(ServiceRestartTool), + Box::new(ServiceShutdownTool), + Box::new(ServiceInstallTool::new(config.clone())), + Box::new(ServiceUninstallTool::new(config.clone())), + Box::new(DaemonHostPrefsSetTool::new(config.clone())), + // Config: read-only surface (default-ON). The config_update_* mutators + // are deferred (their apply fns take non-Deserialize patch structs); + // see config/tools.rs. + Box::new(ConfigSnapshotTool::new(config.clone())), + Box::new(ConfigClientConfigTool), + Box::new(ConfigAutonomyTool), + Box::new(ConfigSearchTool), + Box::new(ConfigRuntimeFlagsTool), + Box::new(ConfigResolveApiUrlTool), + Box::new(ConfigDataPathsTool), + // Account & money. Reads default-ON; billing money-movers (billing_writes) + // and team admin ops (team_admin) ship default-OFF via `tools::user_filter`. + // credentials exposes only non-secret reads. + Box::new(ReferralStatsTool::new(config.clone())), + Box::new(ReferralClaimTool::new(config.clone())), + Box::new(BillingPlanTool::new(config.clone())), + Box::new(BillingBalanceTool::new(config.clone())), + Box::new(BillingTransactionsTool::new(config.clone())), + Box::new(BillingAutoRechargeTool::new(config.clone())), + Box::new(BillingCardsTool::new(config.clone())), + Box::new(BillingCouponsTool::new(config.clone())), + Box::new(BillingPortalTool::new(config.clone())), + Box::new(BillingPurchasePlanTool::new(config.clone())), + Box::new(BillingTopUpTool::new(config.clone())), + Box::new(BillingCoinbaseChargeTool::new(config.clone())), + Box::new(BillingSetupIntentTool::new(config.clone())), + Box::new(BillingUpdateCardTool::new(config.clone())), + Box::new(BillingDeleteCardTool::new(config.clone())), + Box::new(BillingRedeemCouponTool::new(config.clone())), + Box::new(BillingUpdateAutoRechargeTool::new(config.clone())), + Box::new(TeamListTool::new(config.clone())), + Box::new(TeamUsageTool::new(config.clone())), + Box::new(TeamGetTool::new(config.clone())), + Box::new(TeamListMembersTool::new(config.clone())), + Box::new(TeamListInvitesTool::new(config.clone())), + Box::new(TeamCreateTool::new(config.clone())), + Box::new(TeamUpdateTool::new(config.clone())), + Box::new(TeamDeleteTool::new(config.clone())), + Box::new(TeamSwitchTool::new(config.clone())), + Box::new(TeamJoinTool::new(config.clone())), + Box::new(TeamLeaveTool::new(config.clone())), + Box::new(TeamCreateInviteTool::new(config.clone())), + Box::new(TeamRevokeInviteTool::new(config.clone())), + Box::new(TeamRemoveMemberTool::new(config.clone())), + Box::new(TeamChangeMemberRoleTool::new(config.clone())), + Box::new(CredentialListTool::new(config.clone())), + Box::new(SessionStateTool::new(config.clone())), + Box::new(SessionGetUserTool::new(config.clone())), + Box::new(OAuthConnectUrlTool::new(config.clone())), + Box::new(OAuthListTool::new(config.clone())), + // Desktop perception, MCP registry, workspace persona. Observe/connect/ + // call tools default-ON; OS permission prompts (screen_permissions), + // MCP install/uninstall (mcp_manage), and persona/workspace writers + // (workspace_manage) ship default-OFF via `tools::user_filter`. + Box::new(ScreenStatusTool), + Box::new(ScreenCaptureImageRefTool), + Box::new(ScreenVisionRecentTool), + Box::new(ScreenVisionFlushTool), + Box::new(ScreenRefreshPermissionsTool), + Box::new(ScreenCaptureNowTool), + Box::new(ScreenCaptureTestTool), + Box::new(ScreenSessionStartTool), + Box::new(ScreenSessionStopTool), + Box::new(ScreenInputActionTool), + Box::new(ScreenGlobeStartTool), + Box::new(ScreenGlobePollTool), + Box::new(ScreenGlobeStopTool), + Box::new(ScreenRequestPermissionsTool), + Box::new(ScreenRequestPermissionTool), + Box::new(McpRegistrySearchTool::new(config.clone())), + Box::new(McpRegistryGetTool::new(config.clone())), + Box::new(McpRegistryInstalledListTool::new(config.clone())), + Box::new(McpRegistryStatusTool::new(config.clone())), + Box::new(McpRegistryConnectTool::new(config.clone())), + Box::new(McpRegistryDisconnectTool), + Box::new(McpRegistryToolCallTool), + Box::new(McpRegistryConfigAssistTool::new(config.clone())), + Box::new(McpRegistryInstallTool::new(config.clone())), + Box::new(McpRegistryUninstallTool::new(config.clone())), + Box::new(WorkspaceReadPersonaTool::new(config.clone())), + Box::new(WorkspaceUpdatePersonaTool::new(config.clone())), + Box::new(WorkspaceResetPersonaTool::new(config.clone())), + Box::new(WorkspaceInitTool), ]; if browser_config.enabled { diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 30e963a0a..56efbe1ad 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -1474,3 +1474,622 @@ async fn readonly_acting_tools_carry_policy_blocked_marker() { ); } } + +// ── Agent-tool expansion: shared e2e harness ──────────────────────────────── +// +// Both themes (Task & workflow productivity; Knowledge & memory) exercise the +// full `all_tools` registry: that every tool registers, that the overextending +// siblings are stripped by the user-filter when not opted in (and restored +// when opted in), and a couple of real executions through the boxed `dyn Tool` +// surface. + +/// Build the full tool registry with a disabled browser and a tmp-scoped +/// workspace — enough to exercise the expansion tools end-to-end. +fn expansion_tools_for(tmp: &TempDir) -> Vec> { + let security = Arc::new(SecurityPolicy::default()); + let mem = test_memory(tmp); + let browser = BrowserConfig { + enabled: false, + allowed_domains: vec![], + session_name: None, + ..BrowserConfig::default() + }; + let http = crate::openhuman::config::HttpRequestConfig::default(); + let cfg = test_config(tmp); + all_tools( + Arc::new(cfg.clone()), + &security, + AuditLogger::disabled(), + mem, + &browser, + &http, + tmp.path(), + &HashMap::new(), + &cfg, + ) +} + +// ── Theme: Task & workflow productivity ───────────────────────────────────── + +const PRODUCTIVITY_TOOLS: &[&str] = &[ + "agent_workflow_list", + "agent_workflow_read", + "agent_workflow_phase_info", + "agent_workflow_create", + "agent_workflow_uninstall", + "artifact_list", + "artifact_get", + "artifact_delete", + "todo_list", + "todo_add", + "todo_edit", + "todo_update_status", + "todo_decide_plan", + "todo_remove", + "todo_replace", + "todo_clear", + "task_source_list", + "task_source_get", + "task_source_fetch", + "task_source_list_tasks", + "task_source_preview_filter", + "task_source_status", + "task_source_add", + "task_source_update", + "task_source_remove", +]; + +const PRODUCTIVITY_DEFAULT_OFF: &[&str] = &[ + "agent_workflow_uninstall", + "artifact_delete", + "todo_remove", + "todo_replace", + "todo_clear", + "task_source_add", + "task_source_update", + "task_source_remove", +]; + +const PRODUCTIVITY_ALWAYS_ON: &[&str] = &[ + "agent_workflow_list", + "agent_workflow_create", + "artifact_list", + "artifact_get", + "todo_list", + "todo_add", + "task_source_fetch", + "task_source_status", +]; + +#[test] +fn productivity_tools_are_registered() { + let tmp = TempDir::new().unwrap(); + let names = tool_names(&expansion_tools_for(&tmp)); + assert_contains_all(&names, PRODUCTIVITY_TOOLS); +} + +#[test] +fn productivity_default_off_tools_are_filtered_when_not_opted_in() { + let tmp = TempDir::new().unwrap(); + let mut tools = expansion_tools_for(&tmp); + filter_tools_by_user_preference(&mut tools, &["file_read".to_string()]); + let names = tool_names(&tools); + for off in PRODUCTIVITY_DEFAULT_OFF { + assert!( + !names.iter().any(|n| n == off), + "default-off tool `{off}` must be filtered out when not opted in; got: {names:?}" + ); + } + for on in PRODUCTIVITY_ALWAYS_ON { + assert!( + names.iter().any(|n| n == on), + "always-on tool `{on}` must be retained regardless of preferences" + ); + } +} + +#[test] +fn productivity_default_off_tools_retained_when_opted_in() { + let tmp = TempDir::new().unwrap(); + let mut tools = expansion_tools_for(&tmp); + filter_tools_by_user_preference( + &mut tools, + &[ + "todo_destructive".to_string(), + "task_source_manage".to_string(), + "artifact_delete".to_string(), + "agent_workflow_uninstall".to_string(), + ], + ); + let names = tool_names(&tools); + for on in PRODUCTIVITY_DEFAULT_OFF { + assert!( + names.iter().any(|n| n == on), + "opted-in tool `{on}` must be retained; got: {names:?}" + ); + } +} + +#[tokio::test] +async fn todo_tools_add_then_list_through_registry() { + // Drive the boxed `dyn Tool` surface exactly as the agent loop would: add + // a card, then list it back. Thread-scoped (file-backed under the tmp + // workspace) so the board is isolated from the process-global scratch + // store and from parallel tests. + let tmp = TempDir::new().unwrap(); + let tools = expansion_tools_for(&tmp); + + let add = find_tool(&tools, "todo_add"); + let added = add + .execute(serde_json::json!({ "thread_id": "e2e-thread", "content": "registry e2e task" })) + .await + .expect("todo_add execute"); + assert!(added.output_for_llm(false).contains("registry e2e task")); + + let list = find_tool(&tools, "todo_list"); + let listed = list + .execute(serde_json::json!({ "thread_id": "e2e-thread" })) + .await + .expect("todo_list execute"); + assert!(listed.output_for_llm(false).contains("registry e2e task")); +} + +#[tokio::test] +async fn artifact_list_through_registry_returns_envelope() { + let tmp = TempDir::new().unwrap(); + let tools = expansion_tools_for(&tmp); + let out = find_tool(&tools, "artifact_list") + .execute(serde_json::json!({ "limit": 10 })) + .await + .expect("artifact_list execute"); + let body = out.output_for_llm(false); + assert!(body.contains("artifacts"), "envelope missing: {body}"); + assert!(body.contains("total"), "envelope missing total: {body}"); +} + +// ── Theme: Knowledge & memory ─────────────────────────────────────────────── + +const KNOWLEDGE_TOOLS: &[&str] = &[ + "people_list", + "people_resolve", + "people_score", + "people_get", + "people_add_alias", + "people_record_interaction", + "people_refresh_address_book", + "skill_list", + "skill_describe", + "skill_read_resource", + "skill_recent_runs", + "skill_read_run_log", + "skill_create", + "skill_install_from_url", + "skill_uninstall", + "thread_list", + "thread_read", + "thread_create", + "thread_update_title", + "thread_update_labels", + "thread_message_list", + "thread_message_append", + "thread_message_update", + "thread_title_generate", + "thread_turn_state_get", + "thread_turn_state_list", + "thread_turn_state_clear", + "thread_task_board_read", + "thread_task_board_write", + "thread_delete", + "thread_purge_all", + "learning_list_facets", + "learning_get_facet", + "learning_cache_stats", + "learning_update_facet", + "learning_pin_facet", + "learning_unpin_facet", + "learning_forget_facet", + "learning_rebuild_cache", + "learning_reset_cache", + "learning_save_profile", + "learning_enrich_profile", +]; + +const KNOWLEDGE_DEFAULT_OFF: &[&str] = &[ + "people_refresh_address_book", + "skill_create", + "skill_install_from_url", + "skill_uninstall", + "thread_delete", + "thread_purge_all", + "learning_update_facet", + "learning_pin_facet", + "learning_unpin_facet", + "learning_forget_facet", + "learning_rebuild_cache", + "learning_reset_cache", + "learning_save_profile", + "learning_enrich_profile", +]; + +const KNOWLEDGE_ALWAYS_ON: &[&str] = &[ + "people_list", + "people_resolve", + "skill_list", + "skill_recent_runs", + "thread_list", + "thread_create", + "learning_list_facets", + "learning_cache_stats", +]; + +#[test] +fn knowledge_tools_are_registered() { + let tmp = TempDir::new().unwrap(); + let names = tool_names(&expansion_tools_for(&tmp)); + assert_contains_all(&names, KNOWLEDGE_TOOLS); +} + +#[test] +fn knowledge_default_off_tools_are_filtered_when_not_opted_in() { + let tmp = TempDir::new().unwrap(); + let mut tools = expansion_tools_for(&tmp); + filter_tools_by_user_preference(&mut tools, &["file_read".to_string()]); + let names = tool_names(&tools); + for off in KNOWLEDGE_DEFAULT_OFF { + assert!( + !names.iter().any(|n| n == off), + "default-off tool `{off}` must be filtered out when not opted in; got: {names:?}" + ); + } + for on in KNOWLEDGE_ALWAYS_ON { + assert!( + names.iter().any(|n| n == on), + "always-on tool `{on}` must be retained regardless of preferences" + ); + } +} + +#[test] +fn knowledge_default_off_tools_retained_when_opted_in() { + let tmp = TempDir::new().unwrap(); + let mut tools = expansion_tools_for(&tmp); + filter_tools_by_user_preference( + &mut tools, + &[ + "people_refresh_address_book".to_string(), + "skill_manage".to_string(), + "thread_destructive".to_string(), + "learning_manage".to_string(), + ], + ); + let names = tool_names(&tools); + for on in KNOWLEDGE_DEFAULT_OFF { + assert!( + names.iter().any(|n| n == on), + "opted-in tool `{on}` must be retained; got: {names:?}" + ); + } +} + +// ── Theme: System & self-management (observability + service) ─────────────── + +const SYSTEM_TOOLS: &[&str] = &[ + "doctor_health", + "doctor_models", + "health_snapshot", + "health_system_info", + "cost_get_dashboard", + "cost_get_daily_history", + "cost_get_summary", + "dashboard_model_health", + "security_policy_info", + "service_status", + "daemon_host_prefs_get", + "service_start", + "service_stop", + "service_restart", + "service_shutdown", + "service_install", + "service_uninstall", + "daemon_host_prefs_set", + "config_snapshot", + "config_get_client_config", + "config_get_autonomy", + "config_get_search", + "config_get_runtime_flags", + "config_resolve_api_url", + "config_get_data_paths", +]; + +const SYSTEM_DEFAULT_OFF: &[&str] = &[ + "service_start", + "service_stop", + "service_restart", + "service_shutdown", + "service_install", + "service_uninstall", + "daemon_host_prefs_set", +]; + +const SYSTEM_ALWAYS_ON: &[&str] = &[ + "doctor_health", + "health_snapshot", + "cost_get_summary", + "dashboard_model_health", + "security_policy_info", + "service_status", + "daemon_host_prefs_get", + "config_snapshot", + "config_get_autonomy", +]; + +#[test] +fn system_tools_are_registered() { + let tmp = TempDir::new().unwrap(); + let names = tool_names(&expansion_tools_for(&tmp)); + assert_contains_all(&names, SYSTEM_TOOLS); +} + +#[test] +fn system_default_off_tools_are_filtered_when_not_opted_in() { + let tmp = TempDir::new().unwrap(); + let mut tools = expansion_tools_for(&tmp); + filter_tools_by_user_preference(&mut tools, &["file_read".to_string()]); + let names = tool_names(&tools); + for off in SYSTEM_DEFAULT_OFF { + assert!( + !names.iter().any(|n| n == off), + "default-off tool `{off}` must be filtered out when not opted in; got: {names:?}" + ); + } + for on in SYSTEM_ALWAYS_ON { + assert!( + names.iter().any(|n| n == on), + "always-on tool `{on}` must be retained regardless of preferences" + ); + } +} + +#[test] +fn system_default_off_tools_retained_when_opted_in() { + let tmp = TempDir::new().unwrap(); + let mut tools = expansion_tools_for(&tmp); + filter_tools_by_user_preference(&mut tools, &["service_lifecycle".to_string()]); + let names = tool_names(&tools); + for on in SYSTEM_DEFAULT_OFF { + assert!( + names.iter().any(|n| n == on), + "opted-in tool `{on}` must be retained; got: {names:?}" + ); + } +} + +#[tokio::test] +async fn health_system_info_through_registry() { + let tmp = TempDir::new().unwrap(); + let tools = expansion_tools_for(&tmp); + let out = find_tool(&tools, "health_system_info") + .execute(serde_json::json!({})) + .await + .expect("health_system_info"); + assert!(out.output_for_llm(false).contains("os")); +} + +// ── Theme: Account & money ────────────────────────────────────────────────── + +const MONEY_TOOLS: &[&str] = &[ + "referral_get_stats", + "referral_claim", + "billing_get_plan", + "billing_get_balance", + "billing_list_transactions", + "billing_get_auto_recharge", + "billing_list_cards", + "billing_list_coupons", + "billing_create_stripe_portal", + "billing_purchase_plan", + "billing_top_up_credits", + "billing_create_coinbase_charge", + "billing_create_setup_intent", + "billing_update_card", + "billing_delete_card", + "billing_redeem_coupon", + "billing_update_auto_recharge", + "team_list", + "team_get_usage", + "team_get", + "team_list_members", + "team_list_invites", + "team_create", + "team_update", + "team_delete", + "team_switch", + "team_join", + "team_leave", + "team_create_invite", + "team_revoke_invite", + "team_remove_member", + "team_change_member_role", + "credential_list", + "session_state", + "session_get_user", + "oauth_connect_url", + "oauth_list", +]; + +const MONEY_DEFAULT_OFF: &[&str] = &[ + "billing_purchase_plan", + "billing_top_up_credits", + "billing_create_coinbase_charge", + "billing_create_setup_intent", + "billing_update_card", + "billing_delete_card", + "billing_redeem_coupon", + "billing_update_auto_recharge", + "team_create", + "team_update", + "team_delete", + "team_switch", + "team_join", + "team_leave", + "team_create_invite", + "team_revoke_invite", + "team_remove_member", + "team_change_member_role", +]; + +const MONEY_ALWAYS_ON: &[&str] = &[ + "billing_get_plan", + "billing_list_cards", + "team_list", + "team_get", + "credential_list", + "session_state", + "oauth_list", + "referral_get_stats", +]; + +#[test] +fn money_tools_are_registered() { + let tmp = TempDir::new().unwrap(); + let names = tool_names(&expansion_tools_for(&tmp)); + assert_contains_all(&names, MONEY_TOOLS); +} + +#[test] +fn money_default_off_tools_are_filtered_when_not_opted_in() { + let tmp = TempDir::new().unwrap(); + let mut tools = expansion_tools_for(&tmp); + filter_tools_by_user_preference(&mut tools, &["file_read".to_string()]); + let names = tool_names(&tools); + for off in MONEY_DEFAULT_OFF { + assert!( + !names.iter().any(|n| n == off), + "default-off tool `{off}` must be filtered out when not opted in; got: {names:?}" + ); + } + for on in MONEY_ALWAYS_ON { + assert!( + names.iter().any(|n| n == on), + "always-on tool `{on}` must be retained regardless of preferences" + ); + } +} + +#[test] +fn money_default_off_tools_retained_when_opted_in() { + let tmp = TempDir::new().unwrap(); + let mut tools = expansion_tools_for(&tmp); + filter_tools_by_user_preference( + &mut tools, + &["billing_writes".to_string(), "team_admin".to_string()], + ); + let names = tool_names(&tools); + for on in MONEY_DEFAULT_OFF { + assert!( + names.iter().any(|n| n == on), + "opted-in tool `{on}` must be retained; got: {names:?}" + ); + } +} + +// ── Theme: Desktop perception, MCP registry, workspace ────────────────────── + +const DESKTOP_TOOLS: &[&str] = &[ + "screen_intelligence_status", + "screen_intelligence_capture_image_ref", + "screen_intelligence_vision_recent", + "screen_intelligence_vision_flush", + "screen_intelligence_refresh_permissions", + "screen_intelligence_capture_now", + "screen_intelligence_capture_test", + "screen_intelligence_session_start", + "screen_intelligence_session_stop", + "screen_intelligence_input_action", + "screen_intelligence_globe_listener_start", + "screen_intelligence_globe_listener_poll", + "screen_intelligence_globe_listener_stop", + "screen_intelligence_request_permissions", + "screen_intelligence_request_permission", + "mcp_registry_search", + "mcp_registry_get", + "mcp_registry_installed_list", + "mcp_registry_status", + "mcp_registry_connect", + "mcp_registry_disconnect", + "mcp_registry_tool_call", + "mcp_registry_config_assist", + "mcp_registry_install", + "mcp_registry_uninstall", + "workspace_read_persona", + "workspace_update_persona", + "workspace_reset_persona", + "workspace_init", +]; + +const DESKTOP_DEFAULT_OFF: &[&str] = &[ + "screen_intelligence_request_permissions", + "screen_intelligence_request_permission", + "mcp_registry_install", + "mcp_registry_uninstall", + "workspace_update_persona", + "workspace_reset_persona", + "workspace_init", +]; + +const DESKTOP_ALWAYS_ON: &[&str] = &[ + "screen_intelligence_status", + "screen_intelligence_capture_now", + "mcp_registry_search", + "mcp_registry_tool_call", + "mcp_registry_connect", + "workspace_read_persona", +]; + +#[test] +fn desktop_tools_are_registered() { + let tmp = TempDir::new().unwrap(); + let names = tool_names(&expansion_tools_for(&tmp)); + assert_contains_all(&names, DESKTOP_TOOLS); +} + +#[test] +fn desktop_default_off_tools_are_filtered_when_not_opted_in() { + let tmp = TempDir::new().unwrap(); + let mut tools = expansion_tools_for(&tmp); + filter_tools_by_user_preference(&mut tools, &["file_read".to_string()]); + let names = tool_names(&tools); + for off in DESKTOP_DEFAULT_OFF { + assert!( + !names.iter().any(|n| n == off), + "default-off tool `{off}` must be filtered out when not opted in; got: {names:?}" + ); + } + for on in DESKTOP_ALWAYS_ON { + assert!( + names.iter().any(|n| n == on), + "always-on tool `{on}` must be retained regardless of preferences" + ); + } +} + +#[test] +fn desktop_default_off_tools_retained_when_opted_in() { + let tmp = TempDir::new().unwrap(); + let mut tools = expansion_tools_for(&tmp); + filter_tools_by_user_preference( + &mut tools, + &[ + "screen_permissions".to_string(), + "mcp_manage".to_string(), + "workspace_manage".to_string(), + ], + ); + let names = tool_names(&tools); + for on in DESKTOP_DEFAULT_OFF { + assert!( + names.iter().any(|n| n == on), + "opted-in tool `{on}` must be retained; got: {names:?}" + ); + } +} diff --git a/src/openhuman/tools/user_filter.rs b/src/openhuman/tools/user_filter.rs index 806ac7317..7110017de 100644 --- a/src/openhuman/tools/user_filter.rs +++ b/src/openhuman/tools/user_filter.rs @@ -36,6 +36,110 @@ const TOOL_ID_TO_RUST_NAMES: &[(&str, &[&str])] = &[ // `update_check` is read-only; `update_apply` is gated by both the // tool-level autonomy check and `config.update.rpc_mutations_enabled`. ("update", &["update_check", "update_apply"]), + // Knowledge & memory — overextending tools (agent-tool expansion). Listed + // so onboarding can default them OFF; read/bounded-write siblings are not + // listed and stay always-retained. + ( + "people_refresh_address_book", + &["people_refresh_address_book"], + ), + ( + "skill_manage", + &["skill_create", "skill_install_from_url", "skill_uninstall"], + ), + ("thread_destructive", &["thread_delete", "thread_purge_all"]), + ( + "billing_writes", + &[ + "billing_purchase_plan", + "billing_top_up_credits", + "billing_create_coinbase_charge", + "billing_create_setup_intent", + "billing_update_card", + "billing_delete_card", + "billing_redeem_coupon", + "billing_update_auto_recharge", + ], + ), + ( + "team_admin", + &[ + "team_create", + "team_update", + "team_delete", + "team_switch", + "team_join", + "team_leave", + "team_create_invite", + "team_revoke_invite", + "team_remove_member", + "team_change_member_role", + ], + ), + ( + "service_lifecycle", + &[ + "service_start", + "service_stop", + "service_restart", + "service_shutdown", + "service_install", + "service_uninstall", + "daemon_host_prefs_set", + ], + ), + ( + "screen_permissions", + &[ + "screen_intelligence_request_permissions", + "screen_intelligence_request_permission", + ], + ), + ( + "mcp_manage", + &["mcp_registry_install", "mcp_registry_uninstall"], + ), + ( + "workspace_manage", + &[ + "workspace_update_persona", + "workspace_reset_persona", + "workspace_init", + ], + ), + ( + "learning_manage", + &[ + "learning_update_facet", + "learning_pin_facet", + "learning_unpin_facet", + "learning_forget_facet", + "learning_rebuild_cache", + "learning_reset_cache", + "learning_save_profile", + "learning_enrich_profile", + ], + ), + // Task & workflow productivity — overextending tools (agent-tool + // expansion). Only the destructive/persistent-config mutators are listed + // here so the onboarding toggle surface can default them OFF and let users + // opt in; the read-only + bounded-write siblings (e.g. `artifact_list`, + // `todo_add`, `task_source_fetch`) are intentionally NOT listed, so they + // are always-retained infrastructure. Grouped one toggle per risk family. + ("agent_workflow_uninstall", &["agent_workflow_uninstall"]), + ("artifact_delete", &["artifact_delete"]), + ( + "todo_destructive", + &["todo_remove", "todo_replace", "todo_clear"], + ), + ( + "task_source_manage", + &[ + "task_source_add", + "task_source_update", + "task_source_remove", + ], + ), ]; /// All Rust tool names that are filterable (union of all mapping values). diff --git a/src/openhuman/workspace/mod.rs b/src/openhuman/workspace/mod.rs index 5a29c2fbc..1e75e7c3a 100644 --- a/src/openhuman/workspace/mod.rs +++ b/src/openhuman/workspace/mod.rs @@ -3,6 +3,7 @@ pub mod ops; pub mod rpc; mod schemas; +pub mod tools; pub use ops::*; pub use schemas::{ diff --git a/src/openhuman/workspace/tools.rs b/src/openhuman/workspace/tools.rs new file mode 100644 index 000000000..3f0cd1cd8 --- /dev/null +++ b/src/openhuman/workspace/tools.rs @@ -0,0 +1,215 @@ +//! LLM-callable wrappers over the `workspace` domain (persona files). +//! +//! `workspace_read_persona` reads an allowlisted persona file (SOUL.md / +//! IDENTITY.md), falling back to the bundled default. It is default-ON. +//! +//! The mutators — rewriting a persona file, resetting it to the bundled +//! default, and scaffolding the workspace — change the assistant's durable +//! identity/scaffold, so they ship default-OFF via `tools/user_filter.rs` +//! (`workspace_manage` toggle). + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use crate::openhuman::workspace::{ops, rpc}; + +fn req_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`")) +} + +/// Read an allowlisted persona file. +pub struct WorkspaceReadPersonaTool { + config: Arc, +} +impl WorkspaceReadPersonaTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for WorkspaceReadPersonaTool { + fn name(&self) -> &str { + "workspace_read_persona" + } + fn description(&self) -> &str { + "Read an allowlisted workspace persona file (`filename` = SOUL.md or \ + IDENTITY.md), returning its contents and whether it is the bundled \ + default." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "filename": { "type": "string", "enum": ["SOUL.md", "IDENTITY.md"] } }, + "required": ["filename"] + }) + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][workspace] read_persona invoked"); + let filename = req_str(&args, "filename")?; + let outcome = rpc::read_workspace_file(&self.config.workspace_dir, &filename) + .map_err(|e| anyhow::anyhow!("workspace_read_persona: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } +} + +/// Rewrite a persona file. Default-OFF. +pub struct WorkspaceUpdatePersonaTool { + config: Arc, +} +impl WorkspaceUpdatePersonaTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for WorkspaceUpdatePersonaTool { + fn name(&self) -> &str { + "workspace_update_persona" + } + fn description(&self) -> &str { + "Overwrite an allowlisted persona file (`filename` = SOUL.md or \ + IDENTITY.md) with new `contents`. Changes the assistant's durable \ + identity. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "filename": { "type": "string", "enum": ["SOUL.md", "IDENTITY.md"] }, + "contents": { "type": "string" } + }, + "required": ["filename", "contents"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][workspace] update_persona invoked"); + let filename = req_str(&args, "filename")?; + let contents = args + .get("contents") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required string argument `contents`"))?; + let outcome = rpc::write_workspace_file(&self.config.workspace_dir, &filename, contents) + .map_err(|e| anyhow::anyhow!("workspace_update_persona: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } +} + +/// Reset a persona file to its bundled default. Default-OFF. +pub struct WorkspaceResetPersonaTool { + config: Arc, +} +impl WorkspaceResetPersonaTool { + pub fn new(config: Arc) -> Self { + Self { config } + } +} +#[async_trait] +impl Tool for WorkspaceResetPersonaTool { + fn name(&self) -> &str { + "workspace_reset_persona" + } + fn description(&self) -> &str { + "Reset an allowlisted persona file (`filename`) to its bundled default, \ + overwriting any customization. Default-OFF (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "filename": { "type": "string", "enum": ["SOUL.md", "IDENTITY.md"] } }, + "required": ["filename"] + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][workspace] reset_persona invoked"); + let filename = req_str(&args, "filename")?; + let outcome = rpc::reset_workspace_file(&self.config.workspace_dir, &filename) + .map_err(|e| anyhow::anyhow!("workspace_reset_persona: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&outcome.value)?)) + } +} + +/// Scaffold the workspace. Default-OFF. +pub struct WorkspaceInitTool; +#[async_trait] +impl Tool for WorkspaceInitTool { + fn name(&self) -> &str { + "workspace_init" + } + fn description(&self) -> &str { + "Scaffold the workspace (memory/sessions/state dirs, bundled prompts, \ + HEARTBEAT.md). `force` re-initializes existing files. Default-OFF \ + (opt-in)." + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { "force": { "type": "boolean", "description": "Re-initialize existing files (default false)." } } + }) + } + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + log::debug!("[tool][workspace] init invoked"); + let force = args.get("force").and_then(Value::as_bool).unwrap_or(false); + let value = ops::init_workspace(force) + .await + .map_err(|e| anyhow::anyhow!("workspace_init: {e}"))?; + Ok(ToolResult::success(serde_json::to_string(&value)?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolScope; + + fn cfg() -> Arc { + Arc::new(Config::default()) + } + + #[test] + fn names_and_levels() { + assert_eq!( + WorkspaceReadPersonaTool::new(cfg()).name(), + "workspace_read_persona" + ); + assert_eq!( + WorkspaceReadPersonaTool::new(cfg()).permission_level(), + PermissionLevel::ReadOnly + ); + assert_eq!( + WorkspaceUpdatePersonaTool::new(cfg()).permission_level(), + PermissionLevel::Write + ); + assert_eq!(WorkspaceInitTool.permission_level(), PermissionLevel::Write); + assert_eq!(WorkspaceReadPersonaTool::new(cfg()).scope(), ToolScope::All); + } + + #[tokio::test] + async fn read_requires_filename() { + let err = WorkspaceReadPersonaTool::new(cfg()) + .execute(json!({})) + .await + .expect_err("missing filename"); + assert!(err.to_string().contains("filename")); + } +}