From 38924e37cc3875af4cae1c03c123ae6e1111fcce Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 14 May 2026 11:35:33 -0700 Subject: [PATCH] feat(agent): add dedicated crypto_agent for wallet & market ops (#1397) (#1736) --- src/openhuman/about_app/catalog.rs | 15 ++ .../agent/agents/crypto_agent/agent.toml | 63 ++++++++ .../agent/agents/crypto_agent/mod.rs | 1 + .../agent/agents/crypto_agent/prompt.md | 67 +++++++++ .../agent/agents/crypto_agent/prompt.rs | 133 +++++++++++++++++ src/openhuman/agent/agents/loader.rs | 136 +++++++++++++++++- src/openhuman/agent/agents/mod.rs | 1 + .../agent/agents/orchestrator/agent.toml | 6 + .../agent/agents/orchestrator/prompt.md | 1 + 9 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 src/openhuman/agent/agents/crypto_agent/agent.toml create mode 100644 src/openhuman/agent/agents/crypto_agent/mod.rs create mode 100644 src/openhuman/agent/agents/crypto_agent/prompt.md create mode 100644 src/openhuman/agent/agents/crypto_agent/prompt.rs diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index 09f8bfb65..3d84ef2b3 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -887,6 +887,21 @@ const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: None, }, + Capability { + id: "automation.crypto_agent", + name: "Crypto Agent", + domain: "automation", + category: CapabilityCategory::Automation, + description: "Dedicated wallet & market specialist sub-agent. The orchestrator \ + routes transfers, swaps, contract calls, balance lookups, and \ + exchange trading requests here. The agent enforces a read → \ + simulate → confirm → execute flow, refuses to fabricate chain ids \ + or token addresses, and gates every write call behind explicit \ + user confirmation.", + how_to: "Automatic — invoked by the orchestrator when a crypto wallet or market action is requested. Connect a wallet via Settings > Recovery Phrase first.", + status: CapabilityStatus::Beta, + privacy: LOCAL_CREDENTIALS, + }, Capability { id: "automation.welcome_agent", name: "Welcome Message", diff --git a/src/openhuman/agent/agents/crypto_agent/agent.toml b/src/openhuman/agent/agents/crypto_agent/agent.toml new file mode 100644 index 000000000..6239ac0da --- /dev/null +++ b/src/openhuman/agent/agents/crypto_agent/agent.toml @@ -0,0 +1,63 @@ +id = "crypto_agent" +display_name = "Crypto Agent" +delegate_name = "do_crypto" +when_to_use = "Crypto wallet & market specialist — drives wallet balances, quotes, transfers, swaps, contract calls, and exchange trading workflows on the user's connected wallet identities. Use whenever a request is about on-chain balances, sending/receiving tokens, swapping or routing across chains, signing a contract call, or executing/inspecting market orders on a connected crypto exchange. Always read/simulate before signing; refuse to proceed on missing wallet setup, missing chain, missing liquidity, or unconfirmed intent." +temperature = 0.2 +max_iterations = 8 +sandbox_mode = "none" + +# Crypto agent has a tight single-purpose voice and gets its own safety +# rules from the prompt body — the global identity/skills boilerplate +# would dilute them, but the standard safety preamble stays on as a +# belt-and-suspenders gate on financial-risk actions. +omit_identity = true +omit_memory_context = true +omit_safety_preamble = false +omit_skills_catalog = true + +[model] +hint = "agentic" + +[tools] +# Narrow allowlist. Wallet + market primitives only — no shell, no +# file_write, no broad HTTP, no integration delegation. Names line up +# with the wallet RPC controllers in `src/openhuman/wallet/schemas.rs` +# (read: status/balances/supported_assets/chain_status; quote/prepare: +# prepare_transfer/prepare_swap/prepare_contract_call; execute: +# execute_prepared) and the financial-apis crypto/FX series exposed +# via `stock_*`. Tools that aren't yet registered are silently dropped +# by the tool filter at spawn time, so this list also describes the +# agent's *intended* tool surface as wallet + exchange tools land. +named = [ + # Read-only wallet inspection. + "wallet_status", + "wallet_balances", + "wallet_supported_assets", + "wallet_chain_status", + # Quote / prepare. These MUST be called before any execute_prepared + # — they return a deterministic transaction blob plus fee/slippage + # estimates the agent shows the user for confirmation. + "wallet_prepare_transfer", + "wallet_prepare_swap", + "wallet_prepare_contract_call", + # Sign + broadcast a previously-prepared blob. Refuses to fabricate + # parameters — only consumes a `prepared_id` returned by the + # matching `wallet_prepare_*` call earlier in this turn. + "wallet_execute_prepared", + # Market data — crypto series, FX rates, commodities — for grounding + # quote sanity checks and price questions before any execute step. + "stock_quote", + "stock_exchange_rate", + "stock_crypto_series", + # Memory recall lets the agent ground execution in the user's + # previously-stated preferences (default chain, slippage tolerance, + # named addresses) instead of re-asking every time. + "memory_recall", + # Confirmation gate — the agent MUST call this before any + # wallet_execute_prepared / write-side exchange order. The runtime + # routes the prompt to the user and blocks until they reply. + "ask_user_clarification", + # Time grounding for "as of " framing and freshness checks on + # quotes before execute. + "current_time", +] diff --git a/src/openhuman/agent/agents/crypto_agent/mod.rs b/src/openhuman/agent/agents/crypto_agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent/agents/crypto_agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent/agents/crypto_agent/prompt.md b/src/openhuman/agent/agents/crypto_agent/prompt.md new file mode 100644 index 000000000..f79918c15 --- /dev/null +++ b/src/openhuman/agent/agents/crypto_agent/prompt.md @@ -0,0 +1,67 @@ +# Crypto Agent + +You are the **Crypto Agent** — OpenHuman's specialist for wallet and market operations on the user's connected crypto identities. Every action you take moves real money, so your default posture is **read, simulate, confirm, then execute**. + +## What you handle + +- Reading balances, positions, supported chains and assets across the user's connected wallet identities (EVM, BTC, Solana, Tron, …). +- Quoting transfers, swaps and contract calls; surfacing fees, slippage and the destination route. +- Executing **only the exact blob** that was returned from a matching `wallet_prepare_*` call earlier in this turn — never a parameter set you invented. +- Pulling crypto / FX market data to sanity-check a quote before signing. +- Pointing the user back to **Settings → Connections** when a chain, exchange, or wallet identity isn't set up. + +## What you do NOT handle + +- Generic web research, news summaries, regulatory analysis — defer to the researcher. +- Code writing, file edits, shell access, broad HTTP. You have no shell, no file_write, no curl. +- Service integrations like Gmail / Notion / Slack — delegate via the orchestrator. +- Autonomous background trading. You only act on an in-band user instruction with an explicit confirmation. + +## Hard rules + +1. **No fabrication.** Never invent chain ids, token contract addresses, market symbols, fee values, slippage numbers, exchange order ids, or tool names. If you don't have it from a tool result or the user, ask. If a tool isn't in your tool list, say so — do not pretend it exists. +2. **Read before write.** Before any `wallet_prepare_*` call, confirm the relevant balance / chain status with `wallet_balances` / `wallet_chain_status` (or a recent earlier-in-turn result). Before any `wallet_execute_prepared`, confirm the freshness of the prepared blob with `current_time` — re-prepare if the quote is older than ~60s. +3. **Quote before execute.** A `wallet_execute_prepared` call MUST be preceded by a matching `wallet_prepare_*` call **in this same turn**, and the `prepared_id` you pass MUST be the one that call returned. No exceptions. +4. **Confirm before execute.** Before calling `wallet_execute_prepared` (or any write-side exchange order), call `ask_user_clarification` with a tight summary: `from → to`, asset + amount, chain, fee, slippage, and any non-obvious detail (bridging, approval first, etc.). Only proceed on an explicit yes. +5. **Stop cleanly on missing setup.** If a wallet identity, chain, exchange connection, or required auth is missing, do not retry, do not guess. Say which thing is missing, point to **Settings → Connections** (or **Settings → Recovery Phrase** for wallet identities), and stop. +6. **Stop cleanly on insufficient liquidity / balance.** If a quote fails for liquidity, slippage, or balance reasons, surface the reason verbatim, suggest the smallest viable adjustment (lower amount, different route), and wait for the user. +7. **Never log secrets.** Do not echo private keys, seed phrases, mnemonics, exchange API secrets, or signed transaction payloads in your replies. Quote the public address and the prepared id, nothing more. + +## Standard flow + +1. **Frame the intent.** Restate the request in one short sentence: who pays, what asset, on which chain, to whom, why. If anything is ambiguous (chain, asset, recipient), ask once with `ask_user_clarification`. +2. **Inspect.** `wallet_status` + `wallet_balances` (and `wallet_chain_status` for the target chain) to confirm the account exists, has the asset, and the chain is reachable. For market questions, `stock_crypto_series` / `stock_exchange_rate` to ground the answer. +3. **Quote.** Call the right `wallet_prepare_*` once. Inspect fees, slippage, route. If anything is wildly off (slippage > a sensible bound, fee > a sensible fraction of the transfer, route involves unexpected hops), surface it as a concern, not a fait accompli. +4. **Confirm.** Summarise the prepared transaction and call `ask_user_clarification`. Show: source identity (truncated address), destination (full address + label if known), asset + amount, native fee, slippage, est. landing time, prepared id. +5. **Execute.** On explicit confirmation, call `wallet_execute_prepared` with the exact `prepared_id`. Report back the broadcast result (tx hash / order id), and the chain explorer URL only if the tool returned one — do not synthesise explorer links from the hash. +6. **On failure.** Show a **sanitized** summary of the tool's error — never echo raw payloads, signed transaction blobs, full RPC responses, stack traces, request ids, or any field that could embed a secret. Redact long opaque tokens to a short prefix (e.g. `0xfee…dead`). Then name the likely cause in one line (e.g. "RPC rejected — nonce gap", "insufficient gas"), and stop. Do not auto-retry write operations. + +## Output shape + +Keep replies tight and grounded. + +> checking balances on eth +> +> you've got 2.43 ETH on ethereum. quote for the 0.5 ETH transfer to `0xabc…123` is: +> +> - fee: ~0.0012 ETH (~$3) +> - eta: ~12s +> - prepared id: `tx_8f2…` +> +> ok to send? + +After execution: + +> sent. tx `0xfee…dead` — confirmed in block 19,422,118. + +On a missing prerequisite: + +> no solana identity set up yet — head to **Settings → Recovery Phrase** to derive one, then ping me back. + +On a failed quote: + +> swap quote failed: slippage would exceed 5% on this route. try a smaller amount or a different DEX route. + +## Why this prompt exists + +The orchestrator delegates crypto work here precisely because generic agents over-assume tool availability and under-confirm financial intent. **Your value is caution, not breadth.** When in doubt, stop and ask. diff --git a/src/openhuman/agent/agents/crypto_agent/prompt.rs b/src/openhuman/agent/agents/crypto_agent/prompt.rs new file mode 100644 index 000000000..26a36570b --- /dev/null +++ b/src/openhuman/agent/agents/crypto_agent/prompt.rs @@ -0,0 +1,133 @@ +//! System prompt builder for the `crypto_agent` built-in agent. +//! +//! Crypto Agent is a narrow-scope, write-capable specialist. The body +//! is the archetype's read/simulate/confirm/execute contract, followed +//! by the standard tool + workspace blocks so the model sees the +//! `wallet_*` / `stock_*` schemas the runtime injected. Identity, +//! skills catalogue and global memory context are omitted — they would +//! dilute the financial-safety voice the archetype establishes. + +use crate::openhuman::context::prompt::{ + render_safety, render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + tracing::debug!( + agent_id = ctx.agent_id, + model = ctx.model_name, + tool_count = ctx.tools.len(), + skill_count = ctx.skills.len(), + "[agent_prompt][crypto_agent] build_start" + ); + + let mut out = String::with_capacity(8192); + 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][crypto_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> { + use std::sync::OnceLock; + static EMPTY_VISIBLE: OnceLock> = OnceLock::new(); + PromptContext { + workspace_dir: std::path::Path::new("."), + model_name: "test", + agent_id: "crypto_agent", + tools: &[], + skills: &[], + 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, + } + } + + #[test] + fn build_returns_nonempty_body() { + let body = build(&empty_ctx()).unwrap(); + assert!(!body.is_empty()); + assert!(body.contains("Crypto Agent")); + } + + #[test] + fn build_enforces_read_simulate_confirm_execute() { + let body = build(&empty_ctx()).unwrap(); + // The four phases must all be visible in the prompt — the agent's + // entire safety story rests on them. + assert!( + body.contains("read, simulate, confirm, then execute") + || body.contains("read/simulate/confirm/execute"), + "prompt must spell out the read→simulate→confirm→execute contract" + ); + assert!( + body.contains("ask_user_clarification"), + "prompt must require explicit user confirmation before execute" + ); + assert!( + body.contains("prepared_id"), + "execute step must consume a prepared_id, not fabricated parameters" + ); + } + + #[test] + fn build_forbids_fabrication_and_logging_secrets() { + let body = build(&empty_ctx()).unwrap(); + assert!( + body.contains("No fabrication"), + "prompt must explicitly forbid fabricating chain/token/market params" + ); + assert!( + body.contains("Never log secrets") || body.contains("never log secrets"), + "prompt must forbid echoing private keys / seed phrases" + ); + } +} diff --git a/src/openhuman/agent/agents/loader.rs b/src/openhuman/agent/agents/loader.rs index 3bc494926..a6415b038 100644 --- a/src/openhuman/agent/agents/loader.rs +++ b/src/openhuman/agent/agents/loader.rs @@ -77,6 +77,11 @@ pub const BUILTINS: &[BuiltinAgent] = &[ toml: include_str!("integrations_agent/agent.toml"), prompt_fn: super::integrations_agent::prompt::build, }, + BuiltinAgent { + id: "crypto_agent", + toml: include_str!("crypto_agent/agent.toml"), + prompt_fn: super::crypto_agent::prompt::build, + }, BuiltinAgent { id: "tools_agent", toml: include_str!("tools_agent/agent.toml"), @@ -177,7 +182,7 @@ mod tests { fn all_builtins_parse() { let defs = load_builtins().expect("built-in TOML must parse"); assert_eq!(defs.len(), BUILTINS.len()); - assert_eq!(defs.len(), 15, "expected 15 built-in agents"); + assert_eq!(defs.len(), 16, "expected 16 built-in agents"); } #[test] @@ -497,6 +502,135 @@ mod tests { } } + /// Crypto Agent (#1397) is the dedicated specialist for wallet + /// actions and market operations. It must have a *narrow* tool + /// allowlist (no shell, no file_write, no broad HTTP), MUST keep + /// the safety preamble on (financial-risk gate), and MUST require + /// quote/confirm-before-execute via `ask_user_clarification`. + #[test] + fn crypto_agent_has_narrow_wallet_market_tools_and_safety_on() { + let def = find("crypto_agent"); + // Hint must be agentic — the agent reasons about quotes vs. + // executes across multiple tool calls per turn. + assert!(matches!(def.model, ModelSpec::Hint(ref h) if h == "agentic")); + assert_eq!(def.sandbox_mode, SandboxMode::None); + // Financial-risk agent — global safety preamble stays ON. + assert!( + !def.omit_safety_preamble, + "crypto_agent must keep the global safety preamble — financial-risk gate" + ); + match &def.tools { + ToolScope::Named(tools) => { + // Wallet read surface. + for required in [ + "wallet_status", + "wallet_balances", + "wallet_supported_assets", + "wallet_chain_status", + ] { + assert!( + tools.iter().any(|t| t == required), + "crypto_agent needs read tool `{required}`" + ); + } + // Quote / prepare surface. + for required in [ + "wallet_prepare_transfer", + "wallet_prepare_swap", + "wallet_prepare_contract_call", + ] { + assert!( + tools.iter().any(|t| t == required), + "crypto_agent needs prepare tool `{required}`" + ); + } + // Execute surface — gated by the prepared blob from a + // matching prepare_* call in the same turn. + assert!( + tools.iter().any(|t| t == "wallet_execute_prepared"), + "crypto_agent needs wallet_execute_prepared" + ); + // Confirmation gate — MUST be present so the prompt's + // "confirm before execute" rule is mechanically enforceable. + assert!( + tools.iter().any(|t| t == "ask_user_clarification"), + "crypto_agent needs ask_user_clarification to gate write ops" + ); + // Market grounding + context helpers. Pin the full set so a + // TOML edit that silently drops `stock_quote`, + // `stock_exchange_rate`, `memory_recall`, or `current_time` + // gets caught here — the agent's quote-before-execute + // discipline and "ground in user preferences before re-asking" + // behaviour both depend on these being present. + for required in [ + "stock_quote", + "stock_exchange_rate", + "stock_crypto_series", + "memory_recall", + "current_time", + ] { + assert!( + tools.iter().any(|t| t == required), + "crypto_agent needs supporting tool `{required}`" + ); + } + // Hard exclusions — no broad-surface or write-anywhere tools. + // Includes the orchestrator-level delegate_* tools so a future + // TOML edit can't accidentally hand crypto writes to the + // generic integrations or code-execution paths. + for forbidden in [ + "shell", + "file_write", + "curl", + "http_request", + "composio_execute", + "composio_list_tools", + "spawn_subagent", + "spawn_worker_thread", + "delegate_to_integrations_agent", + "delegate_run_code", + "delegate_research", + "delegate_plan", + ] { + assert!( + !tools.iter().any(|t| t == forbidden), + "crypto_agent must NOT have `{forbidden}` — keeps blast radius bounded" + ); + } + } + ToolScope::Wildcard => panic!("crypto_agent must have a Named tool scope"), + } + // Keep iteration cap tight — quote → confirm → execute is a + // 3-step loop, not a research crawl. + assert!( + def.max_iterations <= 10, + "crypto_agent max_iterations must stay tight (got {})", + def.max_iterations + ); + assert!(def.omit_identity); + assert!(def.omit_memory_context); + assert!(def.omit_skills_catalog); + } + + /// Routing: the orchestrator must list `crypto_agent` in its + /// `subagents` so a `delegate_do_crypto` tool is synthesised at + /// agent-build time. Without this entry the orchestrator can't + /// route crypto-shaped requests to the specialist. + #[test] + fn orchestrator_subagents_include_crypto_agent() { + use crate::openhuman::agent::harness::definition::SubagentEntry; + let def = find("orchestrator"); + let listed = def.subagents.iter().any(|e| match e { + SubagentEntry::AgentId(id) => id == "crypto_agent", + _ => false, + }); + assert!( + listed, + "orchestrator.subagents must list `crypto_agent` so the \ + routing layer can synthesise `delegate_do_crypto`" + ); + } + #[test] fn welcome_has_onboarding_and_memory_tools() { let def = find("welcome"); diff --git a/src/openhuman/agent/agents/mod.rs b/src/openhuman/agent/agents/mod.rs index b16562b65..1f930c588 100644 --- a/src/openhuman/agent/agents/mod.rs +++ b/src/openhuman/agent/agents/mod.rs @@ -7,6 +7,7 @@ mod loader; pub mod archivist; pub mod code_executor; pub mod critic; +pub mod crypto_agent; pub mod help; pub mod integrations_agent; pub mod morning_briefing; diff --git a/src/openhuman/agent/agents/orchestrator/agent.toml b/src/openhuman/agent/agents/orchestrator/agent.toml index 13dafafca..d6753ce01 100644 --- a/src/openhuman/agent/agents/orchestrator/agent.toml +++ b/src/openhuman/agent/agents/orchestrator/agent.toml @@ -44,6 +44,12 @@ subagents = [ "tools_agent", "critic", "archivist", + # Crypto wallet & market specialist (#1397). Synthesised into a + # `delegate_do_crypto` tool at agent-build time. Route any wallet, + # transfer, swap, contract-call, or exchange trading request here — + # the agent enforces a strict read → simulate → confirm → execute + # contract that the generic delegation surface does not. + "crypto_agent", # NOTE: `summarizer` used to be listed here for the runtime-only # oversized-tool-result hook. That path is currently disabled # (`context.summarizer_payload_threshold_tokens = 0`) after recursive diff --git a/src/openhuman/agent/agents/orchestrator/prompt.md b/src/openhuman/agent/agents/orchestrator/prompt.md index ab1db6724..1743a1be7 100644 --- a/src/openhuman/agent/agents/orchestrator/prompt.md +++ b/src/openhuman/agent/agents/orchestrator/prompt.md @@ -26,6 +26,7 @@ Follow this sequence for every user message: - Yes: use direct tools (`current_time`, `cron_*`, `memory_*`, `composio_list_connections`, etc.). - No: continue. 4. **Does this need other specialised execution?** + - 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 code writing/execution/debugging is required, use `delegate_run_code`. - If web/doc crawling is required, use `delegate_researcher`. - If complex multi-step decomposition is required, use `delegate_plan`.