mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(tools): expose ~160 agent tools across 23 core domains (overextending tools default-OFF) (#3050)
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
pub mod ops;
|
||||
pub mod schemas;
|
||||
pub mod store;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
|
||||
pub use schemas::{
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
mod ops;
|
||||
mod schemas;
|
||||
pub mod tools;
|
||||
|
||||
pub use ops::*;
|
||||
pub use schemas::{
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
mod global;
|
||||
mod rpc;
|
||||
mod schemas;
|
||||
pub mod tools;
|
||||
pub mod tracker;
|
||||
pub mod types;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
mod ops;
|
||||
mod schemas;
|
||||
pub mod tools;
|
||||
mod types;
|
||||
|
||||
pub use ops::model_health;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod core;
|
||||
pub mod ops;
|
||||
mod schemas;
|
||||
pub mod tools;
|
||||
|
||||
pub use core::*;
|
||||
pub use ops as rpc;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod ops;
|
||||
mod schemas;
|
||||
pub mod tools;
|
||||
|
||||
pub use ops::*;
|
||||
pub use schemas::{
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub(crate) mod cli;
|
||||
pub mod ops;
|
||||
mod schemas;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
|
||||
mod capture;
|
||||
mod capture_worker;
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
mod core;
|
||||
pub mod ops;
|
||||
mod schemas;
|
||||
pub mod tools;
|
||||
|
||||
pub mod audit;
|
||||
pub mod bubblewrap;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod ops;
|
||||
mod restart;
|
||||
mod schemas;
|
||||
mod shutdown;
|
||||
pub mod tools;
|
||||
|
||||
mod common;
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod ops;
|
||||
pub mod rpc;
|
||||
mod schemas;
|
||||
pub mod tools;
|
||||
|
||||
pub use ops::*;
|
||||
pub use schemas::{
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user