feat(tools): expose ~160 agent tools across 23 core domains (overextending tools default-OFF) (#3050)

This commit is contained in:
Steven Enamakel
2026-05-30 21:25:20 -07:00
committed by GitHub
parent a997d8ad17
commit 18435f11d6
48 changed files with 8507 additions and 0 deletions
+1
View File
@@ -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;
+334
View File
@@ -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<String> {
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<Config>) -> 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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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/<slug>/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<ToolResult> {
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<ToolResult> {
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"));
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod ops;
pub mod schemas;
pub mod store;
pub mod tools;
pub mod types;
pub use schemas::{
+263
View File
@@ -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
//! `<workspace>/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<usize> {
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<String> {
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<Config>,
}
impl ArtifactListTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl ArtifactGetTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl ArtifactDeleteTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config> {
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}");
}
}
}
+1
View File
@@ -5,6 +5,7 @@
mod ops;
mod schemas;
pub mod tools;
pub use ops::*;
pub use schemas::{
+516
View File
@@ -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<String> {
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<RpcOutcome<Value>, String>`.
macro_rules! cfg_read {
($ty:ident, $name:literal, $fn:ident, $desc:literal) => {
pub struct $ty {
config: Arc<Config>,
}
impl $ty {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl BillingTransactionsTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl BillingPurchasePlanTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl BillingTopUpTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl BillingCoinbaseChargeTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl BillingSetupIntentTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
emit!(
billing::create_setup_intent(&self.config).await,
"billing_create_setup_intent"
)
}
}
/// Update a saved card.
pub struct BillingUpdateCardTool {
config: Arc<Config>,
}
impl BillingUpdateCardTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl BillingDeleteCardTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl BillingRedeemCouponTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl BillingUpdateAutoRechargeTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config> {
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"));
}
}
+1
View File
@@ -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;
+174
View File
@@ -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<RpcOutcome<Value>, 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<ToolResult> {
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<Config>,
}
impl ConfigSnapshotTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<ToolResult> {
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);
}
}
+1
View File
@@ -1,6 +1,7 @@
mod global;
mod rpc;
mod schemas;
pub mod tools;
pub mod tracker;
pub mod types;
+164
View File
@@ -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<Config>,
}
impl CostDashboardTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl CostDailyHistoryTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl CostSummaryTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config> {
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);
}
}
+1
View File
@@ -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,
+237
View File
@@ -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<Config>,
}
impl CredentialListTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl SessionStateTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl SessionGetUserTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl OAuthConnectUrlTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl OAuthListTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config> {
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"));
}
}
+1
View File
@@ -11,6 +11,7 @@
mod ops;
mod schemas;
pub mod tools;
mod types;
pub use ops::model_health;
+64
View File
@@ -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<Config>,
}
impl DashboardModelHealthTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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);
}
}
+1
View File
@@ -3,6 +3,7 @@
mod core;
pub mod ops;
mod schemas;
pub mod tools;
pub use core::*;
pub use ops as rpc;
+127
View File
@@ -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<Config>,
}
impl DoctorHealthTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl DoctorModelsTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config> {
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);
}
}
+1
View File
@@ -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;
+92
View File
@@ -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<ToolResult> {
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<ToolResult> {
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"));
}
}
+1
View File
@@ -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;
+662
View File
@@ -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<FacetCache> {
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<String> {
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<ToolResult> {
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<serde_json::Value> = 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<ToolResult> {
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<ToolResult> {
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<String, usize> =
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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"));
}
}
+1
View File
@@ -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::{
+460
View File
@@ -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<String> {
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<Config>,
}
impl McpRegistrySearchTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl McpRegistryGetTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl McpRegistryInstalledListTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl McpRegistryStatusTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl McpRegistryConnectTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<Config>,
}
impl McpRegistryConfigAssistTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl McpRegistryInstallTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
let qn = req_str(&args, "qualified_name")?;
let env: HashMap<String, String> = 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<Config>,
}
impl McpRegistryUninstallTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config> {
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"));
}
}
+1
View File
@@ -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::{
+417
View File
@@ -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<std::sync::Arc<PeopleStore>> {
store::get().map_err(|e| anyhow::anyhow!("people store unavailable: {e}"))
}
fn read_required_str(args: &serde_json::Value, key: &str) -> anyhow::Result<String> {
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<PersonId> {
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<Handle> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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"));
}
}
+1
View File
@@ -2,6 +2,7 @@
mod ops;
mod schemas;
pub mod tools;
pub use ops::*;
pub use schemas::{
+142
View File
@@ -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<Config>,
}
impl ReferralStatsTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl ReferralClaimTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config> {
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"));
}
}
+1
View File
@@ -4,6 +4,7 @@ pub(crate) mod cli;
pub mod ops;
mod schemas;
pub mod server;
pub mod tools;
mod capture;
mod capture_worker;
+391
View File
@@ -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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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"));
}
}
+1
View File
@@ -1,6 +1,7 @@
mod core;
pub mod ops;
mod schemas;
pub mod tools;
pub mod audit;
pub mod bubblewrap;
+68
View File
@@ -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<Config>,
}
impl SecurityPolicyInfoTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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());
}
}
+1
View File
@@ -8,6 +8,7 @@ pub mod ops;
mod restart;
mod schemas;
mod shutdown;
pub mod tools;
mod common;
#[cfg(target_os = "linux")]
+278
View File
@@ -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<String> {
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<Config>,
}
impl $ty {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl DaemonHostPrefsSetTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<Config> {
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"));
}
}
+1
View File
@@ -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::*;
+539
View File
@@ -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<String> {
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<Config>) -> 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<ToolResult> {
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<Config>) -> 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<ToolResult> {
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<Config>) -> 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<ToolResult> {
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<Config>) -> 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<ToolResult> {
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<Config>) -> 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<ToolResult> {
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<Config>) -> 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<ToolResult> {
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<Config>) -> 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<ToolResult> {
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<ToolResult> {
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<Config> {
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"));
}
}
+1
View File
@@ -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};
+570
View File
@@ -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<T>`; 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<String> {
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<u64> {
args.get(key).and_then(serde_json::Value::as_u64)
}
fn opt_str(args: &serde_json::Value, key: &str) -> Option<String> {
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<ProviderSlug> {
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<FilterSpec> {
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<Config>,
}
impl TaskSourceListTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TaskSourceGetTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TaskSourceFetchTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TaskSourceListTasksTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TaskSourcePreviewFilterTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TaskSourceStatusTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TaskSourceAddTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TaskSourceUpdateTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TaskSourceRemoveTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config> {
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"));
}
}
+1
View File
@@ -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};
+486
View File
@@ -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<String> {
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<Config>,
}
impl $ty {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl $ty {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl $ty {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TeamCreateTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TeamUpdateTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TeamJoinTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TeamCreateInviteTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TeamRevokeInviteTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TeamRemoveMemberTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TeamChangeMemberRoleTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config> {
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"));
}
}
+1
View File
@@ -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;
+746
View File
@@ -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<ApiEnvelope<_>>`); 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<String> {
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<T: serde::de::DeserializeOwned>(
args: serde_json::Value,
tool: &str,
) -> anyhow::Result<T> {
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<ToolResult> {
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<serde_json::Value> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
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<Config>,
}
impl ThreadTaskBoardReadTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl ThreadTaskBoardWriteTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<TaskBoardCard> = 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<ToolResult> {
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<ToolResult> {
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<Config> {
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"));
}
}
+1
View File
@@ -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,
+601
View File
@@ -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<String> {
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<String> {
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<Vec<String>> {
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<CardPatch> {
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<ToolResult> {
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<Config>,
}
impl TodoListTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TodoAddTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TodoEditTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TodoUpdateStatusTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TodoDecidePlanTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TodoRemoveTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TodoReplaceTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl TodoClearTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config> {
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"));
}
}
+22
View File
@@ -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};
+194
View File
@@ -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 {
+619
View File
@@ -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<Box<dyn Tool>> {
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:?}"
);
}
}
+104
View File
@@ -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).
+1
View File
@@ -3,6 +3,7 @@
pub mod ops;
pub mod rpc;
mod schemas;
pub mod tools;
pub use ops::*;
pub use schemas::{
+215
View File
@@ -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<String> {
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<Config>,
}
impl WorkspaceReadPersonaTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl WorkspaceUpdatePersonaTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<Config>,
}
impl WorkspaceResetPersonaTool {
pub fn new(config: Arc<Config>) -> 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<ToolResult> {
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<ToolResult> {
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<Config> {
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"));
}
}