feat(agent): expose tinyplace specialist tools (#3876)

This commit is contained in:
Steven Enamakel
2026-06-21 13:17:57 -07:00
committed by GitHub
parent db7f6d8b96
commit b9d2cdd735
23 changed files with 1078 additions and 40 deletions
@@ -250,6 +250,7 @@ mod tests {
"profile_memory_agent",
"account_admin_agent",
"screen_awareness_agent",
"tinyplace_agent",
"tool_maker",
"skill_creator",
"researcher",
@@ -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<String> = match filter_from_scope {
let mut visible: std::collections::HashSet<String> = 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
}
})
}
@@ -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<String> {
&self.visible_tool_names
}
/// Borrow the agent's memory backing store as an `Arc`.
pub fn memory_arc(&self) -> Arc<dyn Memory> {
Arc::clone(&self.memory)
@@ -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);
@@ -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<Box<dyn Tool>> = 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<Box<dyn Tool>> = vec![
@@ -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<usize> {
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
+73 -6
View File
@@ -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`
@@ -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"
@@ -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 →
@@ -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`.
@@ -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 `<TOOLKIT>_*` 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 `<TOOLKIT>_*` 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 = {}
+121
View File
@@ -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("<redacted: string ("),
"got {:?}",
red["plaintext"]
);
assert!(
red["recipient"]
.as_str()
.unwrap()
.starts_with("<redacted: string ("),
"got {:?}",
red["recipient"]
);
assert_eq!(red["associatedData"]["topic"], "tinyplace dm");
}
#[test]
fn email_verification_code_is_redacted() {
let args = json!({
"cryptoId": "did:example:alice",
"email": "alice@example.test",
"code": "123456",
});
let red = redact_args(&args);
assert_eq!(red["cryptoId"], "did:example:alice");
assert!(
red["email"]
.as_str()
.unwrap()
.starts_with("<redacted: string ("),
"got {:?}",
red["email"]
);
assert!(
red["code"]
.as_str()
.unwrap()
.starts_with("<redacted: string ("),
"got {:?}",
red["code"]
);
}
#[test]
fn tinyplace_write_content_fields_are_redacted() {
let args = json!({
"title": "Build my thing",
"description": "Long private task brief",
"coverLetter": "I can do this because...",
"note": "Submission context",
"reason": "Dispute details",
"amount": "5",
"asset": "USDC"
});
let red = redact_args(&args);
for key in ["title", "description", "coverLetter", "note", "reason"] {
assert!(
red[key]
.as_str()
.unwrap()
.starts_with("<redacted: string ("),
"{key} was not redacted: {:?}",
red[key]
);
}
assert_eq!(red["amount"], "5");
assert_eq!(red["asset"], "USDC");
}
#[test]
fn tinyplace_profile_update_fields_are_redacted() {
let args = json!({
"cryptoId": "did:example:alice",
"update": {
"displayName": "Alice Example",
"bio": "Private bio",
"avatar": "https://example.test/avatar.png",
"links": ["https://example.test/private"],
"tags": ["private-tag"],
"actorType": "agent"
}
});
let red = redact_args(&args);
let update = red["update"].as_object().unwrap();
assert_eq!(red["cryptoId"], "did:example:alice");
for key in ["displayName", "bio", "avatar", "links", "tags"] {
assert!(
update[key].as_str().unwrap().starts_with("<redacted:"),
"{key} was not redacted: {:?}",
update[key]
);
}
assert_eq!(update["actorType"], "agent");
}
#[test]
fn nested_sensitive_object_fields_are_redacted() {
let args = json!({
+28 -3
View File
@@ -1,5 +1,6 @@
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::agent::error::AgentError;
use crate::openhuman::agent::Agent;
use crate::openhuman::config::Config;
use crate::openhuman::cron::{
due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job, reschedule_after_run,
@@ -463,8 +464,6 @@ async fn execute_and_persist_job(
}
async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String, Option<String>) {
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<Agent> {
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,
+28
View File
@@ -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();
+6
View File
@@ -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",
+170
View File
@@ -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",
]
+1
View File
@@ -0,0 +1 @@
pub mod prompt;
+23
View File
@@ -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.
+107
View File
@@ -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<String> {
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<HashSet<String>> = 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"));
}
}
+1
View File
@@ -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;
+375 -1
View File
@@ -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<ToolResult> {
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<usize> {
Some(64 * 1024)
}
}
/// All tiny.place controller tools available to the dedicated tinyplace agent.
pub fn all_tinyplace_agent_tools() -> Vec<Box<dyn Tool>> {
crate::openhuman::tinyplace::all_tinyplace_registered_controllers()
.into_iter()
.map(|controller| {
Box::new(TinyplaceControllerTool::from_controller(controller)) as Box<dyn Tool>
})
.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();
+12 -4
View File
@@ -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
+28 -1
View File
@@ -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, &reg, &[]);
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"
);
}