walletpress/backend/agent/orchestrator.py
Rug Munch Media LLC 85d8ef5eac
refactor(routers): split chain_vault.py god router — WP-085..WP-087
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
2026-06-30 21:40:45 +07:00

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)