@agentunion/kite 1.2.0 → 1.3.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 (53) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +48 -0
  3. package/cli.js +1 -1
  4. package/extensions/agents/assistant/entry.py +30 -81
  5. package/extensions/agents/assistant/module.md +1 -1
  6. package/extensions/agents/assistant/server.py +83 -122
  7. package/extensions/channels/acp_channel/entry.py +30 -81
  8. package/extensions/channels/acp_channel/module.md +1 -1
  9. package/extensions/channels/acp_channel/server.py +83 -122
  10. package/extensions/event_hub_bench/entry.py +81 -121
  11. package/extensions/services/backup/entry.py +213 -85
  12. package/extensions/services/model_service/entry.py +213 -85
  13. package/extensions/services/watchdog/entry.py +513 -460
  14. package/extensions/services/watchdog/monitor.py +55 -69
  15. package/extensions/services/web/entry.py +11 -108
  16. package/extensions/services/web/server.py +120 -77
  17. package/{core/registry → kernel}/entry.py +65 -37
  18. package/{core/event_hub/hub.py → kernel/event_hub.py} +61 -81
  19. package/kernel/module.md +33 -0
  20. package/{core/registry/store.py → kernel/registry_store.py} +13 -4
  21. package/kernel/rpc_router.py +388 -0
  22. package/kernel/server.py +267 -0
  23. package/launcher/__init__.py +10 -0
  24. package/launcher/__main__.py +6 -0
  25. package/launcher/count_lines.py +258 -0
  26. package/{core/launcher → launcher}/entry.py +693 -767
  27. package/launcher/logging_setup.py +289 -0
  28. package/{core/launcher → launcher}/module_scanner.py +11 -6
  29. package/main.py +11 -350
  30. package/package.json +6 -9
  31. package/__init__.py +0 -1
  32. package/__main__.py +0 -15
  33. package/core/event_hub/BENCHMARK.md +0 -94
  34. package/core/event_hub/__init__.py +0 -0
  35. package/core/event_hub/bench.py +0 -459
  36. package/core/event_hub/bench_extreme.py +0 -308
  37. package/core/event_hub/bench_perf.py +0 -350
  38. package/core/event_hub/entry.py +0 -436
  39. package/core/event_hub/module.md +0 -20
  40. package/core/event_hub/server.py +0 -269
  41. package/core/kite_log.py +0 -241
  42. package/core/launcher/__init__.py +0 -0
  43. package/core/registry/__init__.py +0 -0
  44. package/core/registry/module.md +0 -30
  45. package/core/registry/server.py +0 -339
  46. package/extensions/services/backup/server.py +0 -244
  47. package/extensions/services/model_service/server.py +0 -236
  48. package/extensions/services/watchdog/server.py +0 -229
  49. /package/{core → kernel}/__init__.py +0 -0
  50. /package/{core/event_hub → kernel}/dedup.py +0 -0
  51. /package/{core/event_hub → kernel}/router.py +0 -0
  52. /package/{core/launcher → launcher}/module.md +0 -0
  53. /package/{core/launcher → launcher}/process_manager.py +0 -0
@@ -1,7 +1,14 @@
1
1
  """
2
- Registry entry point.
3
- Reads token from stdin boot_info, starts HTTP server on dynamic port,
4
- outputs port via stdout structured message, waits for Event Hub to trigger module.ready.
2
+ Kernel entry point.
3
+ Reads boot_info token + tokens dict from stdin, starts unified WebSocket server,
4
+ outputs port via stdout, self-registers, publishes module.ready.
5
+
6
+ Stdin protocol:
7
+ Line 1: {"token": "launcher_kite_token"} (boot_info)
8
+ Line 2: {"kite": "tokens", "tokens": {"mod1":"tok1", ...}} (token mapping)
9
+
10
+ Stdout protocol:
11
+ {"kite": "port", "port": N} (Kernel port)
5
12
  """
6
13
 
7
14
  import builtins
@@ -19,11 +26,11 @@ import uvicorn
19
26
 
20
27
 
21
28
  # ── Module configuration ──
