mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent): expose tinyplace specialist tools (#3876)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
pub mod prompt;
|
||||
@@ -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.
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user