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.
- package/README.md +202 -0
- package/gateway/agents.json.template +18 -0
- package/gateway/genesis-questions.md +592 -0
- package/gateway/identity-schemas.yaml +46 -0
- package/gateway/requirements.txt +1 -0
- package/gateway/server.py +754 -0
- package/index.ts +515 -0
- package/openclaw.plugin.json +32 -0
- package/package.json +49 -0
- package/scripts/install-gateway.sh +68 -0
- package/scripts/onboard-agent.sh +88 -0
|
@@ -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
|
+
|