22
- MODULE_NAME = "registry"
29
+ MODULE_NAME = "kernel"
23
30
 
24
31
 
25
32
  def _fmt_elapsed(t0: float) -> str:
26
- """Format elapsed time since t0: <1s 'NNNms', >=1s 'N.Ns', >=10s 'NNs'."""
33
+ """Format elapsed time since t0: <1s -> 'NNNms', >=1s -> 'N.Ns', >=10s -> 'NNs'."""
27
34
  d = time.monotonic() - t0
28
35
  if d < 1:
29
36
  return f"{d * 1000:.0f}ms"
@@ -59,7 +66,6 @@ sys.stderr = _SafeWriter(sys.stderr)
59
66
 
60
67
 
61
68
  # ── Timestamped print + log file writer ──
62
- # Implements format: [timestamp] HH:MM:SS.mmm +delta [module_name] message
63
69
  # Independent implementation per module (no shared code dependency)
64
70
 
65
71
  _builtin_print = builtins.print
@@ -247,8 +253,7 @@ _project_root = os.environ.get("KITE_PROJECT") or os.path.dirname(os.path.dirnam
247
253
  if _project_root not in sys.path:
248
254
  sys.path.insert(0, _project_root)
249
255
 
250
- from core.registry.store import RegistryStore
251
- from core.registry.server import RegistryServer
256
+ from kernel.server import KernelServer
252
257
 
253
258
 
254
259
  def _read_module_md() -> dict:
@@ -258,7 +263,6 @@ def _read_module_md() -> dict:
258
263
  try:
259
264
  with open(md_path, "r", encoding="utf-8") as f:
260
265
  text = f.read()
261
- import re
262
266
  m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
263
267
  if m:
264
268
  try:
@@ -287,6 +291,39 @@ def _bind_port(preferred: int, host: str) -> int:
287
291
  return s.getsockname()[1]
288
292
 
289
293
 
294
+ def _read_stdin_kite_messages(expected: set[str], timeout: float = 10) -> dict[str, dict]:
295
+ """Read multiple structured kite messages from stdin until all expected types received.
296
+
297
+ Args:
298
+ expected: set of kite message types to wait for (e.g. {"tokens"})
299
+ timeout: total timeout in seconds
300
+
301
+ Returns:
302
+ dict mapping kite type -> message dict. Missing types are absent from the result.
303
+ """
304
+ collected: dict[str, dict] = {}
305
+ remaining = set(expected)
306
+
307
+ def _read():
308
+ while remaining:
309
+ try:
310
+ line = sys.stdin.readline().strip()
311
+ if not line:
312
+ return # stdin closed
313
+ msg = json.loads(line)
314
+ if isinstance(msg, dict) and "kite" in msg:
315
+ kite_type = msg["kite"]
316
+ collected[kite_type] = msg
317
+ remaining.discard(kite_type)
318
+ except Exception:
319
+ return # parse error or stdin closed
320
+
321
+ t = threading.Thread(target=_read, daemon=True)
322
+ t.start()
323
+ t.join(timeout=timeout)
324
+ return collected
325
+
326
+
290
327
  def main():
291
328
  # Initialize log file paths
292
329
  global _log_dir, _log_latest_path, _crash_log_path
@@ -320,54 +357,45 @@ def main():
320
357
  _t0 = time.monotonic()
321
358
 
322
359
  # Kite environment
323
- kite_instance = os.environ.get("KITE_INSTANCE", "")
324
360
  is_debug = os.environ.get("KITE_DEBUG") == "1"
325
361
 
326
- # Step 1: Read token from stdin boot_info (only token)
327
- launcher_token = ""
328
- try:
329
- line = sys.stdin.readline().strip()
330
- if line:
331
- boot_info = json.loads(line)
332
- launcher_token = boot_info.get("token", "")
333
- except Exception:
334
- pass
335
-
336
- if not launcher_token:
337
- print("[registry] 错误: 未通过 stdin boot_info 提供启动器令牌")
338
- sys.exit(1)
339
-
340
- print(f"[registry] 已收到启动器令牌 ({len(launcher_token)} 字符) ({_fmt_elapsed(_t0)})")
341
-
342
362
  if is_debug:
343
- print("[registry] 调试模式已启用 (KITE_DEBUG=1),接受所有令牌")
363
+ print("[kernel] 调试模式已启用 (KITE_DEBUG=1),接受所有令牌")
344
364
 
345
- # Step 2: Read config from own module.md
365
+ # Step 1: Read config from own module.md
346
366
  md_config = _read_module_md()
347
367
  advertise_ip = md_config["advertise_ip"]
348
368
  preferred_port = md_config["preferred_port"]
349
369
 
350
- # Step 3: Create store and server
351
- store = RegistryStore(launcher_token)
352
- server = RegistryServer(store, launcher_token=launcher_token, advertise_ip=advertise_ip)
370
+ # Step 2: Generate launcher token
371
+ import secrets
372
+ launcher_token = secrets.token_urlsafe(32)
373
+
374
+ # Step 3: Create KernelServer with launcher_token
375
+ server = KernelServer(launcher_token=launcher_token, advertise_ip=advertise_ip)
353
376
 
354
377
  # Step 4: Bind port
355
378
  bind_host = advertise_ip
356
379
  port = _bind_port(preferred_port, bind_host)
380
+ server.port = port
357
381
 
358
- # Step 5: Output port via stdout structured message (Launcher reads this)
359
- # This message proves HTTP is about to start — Launcher uses it as readiness signal
360
- print(json.dumps({"kite": "port", "port": port}), flush=True)
382
+ # Step 5: Output port + launcher_token via stdout
383
+ print(json.dumps({"kite": "port", "port": port, "token": launcher_token}), flush=True)
361
384
 
362
- print(f"[registry] 启动中 {bind_host}:{port} ({_fmt_elapsed(_t0)})")
385
+ print(f"[kernel] 启动中 {bind_host}:{port} ({_fmt_elapsed(_t0)})")
363
386
 
364
- # Store port and advertise_ip for module.ready event later
365
- server.port = port
387
+ # Step 6: Self-register in registry (direct memory write)
388
+ server.self_register()
366
389
 
390
+ # Step 7: Start uvicorn (module.ready will be published when Launcher subscribes)
367
391
  try:
368
392
  config = uvicorn.Config(server.app, host=bind_host, port=port, log_level="warning")
369
393
  uvi_server = uvicorn.Server(config)
370
394
  server._uvicorn_server = uvi_server
395
+
396
+ # module.ready is now published when Launcher subscribes (not at startup)
397
+ # This ensures Launcher is connected and subscribed before receiving the event
398
+
371
399
  uvi_server.run()
372
400
  except Exception as e:
373
401
  _write_crash(type(e), e, e.__traceback__, severity="critical", handled=True)
@@ -1,7 +1,13 @@
1
1
  """
2
- Core Event Hub logic.
3
- WebSocket handling, event processing (validate dedup route → ack),
4
- connection and subscription management.
2
+ Kernel Event Hub logic.
3
+ Connection management, subscription matching, event routing (validate -> dedup -> route),
4
+ per-subscriber delivery queues.
5
+
6
+ Adapted from core/event_hub/hub.py for the merged Kernel module:
7
+ - Removed handle_message, _handle_event, _send_ack, _send_error (moved to RpcRouter)
8
+ - Added publish_event() for RPC-driven event publishing
9
+ - Added publish_internal() for Kernel-originated events (e.g. module.offline)
10
+ - Event delivery uses JSON-RPC 2.0 Notification format
5
11
  """
6
12
 
7
13
  import asyncio
@@ -14,7 +20,7 @@ try:
14
20
  except ImportError:
15
21
  orjson = None
16
22
 
17
- from fastapi import WebSocket, WebSocketDisconnect
23
+ from starlette.websockets import WebSocket
18
24
 
19
25
  from .dedup import EventDedup
20
26
  from .router import match_parts
@@ -31,6 +37,7 @@ def _loads(raw: str):
31
37
  return orjson.loads(raw)
32
38
  return json.loads(raw)
33
39
 
40
+
34
41
  QUEUE_MAXSIZE = 10000
35
42
 
36
43
 
@@ -55,8 +62,6 @@ class EventHub:
55
62
  self._cnt_dedup = 0
56
63
  self._cnt_errors = 0
57
64
  self._start_time = time.time()
58
- # Shutdown callback: set by EventHubServer to handle own shutdown
59
- self._shutdown_cb = None
60
65
 
61
66
  # ── Connection lifecycle ──
62
67
 
@@ -77,14 +82,14 @@ class EventHub:
77
82
  self._senders[module_id] = asyncio.create_task(
78
83
  self._sender_loop(module_id, ws, q)
79
84
  )
80
- print(f"[event_hub] {module_id} connected")
85
+ print(f"[kernel] {module_id} connected")
81
86
 
82
87
  async def _close_old(self, module_id: str, ws: WebSocket):
83
88
  try:
84
89
  await ws.close(code=4000, reason="replaced by new connection")
85
90
  except Exception:
86
91
  pass
87
- print(f"[event_hub] Closed old connection for {module_id}")
92
+ print(f"[kernel] Closed old connection for {module_id}")
88
93
 
89
94
  def remove_connection(self, module_id: str):
90
95
  """Clean up on disconnect."""
@@ -95,7 +100,7 @@ class EventHub:
95
100
  task = self._senders.pop(module_id, None)
96
101
  if task:
97
102
  task.cancel()
98
- print(f"[event_hub] {module_id} disconnected")
103
+ print(f"[kernel] {module_id} disconnected")
99
104
 
100
105
  # ── Sender loop (per-subscriber) ──
101
106
 
@@ -111,7 +116,7 @@ class EventHub:
111
116
  await ws.send_text(raw)
112
117
  self._cnt_routed += 1
113
118
  except Exception:
114
- print(f"[event_hub] Send failed to {mid}, closing sender")
119
+ print(f"[kernel] Send failed to {mid}, closing sender")
115
120
  break
116
121
  except asyncio.CancelledError:
117
122
  pass
@@ -124,83 +129,72 @@ class EventHub:
124
129
  self.subscriptions[module_id].update(
125
130
  (p, tuple(p.split("."))) for p in events
126
131
  )
127
- print(f"[event_hub] {module_id} subscribed: {events}")
132
+ print(f"[kernel] {module_id} subscribed: {events}")
128
133
 
129
134
  def handle_unsubscribe(self, module_id: str, events: list[str]):
130
135
  subs = self.subscriptions.get(module_id)
131
136
  if subs:
132
137
  to_remove = {item for item in subs if item[0] in events}
133
138
  subs.difference_update(to_remove)
134
- print(f"[event_hub] {module_id} unsubscribed: {events}")
135
-
136
- # ── Main message handler ──
137
-
138
- async def handle_message(self, module_id: str, ws: WebSocket, raw: str):
139
- try:
140
- msg = _loads(raw)
141
- except Exception:
142
- await self._send_error(ws, "Invalid JSON")
143
- return
144
-
145
- msg_type = msg.get("type", "")
146
-
147
- if msg_type == "subscribe":
148
- events = msg.get("events", [])
149
- if isinstance(events, list) and events:
150
- self.handle_subscribe(module_id, events)
151
- return
139
+ print(f"[kernel] {module_id} unsubscribed: {events}")
152
140
 
153
- if msg_type == "unsubscribe":
154
- events = msg.get("events", [])
155
- if isinstance(events, list) and events:
156
- self.handle_unsubscribe(module_id, events)
157
- return
141
+ # ── Event publishing (called by RpcRouter) ──
158
142
 
159
- if msg_type == "event":
160
- await self._handle_event(module_id, ws, msg)
161
- return
162
-
163
- await self._send_error(ws, f"Unknown message type: {msg_type}")
164
-
165
- # ── Event processing ──
166
-
167
- async def _handle_event(self, module_id: str, ws: WebSocket, msg: dict):
168
- """Validate → dedup → auto-fill → route → ack."""
143
+ def publish_event(self, sender_id: str, event_id: str, event_type: str,
144
+ data: dict = None, echo: bool = False) -> dict:
145
+ """Publish an event from a module. Called by RpcRouter for event.publish RPC.
146
+ Returns {ok: True} on success, or error dict if validation fails."""
169
147
  self._cnt_received += 1
170
148
 
171
- event_id = msg.get("event_id")
172
- event_type = msg.get("event")
173
149
  if not event_id or not event_type:
174
150
  self._cnt_errors += 1
175
- await self._send_error(ws, "Missing required field: event_id or event")
176
- return
151
+ return {"ok": False, "error": "Missing required field: event_id or event"}
177
152
 
178
153
  if self.dedup.is_duplicate(event_id):
179
154
  self._cnt_dedup += 1
180
- await self._send_ack(ws, event_id)
181
- return
155
+ return {"ok": True} # silently accept duplicates
156
+
157
+ msg = {
158
+ "jsonrpc": "2.0",
159
+ "method": "event",
160
+ "params": {
161
+ "event_id": event_id,
162
+ "event": event_type,
163
+ "source": sender_id,
164
+ "timestamp": datetime.now(timezone.utc).isoformat(),
165
+ "data": data or {},
166
+ },
167
+ }
182
168
 
183
- if not msg.get("source"):
184
- msg["source"] = module_id
185
- if not msg.get("timestamp"):
186
- msg["timestamp"] = datetime.now(timezone.utc).isoformat()
169
+ self._route_event(sender_id, msg, event_type, echo)
170
+ return {"ok": True}
187
171
 
188
- await self._route_event(module_id, msg)
189
- await self._send_ack(ws, event_id)
172
+ def publish_internal(self, event_type: str, data: dict):
173
+ """Publish a Kernel-originated event (e.g. module.offline, module.registered).
174
+ No dedup check — internal events are unique by nature."""
175
+ import uuid
176
+ event_id = str(uuid.uuid4())
177
+ self._cnt_received += 1
190
178
 
191
- # Check if this is a shutdown event targeting event_hub
192
- if event_type == "module.shutdown" and self._shutdown_cb:
193
- data = msg.get("data", {})
194
- if data.get("module_id") == "event_hub":
195
- asyncio.create_task(self._shutdown_cb(data))
179
+ msg = {
180
+ "jsonrpc": "2.0",
181
+ "method": "event",
182
+ "params": {
183
+ "event_id": event_id,
184
+ "event": event_type,
185
+ "source": "kernel",
186
+ "timestamp": datetime.now(timezone.utc).isoformat(),
187
+ "data": data,
188
+ },
189
+ }
190
+
191
+ self._route_event("kernel", msg, event_type, echo=False)
196
192
 
197
193
  # ── Routing ──
198
194
 
199
- async def _route_event(self, sender_id: str, msg: dict):
195
+ def _route_event(self, sender_id: str, msg: dict, event_type: str, echo: bool):
200
196
  """Enqueue event to all matching subscribers' delivery queues."""
201
- event_type = msg["event"]
202
197
  e_parts = tuple(event_type.split("."))
203
- echo = msg.get("echo", False)
204
198
  raw = None # lazy serialization
205
199
 
206
200
  for mid, patterns in self.subscriptions.items():
@@ -215,26 +209,12 @@ class EventHub:
215
209
  try:
216
210
  queue.put_nowait(raw)
217
211
  except asyncio.QueueFull:
218
- await queue.put(raw)
212
+ # Best-effort: drop if queue is full and we can't await
213
+ # (we're not in an async context in _route_event)
214
+ pass
219
215
  self._cnt_queued += 1
220
216
  break
221
217
 
222
- # ── Helpers ──
223
-
224
- @staticmethod
225
- async def _send_ack(ws: WebSocket, event_id: str):
226
- try:
227
- await ws.send_text('{"type":"ack","event_id":"' + event_id + '"}')
228
- except Exception:
229
- pass
230
-
231
- @staticmethod
232
- async def _send_error(ws: WebSocket, message: str):
233
- try:
234
- await ws.send_json({"type": "error", "message": message})
235
- except Exception:
236
- pass
237
-
238
218
  # ── Stats ──
239
219
 
240
220
  def _counters_dict(self) -> dict:
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: kernel
3
+ display_name: Kernel
4
+ type: infrastructure
5
+ state: enabled
6
+ runtime: python
7
+ entry: entry.py
8
+ ---
9
+
10
+ # Kernel
11
+
12
+ Unified infrastructure module that merges Registry (service discovery) and Event Hub (event routing) into a single process with a WebSocket JSON-RPC 2.0 interface.
13
+
14
+ ## Responsibilities
15
+
16
+ - **Service Registry**: Module registration, heartbeat TTL, glob lookup, dot-path queries
17
+ - **Event Routing**: NATS-style wildcard subscriptions, per-subscriber queues, 1h dedup
18
+ - **RPC Routing**: JSON-RPC 2.0 dispatch for builtin methods + cross-module forwarding
19
+ - **Token Verification**: In-memory token→module_id resolution (no cross-process HTTP)
20
+
21
+ ## Protocol
22
+
23
+ Single WebSocket endpoint: `ws://127.0.0.1:{port}/ws?token={TOKEN}&id={MODULE_ID}`
24
+
25
+ Three frame types on the wire:
26
+ - **RPC Request**: `{jsonrpc:"2.0", id, method, params}` — client→Kernel or Kernel→client (forwarded)
27
+ - **RPC Response**: `{jsonrpc:"2.0", id, result}` or `{jsonrpc:"2.0", id, error}` — response to request
28
+ - **Event Notification**: `{jsonrpc:"2.0", method:"event", params:{event_id, event, source, timestamp, data}}` — no id, no response
29
+
30
+ ## HTTP Endpoints (debug only)
31
+
32
+ - `GET /health` — combined registry + event hub health
33
+ - `GET /stats` — connections, subscriptions, counters
@@ -1,7 +1,11 @@
1
1
  """
2
- Registry in-memory store.
2
+ Kernel registry in-memory store.
3
3
  Manages module records, token verification, heartbeat TTL, lookup and get-by-path.
4
- No persistence — Registry crash triggers Kite full restart, all modules re-register.
4
+ No persistence — Kernel crash triggers Kite full restart, all modules re-register.
5
+
6
+ Adapted from core/registry/store.py for the merged Kernel module:
7
+ - api_endpoint is now optional (modules no longer need HTTP)
8
+ - Added set_offline() for WebSocket disconnect handling
5
9
  """
6
10
 
7
11
  import fnmatch
@@ -50,7 +54,7 @@ class RegistryStore:
50
54
 
51
55
  # ── Module lifecycle ──
52
56
 
53
- _REQUIRED_FIELDS = ("module_id", "module_type", "api_endpoint")
57
+ _REQUIRED_FIELDS = ("module_id", "module_type") # api_endpoint now optional
54
58
 
55
59
  def register_module(self, data: dict) -> dict:
56
60
  """Register or update a module. Idempotent — same module_id overwrites."""
@@ -69,7 +73,7 @@ class RegistryStore:
69
73
  if other_mid == mid:
70
74
  continue
71
75
  if tool_name in other_data.get("tools", {}):
72
- print(f"[registry] WARNING: tool '{tool_name}' registered by both '{other_mid}' and '{mid}'")
76
+ print(f"[kernel] WARNING: tool '{tool_name}' registered by both '{other_mid}' and '{mid}'")
73
77
 
74
78
  # Strip action field — it's a request verb, not part of the registration payload
75
79
  record = {k: v for k, v in data.items() if k != "action"}
@@ -93,6 +97,11 @@ class RegistryStore:
93
97
  self.modules[module_id]["status"] = "online"
94
98
  return {"ok": True}
95
99
 
100
+ def set_offline(self, module_id: str):
101
+ """Mark a module as offline (called on WebSocket disconnect)."""
102
+ if module_id in self.modules:
103
+ self.modules[module_id]["status"] = "offline"
104
+
96
105
  def check_ttl(self) -> list[str]:
97
106
  """Mark modules as offline if heartbeat expired. Returns list of newly-offline module_ids."""
98
107
  now = time.time()