@agentunion/kite 1.0.6 → 1.2.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/cli.js +127 -25
- package/core/event_hub/entry.py +384 -61
- package/core/event_hub/hub.py +8 -0
- package/core/event_hub/module.md +0 -1
- package/core/event_hub/server.py +169 -38
- package/core/kite_log.py +241 -0
- package/core/launcher/entry.py +1306 -425
- package/core/launcher/module_scanner.py +10 -9
- package/core/launcher/process_manager.py +555 -121
- package/core/registry/entry.py +335 -30
- package/core/registry/server.py +339 -256
- package/core/registry/store.py +13 -2
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +380 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +236 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +380 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +236 -0
- package/{core → extensions}/event_hub_bench/entry.py +664 -371
- package/{core → extensions}/event_hub_bench/module.md +4 -2
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +380 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/backup/server.py +244 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +380 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/model_service/server.py +236 -0
- package/extensions/services/watchdog/entry.py +460 -143
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +128 -13
- package/extensions/services/watchdog/server.py +75 -13
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +487 -0
- package/extensions/services/web/module.md +24 -0
- package/extensions/services/web/routes/__init__.py +1 -0
- package/extensions/services/web/routes/routes_call.py +189 -0
- package/extensions/services/web/routes/routes_config.py +512 -0
- package/extensions/services/web/routes/routes_contacts.py +98 -0
- package/extensions/services/web/routes/routes_devlog.py +99 -0
- package/extensions/services/web/routes/routes_phone.py +81 -0
- package/extensions/services/web/routes/routes_sms.py +48 -0
- package/extensions/services/web/routes/routes_stats.py +17 -0
- package/extensions/services/web/routes/routes_voicechat.py +554 -0
- package/extensions/services/web/routes/schemas.py +216 -0
- package/extensions/services/web/server.py +332 -0
- package/extensions/services/web/static/css/style.css +1064 -0
- package/extensions/services/web/static/index.html +1445 -0
- package/extensions/services/web/static/js/app.js +4671 -0
- package/extensions/services/web/vendor/__init__.py +1 -0
- package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
- package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
- package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
- package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
- package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
- package/extensions/services/web/vendor/config.py +139 -0
- package/extensions/services/web/vendor/conversation/__init__.py +0 -0
- package/extensions/services/web/vendor/conversation/asr.py +936 -0
- package/extensions/services/web/vendor/conversation/engine.py +548 -0
- package/extensions/services/web/vendor/conversation/llm.py +534 -0
- package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
- package/extensions/services/web/vendor/conversation/tts.py +322 -0
- package/extensions/services/web/vendor/conversation/vad.py +138 -0
- package/extensions/services/web/vendor/storage/__init__.py +1 -0
- package/extensions/services/web/vendor/storage/identity.py +312 -0
- package/extensions/services/web/vendor/storage/store.py +507 -0
- package/extensions/services/web/vendor/task/__init__.py +0 -0
- package/extensions/services/web/vendor/task/manager.py +864 -0
- package/extensions/services/web/vendor/task/models.py +45 -0
- package/extensions/services/web/vendor/task/webhook.py +263 -0
- package/extensions/services/web/vendor/tools/__init__.py +0 -0
- package/extensions/services/web/vendor/tools/registry.py +321 -0
- package/main.py +344 -4
- package/package.json +11 -2
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
- package/core/data_dir.py +0 -62
- package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
- package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
- package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
- package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
- package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
- package/core/launcher/data/log/lifecycle.jsonl +0 -1158
- package/core/launcher/data/token.txt +0 -1
- package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
- package/core/registry/data/port.txt +0 -1
- package/core/registry/data/port_484.txt +0 -1
- package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
- /package/{core/event_hub/bench_results/.gitkeep → extensions/services/web/vendor/bluetooth/__init__.py} +0 -0
package/core/registry/store.py
CHANGED
|
@@ -5,6 +5,7 @@ No persistence — Registry crash triggers Kite full restart, all modules re-reg
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import fnmatch
|
|
8
|
+
import os
|
|
8
9
|
import time
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -18,20 +19,30 @@ class RegistryStore:
|
|
|
18
19
|
self.heartbeats: dict[str, float] = {} # module_id -> last heartbeat timestamp
|
|
19
20
|
self.ttl = 60 # seconds before marking offline
|
|
20
21
|
self.heartbeat_interval = 30
|
|
22
|
+
self.is_debug = os.environ.get("KITE_DEBUG") == "1"
|
|
21
23
|
|
|
22
24
|
# ── Token verification ──
|
|
23
25
|
|
|
24
26
|
def verify_token(self, token: str) -> str | None:
|
|
25
|
-
"""Return module_id if token is valid, None otherwise.
|
|
27
|
+
"""Return module_id if token is valid, None otherwise.
|
|
28
|
+
In debug mode (KITE_DEBUG=1), any non-empty token is accepted,
|
|
29
|
+
but known tokens still resolve to their proper module_id first."""
|
|
26
30
|
if token == self.launcher_token:
|
|
27
31
|
return "launcher"
|
|
28
32
|
for mid, tok in self.token_map.items():
|
|
29
33
|
if token == tok:
|
|
30
34
|
return mid
|
|
35
|
+
if self.is_debug and token:
|
|
36
|
+
return "debug"
|
|
31
37
|
return None
|
|
32
38
|
|
|
33
39
|
def is_launcher(self, token: str) -> bool:
|
|
34
|
-
|
|
40
|
+
"""Check if token belongs to Launcher. In debug mode, also accept any token."""
|
|
41
|
+
if token == self.launcher_token:
|
|
42
|
+
return True
|
|
43
|
+
if self.is_debug and token:
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
35
46
|
|
|
36
47
|
def register_tokens(self, mapping: dict[str, str]):
|
|
37
48
|
"""Register module_id -> token mapping. Only Launcher may call this."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Assistant entry point.
|
|
3
|
+
Reads boot_info from stdin, registers to Registry, starts assistant agent.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import builtins
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import uvicorn
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── Safe stdout/stderr: ignore BrokenPipeError after Launcher closes stdio ──
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── Module configuration ──
|
|
24
|
+
MODULE_NAME = "assistant"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _SafeWriter:
|
|
28
|
+
"""Wraps a stream to silently swallow BrokenPipeError on write/flush."""
|
|
29
|
+
def __init__(self, stream):
|
|
30
|
+
self._stream = stream
|
|
31
|
+
|
|
32
|
+
def write(self, s):
|
|
33
|
+
try:
|
|
34
|
+
self._stream.write(s)
|
|
35
|
+
except (BrokenPipeError, OSError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def flush(self):
|
|
39
|
+
try:
|
|
40
|
+
self._stream.flush()
|
|
41
|
+
except (BrokenPipeError, OSError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def __getattr__(self, name):
|
|
45
|
+
return getattr(self._stream, name)
|
|
46
|
+
|
|
47
|
+
sys.stdout = _SafeWriter(sys.stdout)
|
|
48
|
+
sys.stderr = _SafeWriter(sys.stderr)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Timestamped print + log file writer ──
|
|
52
|
+
# Independent implementation per module (no shared code dependency)
|
|
53
|
+
|
|
54
|
+
_builtin_print = builtins.print
|
|
55
|
+
_start_ts = time.monotonic()
|
|
56
|
+
_last_ts = time.monotonic()
|
|
57
|
+
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
58
|
+
_log_lock = threading.Lock()
|
|
59
|
+
_log_latest_path = None
|
|
60
|
+
_log_daily_path = None
|
|
61
|
+
_log_daily_date = ""
|
|
62
|
+
_log_dir = None
|
|
63
|
+
_crash_log_path = None
|
|
64
|
+
|
|
65
|
+
def _strip_ansi(s: str) -> str:
|
|
66
|
+
return _ANSI_RE.sub("", s)
|
|
67
|
+
|
|
68
|
+
def _resolve_daily_log_path():
|
|
69
|
+
"""Resolve daily log path based on current date."""
|
|
70
|
+
global _log_daily_path, _log_daily_date
|
|
71
|
+
if not _log_dir:
|
|
72
|
+
return
|
|
73
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
74
|
+
if today == _log_daily_date and _log_daily_path:
|
|
75
|
+
return
|
|
76
|
+
month_dir = os.path.join(_log_dir, today[:7])
|
|
77
|
+
os.makedirs(month_dir, exist_ok=True)
|
|
78
|
+
_log_daily_path = os.path.join(month_dir, f"{today}.log")
|
|
79
|
+
_log_daily_date = today
|
|
80
|
+
|
|
81
|
+
def _write_log(plain_line: str):
|
|
82
|
+
"""Write a plain-text line to both latest.log and daily log."""
|
|
83
|
+
with _log_lock:
|
|
84
|
+
if _log_latest_path:
|
|
85
|
+
try:
|
|
86
|
+
with open(_log_latest_path, "a", encoding="utf-8") as f:
|
|
87
|
+
f.write(plain_line)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
_resolve_daily_log_path()
|
|
91
|
+
if _log_daily_path:
|
|
92
|
+
try:
|
|
93
|
+
with open(_log_daily_path, "a", encoding="utf-8") as f:
|
|
94
|
+
f.write(plain_line)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def _write_crash(exc_type, exc_value, exc_tb, thread_name=None, severity="critical", handled=False):
|
|
99
|
+
"""Write crash record to crashes.jsonl + daily crash archive."""
|
|
100
|
+
record = {
|
|
101
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
102
|
+
"module": MODULE_NAME,
|
|
103
|
+
"thread": thread_name or threading.current_thread().name,
|
|
104
|
+
"exception_type": exc_type.__name__ if exc_type else "Unknown",
|
|
105
|
+
"exception_message": str(exc_value),
|
|
106
|
+
"traceback": "".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
|
|
107
|
+
"severity": severity,
|
|
108
|
+
"handled": handled,
|
|
109
|
+
"process_id": os.getpid(),
|
|
110
|
+
"platform": sys.platform,
|
|
111
|
+
"runtime_version": f"Python {sys.version.split()[0]}",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if exc_tb:
|
|
115
|
+
tb_entries = traceback.extract_tb(exc_tb)
|
|
116
|
+
if tb_entries:
|
|
117
|
+
last = tb_entries[-1]
|
|
118
|
+
record["context"] = {
|
|
119
|
+
"function": last.name,
|
|
120
|
+
"file": os.path.basename(last.filename),
|
|
121
|
+
"line": last.lineno,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
line = json.dumps(record, ensure_ascii=False) + "\n"
|
|
125
|
+
|
|
126
|
+
if _crash_log_path:
|
|
127
|
+
try:
|
|
128
|
+
with open(_crash_log_path, "a", encoding="utf-8") as f:
|
|
129
|
+
f.write(line)
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
if _log_dir:
|
|
134
|
+
try:
|
|
135
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
136
|
+
archive_dir = os.path.join(_log_dir, "crashes", today[:7])
|
|
137
|
+
os.makedirs(archive_dir, exist_ok=True)
|
|
138
|
+
archive_path = os.path.join(archive_dir, f"{today}.jsonl")
|
|
139
|
+
with open(archive_path, "a", encoding="utf-8") as f:
|
|
140
|
+
f.write(line)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
def _print_crash_summary(exc_type, exc_tb, thread_name=None):
|
|
145
|
+
"""Print crash summary to console (red highlight)."""
|
|
146
|
+
RED = "\033[91m"
|
|
147
|
+
RESET = "\033[0m"
|
|
148
|
+
|
|
149
|
+
if exc_tb:
|
|
150
|
+
tb_entries = traceback.extract_tb(exc_tb)
|
|
151
|
+
if tb_entries:
|
|
152
|
+
last = tb_entries[-1]
|
|
153
|
+
location = f"{os.path.basename(last.filename)}:{last.lineno}"
|
|
154
|
+
else:
|
|
155
|
+
location = "unknown"
|
|
156
|
+
else:
|
|
157
|
+
location = "unknown"
|
|
158
|
+
|
|
159
|
+
prefix = f"[{MODULE_NAME}]"
|
|
160
|
+
if thread_name:
|
|
161
|
+
_builtin_print(f"{prefix} {RED}线程 {thread_name} 崩溃: "
|
|
162
|
+
f"{exc_type.__name__} in {location}{RESET}")
|
|
163
|
+
else:
|
|
164
|
+
_builtin_print(f"{prefix} {RED}崩溃: {exc_type.__name__} in {location}{RESET}")
|
|
165
|
+
if _crash_log_path:
|
|
166
|
+
_builtin_print(f"{prefix} 崩溃日志: {_crash_log_path}")
|
|
167
|
+
|
|
168
|
+
def _setup_exception_hooks():
|
|
169
|
+
"""Set up global exception hooks."""
|
|
170
|
+
_orig_excepthook = sys.excepthook
|
|
171
|
+
|
|
172
|
+
def _excepthook(exc_type, exc_value, exc_tb):
|
|
173
|
+
_write_crash(exc_type, exc_value, exc_tb, severity="critical", handled=False)
|
|
174
|
+
_print_crash_summary(exc_type, exc_tb)
|
|
175
|
+
_orig_excepthook(exc_type, exc_value, exc_tb)
|
|
176
|
+
|
|
177
|
+
sys.excepthook = _excepthook
|
|
178
|
+
|
|
179
|
+
if hasattr(threading, "excepthook"):
|
|
180
|
+
def _thread_excepthook(args):
|
|
181
|
+
_write_crash(args.exc_type, args.exc_value, args.exc_traceback,
|
|
182
|
+
thread_name=args.thread.name if args.thread else "unknown",
|
|
183
|
+
severity="error", handled=False)
|
|
184
|
+
_print_crash_summary(args.exc_type, args.exc_traceback,
|
|
185
|
+
thread_name=args.thread.name if args.thread else None)
|
|
186
|
+
|
|
187
|
+
threading.excepthook = _thread_excepthook
|
|
188
|
+
|
|
189
|
+
def _tprint(*args, **kwargs):
|
|
190
|
+
"""Timestamped print that adds [timestamp] HH:MM:SS.mmm +delta prefix."""
|
|
191
|
+
global _last_ts
|
|
192
|
+
now = time.monotonic()
|
|
193
|
+
elapsed = now - _start_ts
|
|
194
|
+
delta = now - _last_ts
|
|
195
|
+
_last_ts = now
|
|
196
|
+
|
|
197
|
+
if elapsed < 1:
|
|
198
|
+
elapsed_str = f"{elapsed * 1000:.0f}ms"
|
|
199
|
+
elif elapsed < 100:
|
|
200
|
+
elapsed_str = f"{elapsed:.1f}s"
|
|
201
|
+
else:
|
|
202
|
+
elapsed_str = f"{elapsed:.0f}s"
|
|
203
|
+
|
|
204
|
+
if delta < 0.001:
|
|
205
|
+
delta_str = ""
|
|
206
|
+
elif delta < 1:
|
|
207
|
+
delta_str = f"+{delta * 1000:.0f}ms"
|
|
208
|
+
elif delta < 100:
|
|
209
|
+
delta_str = f"+{delta:.1f}s"
|
|
210
|
+
else:
|
|
211
|
+
delta_str = f"+{delta:.0f}s"
|
|
212
|
+
|
|
213
|
+
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
214
|
+
|
|
215
|
+
_builtin_print(*args, **kwargs)
|
|
216
|
+
|
|
217
|
+
if _log_latest_path or _log_daily_path:
|
|
218
|
+
sep = kwargs.get("sep", " ")
|
|
219
|
+
end = kwargs.get("end", "\n")
|
|
220
|
+
text = sep.join(str(a) for a in args)
|
|
221
|
+
prefix = f"[{elapsed_str:>6}] {ts} {delta_str:>8} "
|
|
222
|
+
_write_log(prefix + _strip_ansi(text) + end)
|
|
223
|
+
|
|
224
|
+
builtins.print = _tprint
|
|
225
|
+
|
|
226
|
+
# Ensure project root is on sys.path (set by main.py or cli.js)
|
|
227
|
+
_project_root = os.environ.get("KITE_PROJECT") or os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
228
|
+
if _project_root not in sys.path:
|
|
229
|
+
sys.path.insert(0, _project_root)
|
|
230
|
+
|
|
231
|
+
from extensions.agents.assistant.server import AssistantServer
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _fmt_elapsed(t0: float) -> str:
|
|
235
|
+
d = time.monotonic() - t0
|
|
236
|
+
if d < 1:
|
|
237
|
+
return f"{d * 1000:.0f}ms"
|
|
238
|
+
if d < 10:
|
|
239
|
+
return f"{d:.1f}s"
|
|
240
|
+
return f"{d:.0f}s"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _get_free_port() -> int:
|
|
244
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
245
|
+
s.bind(("127.0.0.1", 0))
|
|
246
|
+
return s.getsockname()[1]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _register_to_registry(client: httpx.Client, token: str, registry_url: str, port: int):
|
|
250
|
+
payload = {
|
|
251
|
+
"action": "register",
|
|
252
|
+
"module_id": "assistant",
|
|
253
|
+
"module_type": "agent",
|
|
254
|
+
"name": "Assistant",
|
|
255
|
+
"api_endpoint": f"http://127.0.0.1:{port}",
|
|
256
|
+
"health_endpoint": "/health",
|
|
257
|
+
"events_publish": {
|
|
258
|
+
"assistant.test": {"description": "Test event from assistant module"},
|
|
259
|
+
},
|
|
260
|
+
"events_subscribe": [
|
|
261
|
+
"module.started",
|
|
262
|
+
"module.stopped",
|
|
263
|
+
"module.shutdown",
|
|
264
|
+
],
|
|
265
|
+
}
|
|
266
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
267
|
+
try:
|
|
268
|
+
resp = client.post(f"{registry_url}/modules", json=payload, headers=headers)
|
|
269
|
+
if resp.status_code == 200:
|
|
270
|
+
pass # timing printed in main()
|
|
271
|
+
else:
|
|
272
|
+
print(f"[assistant] WARNING: Registry returned {resp.status_code}")
|
|
273
|
+
except Exception as e:
|
|
274
|
+
print(f"[assistant] WARNING: Registry registration failed: {e}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _get_event_hub_ws(client: httpx.Client, token: str, registry_url: str) -> str:
|
|
278
|
+
"""Discover Event Hub WebSocket endpoint from Registry, with retry."""
|
|
279
|
+
import time
|
|
280
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
281
|
+
deadline = time.time() + 10
|
|
282
|
+
while time.time() < deadline:
|
|
283
|
+
try:
|
|
284
|
+
resp = client.get(
|
|
285
|
+
f"{registry_url}/get/event_hub.metadata.ws_endpoint",
|
|
286
|
+
headers=headers,
|
|
287
|
+
)
|
|
288
|
+
if resp.status_code == 200:
|
|
289
|
+
val = resp.json()
|
|
290
|
+
if val:
|
|
291
|
+
return val
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
time.sleep(0.2)
|
|
295
|
+
return ""
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def main():
|
|
299
|
+
# Initialize log file paths
|
|
300
|
+
global _log_dir, _log_latest_path, _crash_log_path
|
|
301
|
+
module_data = os.environ.get("KITE_MODULE_DATA")
|
|
302
|
+
if module_data:
|
|
303
|
+
_log_dir = os.path.join(module_data, "log")
|
|
304
|
+
os.makedirs(_log_dir, exist_ok=True)
|
|
305
|
+
suffix = os.environ.get("KITE_INSTANCE_SUFFIX", "")
|
|
306
|
+
|
|
307
|
+
_log_latest_path = os.path.join(_log_dir, f"latest{suffix}.log")
|
|
308
|
+
try:
|
|
309
|
+
with open(_log_latest_path, "w", encoding="utf-8") as f:
|
|
310
|
+
pass
|
|
311
|
+
except Exception:
|
|
312
|
+
_log_latest_path = None
|
|
313
|
+
|
|
314
|
+
_crash_log_path = os.path.join(_log_dir, f"crashes{suffix}.jsonl")
|
|
315
|
+
try:
|
|
316
|
+
with open(_crash_log_path, "w", encoding="utf-8") as f:
|
|
317
|
+
pass
|
|
318
|
+
except Exception:
|
|
319
|
+
_crash_log_path = None
|
|
320
|
+
|
|
321
|
+
_resolve_daily_log_path()
|
|
322
|
+
|
|
323
|
+
_setup_exception_hooks()
|
|
324
|
+
|
|
325
|
+
_t0 = time.monotonic()
|
|
326
|
+
|
|
327
|
+
# Read boot_info from stdin (only token)
|
|
328
|
+
token = ""
|
|
329
|
+
try:
|
|
330
|
+
line = sys.stdin.readline().strip()
|
|
331
|
+
if line:
|
|
332
|
+
boot_info = json.loads(line)
|
|
333
|
+
token = boot_info.get("token", "")
|
|
334
|
+
except Exception:
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
# Read registry_port from environment variable
|
|
338
|
+
registry_port = int(os.environ.get("KITE_REGISTRY_PORT", "0"))
|
|
339
|
+
|
|
340
|
+
if not token or not registry_port:
|
|
341
|
+
print("[assistant] ERROR: Missing token or KITE_REGISTRY_PORT")
|
|
342
|
+
sys.exit(1)
|
|
343
|
+
|
|
344
|
+
print(f"[assistant] Token received ({len(token)} chars), registry port: {registry_port} ({_fmt_elapsed(_t0)})")
|
|
345
|
+
|
|
346
|
+
registry_url = f"http://127.0.0.1:{registry_port}"
|
|
347
|
+
port = _get_free_port()
|
|
348
|
+
|
|
349
|
+
# Register and discover Event Hub synchronously before starting uvicorn
|
|
350
|
+
client = httpx.Client(timeout=5)
|
|
351
|
+
_register_to_registry(client, token, registry_url, port)
|
|
352
|
+
print(f"[assistant] Registered to Registry ({_fmt_elapsed(_t0)})")
|
|
353
|
+
event_hub_ws = _get_event_hub_ws(client, token, registry_url)
|
|
354
|
+
if not event_hub_ws:
|
|
355
|
+
print("[assistant] WARNING: Could not discover Event Hub WS, events disabled")
|
|
356
|
+
else:
|
|
357
|
+
print(f"[assistant] Discovered Event Hub: {event_hub_ws}")
|
|
358
|
+
client.close()
|
|
359
|
+
|
|
360
|
+
server = AssistantServer(
|
|
361
|
+
token=token,
|
|
362
|
+
registry_url=registry_url,
|
|
363
|
+
event_hub_ws=event_hub_ws,
|
|
364
|
+
boot_t0=_t0,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
print(f"[assistant] Starting on port {port} ({_fmt_elapsed(_t0)})")
|
|
368
|
+
try:
|
|
369
|
+
config = uvicorn.Config(server.app, host="127.0.0.1", port=port, log_level="warning")
|
|
370
|
+
uvi_server = uvicorn.Server(config)
|
|
371
|
+
server._uvicorn_server = uvi_server
|
|
372
|
+
uvi_server.run()
|
|
373
|
+
except Exception as e:
|
|
374
|
+
_write_crash(type(e), e, e.__traceback__, severity="critical", handled=True)
|
|
375
|
+
_print_crash_summary(type(e), e.__traceback__)
|
|
376
|
+
sys.exit(1)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assistant
|
|
3
|
+
display_name: Assistant
|
|
4
|
+
version: "1.0"
|
|
5
|
+
type: agent
|
|
6
|
+
state: enabled
|
|
7
|
+
runtime: python
|
|
8
|
+
entry: entry.py
|
|
9
|
+
events:
|
|
10
|
+
- assistant.test
|
|
11
|
+
subscriptions:
|
|
12
|
+
- module.started
|
|
13
|
+
- module.stopped
|
|
14
|
+
- module.shutdown
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Assistant(AI 助手代理)
|
|
18
|
+
|
|
19
|
+
AI 助手代理模块,负责对话管理和任务执行。
|
|
20
|
+
|
|
21
|
+
- 对话管理 — 处理用户对话请求并生成响应
|
|
22
|
+
- 事件通知 — 通过 Event Hub 发布助手状态事件
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Assistant HTTP server.
|
|
3
|
+
Exposes /health and /status endpoints.
|
|
4
|
+
Connects to Event Hub via WebSocket for event publishing and subscription.
|
|
5
|
+
Sends periodic heartbeat to Registry and test events to Event Hub.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import websockets
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AssistantServer:
|
|
20
|
+
|
|
21
|
+
def __init__(self, token: str = "", registry_url: str = "",
|
|
22
|
+
event_hub_ws: str = "", boot_t0: float = 0):
|
|
23
|
+
self.token = token
|
|
24
|
+
self.registry_url = registry_url
|
|
25
|
+
self.event_hub_ws = event_hub_ws
|
|
26
|
+
self.boot_t0 = boot_t0
|
|
27
|
+
self._ws_task: asyncio.Task | None = None
|
|
28
|
+
self._heartbeat_task: asyncio.Task | None = None
|
|
29
|
+
self._test_task: asyncio.Task | None = None
|
|
30
|
+
self._ws: object | None = None
|
|
31
|
+
self._ready_sent = False
|
|
32
|
+
self._shutting_down = False
|
|
33
|
+
self._uvicorn_server = None # set by entry.py for graceful shutdown
|
|
34
|
+
self._start_time = time.time()
|
|
35
|
+
self.app = self._create_app()
|
|
36
|
+
|
|
37
|
+
def _create_app(self) -> FastAPI:
|
|
38
|
+
app = FastAPI(title="Kite Assistant", docs_url=None, redoc_url=None)
|
|
39
|
+
server = self
|
|
40
|
+
|
|
41
|
+
@app.on_event("startup")
|
|
42
|
+
async def _startup():
|
|
43
|
+
server._heartbeat_task = asyncio.create_task(server._heartbeat_loop())
|
|
44
|
+
if server.event_hub_ws:
|
|
45
|
+
server._ws_task = asyncio.create_task(server._ws_loop())
|
|
46
|
+
server._test_task = asyncio.create_task(server._test_event_loop())
|
|
47
|
+
|
|
48
|
+
@app.on_event("shutdown")
|
|
49
|
+
async def _shutdown():
|
|
50
|
+
if server._heartbeat_task:
|
|
51
|
+
server._heartbeat_task.cancel()
|
|
52
|
+
if server._ws_task:
|
|
53
|
+
server._ws_task.cancel()
|
|
54
|
+
if server._test_task:
|
|
55
|
+
server._test_task.cancel()
|
|
56
|
+
if server._ws:
|
|
57
|
+
await server._ws.close()
|
|
58
|
+
print("[assistant] Shutdown complete")
|
|
59
|
+
|
|
60
|
+
@app.get("/health")
|
|
61
|
+
async def health():
|
|
62
|
+
return {
|
|
63
|
+
"status": "healthy",
|
|
64
|
+
"details": {
|
|
65
|
+
"event_hub_connected": server._ws is not None,
|
|
66
|
+
"uptime_seconds": round(time.time() - server._start_time),
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@app.get("/status")
|
|
71
|
+
async def status():
|
|
72
|
+
return {
|
|
73
|
+
"module": "assistant",
|
|
74
|
+
"status": "running",
|
|
75
|
+
"event_hub_connected": server._ws is not None,
|
|
76
|
+
"uptime_seconds": round(time.time() - server._start_time),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return app
|
|
80
|
+
|
|
81
|
+
# ── Event Hub WebSocket client ──
|
|
82
|
+
|
|
83
|
+
async def _ws_loop(self):
|
|
84
|
+
"""Connect to Event Hub, subscribe, and listen. Reconnect on failure."""
|
|
85
|
+
retry_delay = 0.5 # start with 0.5s
|
|
86
|
+
max_delay = 30 # cap at 30s
|
|
87
|
+
while not self._shutting_down:
|
|
88
|
+
try:
|
|
89
|
+
await self._ws_connect()
|
|
90
|
+
except asyncio.CancelledError:
|
|
91
|
+
return
|
|
92
|
+
retry_delay = 0.5 # reset on successful connection
|
|
93
|
+
except Exception as e:
|
|
94
|
+
print(f"[assistant] Event Hub connection error: {e}, retrying in {retry_delay:.1f}s")
|
|
95
|
+
self._ws = None
|
|
96
|
+
if self._shutting_down:
|
|
97
|
+
return
|
|
98
|
+
await asyncio.sleep(retry_delay)
|
|
99
|
+
retry_delay = min(retry_delay * 2, max_delay) # exponential backoff
|
|
100
|
+
|
|
101
|
+
async def _ws_connect(self):
|
|
102
|
+
"""Single WebSocket session: connect, subscribe, receive loop."""
|
|
103
|
+
url = f"{self.event_hub_ws}?token={self.token}&id=assistant"
|
|
104
|
+
print(f"[assistant] WS connecting to {self.event_hub_ws}")
|
|
105
|
+
async with websockets.connect(url, open_timeout=3, ping_interval=None, ping_timeout=None, close_timeout=10) as ws:
|
|
106
|
+
self._ws = ws
|
|
107
|
+
elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
|
|
108
|
+
elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
|
|
109
|
+
print(f"[assistant] Connected to Event Hub{elapsed_str}")
|
|
110
|
+
|
|
111
|
+
# Subscribe to module lifecycle events + shutdown
|
|
112
|
+
await ws.send(json.dumps({
|
|
113
|
+
"type": "subscribe",
|
|
114
|
+
"events": ["module.started", "module.stopped", "module.shutdown"],
|
|
115
|
+
}))
|
|
116
|
+
|
|
117
|
+
# Send module.ready (once) so Launcher knows we're up
|
|
118
|
+
if not self._ready_sent:
|
|
119
|
+
ready_msg = {
|
|
120
|
+
"type": "event",
|
|
121
|
+
"event_id": str(uuid.uuid4()),
|
|
122
|
+
"event": "module.ready",
|
|
123
|
+
"source": "assistant",
|
|
124
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
125
|
+
"data": {
|
|
126
|
+
"module_id": "assistant",
|
|
127
|
+
"graceful_shutdown": True,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
await ws.send(json.dumps(ready_msg))
|
|
131
|
+
self._ready_sent = True
|
|
132
|
+
elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
|
|
133
|
+
elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
|
|
134
|
+
print(f"[assistant] module.ready sent{elapsed_str}")
|
|
135
|
+
|
|
136
|
+
# Receive loop
|
|
137
|
+
async for raw in ws:
|
|
138
|
+
try:
|
|
139
|
+
msg = json.loads(raw)
|
|
140
|
+
except (json.JSONDecodeError, TypeError):
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
msg_type = msg.get("type", "")
|
|
145
|
+
if msg_type == "event":
|
|
146
|
+
event_name = msg.get("event", "")
|
|
147
|
+
if event_name == "module.shutdown":
|
|
148
|
+
target = (msg.get("data") if isinstance(msg.get("data"), dict) else {}).get("module_id", "")
|
|
149
|
+
if target == "assistant":
|
|
150
|
+
await self._handle_shutdown(ws)
|
|
151
|
+
return
|
|
152
|
+
elif msg_type == "ack":
|
|
153
|
+
pass # publish confirmed
|
|
154
|
+
elif msg_type == "error":
|
|
155
|
+
print(f"[assistant] Event Hub error: {msg.get('message')}")
|
|
156
|
+
except Exception as e:
|
|
157
|
+
print(f"[assistant] 事件处理异常(已忽略): {e}")
|
|
158
|
+
|
|
159
|
+
async def _handle_shutdown(self, ws):
|
|
160
|
+
"""Handle module.shutdown: ack → cleanup → ready → exit."""
|
|
161
|
+
print("[assistant] Received module.shutdown")
|
|
162
|
+
self._shutting_down = True
|
|
163
|
+
|
|
164
|
+
# Step 1: Send ack
|
|
165
|
+
await self._publish_event({
|
|
166
|
+
"event": "module.shutdown.ack",
|
|
167
|
+
"data": {"module_id": "assistant", "estimated_cleanup": 2},
|
|
168
|
+
})
|
|
169
|
+
print("[assistant] shutdown ack sent")
|
|
170
|
+
|
|
171
|
+
# Step 2: Cleanup (cancel background tasks)
|
|
172
|
+
if self._heartbeat_task:
|
|
173
|
+
self._heartbeat_task.cancel()
|
|
174
|
+
if self._test_task:
|
|
175
|
+
self._test_task.cancel()
|
|
176
|
+
|
|
177
|
+
# Step 3: Send ready (before closing WS!)
|
|
178
|
+
await self._publish_event({
|
|
179
|
+
"event": "module.shutdown.ready",
|
|
180
|
+
"data": {"module_id": "assistant"},
|
|
181
|
+
})
|
|
182
|
+
print("[assistant] Shutdown complete")
|
|
183
|
+
|
|
184
|
+
# Step 4: Trigger uvicorn exit (WS will close when uvicorn shuts down)
|
|
185
|
+
if self._uvicorn_server:
|
|
186
|
+
self._uvicorn_server.should_exit = True
|
|
187
|
+
|
|
188
|
+
async def _publish_event(self, event: dict):
|
|
189
|
+
"""Publish an event to Event Hub via WebSocket."""
|
|
190
|
+
if not self._ws:
|
|
191
|
+
return
|
|
192
|
+
msg = {
|
|
193
|
+
"type": "event",
|
|
194
|
+
"event_id": str(uuid.uuid4()),
|
|
195
|
+
"event": event.get("event", ""),
|
|
196
|
+
"source": "assistant",
|
|
197
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
198
|
+
"data": event.get("data", {}),
|
|
199
|
+
}
|
|
200
|
+
try:
|
|
201
|
+
await self._ws.send(json.dumps(msg))
|
|
202
|
+
except Exception as e:
|
|
203
|
+
print(f"[assistant] Failed to publish event: {e}")
|
|
204
|
+
|
|
205
|
+
# ── Heartbeat to Registry ──
|
|
206
|
+
|
|
207
|
+
async def _heartbeat_loop(self):
|
|
208
|
+
"""Send heartbeat to Registry every 30 seconds."""
|
|
209
|
+
while True:
|
|
210
|
+
await asyncio.sleep(30)
|
|
211
|
+
try:
|
|
212
|
+
async with httpx.AsyncClient() as client:
|
|
213
|
+
await client.post(
|
|
214
|
+
f"{self.registry_url}/modules",
|
|
215
|
+
json={"action": "heartbeat", "module_id": "assistant"},
|
|
216
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
217
|
+
timeout=5,
|
|
218
|
+
)
|
|
219
|
+
print("[assistant] heartbeat sent")
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
# ── Test event loop ──
|
|
224
|
+
|
|
225
|
+
async def _test_event_loop(self):
|
|
226
|
+
"""Publish a test event every 10 seconds."""
|
|
227
|
+
while True:
|
|
228
|
+
await asyncio.sleep(10)
|
|
229
|
+
await self._publish_event({
|
|
230
|
+
"event": "assistant.test",
|
|
231
|
+
"data": {
|
|
232
|
+
"message": "test event from assistant",
|
|
233
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
print("[assistant] test event published")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|