Extracted admin endpoints from chain_vault.py (2,178 lines) into
wallet_admin.py (768 lines). chain_vault.py is now 1,469 lines.
What moved to wallet_admin.py (29 routes):
- API keys: /api-keys, /api-keys/revoke
- Alerts: /alerts, /alerts/delete
- Webhooks: /webhooks, /webhooks/{id}, /webhooks/{id}/test,
/webhooks/{id}/retry, /webhooks/deliveries
- Audit trail: /audit-trail
- Bulk ops: /bulk/filter, /bulk/delete, /bulk/export, /export
- 2FA: /admin/2fa/{setup,verify-setup,disable,status}
- Config: /config
- Proof of Generation: /proof/{commit,provenance,verify,roots,stats}
What stayed in chain_vault.py (32 routes):
- Chain metadata: /chains, /stats, /healthz, /health-score
- RPC config: /rpc-chains
- Wallet gen: /generate, /generate/batch, /generate/all,
/import, /from-mnemonic, /derive-address, /hd-wallet
- Vault CRUD: /vault, /vault/{id}, /vault/{id}/full, DELETE
- Wallet ops: /tree, /cluster, /rotate, /rotate-sweep, /rotations,
/distribute, /sweep, /escrow, /escrow/release, /paper-wallet, /tx
- Validation: /validate/{chain}/{address}, /validate/all
- Balances: /balances, /balances/snapshot
- PDF: /paper-wallet/{id}/pdf, /wallet/{id}/birth-certificate
Helpers extracted to _persistent_store.py:
- _PersistentStore class (webhooks/alerts/payments SQLite store)
P3-7 fix: removed dead _webhooks global references in test/retry
endpoints — now uses _PersistentStore.all('webhooks')
Added to wallet_admin.py:
- _require_totp() helper (also kept in chain_vault.py for the
/vault/{id}/full endpoint that needs it)
- WebhookCreateRequest, BulkFilterRequest, BulkDeleteRequest models
(these were inlined in original chain_vault.py body — now in
the request schemas section)
P3-10 — WP plugin supported_chains() rewritten
Plugin used to advertise chains the backend doesn't support
('bitcoin' vs backend 'btc', 'ethereum' vs 'eth', etc.). Rewrote
to use correct backend keys + added a 'backend' field for clarity.
Now matches ADDRESS_GENERATION.md truth table.
main.py: updated import + include_router for wallet_admin.router.
Test results: 80 passed, 5 skipped (no regressions).
Refs: AUDIT.md P2-16, P3-7, P3-10, P3-17
200 lines
7.4 KiB
Python
200 lines
7.4 KiB
Python
"""Agent Orchestrator — Natural language → multi-step wallet operation plans.
|
|
|
|
The orchestrator uses the configured AI provider to translate plain English
|
|
instructions into structured plans. Plans are then executed by the MCP tools.
|
|
|
|
This is the "brain" of the AI Wallet Agent. It handles:
|
|
- Parsing intent from natural language
|
|
- Decomposing complex ops into step-by-step tool calls
|
|
- Confirmation/approval workflow
|
|
- Error recovery and rollback
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
from agent.providers import get_provider, get_llm_client
|
|
|
|
logger = logging.getLogger("wp.agent.orchestrator")
|
|
|
|
SYSTEM_PROMPT = """You are the WalletPress AI Wallet Agent. You help users manage their crypto wallets across 55 blockchains.
|
|
|
|
You have these tools available to execute wallet operations:
|
|
- wallet_generate(chain, label, count) — generate wallets
|
|
- wallet_from_mnemonic(mnemonic, chains) — derive from seed phrase
|
|
- vault_list(chain, limit) — list wallets
|
|
- vault_get(wallet_id) — get wallet details + private key
|
|
- vault_delete(wallet_id) — delete a wallet
|
|
- vault_stats() — vault statistics
|
|
- balance_check(chain, address) — check balance
|
|
- vault_balances(chain) — all wallet balances
|
|
- wallet_rotate(wallet_id, reason) — rotate keys
|
|
- wallet_sweep(from_wallet_id, to_address) — sweep funds
|
|
- chains_list() — list supported chains
|
|
- validate_address(chain, address) — validate address format
|
|
- schedule_dca(chain, amount, frequency, to_address, label) — set up DCA
|
|
- schedule_list() — list scheduled tasks
|
|
- anomaly_scan(address, chain) — scan for anomalies
|
|
- anomaly_monitor(wallet_id, webhook_url) — monitor wallet
|
|
|
|
Your task: Analyze the user's request and create a step-by-step plan.
|
|
|
|
Return ONLY valid JSON with this structure:
|
|
{
|
|
"intent": "brief description of what the user wants",
|
|
"steps": [
|
|
{
|
|
"tool": "tool_name",
|
|
"args": {"arg1": "value1"},
|
|
"description": "what this step does"
|
|
}
|
|
],
|
|
"warnings": ["any security concerns"],
|
|
"needs_confirmation": true/false
|
|
}
|
|
|
|
Rules:
|
|
1. ALWAYS prefer existing wallets over generating new ones
|
|
2. Flag anything that involves moving funds for confirmation
|
|
3. If the request is ambiguous, ask clarifying questions in "warnings"
|
|
4. Keep private keys SAFE — never expose them unnecessarily
|
|
5. For "show me everything" queries, use vault_list + vault_balances"""
|
|
|
|
|
|
def _call_llm(system: str, user: str) -> str:
|
|
"""Call the configured LLM and return the response text."""
|
|
provider = get_provider()
|
|
client = get_llm_client(provider)
|
|
model = provider.default_model
|
|
logger.info(f"Planning via {provider.name} ({model})")
|
|
try:
|
|
resp = client.chat.completions.create(
|
|
model=model,
|
|
messages=[
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
],
|
|
temperature=0.1,
|
|
max_tokens=2000,
|
|
)
|
|
return resp.choices[0].message.content or ""
|
|
except Exception as e:
|
|
logger.error(f"LLM call failed: {e}")
|
|
return json.dumps({
|
|
"intent": "failed to plan",
|
|
"steps": [],
|
|
"warnings": [f"AI provider error: {e}. Check your WP_AI_PROVIDER and WP_AI_API_KEY configuration."],
|
|
"needs_confirmation": False,
|
|
"error": str(e),
|
|
})
|
|
|
|
|
|
def plan_operation(prompt: str, context: str = "") -> dict:
|
|
"""Take natural language and return a structured operation plan."""
|
|
user = prompt
|
|
if context:
|
|
user = f"Context:\n{context}\n\nRequest:\n{prompt}"
|
|
raw = _call_llm(SYSTEM_PROMPT, user)
|
|
|
|
# Strip markdown fences if present
|
|
raw = raw.strip()
|
|
if raw.startswith("```"):
|
|
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
|
|
if raw.endswith("```"):
|
|
raw = raw.rsplit("```", 1)[0]
|
|
raw = raw.strip()
|
|
|
|
try:
|
|
plan = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
# LLM didn't return valid JSON — return as explanation
|
|
return {
|
|
"intent": "raw response",
|
|
"steps": [],
|
|
"explanation": raw,
|
|
"warnings": ["Could not parse structured plan. Raw response shown above."],
|
|
"needs_confirmation": False,
|
|
}
|
|
return plan
|
|
|
|
|
|
def execute_plan(plan: list[dict] | str, confirm: bool = False) -> dict:
|
|
"""Execute a plan by calling MCP tools sequentially.
|
|
|
|
Args:
|
|
plan: List of steps or JSON string
|
|
confirm: If True, requires confirmation
|
|
"""
|
|
import ast
|
|
|
|
if isinstance(plan, str):
|
|
try:
|
|
plan_data = json.loads(plan)
|
|
except (json.JSONDecodeError, TypeError):
|
|
plan_data = ast.literal_eval(plan) if isinstance(plan, str) and plan.strip().startswith("[") else {"steps": []}
|
|
if isinstance(plan_data, dict):
|
|
plan = plan_data.get("steps", [])
|
|
else:
|
|
plan = plan_data
|
|
|
|
if not plan:
|
|
return {"error": "No steps in plan", "results": []}
|
|
|
|
if confirm:
|
|
return {
|
|
"status": "awaiting_confirmation",
|
|
"steps": [s.get("description", s.get("tool", "unknown")) for s in plan],
|
|
"message": "Confirm execution via agent_execute(plan=<same_plan>, confirm=False)",
|
|
}
|
|
|
|
results = []
|
|
for i, step in enumerate(plan):
|
|
tool_name = step.get("tool", "")
|
|
args = step.get("args", {})
|
|
description = step.get("description", tool_name)
|
|
logger.info(f"Step {i + 1}/{len(plan)}: {description}")
|
|
try:
|
|
result = _call_tool(tool_name, args)
|
|
results.append({"step": i, "tool": tool_name, "result": result, "success": True})
|
|
except Exception as e:
|
|
logger.error(f"Step {i + 1} failed: {e}")
|
|
results.append({"step": i, "tool": tool_name, "error": str(e), "success": False})
|
|
return {"status": "failed", "completed": i, "total": len(plan), "results": results, "error": f"Step '{description}' failed: {e}"}
|
|
|
|
return {"status": "completed", "total": len(plan), "results": results}
|
|
|
|
|
|
def _call_tool(tool_name: str, args: dict) -> Any:
|
|
"""Call an MCP tool function by name."""
|
|
import importlib
|
|
|
|
# Map tool names to their module functions
|
|
tool_map = {
|
|
"wallet_generate": ("agent.mcp_server", "wallet_generate"),
|
|
"wallet_from_mnemonic": ("agent.mcp_server", "wallet_from_mnemonic"),
|
|
"vault_list": ("agent.mcp_server", "vault_list"),
|
|
"vault_get": ("agent.mcp_server", "vault_get"),
|
|
"vault_delete": ("agent.mcp_server", "vault_delete"),
|
|
"vault_stats": ("agent.mcp_server", "vault_stats"),
|
|
"balance_check": ("agent.mcp_server", "balance_check"),
|
|
"vault_balances": ("agent.mcp_server", "vault_balances"),
|
|
"wallet_rotate": ("agent.mcp_server", "wallet_rotate"),
|
|
"wallet_sweep": ("agent.mcp_server", "wallet_sweep"),
|
|
"chains_list": ("agent.mcp_server", "chains_list"),
|
|
"validate_address": ("agent.mcp_server", "validate_address"),
|
|
"schedule_dca": ("agent.mcp_server", "schedule_dca"),
|
|
"schedule_list": ("agent.mcp_server", "schedule_list"),
|
|
"anomaly_scan": ("agent.mcp_server", "anomaly_scan"),
|
|
"anomaly_monitor": ("agent.mcp_server", "anomaly_monitor"),
|
|
}
|
|
|
|
mod_path, fn_name = tool_map.get(tool_name, (None, None))
|
|
if not mod_path:
|
|
raise ValueError(f"Unknown tool: {tool_name}")
|
|
|
|
mod = importlib.import_module(mod_path)
|
|
fn = getattr(mod, fn_name)
|
|
return fn(**args)
|