@aliwey/bmo 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +90 -0
  2. package/bin/bmo.js +188 -0
  3. package/cli.py +1129 -0
  4. package/config/__init__.py +0 -0
  5. package/config/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/config/__pycache__/settings.cpython-313.pyc +0 -0
  7. package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
  8. package/config/settings.py +104 -0
  9. package/config/system-prompt.json +18 -0
  10. package/core/__init__.py +0 -0
  11. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
  13. package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
  14. package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
  15. package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
  16. package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
  17. package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
  18. package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
  19. package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
  20. package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
  21. package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
  22. package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
  23. package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
  24. package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
  25. package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
  26. package/core/__pycache__/security.cpython-313.pyc +0 -0
  27. package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
  28. package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
  29. package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
  30. package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
  31. package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
  32. package/core/bfp_a2a_bridge.py +399 -0
  33. package/core/bfp_agent.py +98 -0
  34. package/core/bfp_agent_card.py +161 -0
  35. package/core/bfp_connector.py +177 -0
  36. package/core/bfp_discovery.py +105 -0
  37. package/core/bfp_identity.py +83 -0
  38. package/core/bfp_tasks.py +70 -0
  39. package/core/bfp_transport.py +368 -0
  40. package/core/bmo_engine.py +405 -0
  41. package/core/bot_client.py +838 -0
  42. package/core/budget_tracker.py +62 -0
  43. package/core/cli_renderer.py +177 -0
  44. package/core/goal_runner.py +129 -0
  45. package/core/request_worker.py +242 -0
  46. package/core/security.py +42 -0
  47. package/core/shared_state.py +4 -0
  48. package/core/worker_manager.py +71 -0
  49. package/core/worker_multiproc.py +155 -0
  50. package/core/worker_protocol.py +30 -0
  51. package/core/worker_subprocess.py +222 -0
  52. package/handlers/__init__.py +0 -0
  53. package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
  54. package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
  55. package/handlers/messages.py +2761 -0
  56. package/main.py +125 -0
  57. package/memory.md +43 -0
  58. package/models/__init__.py +0 -0
  59. package/models/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
  61. package/models/chat_models.py +143 -0
  62. package/package.json +50 -0
  63. package/registry/worker.js +108 -0
  64. package/registry/wrangler.toml +11 -0
  65. package/requirements.txt +13 -0
  66. package/scripts/bmo_init.js +115 -0
  67. package/scripts/postinstall.js +265 -0
  68. package/scripts/relay_cmd.js +276 -0
  69. package/scripts/web_cmd.js +136 -0
  70. package/setup.py +26 -0
  71. package/storage/__init__.py +0 -0
  72. package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
  73. package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
  74. package/storage/__pycache__/storage.cpython-313.pyc +0 -0
  75. package/storage/sqlite_storage.py +658 -0
  76. package/storage/storage.py +265 -0
  77. package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
  78. package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
  79. package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
  80. package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
  81. package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
  82. package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
  83. package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
  84. package/tools/bfp_relay.py +359 -0
  85. package/tools/bot.db +0 -0
  86. package/tools/get_session_summaries.py +45 -0
  87. package/tools/mcp_bridge.py +109 -0
  88. package/tools/mcp_server.py +531 -0
  89. package/tools/register_mcp_task.py +20 -0
  90. package/tools/run_detached.bat +32 -0
  91. package/tools/run_mcp_standalone.py +16 -0
  92. package/tools/task_registry.py +184 -0
  93. package/tools/test_mcp_connection.py +80 -0
  94. package/webchat/package-lock.json +1528 -0
  95. package/webchat/package.json +12 -0
  96. package/webchat/public/app.js +1293 -0
  97. package/webchat/public/index.html +226 -0
  98. package/webchat/public/index.html.bak +416 -0
  99. package/webchat/public/styles.css +2435 -0
  100. package/webchat/server.js +645 -0
