openclaw-agent-wake-protocol 1.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.
@@ -0,0 +1,754 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Unified LIFE Gateway MCP Server
4
+
5
+ Single FastMCP endpoint that serves Quin main + all C-level agents.
6
+ Handles agent lifecycle (discovery, registration, Genesis interview, wake)
7
+ via tool calls. Routes LIFE module calls by agent_id via subprocess.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import select
13
+ import sqlite3
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ from fastmcp import FastMCP
21
+
22
+ sys.stdout.reconfigure(encoding="utf-8")
23
+
24
+ BASE_DIR = Path(__file__).resolve().parent
25
+ REGISTRY_PATH = Path(os.getenv("LIFE_GATEWAY_REGISTRY", str(BASE_DIR / "agents.json")))
26
+ PYTHON_BIN = os.getenv("LIFE_PYTHON_BIN", sys.executable)
27
+ CALL_TIMEOUT_SEC = int(os.getenv("LIFE_CALL_TIMEOUT_SEC", "45"))
28
+
29
+ # Central SQLite DB for all agent registrations (C-level + Quin main)
30
+ LIFE_DB_PATH = BASE_DIR / "agents.db"
31
+
32
+ # Genesis questions (pulled once, shared across agents)
33
+ GENESIS_QUESTIONS_PATH = BASE_DIR / "genesis-questions.md"
34
+
35
+ # Shared agents directory (where Antfarm provisions C-level personas)
36
+ SHARED_AGENTS_PATH = (
37
+ Path.home()
38
+ / ".openclaw"
39
+ / "workspaces"
40
+ / "quin"
41
+ / "quin-workflows"
42
+ / "agents"
43
+ / "shared"
44
+ )
45
+
46
+ # FastMCP instance
47
+ mcp = FastMCP("life-gateway")
48
+
49
+ ALLOWED_MODULE_TOOLS = {
50
+ "drives": {"start", "snapshot"},
51
+ "heart": {"feel", "search", "check", "wall"},
52
+ "semantic": {"store", "search", "expand"},
53
+ "working": {"create", "add", "view", "see"},
54
+ "journal": {"write", "read"},
55
+ "state": {"want", "horizon"},
56
+ "history": {"recall", "discover"},
57
+ }
58
+
59
+ WAKE_SEQUENCE = [
60
+ ("drives", "start", {}),
61
+ ("heart", "search", {}),
62
+ ("working", "view", {}),
63
+ ("semantic", "search", {}),
64
+ ("history", "discover", {"section": "self"}),
65
+ ]
66
+
67
+
68
+ def _init_life_db() -> None:
69
+ """Initialize central SQLite DB schema. Safe to call multiple times."""
70
+ conn = sqlite3.connect(LIFE_DB_PATH)
71
+ c = conn.cursor()
72
+ c.execute("""
73
+ CREATE TABLE IF NOT EXISTS agents (
74
+ agent_id TEXT PRIMARY KEY,
75
+ name TEXT NOT NULL,
76
+ role TEXT NOT NULL,
77
+ clickup_email TEXT DEFAULT '',
78
+ clickup_user_id TEXT DEFAULT '',
79
+ workspace_dir TEXT NOT NULL,
80
+ life_root TEXT DEFAULT '',
81
+ enabled_modules TEXT DEFAULT '[]',
82
+ genesis_completed INTEGER DEFAULT 0,
83
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
84
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
85
+ )
86
+ """)
87
+ c.execute("""
88
+ CREATE TABLE IF NOT EXISTS traits (
89
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ agent_id TEXT NOT NULL,
91
+ trait_key TEXT NOT NULL,
92
+ trait_value TEXT,
93
+ active INTEGER DEFAULT 1,
94
+ FOREIGN KEY (agent_id) REFERENCES agents(agent_id)
95
+ )
96
+ """)
97
+ c.execute("""
98
+ CREATE TABLE IF NOT EXISTS history (
99
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
100
+ agent_id TEXT NOT NULL,
101
+ history_type TEXT NOT NULL,
102
+ content TEXT NOT NULL,
103
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
104
+ FOREIGN KEY (agent_id) REFERENCES agents(agent_id)
105
+ )
106
+ """)
107
+ conn.commit()
108
+ conn.close()
109
+
110
+
111
+ def _migrate_json_registry() -> None:
112
+ """Seed SQLite DB from agents.json on first run (preserves quin-ea-v1)."""
113
+ if not REGISTRY_PATH.exists():
114
+ return
115
+ try:
116
+ data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
117
+ agents = data.get("agents", {})
118
+ except Exception:
119
+ return
120
+
121
+ conn = sqlite3.connect(LIFE_DB_PATH)
122
+ c = conn.cursor()
123
+ for agent_id, cfg in agents.items():
124
+ c.execute("SELECT 1 FROM agents WHERE agent_id = ?", (agent_id,))
125
+ if c.fetchone():
126
+ continue # already migrated
127
+ life_root = cfg.get("life_root", "")
128
+ c.execute("""
129
+ INSERT INTO agents (agent_id, name, role, workspace_dir, life_root, enabled_modules)
130
+ VALUES (?, ?, ?, ?, ?, ?)
131
+ """, (
132
+ agent_id,
133
+ cfg.get("name", agent_id),
134
+ cfg.get("name", agent_id),
135
+ life_root,
136
+ life_root,
137
+ json.dumps(cfg.get("enabled_modules", [])),
138
+ ))
139
+ conn.commit()
140
+ conn.close()
141
+
142
+
143
+ def _db_get_agent(agent_id: str) -> Optional[Dict[str, Any]]:
144
+ conn = sqlite3.connect(LIFE_DB_PATH)
145
+ conn.row_factory = sqlite3.Row
146
+ c = conn.cursor()
147
+ c.execute("SELECT * FROM agents WHERE agent_id = ?", (agent_id,))
148
+ row = c.fetchone()
149
+ conn.close()
150
+ return dict(row) if row else None
151
+
152
+
153
+ def _identity_agent_id(agent_dir: Path) -> str:
154
+ """Resolve canonical agent_id from IDENTITY.md (fallback: directory name)."""
155
+ identity_file = agent_dir / "IDENTITY.md"
156
+ if identity_file.exists():
157
+ try:
158
+ for line in identity_file.read_text(encoding="utf-8").splitlines():
159
+ if "**Agent ID:**" in line:
160
+ candidate = line.split("**Agent ID:**", 1)[1].strip()
161
+ if candidate:
162
+ return candidate
163
+ except Exception:
164
+ pass
165
+ return agent_dir.name
166
+
167
+
168
+ # ===== Lifecycle tools (FastMCP) =====
169
+
170
+ @mcp.tool()
171
+ def discover_agents() -> List[Dict]:
172
+ """Auto-discover C-level agents from Antfarm-provisioned shared paths."""
173
+ found = []
174
+ if not SHARED_AGENTS_PATH.exists():
175
+ return found
176
+
177
+ for agent_dir in sorted(SHARED_AGENTS_PATH.iterdir()):
178
+ if not agent_dir.is_dir():
179
+ continue
180
+
181
+ identity_file = agent_dir / "IDENTITY.md"
182
+ if not identity_file.exists():
183
+ continue
184
+
185
+ agent_name = agent_dir.name
186
+ agent_id = _identity_agent_id(agent_dir)
187
+
188
+ clickup_email = ""
189
+ with open(identity_file) as f:
190
+ for line in f:
191
+ if line.startswith("clickup_email:"):
192
+ clickup_email = line.split(":", 1)[1].strip()
193
+ break
194
+
195
+ db_row = _db_get_agent(agent_id)
196
+ if not db_row:
197
+ legacy_id = _short_to_legacy(agent_id)
198
+ if legacy_id:
199
+ db_row = _db_get_agent(legacy_id)
200
+ found.append({
201
+ "agent_id": agent_id,
202
+ "name": agent_name,
203
+ "workspace": str(agent_dir),
204
+ "clickup_email": clickup_email,
205
+ "registered": db_row is not None,
206
+ "genesis_completed": bool(db_row.get("genesis_completed")) if db_row else False,
207
+ })
208
+
209
+ return found
210
+
211
+
212
+ @mcp.tool()
213
+ def register_agent(
214
+ agent_id: str,
215
+ name: str,
216
+ workspace_dir: str,
217
+ clickup_email: str = "",
218
+ life_root: str = "",
219
+ ) -> Dict:
220
+ """Register a C-level agent in the central LIFE database."""
221
+ conn = sqlite3.connect(LIFE_DB_PATH)
222
+ c = conn.cursor()
223
+ c.execute("""
224
+ INSERT OR REPLACE INTO agents
225
+ (agent_id, name, role, clickup_email, workspace_dir, life_root, enabled_modules, updated_at)
226
+ VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
227
+ """, (
228
+ agent_id, name, name, clickup_email, workspace_dir,
229
+ life_root or _legacy_runtime_life_root() or workspace_dir,
230
+ json.dumps(["drives", "heart", "semantic", "working", "journal", "state", "history"]),
231
+ ))
232
+ conn.commit()
233
+ conn.close()
234
+ return {"status": "registered", "agent_id": agent_id, "name": name}
235
+
236
+
237
+ @mcp.tool()
238
+ def initialize_life_core(agent_id: str) -> Dict:
239
+ """Initialize LIFE core for an agent: create DATA dirs and traits DB."""
240
+ row = _db_get_agent(agent_id)
241
+ if not row:
242
+ return {"error": f"Agent not found: {agent_id}"}
243
+
244
+ workspace_dir = Path(row["workspace_dir"])
245
+ life_data_dir = workspace_dir / "LIFE" / "DATA"
246
+ life_data_dir.mkdir(parents=True, exist_ok=True)
247
+ (life_data_dir / "history").mkdir(exist_ok=True)
248
+ (life_data_dir / "traits").mkdir(exist_ok=True)
249
+
250
+ # Copy Genesis questions into agent workspace
251
+ questions_dest = workspace_dir / "CORE" / "genesis" / "questions.md"
252
+ questions_dest.parent.mkdir(parents=True, exist_ok=True)
253
+ if GENESIS_QUESTIONS_PATH.exists() and not questions_dest.exists():
254
+ questions_dest.write_text(GENESIS_QUESTIONS_PATH.read_text())
255
+
256
+ # Per-agent traits DB
257
+ traits_db = life_data_dir / "traits" / "traits.db"
258
+ if not traits_db.exists():
259
+ agent_conn = sqlite3.connect(traits_db)
260
+ agent_conn.execute("""
261
+ CREATE TABLE IF NOT EXISTS traits (
262
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
263
+ trait_key TEXT NOT NULL,
264
+ trait_value TEXT,
265
+ active INTEGER DEFAULT 1
266
+ )
267
+ """)
268
+ agent_conn.commit()
269
+ agent_conn.close()
270
+
271
+ return {
272
+ "status": "initialized",
273
+ "agent_id": agent_id,
274
+ "life_data_dir": str(life_data_dir),
275
+ "traits_db": str(traits_db),
276
+ "questions_copied": questions_dest.exists(),
277
+ }
278
+
279
+
280
+ @mcp.tool()
281
+ def run_genesis_interview(agent_id: str) -> Dict:
282
+ """Return instructions for spawning an agent to complete its Genesis interview."""
283
+ answers_path = ""
284
+ row = _db_get_agent(agent_id)
285
+ if row:
286
+ answers_path = str(Path(row["workspace_dir"]) / "CORE" / "genesis" / "answers.md")
287
+ return {
288
+ "status": "interview_required",
289
+ "agent_id": agent_id,
290
+ "answers_path": answers_path,
291
+ "command": (
292
+ "Use any configured OpenClaw agent to create answers.md at the path above. "
293
+ "Recommended: openclaw agent --agent main --message "
294
+ f"'Write LIFE Genesis answers for agent_id={agent_id}. Read the agent persona files in its workspace and save to CORE/genesis/answers.md.'"
295
+ ),
296
+ }
297
+
298
+
299
+ @mcp.tool()
300
+ def apply_genesis_answers(agent_id: str, answers_path: str = "") -> Dict:
301
+ """Mark Genesis as complete after agent has saved answers.md."""
302
+ row = _db_get_agent(agent_id)
303
+ if not row:
304
+ return {"error": f"Agent not found: {agent_id}"}
305
+
306
+ resolved_path = answers_path or str(
307
+ Path(row["workspace_dir"]) / "CORE" / "genesis" / "answers.md"
308
+ )
309
+ if not Path(resolved_path).exists():
310
+ return {"error": f"answers.md not found at: {resolved_path}"}
311
+
312
+ conn = sqlite3.connect(LIFE_DB_PATH)
313
+ conn.execute(
314
+ "UPDATE agents SET genesis_completed = 1, updated_at = CURRENT_TIMESTAMP WHERE agent_id = ?",
315
+ (agent_id,),
316
+ )
317
+ conn.commit()
318
+ conn.close()
319
+
320
+ return {
321
+ "status": "applied",
322
+ "agent_id": agent_id,
323
+ "answers_path": resolved_path,
324
+ "message": "Genesis complete. Run wake_agent to activate.",
325
+ }
326
+
327
+
328
+ @mcp.tool()
329
+ def get_agent_clickup_info(agent_id: str) -> Dict:
330
+ """Retrieve ClickUp credentials for an agent."""
331
+ row = _db_get_agent(agent_id)
332
+ if not row:
333
+ return {"error": f"Agent not found: {agent_id}"}
334
+ return {
335
+ "agent_id": agent_id,
336
+ "clickup_email": row.get("clickup_email", ""),
337
+ "clickup_user_id": row.get("clickup_user_id", ""),
338
+ }
339
+
340
+
341
+ @mcp.resource("life://agents")
342
+ def list_registered_agents() -> str:
343
+ """List all agents registered in the LIFE gateway."""
344
+ conn = sqlite3.connect(LIFE_DB_PATH)
345
+ conn.row_factory = sqlite3.Row
346
+ rows = conn.execute(
347
+ "SELECT agent_id, name, clickup_email, genesis_completed FROM agents ORDER BY agent_id"
348
+ ).fetchall()
349
+ conn.close()
350
+
351
+ lines = ["LIFE Gateway — Registered Agents:", ""]
352
+ for r in rows:
353
+ status = "✓ Active" if r["genesis_completed"] else "○ Pending Genesis"
354
+ lines.append(f" {r['agent_id']} ({r['name']}) — {r['clickup_email'] or 'no email'} — {status}")
355
+ return "\n".join(lines) if rows else "No agents registered yet."
356
+
357
+
358
+ # ===== Module-routing helpers (subprocess-based LIFE module calls) =====
359
+
360
+
361
+ def _load_registry(allow_missing: bool = False) -> Dict[str, Any]:
362
+ if not REGISTRY_PATH.exists():
363
+ if allow_missing:
364
+ return {}
365
+ raise FileNotFoundError(f"Registry file not found: {REGISTRY_PATH}")
366
+
367
+ data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
368
+ agents_json = data.get("agents", {})
369
+ if not isinstance(agents_json, dict):
370
+ if allow_missing:
371
+ return {}
372
+ raise ValueError("Registry must contain object: agents")
373
+ if not agents_json and not allow_missing:
374
+ raise ValueError("Registry must contain non-empty object: agents")
375
+ return agents_json
376
+
377
+
378
+ def _parse_enabled_modules(value: Any) -> List[str]:
379
+ if isinstance(value, list) and value:
380
+ return [str(v) for v in value]
381
+ if isinstance(value, str) and value.strip():
382
+ try:
383
+ parsed = json.loads(value)
384
+ if isinstance(parsed, list) and parsed:
385
+ return [str(v) for v in parsed]
386
+ except Exception:
387
+ pass
388
+ return list(ALLOWED_MODULE_TOOLS.keys())
389
+
390
+
391
+ def _legacy_to_short(agent_id: str) -> Optional[str]:
392
+ if agent_id == "quin-ea-v1":
393
+ return None
394
+ if agent_id.startswith("quin-") and agent_id.endswith("-v1") and len(agent_id) > len("quin--v1"):
395
+ return agent_id[len("quin-"):-len("-v1")]
396
+ return None
397
+
398
+
399
+ def _short_to_legacy(agent_id: str) -> Optional[str]:
400
+ if agent_id == "quin-ea-v1" or agent_id.startswith("quin-"):
401
+ return None
402
+ return f"quin-{agent_id}-v1"
403
+
404
+
405
+ def _resolve_agent_id(agent_id: str, registry: Dict[str, Any]) -> Optional[str]:
406
+ candidates: List[str] = [agent_id]
407
+ short = _legacy_to_short(agent_id)
408
+ if short:
409
+ candidates.append(short)
410
+ legacy = _short_to_legacy(agent_id)
411
+ if legacy:
412
+ candidates.append(legacy)
413
+
414
+ seen = set()
415
+ ordered = []
416
+ for c in candidates:
417
+ if c and c not in seen:
418
+ ordered.append(c)
419
+ seen.add(c)
420
+
421
+ for c in ordered:
422
+ if _db_get_agent(c):
423
+ return c
424
+ for c in ordered:
425
+ if c in registry:
426
+ return c
427
+ return None
428
+
429
+
430
+ def _legacy_runtime_life_root(registry: Optional[Dict[str, Any]] = None) -> str:
431
+ row = _db_get_agent("quin-ea-v1")
432
+ if row and row.get("life_root"):
433
+ return str(row.get("life_root"))
434
+
435
+ reg = registry or _load_registry(allow_missing=True)
436
+ root = reg.get("quin-ea-v1", {}).get("life_root", "")
437
+ return str(root or "")
438
+
439
+
440
+ def _get_agent_cfg(agent_id: str) -> Dict[str, Any]:
441
+ """Resolve agent config with DB-first lookup and legacy ID compatibility."""
442
+ registry = _load_registry(allow_missing=True)
443
+ resolved_id = _resolve_agent_id(agent_id, registry)
444
+ if not resolved_id:
445
+ raise ValueError(f"Unknown agent_id: {agent_id}")
446
+
447
+ row = _db_get_agent(resolved_id)
448
+ if row:
449
+ cfg: Dict[str, Any] = {
450
+ "name": row.get("name", resolved_id),
451
+ "life_root": row.get("life_root", "") or "",
452
+ "enabled_modules": _parse_enabled_modules(row.get("enabled_modules")),
453
+ "voice_enabled": False,
454
+ "workspace_dir": row.get("workspace_dir", "") or "",
455
+ "clickup_email": row.get("clickup_email", "") or "",
456
+ }
457
+ reg_cfg = registry.get(resolved_id, {})
458
+ if reg_cfg:
459
+ if not cfg["life_root"]:
460
+ cfg["life_root"] = reg_cfg.get("life_root", "") or ""
461
+ if not cfg["enabled_modules"]:
462
+ cfg["enabled_modules"] = _parse_enabled_modules(reg_cfg.get("enabled_modules"))
463
+ cfg["voice_enabled"] = bool(reg_cfg.get("voice_enabled", False))
464
+ cfg["name"] = cfg.get("name") or reg_cfg.get("name", resolved_id)
465
+ cfg["_requested_agent_id"] = agent_id
466
+ cfg["_resolved_agent_id"] = resolved_id
467
+ return cfg
468
+
469
+ # Registry-only fallback (legacy compatibility)
470
+ reg_cfg = registry.get(resolved_id)
471
+ if reg_cfg:
472
+ cfg = dict(reg_cfg)
473
+ cfg.setdefault("name", resolved_id)
474
+ cfg.setdefault("life_root", "")
475
+ cfg["enabled_modules"] = _parse_enabled_modules(cfg.get("enabled_modules"))
476
+ cfg["voice_enabled"] = bool(cfg.get("voice_enabled", False))
477
+ cfg["_requested_agent_id"] = agent_id
478
+ cfg["_resolved_agent_id"] = resolved_id
479
+ return cfg
480
+
481
+ raise ValueError(f"Unknown agent_id: {agent_id}")
482
+
483
+
484
+ def _candidate_life_roots(agent_cfg: Dict[str, Any]) -> List[Path]:
485
+ roots: List[str] = []
486
+ primary = str(agent_cfg.get("life_root", "") or "")
487
+ if primary:
488
+ roots.append(primary)
489
+
490
+ legacy_root = _legacy_runtime_life_root()
491
+ if legacy_root and legacy_root not in roots:
492
+ roots.append(legacy_root)
493
+
494
+ return [Path(r) for r in roots if r]
495
+
496
+
497
+ def _module_script(agent_cfg: Dict[str, Any], module: str) -> Path:
498
+ tried: List[str] = []
499
+ for root in _candidate_life_roots(agent_cfg):
500
+ script = root / "CORE" / module / "server.py"
501
+ tried.append(str(script))
502
+ if script.exists():
503
+ return script
504
+
505
+ requested = agent_cfg.get("_requested_agent_id") or agent_cfg.get("_resolved_agent_id") or "(unknown)"
506
+ raise FileNotFoundError(
507
+ f"Module server missing for agent_id={requested} module={module}. Tried: " + "; ".join(tried)
508
+ )
509
+
510
+
511
+ def _send(proc: subprocess.Popen, payload: Dict[str, Any]) -> None:
512
+ assert proc.stdin is not None
513
+ proc.stdin.write(json.dumps(payload) + "\n")
514
+ proc.stdin.flush()
515
+
516
+
517
+ def _read_for_id(
518
+ proc: subprocess.Popen, rid: int, timeout_sec: int
519
+ ) -> Tuple[Dict[str, Any], List[str]]:
520
+ assert proc.stdout is not None
521
+ assert proc.stderr is not None
522
+
523
+ deadline = time.time() + timeout_sec
524
+ stderr_lines: List[str] = []
525
+
526
+ while time.time() < deadline:
527
+ remaining = max(0.0, deadline - time.time())
528
+ ready, _, _ = select.select([proc.stdout, proc.stderr], [], [], remaining)
529
+ if not ready:
530
+ continue
531
+
532
+ for stream in ready:
533
+ line = stream.readline()
534
+ if not line:
535
+ continue
536
+ if stream is proc.stderr:
537
+ stderr_lines.append(line.rstrip("\n"))
538
+ continue
539
+ try:
540
+ msg = json.loads(line)
541
+ except Exception:
542
+ continue
543
+ if msg.get("id") == rid:
544
+ return msg, stderr_lines
545
+
546
+ tail = " | ".join(stderr_lines[-5:]) if stderr_lines else "(no stderr)"
547
+ raise TimeoutError(f"Timed out waiting for MCP response id={rid}. stderr tail: {tail}")
548
+
549
+
550
+ def _invoke_module(
551
+ agent_cfg: Dict[str, Any], module: str, tool: str, args: Dict[str, Any]
552
+ ) -> List[Dict[str, Any]]:
553
+ script = _module_script(agent_cfg, module)
554
+ proc = subprocess.Popen(
555
+ [PYTHON_BIN, str(script)],
556
+ stdin=subprocess.PIPE,
557
+ stdout=subprocess.PIPE,
558
+ stderr=subprocess.PIPE,
559
+ text=True,
560
+ bufsize=1,
561
+ )
562
+ try:
563
+ _send(proc, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
564
+ _read_for_id(proc, 1, timeout_sec=CALL_TIMEOUT_SEC)
565
+ _send(proc, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
566
+ _send(proc, {
567
+ "jsonrpc": "2.0",
568
+ "id": 2,
569
+ "method": "tools/call",
570
+ "params": {"name": tool, "arguments": args or {}},
571
+ })
572
+ msg, stderr_lines = _read_for_id(proc, 2, timeout_sec=CALL_TIMEOUT_SEC)
573
+ if "error" in msg:
574
+ raise RuntimeError(f"{module}:{tool} error: {msg['error']}")
575
+
576
+ result = msg.get("result", {})
577
+ content = result.get("content", [{"type": "text", "text": "(no content)"}])
578
+ if stderr_lines:
579
+ content = list(content) + [{
580
+ "type": "text",
581
+ "text": "[life-gateway note] module stderr:\n" + "\n".join(stderr_lines[-3:]),
582
+ }]
583
+ return content
584
+ finally:
585
+ try:
586
+ proc.terminate()
587
+ proc.wait(timeout=2)
588
+ except Exception:
589
+ try:
590
+ proc.kill()
591
+ except Exception:
592
+ pass
593
+
594
+
595
+ def _content_to_text(content: List[Dict[str, Any]], max_chars: int = 500000) -> str:
596
+ chunks = [
597
+ item.get("text", "")
598
+ for item in content
599
+ if isinstance(item, dict) and item.get("type") == "text"
600
+ ]
601
+ text = "\n".join(c for c in chunks if c).strip()
602
+ # CQC - Removed truncation for now since it can cut off important info. Consider smarter truncation if needed.
603
+ #if len(text) > max_chars:
604
+ # return text[:max_chars] + "\n…[truncated]"
605
+ return text or "(no text output)"
606
+
607
+
608
+ def _validate_access(agent_cfg: Dict[str, Any], module: str, tool: str) -> None:
609
+ enabled_modules = set(agent_cfg.get("enabled_modules", list(ALLOWED_MODULE_TOOLS.keys())))
610
+ if module not in ALLOWED_MODULE_TOOLS:
611
+ raise ValueError(f"Module not allowed by gateway policy: {module}")
612
+ if module not in enabled_modules:
613
+ raise ValueError(f"Module disabled for this agent: {module}")
614
+ if tool not in ALLOWED_MODULE_TOOLS[module]:
615
+ raise ValueError(f"Tool not allowed: {module}:{tool}")
616
+
617
+
618
+ # ===== Module-routing tools (FastMCP) =====
619
+
620
+
621
+ @mcp.tool()
622
+ def agents() -> str:
623
+ """List registered LIFE agent identities and enabled modules."""
624
+ registry = _load_registry(allow_missing=True)
625
+ lines: List[str] = []
626
+
627
+ conn = sqlite3.connect(LIFE_DB_PATH)
628
+ conn.row_factory = sqlite3.Row
629
+ rows = conn.execute("SELECT agent_id, name, life_root, enabled_modules FROM agents ORDER BY agent_id").fetchall()
630
+ conn.close()
631
+
632
+ db_ids = set()
633
+ for row in rows:
634
+ aid = row["agent_id"]
635
+ db_ids.add(aid)
636
+ name = row["name"] or aid
637
+ life_root = row["life_root"] or ""
638
+ modules = ",".join(_parse_enabled_modules(row["enabled_modules"]))
639
+ lines.append(f"{aid} | {name} | root={life_root} | modules=[{modules}] | source=db")
640
+
641
+ for aid, cfg in registry.items():
642
+ if aid in db_ids:
643
+ continue
644
+ name = cfg.get("name", aid)
645
+ life_root = cfg.get("life_root", "")
646
+ modules = ",".join(_parse_enabled_modules(cfg.get("enabled_modules", [])))
647
+ lines.append(f"{aid} | {name} | root={life_root} | modules=[{modules}] | source=registry")
648
+
649
+ return "\n".join(lines) if lines else "No agents registered."
650
+
651
+
652
+ @mcp.tool()
653
+ def status(agent_id: str) -> str:
654
+ """Check LIFE module availability for an agent_id."""
655
+ agent_cfg = _get_agent_cfg(agent_id)
656
+ resolved = agent_cfg.get("_resolved_agent_id", agent_id)
657
+ lines = [
658
+ f"agent_id: {agent_id}",
659
+ f"resolved_agent_id: {resolved}",
660
+ f"name: {agent_cfg.get('name', resolved)}",
661
+ f"life_root: {agent_cfg.get('life_root')}",
662
+ ]
663
+
664
+ roots = _candidate_life_roots(agent_cfg)
665
+ for module in agent_cfg.get("enabled_modules", []):
666
+ found_path = None
667
+ found_root = None
668
+ for root in roots:
669
+ candidate = root / "CORE" / module / "server.py"
670
+ if candidate.exists():
671
+ found_path = candidate
672
+ found_root = root
673
+ break
674
+ if found_path:
675
+ mode = "primary" if str(found_root) == str(agent_cfg.get("life_root", "")) else "legacy-runtime"
676
+ lines.append(f"module:{module} -> ok ({mode}) [{found_path}]")
677
+ else:
678
+ lines.append(f"module:{module} -> missing")
679
+
680
+ lines.append(f"voice_enabled: {bool(agent_cfg.get('voice_enabled', False))}")
681
+ return "\n".join(lines)
682
+
683
+
684
+ @mcp.tool()
685
+ def wake(agent_id: str) -> str:
686
+ """Run wake protocol for an agent_id (drives, heart, working, semantic, history)."""
687
+ agent_cfg = _get_agent_cfg(agent_id)
688
+ resolved = agent_cfg.get("_resolved_agent_id", agent_id)
689
+ out_parts = [f"Wake protocol for {agent_id} (resolved={resolved})"]
690
+ enabled = set(agent_cfg.get("enabled_modules", []))
691
+
692
+ for module, tool_name, tool_args in WAKE_SEQUENCE:
693
+ if module not in enabled:
694
+ continue
695
+ _validate_access(agent_cfg, module, tool_name)
696
+ content = _invoke_module(agent_cfg, module, tool_name, tool_args)
697
+ out_parts.append(f"\n--- {module}:{tool_name} ---")
698
+ out_parts.append(_content_to_text(content))
699
+
700
+ return "\n".join(out_parts)
701
+
702
+
703
+ @mcp.tool()
704
+ def call(
705
+ agent_id: str,
706
+ module: str,
707
+ tool: str,
708
+ args: Optional[Dict[str, Any]] = None,
709
+ ) -> str:
710
+ """Route a LIFE module tool call by agent_id/module/tool."""
711
+ if not agent_id:
712
+ raise ValueError("agent_id is required")
713
+ if not module:
714
+ raise ValueError("module is required")
715
+ if not tool:
716
+ raise ValueError("tool is required")
717
+ agent_cfg = _get_agent_cfg(agent_id)
718
+ _validate_access(agent_cfg, module, tool)
719
+ content = _invoke_module(agent_cfg, module, tool, args or {})
720
+ return _content_to_text(content)
721
+
722
+
723
+ # ===== Entry point =====
724
+
725
+ if __name__ == "__main__":
726
+ _init_life_db()
727
+ _migrate_json_registry()
728
+
729
+ # Auto-discover and register C-level agents from shared paths
730
+ if SHARED_AGENTS_PATH.exists():
731
+ for agent_dir in sorted(SHARED_AGENTS_PATH.iterdir()):
732
+ if not agent_dir.is_dir() or not (agent_dir / "IDENTITY.md").exists():
733
+ continue
734
+ agent_name = agent_dir.name
735
+ agent_id = _identity_agent_id(agent_dir)
736
+ if not _db_get_agent(agent_id):
737
+ clickup_email = ""
738
+ with open(agent_dir / "IDENTITY.md") as f:
739
+ for line in f:
740
+ if line.startswith("clickup_email:"):
741
+ clickup_email = line.split(":", 1)[1].strip()
742
+ break
743
+ register_agent(agent_id, agent_name, str(agent_dir), clickup_email)
744
+ initialize_life_core(agent_id)
745
+ sys.stderr.write(f"life-gateway: registered {agent_id}\n")
746
+
747
+ # Run with HTTP transport on port 18888
748
+ # FastMCP will expose the MCP endpoint at /mcp by default
749
+ mcp.run(
750
+ transport="http",
751
+ host="localhost",
752
+ port=18888
753
+ )
754
+