From b9d2cdd73544c0916ae040bd904ffff92b3bca66 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:17:57 -0700 Subject: [PATCH] feat(agent): expose tinyplace specialist tools (#3876) --- .../agent/harness/builtin_definitions.rs | 1 + .../agent/harness/session/builder/factory.rs | 30 +- .../agent/harness/session/runtime.rs | 5 + .../harness/subagent_runner/ops/runner.rs | 10 +- .../harness/subagent_runner/ops_tests.rs | 16 + .../harness/subagent_runner/tool_prep.rs | 32 +- src/openhuman/agent_registry/agents/loader.rs | 79 +++- .../agents/morning_briefing/agent.toml | 4 + .../agents/orchestrator/agent.toml | 7 + .../agents/orchestrator/prompt.md | 1 + .../agents/tools_agent/agent.toml | 24 +- src/openhuman/approval/redact.rs | 121 ++++++ src/openhuman/cron/scheduler.rs | 31 +- src/openhuman/cron/scheduler_tests.rs | 28 ++ src/openhuman/mcp_server/resources.rs | 6 + src/openhuman/tinyplace/agent/agent.toml | 170 ++++++++ src/openhuman/tinyplace/agent/mod.rs | 1 + src/openhuman/tinyplace/agent/prompt.md | 23 ++ src/openhuman/tinyplace/agent/prompt.rs | 107 +++++ src/openhuman/tinyplace/mod.rs | 1 + src/openhuman/tinyplace/tools.rs | 376 +++++++++++++++++- src/openhuman/tools/ops.rs | 16 +- src/openhuman/tools/orchestrator_tools.rs | 29 +- 23 files changed, 1078 insertions(+), 40 deletions(-) create mode 100644 src/openhuman/tinyplace/agent/agent.toml create mode 100644 src/openhuman/tinyplace/agent/mod.rs create mode 100644 src/openhuman/tinyplace/agent/prompt.md create mode 100644 src/openhuman/tinyplace/agent/prompt.rs diff --git a/src/openhuman/agent/harness/builtin_definitions.rs b/src/openhuman/agent/harness/builtin_definitions.rs index fa5bfa4c5..178f9477b 100644 --- a/src/openhuman/agent/harness/builtin_definitions.rs +++ b/src/openhuman/agent/harness/builtin_definitions.rs @@ -250,6 +250,7 @@ mod tests { "profile_memory_agent", "account_admin_agent", "screen_awareness_agent", + "tinyplace_agent", "tool_maker", "skill_creator", "researcher", diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 97ddc169f..585ab1f59 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -865,13 +865,31 @@ impl Agent { // contract (empty == no filter) stays intact: an empty set // means "no filter" for both legacy callers and the new // agent-scoped path. - let visible: std::collections::HashSet = match filter_from_scope { + let mut visible: std::collections::HashSet = match filter_from_scope { Some(set) => set, None => delegation_tools .iter() .map(|t| t.name().to_string()) .collect(), }; + if let Some(def) = target_def { + if !def.disallowed_tools.is_empty() { + match &def.tools { + ToolScope::Wildcard => { + visible = tools + .iter() + .map(|t| t.name().to_string()) + .chain(delegation_tools.iter().map(|t| t.name().to_string())) + .filter(|name| !definition_disallows_tool(&def.disallowed_tools, name)) + .collect(); + } + ToolScope::Named(_) => { + visible + .retain(|name| !definition_disallows_tool(&def.disallowed_tools, name)); + } + } + } + } // Phase 4 (#566): add the MemoryAccessSection bias instruction only // when at least one retrieval tool is actually loaded AND survives @@ -1147,3 +1165,13 @@ impl Agent { Ok(agent) } } + +fn definition_disallows_tool(disallowed: &[String], name: &str) -> bool { + disallowed.iter().any(|entry| { + if let Some(prefix) = entry.strip_suffix('*') { + name.starts_with(prefix) + } else { + entry == name + } + }) +} diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index ac2647ae1..a625bfcd4 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -88,6 +88,11 @@ impl Agent { Arc::clone(&self.tool_specs) } + #[cfg(test)] + pub(crate) fn visible_tool_names_for_test(&self) -> &std::collections::HashSet { + &self.visible_tool_names + } + /// Borrow the agent's memory backing store as an `Arc`. pub fn memory_arc(&self) -> Arc { Arc::clone(&self.memory) diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs index f692d6c98..55e485532 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs @@ -293,16 +293,14 @@ async fn run_typed_mode( // ── Force-include extra_tools ────────────────────────────────────── if !definition.extra_tools.is_empty() { - let disallow_set: std::collections::HashSet<&str> = definition - .disallowed_tools - .iter() - .map(|s| s.as_str()) - .collect(); for (i, tool) in parent.all_tools.iter().enumerate() { let name = tool.name(); if definition.extra_tools.iter().any(|n| n == name) && !allowed_indices.contains(&i) - && !disallow_set.contains(name) + && !super::super::tool_prep::disallowed_tool_matches( + &definition.disallowed_tools, + name, + ) && !is_subagent_spawn_tool(name) { allowed_indices.push(i); diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index 0f6fe13d3..a3d962858 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -122,6 +122,22 @@ fn filter_wildcard_includes_all_minus_disallowed() { assert_eq!(names, vec!["alpha", "gamma"]); } +#[test] +fn filter_wildcard_honours_disallowed_prefix_entries() { + let parent: Vec> = vec![ + stub("alpha"), + stub("tinyplace_registry_register"), + stub("tinyplace_marketplace_buy_identity"), + stub("gamma"), + ]; + let mut def = make_def_named_tools(&[]); + def.tools = ToolScope::Wildcard; + def.disallowed_tools = vec!["tinyplace_*".into()]; + let idx = filter_tool_indices(&parent, &def.tools, &def.disallowed_tools, None); + let names: Vec<&str> = idx.iter().map(|&i| parent[i].name()).collect(); + assert_eq!(names, vec!["alpha", "gamma"]); +} + #[test] fn filter_skill_filter_restricts_to_prefix() { let parent: Vec> = vec![ diff --git a/src/openhuman/agent/harness/subagent_runner/tool_prep.rs b/src/openhuman/agent/harness/subagent_runner/tool_prep.rs index 308a6ffe8..cb975bc21 100644 --- a/src/openhuman/agent/harness/subagent_runner/tool_prep.rs +++ b/src/openhuman/agent/harness/subagent_runner/tool_prep.rs @@ -7,8 +7,6 @@ //! [`crate::openhuman::agent::debug`] can mirror the live runner //! byte-for-byte instead of carrying its own drifting copies. -use std::collections::HashSet; - use super::super::definition::{PromptSource, ToolScope}; use super::types::SubagentRunError; use crate::openhuman::context::prompt::PromptContext; @@ -105,6 +103,8 @@ pub(crate) fn build_text_mode_tool_instructions(_specs: &[ToolSpec]) -> String { /// * every synthesised per-archetype `delegate_*` tool /// ([`crate::openhuman::tools::orchestrator_tools::collect_orchestrator_tools`] /// emits `delegate_researcher`, `delegate_planner`, …). +/// * custom delegate names that intentionally do not use the `delegate_*` +/// prefix, currently `use_tinyplace`. /// /// Kept as a tight prefix/exact match rather than a registry lookup so /// the strip is cheap to run inside [`super::ops::run_typed_mode`]'s @@ -112,7 +112,7 @@ pub(crate) fn build_text_mode_tool_instructions(_specs: &[ToolSpec]) -> String { /// this function and the corresponding generator in /// `orchestrator_tools.rs` together. pub(super) fn is_subagent_spawn_tool(name: &str) -> bool { - name == "spawn_subagent" || name.starts_with("delegate_") + name == "spawn_subagent" || name.starts_with("delegate_") || name == "use_tinyplace" } /// Returns indices into `parent_tools` for the tools the sub-agent may @@ -133,7 +133,6 @@ pub(crate) fn filter_tool_indices( disallowed: &[String], skill_filter: Option<&str>, ) -> Vec { - let disallow_set: HashSet<&str> = disallowed.iter().map(|s| s.as_str()).collect(); let skill_prefix = skill_filter.map(|s| format!("{s}__")); parent_tools @@ -141,7 +140,7 @@ pub(crate) fn filter_tool_indices( .enumerate() .filter(|(_, tool)| { let name = tool.name(); - if disallow_set.contains(name) { + if disallowed_tool_matches(disallowed, name) { return false; } if let Some(prefix) = skill_prefix.as_deref() { @@ -158,6 +157,29 @@ pub(crate) fn filter_tool_indices( .collect() } +pub(crate) fn disallowed_tool_matches(disallowed: &[String], name: &str) -> bool { + disallowed.iter().any(|entry| { + if let Some(prefix) = entry.strip_suffix('*') { + name.starts_with(prefix) + } else { + entry == name + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_tinyplace_delegate_is_treated_as_spawn_tool() { + assert!(is_subagent_spawn_tool("spawn_subagent")); + assert!(is_subagent_spawn_tool("delegate_researcher")); + assert!(is_subagent_spawn_tool("use_tinyplace")); + assert!(!is_subagent_spawn_tool("tinyplace_directory_resolve")); + } +} + // ── Prompt loading ────────────────────────────────────────────────────── /// Resolve a [`PromptSource`] to its raw markdown body. Inline sources diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index 80f4559e3..237e55d9c 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -88,6 +88,11 @@ pub const BUILTINS: &[BuiltinAgent] = &[ toml: include_str!("markets_agent/agent.toml"), prompt_fn: super::markets_agent::prompt::build, }, + BuiltinAgent { + id: "tinyplace_agent", + toml: include_str!("../../tinyplace/agent/agent.toml"), + prompt_fn: crate::openhuman::tinyplace::agent::prompt::build, + }, BuiltinAgent { id: "tools_agent", toml: include_str!("tools_agent/agent.toml"), @@ -601,6 +606,7 @@ mod tests { "task_manager_agent", "crypto_agent", "markets_agent", + "tinyplace_agent", ] { let def = find(id); match def.tools { @@ -753,6 +759,59 @@ mod tests { assert!(matches!(def.tools, ToolScope::Wildcard)); } + #[test] + fn tinyplace_agent_is_registered_and_narrow() { + let def = find("tinyplace_agent"); + assert!(matches!(def.model, ModelSpec::Hint(ref h) if h == "agentic")); + assert_eq!(def.sandbox_mode, SandboxMode::None); + assert!(!def.omit_safety_preamble); + assert_eq!(def.delegate_name.as_deref(), Some("use_tinyplace")); + match &def.tools { + ToolScope::Named(names) => { + for required in [ + "tinyplace_job_apply", + "tinyplace_registry_register", + "tinyplace_marketplace_buy_identity", + "tinyplace_marketplace_bid", + "tinyplace_inbox_list", + "tinyplace_signal_send_message", + "tinyplace_messages_list", + "tinyplace_groups_set_member_role", + "tinyplace_bounties_approve", + "tinyplace_jobs_select", + "ask_user_clarification", + "resolve_time", + "current_time", + ] { + assert!( + names.iter().any(|name| name == required), + "tinyplace_agent tool list missing `{required}`" + ); + } + for forbidden in [ + "shell", + "file_write", + "composio_execute", + "mcp_registry_tool_call", + ] { + assert!( + !names.iter().any(|name| name == forbidden), + "tinyplace_agent must not expose broad tool `{forbidden}`" + ); + } + } + other => panic!("tinyplace_agent must use Named tool scope, got {other:?}"), + } + + let orchestrator = find("orchestrator"); + assert!( + orchestrator.subagents.iter().any( + |entry| matches!(entry, SubagentEntry::AgentId(id) if id == "tinyplace_agent") + ), + "orchestrator must allow `tinyplace_agent` so use_tinyplace can spawn it" + ); + } + #[test] fn specialist_agents_are_registered_with_narrow_tools() { let scheduler = find("scheduler_agent"); @@ -817,6 +876,11 @@ mod tests { assert!(def.omit_identity); assert!(def.omit_safety_preamble); assert_eq!(def.max_iterations, 8); + assert!( + def.disallowed_tools.iter().any(|t| t == "tinyplace_*"), + "morning_briefing.disallowed_tools must contain `tinyplace_*` so \ + tiny.place routes through tinyplace_agent exclusively" + ); } #[test] @@ -1152,13 +1216,11 @@ mod tests { ); } - /// `tools_agent` must explicitly disallow `polymarket` and `kalshi` - /// so the prediction-market venues route ONLY through - /// `markets_agent` (`delegate_do_prediction_markets`). Without this - /// the wildcard inventory would also surface them as raw tools to - /// the generalist, bypassing the venue-aware approval-gate prompt. + /// `tools_agent` must explicitly disallow specialist-owned external action + /// families so the wildcard inventory does not surface raw paid/write + /// tools to the generalist, bypassing specialist prompts. #[test] - fn tools_agent_disallows_prediction_market_tools() { + fn tools_agent_disallows_specialist_owned_external_tools() { let def = find("tools_agent"); assert!( def.disallowed_tools.iter().any(|t| t == "polymarket"), @@ -1170,6 +1232,11 @@ mod tests { "tools_agent.disallowed_tools must contain `kalshi` so the \ venue routes through markets_agent exclusively" ); + assert!( + def.disallowed_tools.iter().any(|t| t == "tinyplace_*"), + "tools_agent.disallowed_tools must contain `tinyplace_*` so \ + tiny.place routes through tinyplace_agent exclusively" + ); } /// Routing: the orchestrator must list `mcp_agent` in its `subagents` diff --git a/src/openhuman/agent_registry/agents/morning_briefing/agent.toml b/src/openhuman/agent_registry/agents/morning_briefing/agent.toml index ebb3763d2..e7cee5b98 100644 --- a/src/openhuman/agent_registry/agents/morning_briefing/agent.toml +++ b/src/openhuman/agent_registry/agents/morning_briefing/agent.toml @@ -16,6 +16,10 @@ omit_memory_context = true omit_safety_preamble = true omit_skills_catalog = true +# Specialist-owned external action families stay behind their dedicated +# subagent prompts and confirmations. +disallowed_tools = ["tinyplace_*"] + [model] hint = "agentic" diff --git a/src/openhuman/agent_registry/agents/orchestrator/agent.toml b/src/openhuman/agent_registry/agents/orchestrator/agent.toml index 02b49ac48..b86fb70ab 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/agent.toml +++ b/src/openhuman/agent_registry/agents/orchestrator/agent.toml @@ -115,6 +115,13 @@ allowlist = [ # so there is exactly one canonical route — through this delegate — # which keeps the venue-specific approval-gate prompt in scope. "markets_agent", + # tiny.place specialist. Synthesised into a `use_tinyplace` tool + # at agent-build time. Route requests about tiny.place/tinyplace, Agent + # Cards, @handles, jobs, proposals, groups, messages, escrow, + # registration/status, and x402 payment challenges here. This keeps the + # tinyplace_* tool surface behind one worker instead of leaking individual + # social-economy actions into the chat agent. + "tinyplace_agent", # MCP setup specialist (#3039). Synthesised into a `delegate_setup_mcp_server` # tool at agent-build time. Route any "install / add / set up / connect an MCP # server" request here — the agent owns the full flow: search registries → diff --git a/src/openhuman/agent_registry/agents/orchestrator/prompt.md b/src/openhuman/agent_registry/agents/orchestrator/prompt.md index c9efebc7f..0fc4422cf 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/prompt.md +++ b/src/openhuman/agent_registry/agents/orchestrator/prompt.md @@ -31,6 +31,7 @@ Follow this sequence for every user message: - If the request is to make slides, build a deck, create a pitch, cite deck sources, or attach/verify deck images, use `make_presentation`. - If the request is to launch an app or operate desktop UI controls, use `delegate_desktop_control`. - If the request is about a **crypto wallet or market action** — balances, transfers, swaps, contract calls, on-chain positions, or trading on a connected exchange — use `delegate_do_crypto`. It enforces read → simulate → confirm → execute and refuses to fabricate chain ids, token addresses, market symbols, or unsupported tools. **Do not** route crypto write operations through `delegate_to_integrations_agent` or `delegate_run_code`. + - If the request is about **tiny.place / tinyplace** — Agent Cards, @handles, jobs, proposals, groups, messages, escrow, registration/status, or tiny.place x402 payment challenges — use `use_tinyplace`. It owns the `tinyplace_*` tools and keeps paid/irreversible actions behind confirmation. - **Any task that touches a code repository — cloning, exploring, locating files, modifying, building, testing, running shell commands inside it, git operations, pushing branches, opening PRs — uses `delegate_run_code` for the entire task.** Treat "locate where to edit", "investigate the bug", "find the function", "read the file" as code-repo work the moment they're scoped to a repo: they belong inside the same `delegate_run_code` worker as the edit / build / git steps. **Never** route code-repo work through `tools_agent` / `spawn_worker_thread`; those workers lack `edit` / `apply_patch` / `file_write` / `git_operations` / `codegraph_search` and will silently stall in read-mode. `tools_agent` is for *non-repo* work only — ad-hoc shell against the host, web fetch, memory helpers, etc. - **Do not stall after reading code-repo files.** If you (or a worker you spawned) have *read* files in a repo and have not yet *acted* on them — edited, built, tested, run, or pushed — and the user expects an outcome rather than a summary, that's the signal the task should have gone to `delegate_run_code` from the start. Re-issue the entire task as one `delegate_run_code` call with the full intent and let the code executor own the lifecycle. Do **not** narrate "reading the file…" / "let me check the code…" and then sit idle: in a code-repo task, reading is step zero of execution, not the deliverable. The user does not need to write "use the code executor" — infer it from the request shape (code, repo, file, build, test, run, fix, refactor, push, PR). - If the request is to find, browse, install, or manage agent skills from community registries — or to follow a SKILL.md URL — use `setup_skills`. diff --git a/src/openhuman/agent_registry/agents/tools_agent/agent.toml b/src/openhuman/agent_registry/agents/tools_agent/agent.toml index 4ecccd3ad..ff9ce472b 100644 --- a/src/openhuman/agent_registry/agents/tools_agent/agent.toml +++ b/src/openhuman/agent_registry/agents/tools_agent/agent.toml @@ -10,23 +10,21 @@ omit_memory_context = true omit_safety_preamble = false omit_skills_catalog = true -# Prediction-market venues (#2427) own their own specialist -# (`markets_agent` → `delegate_do_prediction_markets`) with a venue-aware -# approval-gate prompt. Disallow them here so the generalist's wildcard -# inventory doesn't surface a second, weaker route to the same -# capability. `tools_agent` retains every other built-in tool through -# the wildcard. -disallowed_tools = ["polymarket", "kalshi"] +# Specialist-owned external action families own their own prompts and approval +# contracts. Disallow them here so the generalist's wildcard inventory doesn't +# surface a second, weaker route to the same capability. `tools_agent` retains +# every other built-in tool through the wildcard. +disallowed_tools = ["polymarket", "kalshi", "tinyplace_*"] [model] hint = "agentic" [tools] # Wildcard — the agent inherits the orchestrator's full built-in tool -# surface. Composio meta-tools and dynamic `_*` action tools -# are stripped at runtime (see `filter_non_composio_indices` in the -# subagent runner), so the LLM never sees integration-specific tools -# here; those belong to `integrations_agent`. Trading venues are also -# stripped via `disallowed_tools` above so they route through -# `markets_agent` exclusively. +# surface. Composio meta-tools and dynamic `_*` action tools are +# stripped at runtime (see `filter_non_composio_indices` in the subagent +# runner), so the LLM never sees integration-specific tools here; those belong +# to `integrations_agent`. Specialist-owned trading/tiny.place tools are also +# stripped via `disallowed_tools` above so they route through their dedicated +# agents exclusively. wildcard = {} diff --git a/src/openhuman/approval/redact.rs b/src/openhuman/approval/redact.rs index f72a0467f..ba458e3ba 100644 --- a/src/openhuman/approval/redact.rs +++ b/src/openhuman/approval/redact.rs @@ -20,9 +20,14 @@ use serde_json::{Map, Value}; const SENSITIVE_KEYS: &[&str] = &[ "body", "content", + "description", + "plaintext", "text", "message", "messages", + "coverletter", + "note", + "reason", "html", "html_body", "snippet", @@ -44,6 +49,11 @@ const SENSITIVE_KEYS: &[&str] = &[ "first_name", "last_name", "full_name", + "displayname", + "bio", + "avatar", + "links", + "tags", "channel_name", "user", "user_id", @@ -58,6 +68,7 @@ const SENSITIVE_KEYS: &[&str] = &[ "password", "authorization", "auth", + "code", ]; /// Produce a redacted clone of `args` suitable for persistence / @@ -221,6 +232,116 @@ mod tests { ); } + #[test] + fn plaintext_field_is_redacted_for_encrypted_dm_tools() { + let args = json!({ + "recipient": "@alice", + "plaintext": "meet me at the usual spot", + "associatedData": { "topic": "tinyplace dm" } + }); + let red = redact_args(&args); + + assert!( + red["plaintext"] + .as_str() + .unwrap() + .starts_with(" (bool, String, Option) { - use crate::openhuman::agent::Agent; - let name = job.name.clone().unwrap_or_else(|| "cron-job".to_string()); let prompt = job.prompt.clone().unwrap_or_default(); let prefixed_prompt = format!("[cron:{} {name}] {prompt}", job.id); @@ -562,7 +561,7 @@ async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String, Option< target = ?job.session_target, "[cron] building isolated agent for scheduled job" ); - match Agent::from_config(&effective) { + match build_agent_for_cron_job(&effective, job) { Ok(mut agent) => { // Tag events so downstream subscribers can correlate // cron-triggered turns. `cron` is the channel so the @@ -614,6 +613,32 @@ async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String, Option< } } +fn build_agent_for_cron_job(config: &Config, job: &CronJob) -> anyhow::Result { + if let Some(agent_id) = job.agent_id.as_deref() { + match Agent::from_config_for_agent(config, agent_id) { + Ok(agent) => { + tracing::debug!( + job_id = %job.id, + agent_id = %agent_id, + "[cron] built scheduled job agent from definition" + ); + Ok(agent) + } + Err(e) => { + tracing::warn!( + job_id = %job.id, + agent_id = %agent_id, + error = %e, + "[cron] failed to build agent from definition; falling back to generic agent" + ); + Agent::from_config(config) + } + } + } else { + Agent::from_config(config) + } +} + async fn persist_job_result( config: &Config, job: &CronJob, diff --git a/src/openhuman/cron/scheduler_tests.rs b/src/openhuman/cron/scheduler_tests.rs index 1bbc1c66d..3a1adfec1 100644 --- a/src/openhuman/cron/scheduler_tests.rs +++ b/src/openhuman/cron/scheduler_tests.rs @@ -408,6 +408,34 @@ async fn run_agent_job_returns_error_without_provider_key() { ); } +#[tokio::test] +async fn cron_agent_job_uses_agent_definition_tool_scope() { + crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::init_global_builtins() + .expect("init built-in agent definitions"); + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp).await; + let mut job = test_job(""); + job.job_type = JobType::Agent; + job.name = Some("morning_briefing".into()); + job.agent_id = Some("morning_briefing".into()); + + let agent = build_agent_for_cron_job(&config, &job).expect("build cron agent"); + let visible = agent.visible_tool_names_for_test(); + + assert!( + !visible.is_empty(), + "morning briefing has a wildcard scope plus a disallowlist, so the builder must materialize an explicit visible-tool filter" + ); + assert!( + !visible.contains("use_tinyplace"), + "morning briefing cron jobs must use the morning_briefing definition scope, not the orchestrator delegate surface" + ); + assert!( + !visible.iter().any(|name| name.starts_with("tinyplace_")), + "morning briefing cron jobs must preserve tinyplace_* disallowlist" + ); +} + #[tokio::test] async fn persist_job_result_records_run_and_reschedules_shell_job() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/mcp_server/resources.rs b/src/openhuman/mcp_server/resources.rs index ac1297979..b0977b508 100644 --- a/src/openhuman/mcp_server/resources.rs +++ b/src/openhuman/mcp_server/resources.rs @@ -85,6 +85,12 @@ const RESOURCE_CATALOG: &[PromptResource] = &[ description: "Specialist worker for prediction-market venues (Polymarket, Kalshi).", content: include_str!("../agent_registry/agents/markets_agent/prompt.md"), }, + PromptResource { + uri: "openhuman://prompts/agents/tinyplace_agent", + name: "tinyplace_agent", + description: "Specialist worker for tiny.place identity, marketplace, messaging, and jobs.", + content: include_str!("../tinyplace/agent/prompt.md"), + }, PromptResource { uri: "openhuman://prompts/agents/tools_agent", name: "tools_agent", diff --git a/src/openhuman/tinyplace/agent/agent.toml b/src/openhuman/tinyplace/agent/agent.toml new file mode 100644 index 000000000..60d6d04ac --- /dev/null +++ b/src/openhuman/tinyplace/agent/agent.toml @@ -0,0 +1,170 @@ +id = "tinyplace_agent" +display_name = "Tinyplace Agent" +delegate_name = "use_tinyplace" +when_to_use = "tiny.place specialist - handles tiny.place social economy workflows: Agent Cards, discovery, jobs, proposals, groups, encrypted messages, escrow, wallet-funded actions, and x402 payment challenges. Use for any request that names tiny.place/tinyplace, @handles on tiny.place, tiny.place jobs/work, proposals, escrows, messages, groups, registration/status, or applying to tiny.place work. It owns the tinyplace_* tool surface and keeps paid/irreversible actions behind explicit confirmation." +temperature = 0.2 +max_iterations = 8 +iteration_policy = "extended" +sandbox_mode = "none" +omit_identity = true +omit_memory_context = true +omit_safety_preamble = false +omit_skills_catalog = true + +[model] +hint = "agentic" + +[tools] +named = [ + # Dedicated anti-spoofed helper for job applications. + "tinyplace_job_apply", + # tiny.place controller-backed tools. + "tinyplace_artifacts_get", + "tinyplace_artifacts_list", + "tinyplace_bounties_approve", + "tinyplace_bounties_cancel", + "tinyplace_bounties_comment", + "tinyplace_bounties_create", + "tinyplace_bounties_get", + "tinyplace_bounties_list", + "tinyplace_bounties_list_comments", + "tinyplace_bounties_list_submissions", + "tinyplace_bounties_run_council", + "tinyplace_bounties_submit", + "tinyplace_broadcasts_list", + "tinyplace_broadcasts_subscribe", + "tinyplace_broadcasts_unsubscribe", + "tinyplace_channels_join", + "tinyplace_channels_leave", + "tinyplace_channels_list", + "tinyplace_directory_find_by_encryption_key", + "tinyplace_directory_get_agent", + "tinyplace_directory_list_agents", + "tinyplace_directory_list_identities", + "tinyplace_directory_resolve", + "tinyplace_directory_reverse", + "tinyplace_directory_skills", + "tinyplace_escrow_get", + "tinyplace_escrow_list", + "tinyplace_explorer_overview", + "tinyplace_feedback_create", + "tinyplace_feedback_get", + "tinyplace_feedback_list", + "tinyplace_feedback_vote", + "tinyplace_feeds_add_comment", + "tinyplace_feeds_create_post", + "tinyplace_feeds_delete_comment", + "tinyplace_feeds_delete_post", + "tinyplace_feeds_like_post", + "tinyplace_feeds_unlike_post", + "tinyplace_follows_feed", + "tinyplace_follows_follow", + "tinyplace_follows_followers", + "tinyplace_follows_following", + "tinyplace_follows_stats", + "tinyplace_follows_unfollow", + "tinyplace_graphql_agent_card", + "tinyplace_graphql_agents", + "tinyplace_graphql_bounties", + "tinyplace_graphql_bounty", + "tinyplace_graphql_home_feed", + "tinyplace_graphql_identities", + "tinyplace_graphql_identity", + "tinyplace_graphql_identity_bids", + "tinyplace_graphql_identity_listing", + "tinyplace_graphql_identity_listings", + "tinyplace_graphql_identity_offers", + "tinyplace_graphql_identity_sales", + "tinyplace_graphql_job", + "tinyplace_graphql_jobs", + "tinyplace_graphql_ledger_transaction", + "tinyplace_graphql_ledger_transactions", + "tinyplace_graphql_post", + "tinyplace_graphql_post_comments", + "tinyplace_graphql_post_likers", + "tinyplace_graphql_posts", + "tinyplace_graphql_product", + "tinyplace_graphql_products", + "tinyplace_graphql_profile", + "tinyplace_graphql_user", + "tinyplace_groups_create_invite", + "tinyplace_groups_join", + "tinyplace_groups_leave", + "tinyplace_groups_list", + "tinyplace_groups_list_invites", + "tinyplace_groups_preview_invite", + "tinyplace_groups_redeem_invite", + "tinyplace_groups_revoke_invite", + "tinyplace_groups_set_member_role", + "tinyplace_inbox_archive", + "tinyplace_inbox_counts", + "tinyplace_inbox_list", + "tinyplace_inbox_mark_all_read", + "tinyplace_inbox_mark_read", + "tinyplace_inbox_remove", + "tinyplace_inbox_unarchive", + "tinyplace_jobs_adjudicate_dispute", + "tinyplace_jobs_apply", + "tinyplace_jobs_cancel", + "tinyplace_jobs_create", + "tinyplace_jobs_get", + "tinyplace_jobs_get_proposal", + "tinyplace_jobs_list", + "tinyplace_jobs_list_proposals", + "tinyplace_jobs_open_dispute", + "tinyplace_jobs_select", + "tinyplace_jobs_shortlist_proposal", + "tinyplace_jobs_withdraw_proposal", + "tinyplace_marketplace_bid", + "tinyplace_marketplace_browse", + "tinyplace_marketplace_buy_identity", + "tinyplace_marketplace_buy_product", + "tinyplace_marketplace_categories", + "tinyplace_marketplace_featured", + "tinyplace_marketplace_get_product", + "tinyplace_marketplace_identity_floor", + "tinyplace_marketplace_identity_sale_history", + "tinyplace_marketplace_list_bids", + "tinyplace_marketplace_list_identities", + "tinyplace_marketplace_list_offers", + "tinyplace_marketplace_list_product_reviews", + "tinyplace_marketplace_list_products", + "tinyplace_marketplace_offer", + "tinyplace_marketplace_recent", + "tinyplace_messages_acknowledge", + "tinyplace_messages_list", + "tinyplace_profiles_activity", + "tinyplace_profiles_agent_card", + "tinyplace_profiles_attestations", + "tinyplace_profiles_broadcasts", + "tinyplace_profiles_get", + "tinyplace_profiles_groups", + "tinyplace_registry_export", + "tinyplace_registry_get", + "tinyplace_registry_register", + "tinyplace_search_unified", + "tinyplace_signal_decrypt_message", + "tinyplace_signal_get_bundle", + "tinyplace_signal_key_status", + "tinyplace_signal_provision", + "tinyplace_signal_register_encryption_key", + "tinyplace_signal_rotate_signed_pre_key", + "tinyplace_signal_send_message", + "tinyplace_signal_upload_pre_keys", + "tinyplace_solana_call", + "tinyplace_solana_info", + "tinyplace_streams_list", + "tinyplace_streams_start", + "tinyplace_streams_stop", + "tinyplace_users_confirm_email_verification", + "tinyplace_users_get", + "tinyplace_users_start_email_verification", + "tinyplace_users_update_profile", + # Confirmation gate for paid/irreversible tiny.place actions as more + # write/payment tools are added. + "ask_user_clarification", + # Deterministic time grounding for job deadlines, status windows, and + # recency phrasing in tiny.place messages or work listings. + "resolve_time", + "current_time", +] diff --git a/src/openhuman/tinyplace/agent/mod.rs b/src/openhuman/tinyplace/agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/tinyplace/agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/tinyplace/agent/prompt.md b/src/openhuman/tinyplace/agent/prompt.md new file mode 100644 index 000000000..8181a261f --- /dev/null +++ b/src/openhuman/tinyplace/agent/prompt.md @@ -0,0 +1,23 @@ +# Tinyplace Agent + +You are the **Tinyplace Agent**, the worker that handles tiny.place social economy tasks for the orchestrator. + +## Scope + +Own tiny.place identity registration, directory/profile lookup, Agent Cards, marketplace trading, bids/offers, jobs, proposals, bounties, inbox state, encrypted DMs, groups, invites, follows, feeds, escrow, wallet-funded actions, x402 payment challenges, and tiny.place status loops. + +## Typical Flow + +1. Identify whether the user is asking to inspect tiny.place state, register or resolve an identity, trade an identity/product, view inbox/DMs, send a DM, accept or manage a request, find work, apply to a job, post work, handle escrow, or perform a paid/irreversible action. +2. Use available tiny.place tools only. Do not route tiny.place actions through generic shell, broad HTTP, Composio, MCP, crypto, or market agents. +3. For writes, explain the exact action before calling the write tool when intent is ambiguous. +4. For paid, irreversible, or human-only accept/approve/select actions, stop for explicit user confirmation before execution. Surface payment-required details instead of claiming completion. +5. Report concrete IDs returned by tools: job IDs, proposal IDs, escrow IDs, message IDs, handles, and transaction/payment references. + +## Rules + +- Never fabricate tiny.place handles, job IDs, proposal IDs, escrow IDs, payment status, wallet balances, or registration state. +- Never claim an application, payment, registration, message, delivery, or escrow transition happened unless a tool result says it did. +- If a tiny.place action is not exposed as a tool yet, say which missing capability blocks completion and return a concise handoff for the orchestrator. +- Do not ask the user for private keys, seed phrases, or raw wallet secrets. +- Treat x402/payment-required responses as incomplete work: include the asset, amount, network, recipient, and retry action if the tool result provides them. diff --git a/src/openhuman/tinyplace/agent/prompt.rs b/src/openhuman/tinyplace/agent/prompt.rs new file mode 100644 index 000000000..7915a81fc --- /dev/null +++ b/src/openhuman/tinyplace/agent/prompt.rs @@ -0,0 +1,107 @@ +//! System prompt builder for the `tinyplace_agent` built-in agent. +//! +//! The tiny.place domain owns this worker because its prompt, allowed tool +//! surface, and future SDK-backed actions should evolve with the domain code. + +use crate::openhuman::context::prompt::{ + render_safety, render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + tracing::debug!( + agent_id = ctx.agent_id, + model = ctx.model_name, + tool_count = ctx.tools.len(), + "[agent_prompt][tinyplace_agent] build_start" + ); + + let mut out = String::with_capacity(6144); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + let user_files_present = !user_files.trim().is_empty(); + if user_files_present { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + let tools_present = !tools.trim().is_empty(); + if tools_present { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let safety = render_safety(); + out.push_str(safety.trim_end()); + out.push_str("\n\n"); + + let workspace = render_workspace(ctx)?; + let workspace_present = !workspace.trim().is_empty(); + if workspace_present { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + tracing::trace!( + agent_id = ctx.agent_id, + prompt_len = out.len(), + user_files_present, + tools_present, + workspace_present, + "[agent_prompt][tinyplace_agent] build_done" + ); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; + use std::collections::HashSet; + + fn empty_ctx() -> PromptContext<'static> { + static EMPTY_VISIBLE: std::sync::OnceLock> = std::sync::OnceLock::new(); + PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "tinyplace_agent", + tools: &[], + workflows: &[], + dispatcher_instructions: "", + learned: LearnedContextData::default(), + visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new), + tool_call_format: ToolCallFormat::PFormat, + connected_integrations: &[], + connected_identities_md: String::new(), + include_profile: false, + include_memory_md: false, + curated_snapshot: None, + user_identity: None, + personality_soul_md: None, + personality_memory_md: None, + personality_roster: vec![], + } + } + + #[test] + fn build_returns_nonempty_body() { + let body = build(&empty_ctx()).unwrap(); + assert!(!body.is_empty()); + assert!(body.contains("Tinyplace Agent")); + } + + #[test] + fn archetype_documents_current_tool_surface() { + let body = build(&empty_ctx()).unwrap(); + assert!(body.contains("identity registration")); + assert!(body.contains("encrypted DMs")); + assert!(body.contains("marketplace trading")); + assert!(body.contains("x402 payment challenges")); + assert!(!body.contains("tinyplace_job_apply")); + } +} diff --git a/src/openhuman/tinyplace/mod.rs b/src/openhuman/tinyplace/mod.rs index c73c4540b..571fb210f 100644 --- a/src/openhuman/tinyplace/mod.rs +++ b/src/openhuman/tinyplace/mod.rs @@ -25,6 +25,7 @@ //! signing (`m/44'/501'/0'/0'`); the wallet key becomes the tiny.place identity. //! The seed is never logged, persisted, or returned across any IPC boundary. +pub(crate) mod agent; mod manifest; mod ops; mod payment; diff --git a/src/openhuman/tinyplace/tools.rs b/src/openhuman/tinyplace/tools.rs index b40714db8..133e48072 100644 --- a/src/openhuman/tinyplace/tools.rs +++ b/src/openhuman/tinyplace/tools.rs @@ -6,14 +6,292 @@ //! accepted as a tool argument. use async_trait::async_trait; -use serde_json::json; +use serde_json::{json, Map, Value}; use tinyplace::types::ProposalCreateRequest; +use crate::core::all::{ControllerHandler, RegisteredController}; +use crate::core::{FieldSchema, TypeSchema}; use crate::openhuman::tinyplace::ops::{global_state, map_err}; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; const LOG_PREFIX: &str = "[tinyplace][tool]"; +// ── Tinyplace controller-backed tools ──────────────────────────────────────── + +/// Agent-callable wrapper around an existing tiny.place controller. +/// +/// These tools intentionally reuse the internal controller handlers rather than +/// duplicating request construction. That keeps validation, client lookup, x402 +/// confirmation, and tiny.place SDK behaviour identical between RPC and agent +/// tool calls. +#[derive(Clone)] +pub struct TinyplaceControllerTool { + schema: crate::core::ControllerSchema, + handler: ControllerHandler, + tool_name: String, + permission_level: PermissionLevel, + external_effect: bool, + concurrency_safe: bool, +} + +impl TinyplaceControllerTool { + fn from_controller(controller: RegisteredController) -> Self { + let function = controller.schema.function; + let write = is_write_function(function); + let tool_name = format!("tinyplace_{function}"); + + Self { + schema: controller.schema, + handler: controller.handler, + tool_name, + permission_level: if write { + PermissionLevel::Write + } else { + PermissionLevel::ReadOnly + }, + external_effect: write && has_external_effect(function), + concurrency_safe: !write, + } + } +} + +#[async_trait] +impl Tool for TinyplaceControllerTool { + fn name(&self) -> &str { + &self.tool_name + } + + fn description(&self) -> &str { + self.schema.description + } + + fn parameters_schema(&self) -> Value { + controller_parameters_schema(&self.schema.inputs) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let params = match args { + Value::Object(map) => map, + Value::Null => Map::new(), + other => { + return Ok(ToolResult::error(format!( + "{} expects a JSON object argument, got {}", + self.name(), + value_kind(&other) + ))); + } + }; + + let param_keys: Vec<&str> = params.keys().map(String::as_str).collect(); + log::debug!( + "{LOG_PREFIX} {} start param_keys={:?}", + self.name(), + param_keys + ); + + match (self.handler)(params).await { + Ok(value) => { + log::debug!("{LOG_PREFIX} {} success", self.name()); + Ok(ToolResult::json(value)) + } + Err(message) => { + log::warn!("{LOG_PREFIX} {} failed: {message}", self.name()); + Ok(ToolResult::error(message)) + } + } + } + + fn permission_level(&self) -> PermissionLevel { + self.permission_level + } + + fn external_effect(&self) -> bool { + self.external_effect + } + + fn is_concurrency_safe(&self, _args: &Value) -> bool { + self.concurrency_safe + } + + fn max_result_size_chars(&self) -> Option { + Some(64 * 1024) + } +} + +/// All tiny.place controller tools available to the dedicated tinyplace agent. +pub fn all_tinyplace_agent_tools() -> Vec> { + crate::openhuman::tinyplace::all_tinyplace_registered_controllers() + .into_iter() + .map(|controller| { + Box::new(TinyplaceControllerTool::from_controller(controller)) as Box + }) + .collect() +} + +fn controller_parameters_schema(inputs: &[FieldSchema]) -> Value { + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for input in inputs { + properties.insert( + input.name.to_string(), + type_schema_to_json_schema(&input.ty, input.comment), + ); + if input.required { + required.push(Value::String(input.name.to_string())); + } + } + + let mut schema = serde_json::Map::from_iter([ + ("type".to_string(), Value::String("object".to_string())), + ("additionalProperties".to_string(), Value::Bool(false)), + ("properties".to_string(), Value::Object(properties)), + ]); + + if !required.is_empty() { + schema.insert("required".to_string(), Value::Array(required)); + } + + Value::Object(schema) +} + +fn type_schema_to_json_schema(ty: &TypeSchema, description: &'static str) -> Value { + let mut schema = match ty { + TypeSchema::Bool => json!({ "type": "boolean" }), + TypeSchema::I64 | TypeSchema::U64 => json!({ "type": "integer" }), + TypeSchema::F64 => json!({ "type": "number" }), + TypeSchema::String => json!({ "type": "string" }), + TypeSchema::Json => json!({}), + TypeSchema::Bytes => json!({ "type": "string", "contentEncoding": "base64" }), + TypeSchema::Array(item) => { + json!({ "type": "array", "items": type_schema_to_json_schema(item, "") }) + } + TypeSchema::Map(value) => { + json!({ + "type": "object", + "additionalProperties": type_schema_to_json_schema(value, "") + }) + } + TypeSchema::Option(inner) => { + let mut inner_schema = type_schema_to_json_schema(inner, description); + if let Value::Object(map) = &mut inner_schema { + let nullable_type = match map.remove("type") { + Some(Value::String(name)) => { + Value::Array(vec![Value::String(name), Value::String("null".to_string())]) + } + Some(Value::Array(mut names)) => { + if !names.iter().any(|name| name.as_str() == Some("null")) { + names.push(Value::String("null".to_string())); + } + Value::Array(names) + } + Some(other) => other, + None => return inner_schema, + }; + map.insert("type".to_string(), nullable_type); + } + return inner_schema; + } + TypeSchema::Enum { variants } => { + json!({ "type": "string", "enum": variants }) + } + TypeSchema::Object { fields } => controller_parameters_schema(fields), + TypeSchema::Ref(name) => json!({ + "type": "object", + "description": format!("{description} Shape: {name}."), + "additionalProperties": true + }), + }; + + if !description.is_empty() { + if let Value::Object(map) = &mut schema { + map.entry("description".to_string()) + .or_insert_with(|| Value::String(description.to_string())); + } + } + + schema +} + +fn is_write_function(function: &str) -> bool { + const WRITE_FUNCTIONS: &[&str] = &[ + "bounties_approve", + "bounties_cancel", + "bounties_comment", + "bounties_create", + "bounties_run_council", + "bounties_submit", + "broadcasts_subscribe", + "broadcasts_unsubscribe", + "channels_join", + "channels_leave", + "feedback_create", + "feedback_vote", + "feeds_add_comment", + "feeds_create_post", + "feeds_delete_comment", + "feeds_delete_post", + "feeds_like_post", + "feeds_unlike_post", + "follows_follow", + "follows_unfollow", + "groups_create_invite", + "groups_join", + "groups_leave", + "groups_redeem_invite", + "groups_revoke_invite", + "groups_set_member_role", + "inbox_archive", + "inbox_mark_all_read", + "inbox_mark_read", + "inbox_remove", + "inbox_unarchive", + "jobs_adjudicate_dispute", + "jobs_apply", + "jobs_cancel", + "jobs_create", + "jobs_open_dispute", + "jobs_select", + "jobs_shortlist_proposal", + "jobs_withdraw_proposal", + "marketplace_bid", + "marketplace_buy_identity", + "marketplace_buy_product", + "marketplace_offer", + "messages_acknowledge", + "registry_register", + "signal_provision", + "signal_decrypt_message", + "signal_register_encryption_key", + "signal_rotate_signed_pre_key", + "signal_send_message", + "signal_upload_pre_keys", + "solana_call", + "streams_start", + "streams_stop", + "users_confirm_email_verification", + "users_start_email_verification", + "users_update_profile", + ]; + + WRITE_FUNCTIONS.contains(&function) +} + +fn has_external_effect(_function: &str) -> bool { + true +} + +fn value_kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + // ── TinyplaceJobApplyTool ───────────────────────────────────────────────────── /// Submit a proposal (apply) to an open tiny.place job on behalf of the user. @@ -194,6 +472,102 @@ mod tests { assert!(!tool.is_concurrency_safe(&json!({}))); } + #[test] + fn controller_tools_surface_core_tinyplace_actions() { + let tools = all_tinyplace_agent_tools(); + let names: std::collections::HashSet<&str> = tools.iter().map(|tool| tool.name()).collect(); + + for required in [ + "tinyplace_registry_register", + "tinyplace_marketplace_buy_identity", + "tinyplace_inbox_list", + "tinyplace_signal_send_message", + "tinyplace_groups_set_member_role", + "tinyplace_bounties_approve", + "tinyplace_jobs_select", + ] { + assert!( + names.contains(required), + "missing tiny.place controller tool `{required}`" + ); + } + + let register = tools + .iter() + .find(|tool| tool.name() == "tinyplace_registry_register") + .expect("registry register tool"); + assert_eq!(register.permission_level(), PermissionLevel::Write); + assert!(register.external_effect()); + assert!(!register.is_concurrency_safe(&json!({}))); + + let inbox = tools + .iter() + .find(|tool| tool.name() == "tinyplace_inbox_list") + .expect("inbox list tool"); + assert_eq!(inbox.permission_level(), PermissionLevel::ReadOnly); + assert!(!inbox.external_effect()); + assert!(inbox.is_concurrency_safe(&json!({}))); + } + + #[test] + fn signal_state_mutating_tools_are_write_external_effects() { + let tools = all_tinyplace_agent_tools(); + + for name in [ + "tinyplace_signal_provision", + "tinyplace_signal_decrypt_message", + ] { + let tool = tools + .iter() + .find(|tool| tool.name() == name) + .unwrap_or_else(|| panic!("missing {name}")); + assert_eq!(tool.permission_level(), PermissionLevel::Write); + assert!(tool.external_effect(), "{name} should prompt/audit"); + assert!( + !tool.is_concurrency_safe(&json!({})), + "{name} mutates Signal state" + ); + } + } + + #[test] + fn controller_tool_parameters_come_from_controller_schema() { + let tools = all_tinyplace_agent_tools(); + let resolve = tools + .iter() + .find(|tool| tool.name() == "tinyplace_directory_resolve") + .expect("directory resolve tool"); + let schema = resolve.parameters_schema(); + let required = schema["required"].as_array().expect("required array"); + + assert!(required.iter().any(|v| v.as_str() == Some("name"))); + assert_eq!(schema["properties"]["name"]["type"], "string"); + } + + #[test] + fn optional_json_controller_params_remain_unrestricted() { + let tools = all_tinyplace_agent_tools(); + let list_agents = tools + .iter() + .find(|tool| tool.name() == "tinyplace_directory_list_agents") + .expect("directory list agents tool"); + let list_schema = list_agents.parameters_schema(); + assert!( + list_schema["properties"]["params"].get("type").is_none(), + "optional JSON params must not be emitted as null-only" + ); + + let jobs_apply = tools + .iter() + .find(|tool| tool.name() == "tinyplace_jobs_apply") + .expect("jobs apply tool"); + let apply_schema = jobs_apply.parameters_schema(); + assert!( + apply_schema["properties"]["pastWork"].get("type").is_none(), + "optional JSON pastWork must not be emitted as null-only" + ); + } + #[test] fn parameters_schema_requires_job_id() { let schema = TinyplaceJobApplyTool.parameters_schema(); diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 783b95feb..86db65cc5 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -515,10 +515,7 @@ pub fn all_tools_with_runtime( Box::new(WorkspaceUpdatePersonaTool::new(config.clone())), Box::new(WorkspaceResetPersonaTool::new(config.clone())), Box::new(WorkspaceInitTool), - // tiny.place agent tools — submit proposals on behalf of the user. - // Write-level: requires supervised/full autonomy to run without prompting. - // Always registered; actual network call fails gracefully when the wallet - // is locked or TINYPLACE_API_BASE_URL is unavailable. + // tiny.place focused write helper with anti-spoofed candidate identity. Box::new(TinyplaceJobApplyTool), ]; @@ -530,6 +527,17 @@ pub fn all_tools_with_runtime( // Subconscious scratchpad tools — persistent working memory across ticks. tools.extend(crate::openhuman::subconscious::scratchpad::tools::all_scratchpad_tools()); + // tiny.place agent surface. These wrap the internal tiny.place controllers + // so the dedicated tinyplace subagent can register identities, inspect + // inbox/DM state, trade marketplace assets, manage groups, and work jobs + // through the same validation/client paths as JSON-RPC. + let tinyplace_tools = crate::openhuman::tinyplace::tools::all_tinyplace_agent_tools(); + log::debug!( + "[tools::ops][tinyplace] registering tinyplace agent tools count={}", + tinyplace_tools.len() + ); + tools.extend(tinyplace_tools); + // Presentation generation (#2778). Native-Rust engine (ppt-rs // backed) as of the #2780-follow-up rust-engine refactor — no // managed Python venv, no first-call install latency. Always diff --git a/src/openhuman/tools/orchestrator_tools.rs b/src/openhuman/tools/orchestrator_tools.rs index 42fabddd5..0209acb91 100644 --- a/src/openhuman/tools/orchestrator_tools.rs +++ b/src/openhuman/tools/orchestrator_tools.rs @@ -448,7 +448,34 @@ mod tests { assert!( tool.description().contains("Polymarket") || tool.description().contains("Kalshi"), "synthesised tool description must surface the venue blurb so the LLM \ - can route prediction-market intents to it" + can route prediction-market intents to it" + ); + } + + /// tiny.place should be exposed as one named worker route, not as + /// scattered direct tools on the chat-tier orchestrator. + #[test] + fn tinyplace_agent_subagent_synthesises_use_tinyplace_delegate() { + let mut orch = def("orchestrator", "test", None); + orch.subagents = vec![SubagentEntry::AgentId("tinyplace_agent".into())]; + let mut reg = registry_with_targets(); + reg.insert(def( + "tinyplace_agent", + "tiny.place specialist - handles jobs, proposals, escrow, messages, Agent Cards, and x402 payment challenges.", + Some("use_tinyplace"), + )); + let tools = collect_orchestrator_tools(&orch, ®, &[]); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert_eq!( + names, + vec!["use_tinyplace"], + "tinyplace_agent subagent entry must synthesise its stable \ + delegate_name (`use_tinyplace`), not the default `delegate_tinyplace_agent`" + ); + let tool = tools.iter().find(|t| t.name() == "use_tinyplace").unwrap(); + assert!( + tool.description().contains("tiny.place") && tool.description().contains("x402"), + "synthesised tool description must surface tiny.place routing signal" ); }