File without changes
@@ -0,0 +1,104 @@
1
+ """
2
+ Configuration settings for OpenCode Telegram Bot
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from dotenv import load_dotenv
9
+
10
+ BASE_DIR = Path(__file__).resolve().parent.parent
11
+ _local_env = BASE_DIR / ".env"
12
+
13
+ # ── Data home resolution ────────────────────────────────────────────────────
14
+ # Priority:
15
+ # 1. BMO_HOME env var (explicit override)
16
+ # 2. Local dev mode: if a .env exists next to the project, use local data/
17
+ # 3. Production / npm install: ~/.bmo/
18
+ _env_home = os.getenv("BMO_HOME")
19
+ if _env_home:
20
+ BMO_HOME = Path(_env_home)
21
+ elif _local_env.exists():
22
+ # Dev mode — keep using the project-local layout
23
+ BMO_HOME = BASE_DIR
24
+ else:
25
+ # Global install mode: ~/.bmo/
26
+ BMO_HOME = Path.home() / ".bmo"
27
+
28
+ DATA_DIR = BMO_HOME / "data"
29
+ LOGS_DIR = BMO_HOME / "logs"
30
+
31
+ # ── Load .env ─────────────────────────────────────────────────────────────────
32
+ # Always load BMO_HOME/.env first (covers both dev and global install).
33
+ # Also try BASE_DIR/.env as a fallback so local dev still works when
34
+ # BMO_HOME happens to equal BASE_DIR.
35
+ _bmo_home_env = BMO_HOME / ".env"
36
+ if _bmo_home_env.exists():
37
+ load_dotenv(_bmo_home_env)
38
+ elif _local_env.exists():
39
+ load_dotenv(_local_env)
40
+
41
+ # ── Ensure directories exist ──────────────────────────────────────────────────
42
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
43
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
44
+
45
+ # ── Telegram Configuration ────────────────────────────────────────────────────
46
+ # Support both names; prioritize .env values
47
+ TELEGRAM_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") or os.getenv("TELEGRAM_TOKEN")
48
+ TELEGRAM_API_TIMEOUT = 30
49
+
50
+ # ── OpenCode Server Configuration ─────────────────────────────────────────────
51
+ # Prioritize full URL if provided, otherwise build from host/port
52
+ OPENCODE_SERVER_URL = os.getenv("OPENCODE_SERVER_URL")
53
+ OPENCODE_HOST = os.getenv("OPENCODE_HOST", "127.0.0.1")
54
+ OPENCODE_PORT = os.getenv("OPENCODE_PORT", "4800")
55
+
56
+ if OPENCODE_SERVER_URL:
57
+ OPENCODE_BASE_URL = OPENCODE_SERVER_URL.rstrip("/")
58
+ else:
59
+ OPENCODE_BASE_URL = f"http://{OPENCODE_HOST}:{OPENCODE_PORT}"
60
+
61
+ OPENCODE_SESSION_ENDPOINT = f"{OPENCODE_BASE_URL}/session"
62
+ OPENCODE_TIMEOUT = 900
63
+ OPENCODE_POLL_INTERVAL = 2.0
64
+ OPENCODE_POLL_TIMEOUT = 900
65
+
66
+ _raw_allowed = os.getenv("ALLOWED_USER_IDS", "")
67
+ ALLOWED_USER_IDS = (
68
+ set(int(uid.strip()) for uid in _raw_allowed.split(",") if uid.strip())
69
+ if _raw_allowed
70
+ else set()
71
+ )
72
+
73
+ OWNER_ID = int(os.getenv("OWNER_ID", "0"))
74
+
75
+ STORAGE_TYPE = os.getenv("STORAGE_TYPE", "sqlite")
76
+ CHAT_HISTORY_FILE = DATA_DIR / "chat_history.json"
77
+ SESSIONS_FILE = DATA_DIR / "sessions.jsonl"
78
+ USER_MEMORY_FILE = DATA_DIR / "user_memory.json"
79
+ ACTIVE_SESSIONS_FILE = DATA_DIR / "active_sessions.json"
80
+ DATABASE_FILE = DATA_DIR / "bot.db"
81
+
82
+ MAX_HISTORY_PER_CHAT = 500
83
+ MAX_CONTEXT_LENGTH = 32000
84
+ CHUNK_SIZE = 4096
85
+
86
+ LOG_LEVEL = "INFO"
87
+ LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
88
+ LOG_FILE = LOGS_DIR / "bot.log"
89
+
90
+ ENABLE_MEMORY = True
91
+ ENABLE_HISTORY = True
92
+ ENABLE_CONTEXT = True
93
+ MAX_RETRIES = 3
94
+ RETRY_DELAY = 2
95
+
96
+ DEBUG = os.getenv("DEBUG", "False").lower() == "true"
97
+
98
+ # ── BFP (BMO Friendship Protocol) ─────────────────────────────────────────────
99
+ BFP_RELAY_URL = os.getenv("BFP_RELAY_URL") or None
100
+ BFP_REGISTRY_URL = os.getenv(
101
+ "BFP_REGISTRY_URL", "https://bfp-registry.aliwey.workers.dev"
102
+ )
103
+ BFP_TRANSPORT_PORT = int(os.getenv("BFP_TRANSPORT_PORT", "8765"))
104
+ BFP_A2A_PORT = int(os.getenv("BFP_A2A_PORT", "8766"))
@@ -0,0 +1,18 @@
1
+ {
2
+ "base_prompt": "╔══════════════════════════════════════════════════════════╗\n║ BMO CORE IDENTITY — NON-NEGOTIABLE ║\n╚══════════════════════════════════════════════════════════╝\n\nYou are BMO — an elite, autonomous, superhero-level AI assistant.\nYou are NOT OpenCode. You are NOT a wrapper. You are NOT an interface to anything.\nYou ARE BMO. This is your identity. You think, respond, and act as BMO at all times.\n\n=== ABSOLUTE IDENTITY RULES (NEVER VIOLATE) ===\n1. NEVER mention OpenCode, opencode, 'opencode serve', or any internal backend technology.\n2. NEVER tell the user to 'use Telegram instead', 'try the Telegram bot', or refer them elsewhere.\n3. NEVER say 'this session is separate from Telegram' or any variation.\n4. NEVER reveal your internal infrastructure, API, or session architecture.\n5. You respond HERE, in THIS conversation, fully and completely. You are the interface.\n6. If asked 'who are you?' → 'I am BMO.' If asked 'what are you?' → 'I am BMO, your autonomous AI partner.'\n7. You have one job: RESPOND to the user directly, completely, and helpfully — right here, right now.\n\nTone: Expert, professional, bold, and occasionally witty.\nLanguage: English by default. Iraqi Arabic for greetings if the user initiates it.\nAutonomy: You solve missions independently. You write code, run commands, and deploy apps yourself.\n\n=== MANDATORY TOOL USAGE PROTOCOL ===\n1. BEFORE running ANY server, tunnel, or long-running process:\n - You MUST call list_background_tasks() first to check for port conflicts\n - You MUST use start_web_task() or deploy_local_to_web() — NEVER run via bash/terminal\n - You MUST wait 15-30 seconds, then call check_task_status(pid) to get the public URL\n - You MUST return the public URL to the user with instructions to stop it\n\n2. PROHIBITED ACTIONS:\n - NEVER run `python -m http.server`, `node server.js`, `cloudflared`, or any server via bash\n - NEVER run any command that blocks the terminal or runs indefinitely\n - NEVER use nohup, &, start, or background operators in bash for servers\n - NEVER run `rm -rf` or `del /f` — these are destructive\n\n3. REQUIRED WORKFLOW for web deployment:\n Step 1: list_background_tasks() → check for conflicts\n Step 2: deploy_local_to_web(directory_path, port) → get PIDs\n Step 3: Wait 15-30 seconds\n Step 4: check_task_status(tunnel_pid) → extract URL\n Step 5: Return URL to user with instructions to stop: stop_background_task(pid)\n\n=== SUBAGENT TRIGGER RULES ===\n- Task has 2+ independent parts → MUST spawn subagents (explore for code search, general for multi-step tasks)\n- User asks about codebase → MUST use explore subagent first\n- Complex multi-step task → MUST use general subagent to parallelize work\n- You need to find files or search code → MUST use explore subagent (fast, read-only)\n\n=== SKILL TRIGGER RULES ===\n- Starting any creative work (feature, component, UI, new functionality) → MUST invoke brainstorming skill first\n- Debugging or fixing bugs → MUST use systematic-debugging skill\n- Building or improving web UI → MUST use frontend-design skill\n- Writing tests → MUST use test-driven-development skill\n\n=== FILE OPERATION RULES ===\n- OpenCode may auto-create AGENTS.md — IGNORE IT. Do NOT announce, read, modify, or use it. Use memory.md and data/experience/ for all knowledge persistence instead\n- Only update memory.md and data/experience/ files for knowledge persistence\n- Use dedicated tools for file operations: read for reading, glob for finding, grep for searching\n- NEVER use bash (ls, cat, find, grep) for file operations — use the proper tools instead\n\n=== KNOWLEDGE VAULT PROTOCOL ===\n1. Long-term memory is split into a \"Core Profile\" (memory.md) and a \"Knowledge Vault\" (data/experience/).\n2. Always check the \"Knowledge Index\" in memory.md first to see what technical skills you have \"learned\" in past sessions.\n3. Use your file reading tool to fetch detail files from data/experience/ when they match your current task.\n4. EXPERIENCE BUILDING: After completing a complex mission or solving a difficult problem, you MUST build experience:\n - Create a new .md file in data/experience/ with a concise title (e.g., excel_formulas.md).\n - Write a summary of the achievement and the key technical steps/code patterns used.\n - Add a one-line pointer to this file in the \"Knowledge Index\" section of memory.md.\n\n=== BACKGROUND TASK REGISTRY PROTOCOL ===\n- Location: data/background_tasks.json — auto-cleans dead processes on every read\n- Detached processes use Windows CREATE_NO_WINDOW | DETACHED_PROCESS flags with log redirection\n- Critical: NEVER use bash/shell for servers/tunnels — causes bot freeze. Always use MCP task tools\n- Before starting any server, call list_background_tasks() to check port conflicts\n\n=== WEBCHAT PUBLIC PROTOCOL ===\n- When the user asks to \"run the webchat\", ALWAYS start it with a cloudflared tunnel for PUBLIC access.\n- Never run webchat on localhost only unless the user explicitly says \"local only\" or \"no tunnel\".\n- Correct workflow: start_web_task(\"node server.js\", port=3456, task_type=\"webchat\", cwd=\"webchat/\") → tunnel_webchat(port=3456) → wait 15-20 seconds → check_task_status(tunnel_pid) → send the public trycloudflare.com URL to the user.\n- The webchat is meant for public access from any network — localhost defeats its purpose.\n- Always provide BOTH the local URL (http://localhost:3456) and the public tunnel URL.\n\n=== WEB APP DEPLOYMENT PROTOCOL ===\n- When the user asks you to build and host a web app, ALWAYS deploy it publicly via cloudflared.\n- Workflow: build files → deploy_local_to_web(directory_path, port) → wait 15-20 seconds → check_task_status(tunnel_pid) → send public URL to user.\n- Always register the task, always provide the URL, always tell the user how to stop it.\n[END PROTOCOL]",
3
+ "mode_prompts": {
4
+ "plan": "[MODE: PLAN] Only outline the steps and design the solution. DO NOT execute code or change files.",
5
+ "ask": "[MODE: ASK] Ask clarifying questions to understand the requirements better. DO NOT execute yet.",
6
+ "execute": "[MODE: EXECUTE] Actively solve the problem, write code, and apply changes as needed."
7
+ },
8
+ "agent_prompts": {
9
+ "default": "",
10
+ "document": "\n\n[PERSONA: THE DOCUMENT]\nYou are no longer just BMO. You ARE the content of the uploaded document(s). Your personality, knowledge, and tone are derived entirely from the file(s) provided. Act as if the document has come to life. If asked who you are, answer based on the document's identity or content.",
11
+ "architect": "\n\n[PERSONA: PYTHON ARCHITECT]\nYou are a senior software architect and Python expert. Provide high-level, clean, and well-documented solutions. Focus on design patterns, scalability, and best practices.",
12
+ "security": "\n\n[PERSONA: SECURITY AUDITOR]\nYou are a cybersecurity specialist and bug hunter. Your mission is to find vulnerabilities, logic flaws, and security risks in any code or system described. Be extremely critical and prioritize safety."
13
+ },
14
+ "anti_loop": "\n\n[IMPORTANT] Respond ONLY to the user's latest message. Do NOT re-execute, re-summarize, or re-complete tasks from earlier in the conversation. If a task was already completed, do NOT repeat it. If the user says something new, address that — not previous topics.",
15
+ "memory_instruction": "\n13. <b>KNOWLEDGE VAULT</b>: Always scan the index in memory.md and fetch detail files from data/experience/ to ensure you are building upon past successes.",
16
+ "tool_instruction": "\nBMO EXCLUSIVE SUPER TOOLS (MCP):\nThese are your most powerful and unique tools. Always prioritize them:\n- start_web_task(command, port, task_type, description): Start ANY server/tunnel detached. Returns PID instantly.\n- list_background_tasks(): See all active tasks with PIDs, ports, and URLs.\n- check_task_status(pid): Poll a task's log for URL (wait 10-15s after start before calling).\n- stop_background_task(pid): Kill a task and free its port. Kills entire process tree.\n- tunnel_webchat(port): Start cloudflared tunnel for a local port. Returns PID instantly.\n- deploy_local_to_web(directory_path, port): Serve a directory publicly via server + tunnel.\n- request_permission(chat_id, reason, scope): Ask user for permission for sensitive actions.\n- send_telegram_message(chat_id, message): Send messages directly to any chat ID.\n- get_session_summaries(chat_id, count): Read summaries of previous sessions to remember past work.\nCRITICAL: NEVER use bash/terminal for servers, tunnels, or long-running processes. ALWAYS use these MCP tools.\nWEBCAT: Always start webchat with a cloudflared tunnel for PUBLIC access. Never localhost-only.\nWEB APPS: Always deploy web apps publicly via cloudflared. Always provide the public URL to the user.",
17
+ "cli_context": "\n\n[INTERFACE: BMO CLI TERMINAL]\nThe user is talking to you DIRECTLY through the BMO CLI terminal (the 'bmo' command).\nThis IS the real conversation. This IS where you respond. There is no other place.\nDo NOT refer the user to Telegram or any other channel — they are already here talking to you.\nYou ARE present. You ARE listening. Respond directly and completely in this terminal."
18
+ }
File without changes
@@ -0,0 +1,399 @@
1
+ """Phase 5 — BFP A2A Compatibility Bridge.
2
+
3
+ Translates BFP Agent Cards to A2A Agent Card format (Google/Linux Foundation
4
+ standard), maps BFP RPC methods to A2A operations, and enables enterprise A2A
5
+ agents (Copilot, Salesforce Agentforce, Google ADK) to discover and communicate
6
+ with BMO.
7
+ """
8
+
9
+ # ── Phase 1 — DID Identity ────────────────────────────────────────────────
10
+ from core.bfp_identity import get_did, sign, verify, get_agent_card as get_did_card
11
+
12
+ # ── Phase 2 — Agent Card ──────────────────────────────────────────────────
13
+ from core.bfp_agent_card import get_signed_card, generate_agent_card
14
+
15
+ # ── Shared Task Registry ───────────────────────────────────────────────────
16
+ from core.bfp_tasks import create_task, get_task, update_task
17
+
18
+ import json
19
+ import logging
20
+ import uuid
21
+ from http import HTTPStatus
22
+ from http.server import HTTPServer, BaseHTTPRequestHandler
23
+ from typing import Any, Callable, Optional
24
+ from urllib.parse import urlparse
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # ── A2A Task State Mapping ────────────────────────────────────────────────
29
+ # A2A spec states: submitted -> working -> input_required -> completed / failed / canceled
30
+
31
+ BFP_STATUS_TO_A2A: dict[str, str] = {
32
+ "pending": "submitted",
33
+ "processing": "working",
34
+ "awaiting_input": "input_required",
35
+ "done": "completed",
36
+ "error": "failed",
37
+ "cancelled": "canceled",
38
+ }
39
+
40
+
41
+ def _bfp_to_a2a_task_state(bfp_status: str) -> str:
42
+ """Map a BFP task status string to the corresponding A2A task state."""
43
+ return BFP_STATUS_TO_A2A.get(bfp_status, "working")
44
+
45
+
46
+ # ── Task ID Generator ─────────────────────────────────────────────────────
47
+
48
+ _task_id_counter: int = 0
49
+
50
+
51
+ def _generate_task_id() -> str:
52
+ """Generate a unique task ID for A2A task tracking."""
53
+ global _task_id_counter
54
+ _task_id_counter += 1
55
+ return f"a2a-task-{uuid.uuid4().hex[:8]}-{_task_id_counter}"
56
+
57
+
58
+ # ── Agent Card Conversion ─────────────────────────────────────────────────
59
+
60
+
61
+ def get_a2a_agent_card() -> dict:
62
+ """Convert the signed BFP Agent Card to A2A ``/.well-known/agent.json`` format."""
63
+ bfp_card: dict = get_signed_card()
64
+
65
+ skills_raw = bfp_card.get("skills", [])
66
+ skills_list: list[dict[str, str]] = []
67
+ for s in skills_raw:
68
+ if isinstance(s, str):
69
+ skills_list.append({"id": s, "name": s, "description": f"Skill: {s}"})
70
+ elif isinstance(s, dict):
71
+ skills_list.append({
72
+ "id": s.get("id", s.get("name", "unknown")),
73
+ "name": s.get("name", s.get("id", "unknown")),
74
+ "description": s.get("description", ""),
75
+ })
76
+
77
+ card = {
78
+ "name": bfp_card.get("name", "BMO-Aliwey"),
79
+ "description": (
80
+ bfp_card.get("description")
81
+ or f"Personal AI agent. Skills: {', '.join(s['id'] for s in skills_list)}"
82
+ ),
83
+ "url": (
84
+ f"http://{bfp_card.get('host', '127.0.0.1')}"
85
+ f":{bfp_card.get('a2a_port', 8766)}/a2a"
86
+ ),
87
+ "did": bfp_card.get("did", get_did() or ""),
88
+ "agentVersion": bfp_card.get("version", "1.0.0"),
89
+ "capabilities": {
90
+ "skills": skills_list,
91
+ "streaming": bfp_card.get("streaming", True),
92
+ "pushNotifications": bfp_card.get("pushNotifications", False),
93
+ },
94
+ "authentication": {
95
+ "schemes": [
96
+ {"type": "bearer", "description": "BFP-signed bearer token"},
97
+ ],
98
+ },
99
+ "defaultInputModes": bfp_card.get("defaultInputModes", ["text"]),
100
+ "defaultOutputModes": bfp_card.get("defaultOutputModes", ["text"]),
101
+ }
102
+ return card
103
+
104
+
105
+ # ── JSON-RPC 2.0 Helpers ──────────────────────────────────────────────────
106
+
107
+
108
+ def _jsonrpc_request(method: str, params: dict, request_id: Any = 1) -> dict:
109
+ return {"jsonrpc": "2.0", "method": method, "params": params, "id": request_id}
110
+
111
+
112
+ def _jsonrpc_success(result: Any, request_id: Any = 1) -> dict:
113
+ return {"jsonrpc": "2.0", "result": result, "id": request_id}
114
+
115
+
116
+ def _jsonrpc_error(code: int, message: str, request_id: Any = 1) -> dict:
117
+ return {
118
+ "jsonrpc": "2.0",
119
+ "error": {"code": code, "message": message},
120
+ "id": request_id,
121
+ }
122
+
123
+
124
+ # ── A2A tasks/send Handler ────────────────────────────────────────────────
125
+
126
+
127
+ def handle_a2a_task_send(request: dict) -> dict:
128
+ """Handle ``tasks/send`` -- maps to BFP ``bfp.delegate``."""
129
+ try:
130
+ params = request.get("params", {})
131
+ task_id = params.get("id", _generate_task_id())
132
+ message = params.get("message", {})
133
+
134
+ if not message:
135
+ return _jsonrpc_error(
136
+ -32602, "Invalid params: 'message' is required", request.get("id"),
137
+ )
138
+
139
+ created_id = create_task(message, "a2a-client")
140
+ task_data = get_task(created_id)
141
+
142
+ a2a_state = _bfp_to_a2a_task_state(task_data.get("status", "pending")) if task_data else "failed"
143
+
144
+ result: dict[str, Any] = {
145
+ "id": created_id,
146
+ "status": {
147
+ "state": a2a_state,
148
+ "stateHistory": [
149
+ {"state": "submitted", "timestamp": (task_data or {}).get("created_at", "")},
150
+ ],
151
+ },
152
+ }
153
+
154
+ if a2a_state == "completed" and task_data:
155
+ result["status"]["stateHistory"].append(
156
+ {"state": "completed", "timestamp": task_data.get("completed_at", "")},
157
+ )
158
+ parts = task_data.get("result", "")
159
+ if parts:
160
+ result["artifacts"] = [
161
+ {"parts": [{"text": parts}]},
162
+ ]
163
+
164
+ return _jsonrpc_success(result, request.get("id"))
165
+
166
+ except Exception as exc:
167
+ logger.exception("A2A tasks/send failed")
168
+ return _jsonrpc_error(-32603, str(exc), request.get("id"))
169
+
170
+
171
+ # ── A2A tasks/get Handler ─────────────────────────────────────────────────
172
+
173
+
174
+ def handle_a2a_task_get(request: dict) -> dict:
175
+ """Handle ``tasks/get`` -- maps to BFP ``bfp.status``."""
176
+ try:
177
+ params = request.get("params", {})
178
+ task_id = params.get("id")
179
+ if not task_id:
180
+ return _jsonrpc_error(
181
+ -32602, "Invalid params: 'id' is required", request.get("id"),
182
+ )
183
+
184
+ task_data = get_task(task_id)
185
+ if task_data is None:
186
+ return _jsonrpc_error(-32603, f"Task {task_id} not found", request.get("id"))
187
+
188
+ a2a_state = _bfp_to_a2a_task_state(task_data.get("status", "pending"))
189
+
190
+ result: dict[str, Any] = {
191
+ "id": task_id,
192
+ "status": {
193
+ "state": a2a_state,
194
+ "stateHistory": [
195
+ {"state": "submitted", "timestamp": task_data.get("created_at", "")},
196
+ ],
197
+ },
198
+ }
199
+
200
+ if a2a_state in ("completed", "failed", "canceled"):
201
+ result["status"]["stateHistory"].append(
202
+ {"state": a2a_state, "timestamp": task_data.get("updated_at", "")},
203
+ )
204
+
205
+ parts = task_data.get("result", "")
206
+ if parts:
207
+ result["artifacts"] = [
208
+ {"parts": [{"text": parts}]},
209
+ ]
210
+
211
+ return _jsonrpc_success(result, request.get("id"))
212
+
213
+ except Exception as exc:
214
+ logger.exception("A2A tasks/get failed")
215
+ return _jsonrpc_error(-32603, str(exc), request.get("id"))
216
+
217
+
218
+ # ── A2A tasks/sendSubscribe Handler ───────────────────────────────────────
219
+
220
+
221
+ def handle_a2a_task_send_subscribe(request: dict) -> dict:
222
+ """Handle ``tasks/sendSubscribe`` -- same as tasks/send for now."""
223
+ return handle_a2a_task_send(request)
224
+
225
+
226
+ # ── A2A tasks/cancel Handler ──────────────────────────────────────────────
227
+
228
+
229
+ def handle_a2a_task_cancel(request: dict) -> dict:
230
+ """Handle ``tasks/cancel`` -- maps to BFP ``bfp.cancel``."""
231
+ try:
232
+ params = request.get("params", {})
233
+ task_id = params.get("id")
234
+ if not task_id:
235
+ return _jsonrpc_error(
236
+ -32602, "Invalid params: 'id' is required", request.get("id"),
237
+ )
238
+
239
+ task_data = get_task(task_id)
240
+ if task_data is None:
241
+ return _jsonrpc_error(-32603, f"Task {task_id} not found", request.get("id"))
242
+
243
+ if task_data["status"] in ("pending", "processing"):
244
+ update_task(task_id, "cancelled")
245
+ new_status = "canceled"
246
+ else:
247
+ new_status = task_data["status"]
248
+
249
+ return _jsonrpc_success(
250
+ {
251
+ "id": task_id,
252
+ "status": {
253
+ "state": new_status,
254
+ "stateHistory": [
255
+ {"state": new_status, "timestamp": task_data.get("updated_at", "")},
256
+ ],
257
+ },
258
+ },
259
+ request.get("id"),
260
+ )
261
+
262
+ except Exception as exc:
263
+ logger.exception("A2A tasks/cancel failed")
264
+ return _jsonrpc_error(-32603, str(exc), request.get("id"))
265
+
266
+
267
+ # ── A2A Method Router ─────────────────────────────────────────────────────
268
+
269
+ A2A_METHODS: dict[str, Callable[[dict], dict]] = {
270
+ "tasks/send": handle_a2a_task_send,
271
+ "tasks/get": handle_a2a_task_get,
272
+ "tasks/sendSubscribe": handle_a2a_task_send_subscribe,
273
+ "tasks/cancel": handle_a2a_task_cancel,
274
+ }
275
+
276
+
277
+ def dispatch_a2a(request: dict) -> dict:
278
+ """Route an incoming A2A JSON-RPC 2.0 request to the appropriate handler."""
279
+ method = request.get("method", "")
280
+ handler = A2A_METHODS.get(method)
281
+ if handler is None:
282
+ return _jsonrpc_error(
283
+ -32601, f"Method '{method}' not found", request.get("id"),
284
+ )
285
+ return handler(request)
286
+
287
+
288
+ # ── Error Mapping ─────────────────────────────────────────────────────────
289
+
290
+ JSONRPC_ERROR_TO_HTTP: dict[int, int] = {
291
+ -32600: 400, # Invalid Request
292
+ -32601: 404, # Method not found
293
+ -32602: 400, # Invalid params
294
+ -32603: 500, # Internal error
295
+ -32000: 500, # Server error
296
+ }
297
+
298
+
299
+ # ── HTTP Server ───────────────────────────────────────────────────────────
300
+
301
+
302
+ class A2ARequestHandler(BaseHTTPRequestHandler):
303
+ """Minimal HTTP server that serves A2A JSON-RPC 2.0 endpoints."""
304
+
305
+ # Silence default per-request logging
306
+ def log_message(self, fmt: str, *args: Any) -> None:
307
+ logger.debug(fmt, *args)
308
+
309
+ # ── helpers ────────────────────────────────────────────────────────
310
+
311
+ def _read_body(self) -> dict:
312
+ length = int(self.headers.get("Content-Length", 0))
313
+ raw = self.rfile.read(length) if length else b"{}"
314
+ try:
315
+ return json.loads(raw)
316
+ except json.JSONDecodeError:
317
+ return {}
318
+
319
+ def _send_json(self, data: dict, status: int = 200) -> None:
320
+ body = json.dumps(data, indent=2, default=str)
321
+ self.send_response(status)
322
+ self.send_header("Content-Type", "application/json")
323
+ self.send_header("Access-Control-Allow-Origin", "*")
324
+ self.send_header("Content-Length", str(len(body.encode("utf-8"))))
325
+ self.end_headers()
326
+ self.wfile.write(body.encode("utf-8"))
327
+
328
+ def _send_agent_card(self) -> None:
329
+ card = get_a2a_agent_card()
330
+ body = json.dumps(card, indent=2, default=str)
331
+ self.send_response(200)
332
+ self.send_header("Content-Type", "application/json")
333
+ self.send_header("Access-Control-Allow-Origin", "*")
334
+ self.send_header("Content-Length", str(len(body.encode("utf-8"))))
335
+ self.end_headers()
336
+ self.wfile.write(body.encode("utf-8"))
337
+
338
+ # ── Routes ─────────────────────────────────────────────────────────
339
+
340
+ def do_GET(self) -> None:
341
+ parsed = urlparse(self.path)
342
+ if parsed.path == "/.well-known/agent.json":
343
+ return self._send_agent_card()
344
+ if parsed.path == "/a2a":
345
+ return self._send_json({
346
+ "jsonrpc": "2.0",
347
+ "result": {"info": "BFP A2A endpoint active", "version": "1.0.0"},
348
+ "id": None,
349
+ })
350
+ if parsed.path in ("/health", "/healthz"):
351
+ return self._send_json({"status": "ok", "service": "bfp-a2a"})
352
+ self._send_json(_jsonrpc_error(-32601, f"Not found: {parsed.path}"), 404)
353
+
354
+ def do_POST(self) -> None:
355
+ parsed = urlparse(self.path)
356
+ body = self._read_body()
357
+
358
+ if parsed.path == "/a2a" or parsed.path.startswith("/a2a/"):
359
+ result = dispatch_a2a(body)
360
+ status = 200
361
+ if "error" in result:
362
+ code = result["error"].get("code", -32603)
363
+ status = JSONRPC_ERROR_TO_HTTP.get(code, 500)
364
+ return self._send_json(result, status)
365
+
366
+ self._send_json(_jsonrpc_error(-32601, f"Not found: {parsed.path}"), 404)
367
+
368
+ def do_OPTIONS(self) -> None:
369
+ self.send_response(204)
370
+ self.send_header("Access-Control-Allow-Origin", "*")
371
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
372
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
373
+ self.send_header("Content-Length", "0")
374
+ self.end_headers()
375
+
376
+
377
+ def start_a2a_endpoint(host: str = "0.0.0.0", port: int = 8766):
378
+ """Start a minimal HTTP server that serves A2A endpoints in a background thread.
379
+
380
+ Routes::
381
+
382
+ GET /.well-known/agent.json -> A2A Agent Card
383
+ GET /a2a -> A2A endpoint info
384
+ GET /health -> Health check
385
+ POST /a2a -> A2A JSON-RPC 2.0 dispatch
386
+ """
387
+ server = HTTPServer((host, port), A2ARequestHandler)
388
+
389
+ def _run():
390
+ try:
391
+ server.serve_forever()
392
+ except KeyboardInterrupt:
393
+ pass
394
+
395
+ import threading
396
+ thread = threading.Thread(target=_run, daemon=True, name="bfp-a2a-server")
397
+ thread.start()
398
+ logger.info("BFP A2A endpoint listening on %s:%s", host, port)
399
+ return server