walletpress/backend/agent/orchestrator.py
cryptorugmunch e13bd4d774
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / security (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / license (push) Waiting to run
CI / ai-review (push) Waiting to run
docs: apply fleet-template (16-artifact scaffold)
Adds missing standard artifacts:
- README.md (if missing)
- AGENTS.md (AI agent contract)
- PLAN.md (current sprint)
- STATUS.md (where we are)
- DEVELOPMENT.md (dev workflow)
- DEPLOYMENT.md (deploy procedure)
- TESTING.md (test strategy)
- DECISIONS.md (ADR index + templates)
- .github/CODEOWNERS
- .github/workflows/ci.yml

Preserves all existing artifacts.

Refs: RugMunchMedia/fleet-template
2026-07-02 02:07:06 +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)