@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.
Files changed (112) hide show
  1. package/cli.js +127 -25
  2. package/core/event_hub/entry.py +384 -61
  3. package/core/event_hub/hub.py +8 -0
  4. package/core/event_hub/module.md +0 -1
  5. package/core/event_hub/server.py +169 -38
  6. package/core/kite_log.py +241 -0
  7. package/core/launcher/entry.py +1306 -425
  8. package/core/launcher/module_scanner.py +10 -9
  9. package/core/launcher/process_manager.py +555 -121
  10. package/core/registry/entry.py +335 -30
  11. package/core/registry/server.py +339 -256
  12. package/core/registry/store.py +13 -2
  13. package/extensions/agents/__init__.py +1 -0
  14. package/extensions/agents/assistant/__init__.py +1 -0
  15. package/extensions/agents/assistant/entry.py +380 -0
  16. package/extensions/agents/assistant/module.md +22 -0
  17. package/extensions/agents/assistant/server.py +236 -0
  18. package/extensions/channels/__init__.py +1 -0
  19. package/extensions/channels/acp_channel/__init__.py +1 -0
  20. package/extensions/channels/acp_channel/entry.py +380 -0
  21. package/extensions/channels/acp_channel/module.md +22 -0
  22. package/extensions/channels/acp_channel/server.py +236 -0
  23. package/{core → extensions}/event_hub_bench/entry.py +664 -371
  24. package/{core → extensions}/event_hub_bench/module.md +4 -2
  25. package/extensions/services/backup/__init__.py +1 -0
  26. package/extensions/services/backup/entry.py +380 -0
  27. package/extensions/services/backup/module.md +22 -0
  28. package/extensions/services/backup/server.py +244 -0
  29. package/extensions/services/model_service/__init__.py +1 -0
  30. package/extensions/services/model_service/entry.py +380 -0
  31. package/extensions/services/model_service/module.md +22 -0
  32. package/extensions/services/model_service/server.py +236 -0
  33. package/extensions/services/watchdog/entry.py +460 -143
  34. package/extensions/services/watchdog/module.md +3 -0
  35. package/extensions/services/watchdog/monitor.py +128 -13
  36. package/extensions/services/watchdog/server.py +75 -13
  37. package/extensions/services/web/__init__.py +1 -0
  38. package/extensions/services/web/config.yaml +149 -0
  39. package/extensions/services/web/entry.py +487 -0
  40. package/extensions/services/web/module.md +24 -0
  41. package/extensions/services/web/routes/__init__.py +1 -0
  42. package/extensions/services/web/routes/routes_call.py +189 -0
  43. package/extensions/services/web/routes/routes_config.py +512 -0
  44. package/extensions/services/web/routes/routes_contacts.py +98 -0
  45. package/extensions/services/web/routes/routes_devlog.py +99 -0
  46. package/extensions/services/web/routes/routes_phone.py +81 -0
  47. package/extensions/services/web/routes/routes_sms.py +48 -0
  48. package/extensions/services/web/routes/routes_stats.py +17 -0
  49. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  50. package/extensions/services/web/routes/schemas.py +216 -0
  51. package/extensions/services/web/server.py +332 -0
  52. package/extensions/services/web/static/css/style.css +1064 -0
  53. package/extensions/services/web/static/index.html +1445 -0
  54. package/extensions/services/web/static/js/app.js +4671 -0
  55. package/extensions/services/web/vendor/__init__.py +1 -0
  56. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  57. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  58. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  59. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  60. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  61. package/extensions/services/web/vendor/config.py +139 -0
  62. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  63. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  64. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  65. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  66. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  67. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  68. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  69. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  70. package/extensions/services/web/vendor/storage/identity.py +312 -0
  71. package/extensions/services/web/vendor/storage/store.py +507 -0
  72. package/extensions/services/web/vendor/task/__init__.py +0 -0
  73. package/extensions/services/web/vendor/task/manager.py +864 -0
  74. package/extensions/services/web/vendor/task/models.py +45 -0
  75. package/extensions/services/web/vendor/task/webhook.py +263 -0
  76. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  77. package/extensions/services/web/vendor/tools/registry.py +321 -0
  78. package/main.py +344 -4
  79. package/package.json +11 -2
  80. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
  82. package/core/data_dir.py +0 -62
  83. package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
  84. package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
  85. package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
  86. package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
  87. package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
  88. package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
  89. package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
  90. package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
  91. package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
  92. package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
  93. package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
  94. package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
  96. package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
  97. package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
  98. package/core/launcher/data/log/lifecycle.jsonl +0 -1158
  99. package/core/launcher/data/token.txt +0 -1
  100. package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
  101. package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
  102. package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
  103. package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
  104. package/core/registry/data/port.txt +0 -1
  105. package/core/registry/data/port_484.txt +0 -1
  106. package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
  108. package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
  110. package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
  111. package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
  112. /package/{core/event_hub/bench_results/.gitkeep → extensions/services/web/vendor/bluetooth/__init__.py} +0 -0
@@ -0,0 +1,45 @@
1
+ """Task data models — enumerations for call lifecycle states."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class TaskStatus(str, Enum):
9
+ """Lifecycle status of a call task."""
10
+ QUEUED = "queued"
11
+ CONFIRMING = "confirming" # waiting for contact confirmation
12
+ CONNECTING = "connecting"
13
+ DIALING = "dialing"
14
+ ALERTING = "alerting"
15
+ ACTIVE = "active"
16
+ COMPLETED = "completed"
17
+ FAILED = "failed"
18
+ CANCELLED = "cancelled"
19
+
20
+
21
+ class CallDirection(str, Enum):
22
+ """Direction of a phone call."""
23
+ OUTGOING = "outgoing"
24
+ INCOMING = "incoming"
25
+
26
+
27
+ class CallResult(str, Enum):
28
+ """Outcome of a completed call."""
29
+ SUCCESS = "success"
30
+ NO_ANSWER = "no_answer"
31
+ BUSY = "busy"
32
+ REJECTED = "rejected"
33
+ ERROR = "error"
34
+ TIMEOUT = "timeout"
35
+
36
+
37
+ class EndedReason(str, Enum):
38
+ """Reason the call ended."""
39
+ NORMAL = "normal"
40
+ AI_HANGUP = "ai_hangup"
41
+ REMOTE_HANGUP = "remote_hangup"
42
+ TIMEOUT = "timeout"
43
+ MAX_DURATION = "max_duration"
44
+ ERROR = "error"
45
+ CANCELLED = "cancelled"
@@ -0,0 +1,263 @@
1
+ """Webhook client — sends callbacks to the task caller with retry logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .. import config as cfg
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class WebhookClient:
17
+ """Sends webhook notifications and manages pending confirmation futures.
18
+
19
+ Confirmations that require a response from the caller (e.g.
20
+ ``in_call_confirmation``) are tracked as :class:`asyncio.Future` objects
21
+ keyed by *task_id*. The matching API route calls
22
+ :meth:`resolve_confirmation` when the caller replies via
23
+ ``POST /api/call/{task_id}/message``.
24
+ """
25
+
26
+ def __init__(self) -> None:
27
+ timeout = cfg.get("webhook.timeout", 10)
28
+ self._client = httpx.AsyncClient(timeout=timeout)
29
+ self._pending_confirmations: dict[str, asyncio.Future[str]] = {}
30
+
31
+ # ------------------------------------------------------------------
32
+ # Low-level send with retry
33
+ # ------------------------------------------------------------------
34
+
35
+ async def send(self, url: str, payload: dict[str, Any]) -> bool:
36
+ """Send a webhook POST with configurable retry.
37
+
38
+ Retries up to ``webhook.retry_count`` times (default 3) with delays
39
+ taken from ``webhook.retry_delays`` (default ``[1, 5, 30]``).
40
+
41
+ Returns ``True`` if **any** attempt receives a 2xx response.
42
+ """
43
+ retry_count: int = cfg.get("webhook.retry_count", 3)
44
+ retry_delays: list[int] = cfg.get("webhook.retry_delays", [1, 5, 30])
45
+
46
+ for attempt in range(retry_count):
47
+ try:
48
+ resp = await self._client.post(url, json=payload)
49
+ if 200 <= resp.status_code < 300:
50
+ logger.info(
51
+ "Webhook delivered to %s (attempt %d, status %d)",
52
+ url, attempt + 1, resp.status_code,
53
+ )
54
+ return True
55
+ logger.warning(
56
+ "Webhook to %s returned %d (attempt %d/%d)",
57
+ url, resp.status_code, attempt + 1, retry_count,
58
+ )
59
+ except httpx.HTTPError as exc:
60
+ logger.warning(
61
+ "Webhook to %s failed (attempt %d/%d): %s",
62
+ url, attempt + 1, retry_count, exc,
63
+ )
64
+
65
+ # Wait before next retry (if not the last attempt)
66
+ if attempt < retry_count - 1:
67
+ delay = retry_delays[attempt] if attempt < len(retry_delays) else retry_delays[-1]
68
+ await asyncio.sleep(delay)
69
+
70
+ logger.error("Webhook to %s exhausted all %d retries", url, retry_count)
71
+ return False
72
+
73
+ # ------------------------------------------------------------------
74
+ # High-level notification helpers
75
+ # ------------------------------------------------------------------
76
+
77
+ async def notify_call_completed(self, task: dict[str, Any]) -> None:
78
+ """Send a ``call_completed`` webhook after a call finishes."""
79
+ webhook_url = task.get("webhook_url") or cfg.get("webhook.default_url")
80
+ if not webhook_url:
81
+ logger.debug("No webhook URL configured; skipping call_completed notification")
82
+ return
83
+
84
+ payload: dict[str, Any] = {
85
+ "task_id": task["task_id"],
86
+ "type": "call_completed",
87
+ "status": task.get("status", "completed"),
88
+ "phone_number": task.get("phone_number"),
89
+ "contact_name": task.get("contact_name"),
90
+ "direction": task.get("direction"),
91
+ "duration_seconds": task.get("duration_seconds", 0),
92
+ "transcript": task.get("transcript", []),
93
+ "summary": task.get("summary", ""),
94
+ "result": task.get("result"),
95
+ "ended_reason": task.get("ended_reason"),
96
+ "recording_url": f"/api/calls/{task['task_id']}/recording" if task.get("has_recording") else None,
97
+ "timestamp": task.get("ended_at"),
98
+ }
99
+
100
+ await self.send(webhook_url, payload)
101
+
102
+ async def notify_incoming_call(self, task: dict[str, Any]) -> dict[str, Any] | None:
103
+ """Send an ``incoming_call`` webhook and wait for the caller to confirm.
104
+
105
+ Returns the confirmation response dict received through the API, or
106
+ ``None`` if the configured timeout elapses.
107
+ """
108
+ webhook_url = task.get("webhook_url") or cfg.get("webhook.default_url")
109
+ if not webhook_url:
110
+ logger.debug("No webhook URL configured; skipping incoming_call notification")
111
+ return None
112
+
113
+ payload: dict[str, Any] = {
114
+ "task_id": task["task_id"],
115
+ "type": "incoming_call",
116
+ "phone_number": task.get("phone_number"),
117
+ "contact_name": task.get("contact_name"),
118
+ "contact_info": task.get("contact_info"),
119
+ "timestamp": task.get("started_at"),
120
+ }
121
+
122
+ sent = await self.send(webhook_url, payload)
123
+ if not sent:
124
+ return None
125
+
126
+ # Wait for the caller to respond via POST /api/call/{task_id}/confirm
127
+ timeout = cfg.get("call.incoming_confirm_timeout", 15)
128
+ future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
129
+ self._pending_confirmations[task["task_id"]] = future
130
+
131
+ try:
132
+ result = await asyncio.wait_for(future, timeout=timeout)
133
+ # result is a JSON-encoded string; the API layer is responsible for
134
+ # providing a meaningful dict — we simply return what we got.
135
+ return {"response": result}
136
+ except asyncio.TimeoutError:
137
+ logger.info("Incoming-call confirmation timed out for task %s", task["task_id"])
138
+ return None
139
+ finally:
140
+ self._pending_confirmations.pop(task["task_id"], None)
141
+
142
+ async def request_contact_confirmation(
143
+ self,
144
+ task: dict[str, Any],
145
+ matched_contact: dict[str, Any],
146
+ ) -> dict[str, Any] | None:
147
+ """Send a ``contact_confirmation`` webhook and wait for the caller to confirm.
148
+
149
+ Returns the confirmation response or ``None`` on timeout.
150
+ """
151
+ webhook_url = task.get("webhook_url") or cfg.get("webhook.default_url")
152
+ if not webhook_url:
153
+ logger.debug("No webhook URL configured; skipping contact_confirmation")
154
+ return None
155
+
156
+ payload: dict[str, Any] = {
157
+ "task_id": task["task_id"],
158
+ "type": "contact_confirmation",
159
+ "matched_contact": {
160
+ "name": matched_contact.get("name"),
161
+ "phone": matched_contact.get("phone"),
162
+ "company": matched_contact.get("company"),
163
+ },
164
+ "confidence": matched_contact.get("confidence", 0),
165
+ "alternatives": matched_contact.get("alternatives", []),
166
+ }
167
+
168
+ sent = await self.send(webhook_url, payload)
169
+ if not sent:
170
+ return None
171
+
172
+ # Wait for POST /api/call/{task_id}/confirm
173
+ timeout = cfg.get("call.incoming_confirm_timeout", 15)
174
+ future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
175
+ self._pending_confirmations[task["task_id"]] = future
176
+
177
+ try:
178
+ result = await asyncio.wait_for(future, timeout=timeout)
179
+ return {"response": result}
180
+ except asyncio.TimeoutError:
181
+ logger.info("Contact confirmation timed out for task %s", task["task_id"])
182
+ return None
183
+ finally:
184
+ self._pending_confirmations.pop(task["task_id"], None)
185
+
186
+ async def request_in_call_confirmation(
187
+ self,
188
+ task_id: str,
189
+ question: str,
190
+ options: list[str] | None = None,
191
+ ) -> str | None:
192
+ """Send an ``in_call_confirmation`` webhook and wait for a response.
193
+
194
+ The response arrives asynchronously when the caller sends a message
195
+ through ``POST /api/call/{task_id}/message``, which triggers
196
+ :meth:`resolve_confirmation`.
197
+
198
+ Returns the caller's text response, or ``None`` on timeout.
199
+ """
200
+ # Look up webhook URL from the persisted task record
201
+ from storage import get_task
202
+ task = await get_task(task_id)
203
+ webhook_url = (task or {}).get("webhook_url") or cfg.get("webhook.default_url")
204
+ if not webhook_url:
205
+ logger.debug("No webhook URL; skipping in_call_confirmation for %s", task_id)
206
+ return None
207
+
208
+ payload: dict[str, Any] = {
209
+ "task_id": task_id,
210
+ "type": "in_call_confirmation",
211
+ "question": question,
212
+ "options": options,
213
+ }
214
+
215
+ sent = await self.send(webhook_url, payload)
216
+ if not sent:
217
+ return None
218
+
219
+ # Wait for the caller's reply
220
+ timeout = cfg.get("call.no_response_timeout", 15)
221
+ future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
222
+ self._pending_confirmations[task_id] = future
223
+
224
+ try:
225
+ return await asyncio.wait_for(future, timeout=timeout)
226
+ except asyncio.TimeoutError:
227
+ logger.info("In-call confirmation timed out for task %s", task_id)
228
+ return None
229
+ finally:
230
+ self._pending_confirmations.pop(task_id, None)
231
+
232
+ # ------------------------------------------------------------------
233
+ # Confirmation resolution (called by API routes)
234
+ # ------------------------------------------------------------------
235
+
236
+ def resolve_confirmation(self, task_id: str, response: str) -> None:
237
+ """Resolve a pending confirmation future for *task_id*.
238
+
239
+ Called by the API route handler when the caller responds via
240
+ ``POST /api/call/{task_id}/message`` or
241
+ ``POST /api/call/{task_id}/confirm``.
242
+ """
243
+ future = self._pending_confirmations.get(task_id)
244
+ if future is not None and not future.done():
245
+ future.set_result(response)
246
+ logger.info("Resolved pending confirmation for task %s", task_id)
247
+ else:
248
+ logger.debug("No pending confirmation for task %s (or already resolved)", task_id)
249
+
250
+ # ------------------------------------------------------------------
251
+ # Lifecycle
252
+ # ------------------------------------------------------------------
253
+
254
+ async def close(self) -> None:
255
+ """Shut down the underlying HTTP client."""
256
+ await self._client.aclose()
257
+
258
+ # Cancel any dangling futures
259
+ for task_id, future in self._pending_confirmations.items():
260
+ if not future.done():
261
+ future.cancel()
262
+ logger.debug("Cancelled pending confirmation for task %s on shutdown", task_id)
263
+ self._pending_confirmations.clear()
@@ -0,0 +1,321 @@
1
+ """Tool registry — discovers, loads and manages tools installed under data/tools/."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import importlib.util
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Awaitable
10
+
11
+ import yaml
12
+
13
+ from .. import config as cfg
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ ToolHandler = Callable[[dict[str, Any], dict[str, Any]], Awaitable[str]]
18
+
19
+
20
+ class ToolRegistry:
21
+ """Central registry that discovers tool.yaml + handler.py pairs under a tools directory.
22
+
23
+ Parameters
24
+ ----------
25
+ tools_dir:
26
+ Path to the ``data/tools/`` directory.
27
+ """
28
+
29
+ def __init__(self, tools_dir: Path) -> None:
30
+ self.tools_dir = tools_dir
31
+ # name -> parsed tool.yaml content
32
+ self._definitions: dict[str, dict[str, Any]] = {}
33
+ # name -> async execute(args, context) callable
34
+ self._handlers: dict[str, ToolHandler] = {}
35
+
36
+ # ------------------------------------------------------------------
37
+ # Loading
38
+ # ------------------------------------------------------------------
39
+
40
+ def load_all(self) -> None:
41
+ """Scan *tools_dir* and load every tool that has a valid ``tool.yaml``."""
42
+ if not self.tools_dir.exists():
43
+ logger.warning("Tools directory does not exist: %s", self.tools_dir)
44
+ return
45
+
46
+ for child in sorted(self.tools_dir.iterdir()):
47
+ if not child.is_dir():
48
+ continue
49
+ tool_yaml = child / "tool.yaml"
50
+ handler_py = child / "handler.py"
51
+ if not tool_yaml.exists():
52
+ continue
53
+
54
+ try:
55
+ with open(tool_yaml, "r", encoding="utf-8") as f:
56
+ definition = yaml.safe_load(f) or {}
57
+ name = definition.get("name", child.name)
58
+ self._definitions[name] = definition
59
+
60
+ if handler_py.exists():
61
+ handler = self._load_handler(handler_py)
62
+ if handler:
63
+ self._handlers[name] = handler
64
+
65
+ logger.info("Loaded tool: %s", name)
66
+ except Exception:
67
+ logger.exception("Failed to load tool from %s", child)
68
+
69
+ def _load_handler(self, handler_path: Path) -> ToolHandler | None:
70
+ """Dynamically import a handler.py and return its ``execute`` function."""
71
+ try:
72
+ spec = importlib.util.spec_from_file_location(
73
+ f"tool_handler_{handler_path.parent.name}", str(handler_path)
74
+ )
75
+ if spec is None or spec.loader is None:
76
+ return None
77
+ module = importlib.util.module_from_spec(spec)
78
+ spec.loader.exec_module(module)
79
+ fn = getattr(module, "execute", None)
80
+ if fn is None:
81
+ logger.warning("handler.py in %s has no execute() function", handler_path.parent.name)
82
+ return None
83
+ return fn
84
+ except Exception:
85
+ logger.exception("Failed to import handler: %s", handler_path)
86
+ return None
87
+
88
+ # ------------------------------------------------------------------
89
+ # Accessors
90
+ # ------------------------------------------------------------------
91
+
92
+ def get_definition(self, name: str) -> dict[str, Any] | None:
93
+ return self._definitions.get(name)
94
+
95
+ def get_handler(self, name: str) -> ToolHandler | None:
96
+ return self._handlers.get(name)
97
+
98
+ def get_readme(self, name: str) -> str | None:
99
+ """Return the README.md content for a tool, or None."""
100
+ readme = self.tools_dir / name / "README.md"
101
+ if readme.exists():
102
+ return readme.read_text(encoding="utf-8")
103
+ return None
104
+
105
+ def list_tools(self) -> list[str]:
106
+ """Return all loaded tool names."""
107
+ return list(self._definitions.keys())
108
+
109
+ # ------------------------------------------------------------------
110
+ # Permission resolution
111
+ # ------------------------------------------------------------------
112
+
113
+ def resolve_enabled(
114
+ self,
115
+ global_cfg: dict[str, Any] | None,
116
+ user_cfg: dict[str, Any] | None,
117
+ contact_cfg: dict[str, Any] | None,
118
+ call_cfg: dict[str, Any] | None,
119
+ is_owner: bool,
120
+ ) -> list[str]:
121
+ """Resolve the final list of enabled tool names through the 4-level hierarchy.
122
+
123
+ Resolution order:
124
+ 1. Global ``data/tools.yaml`` — base enabled/disabled
125
+ 2. User ``data/users/{phone}/tools.yaml`` — overlay
126
+ 3. Contact ``data/users/{phone}/contacts/{phone}/tools.yaml`` — overlay
127
+ (defaults depend on ``is_owner``)
128
+ 4. Call-level ``call_cfg`` — final overlay
129
+
130
+ Returns the list of tool names that are enabled after all layers.
131
+ """
132
+ all_tools = set(self._definitions.keys())
133
+
134
+ # Layer 1: Global
135
+ enabled = set(self._apply_layer(all_tools, set(), global_cfg))
136
+
137
+ # Layer 2: User
138
+ enabled = set(self._apply_layer(enabled, all_tools - enabled, user_cfg))
139
+
140
+ # Layer 3: Contact (with owner defaults)
141
+ if is_owner:
142
+ # Owners keep everything enabled from above, then apply contact overrides
143
+ if contact_cfg is not None:
144
+ enabled = set(self._apply_layer(enabled, all_tools - enabled, contact_cfg))
145
+ else:
146
+ # Non-owners start with nothing, then apply contact overrides
147
+ enabled = set()
148
+ if contact_cfg is not None:
149
+ enabled = set(self._apply_layer(enabled, all_tools, contact_cfg))
150
+
151
+ # Layer 4: Call-level overrides
152
+ enabled = set(self._apply_layer(enabled, all_tools - enabled, call_cfg))
153
+
154
+ return sorted(enabled)
155
+
156
+ @staticmethod
157
+ def _apply_layer(
158
+ current_enabled: set[str],
159
+ current_disabled: set[str],
160
+ layer_cfg: dict[str, Any] | None,
161
+ ) -> set[str]:
162
+ """Apply a single tools.yaml layer to the current enabled set."""
163
+ if layer_cfg is None:
164
+ return current_enabled
165
+
166
+ to_enable = set(layer_cfg.get("enabled", []))
167
+ to_disable = set(layer_cfg.get("disabled", []))
168
+
169
+ result = set(current_enabled)
170
+ result |= to_enable
171
+ result -= to_disable
172
+ return result
173
+
174
+ # ------------------------------------------------------------------
175
+ # Provider format conversion
176
+ # ------------------------------------------------------------------
177
+
178
+ def get_tools_for_provider(
179
+ self, provider: str, enabled_names: list[str]
180
+ ) -> list[dict[str, Any]]:
181
+ """Convert enabled tools into the schema expected by each LLM provider.
182
+
183
+ Parameters
184
+ ----------
185
+ provider:
186
+ One of ``"openai"``, ``"claude"``, ``"gemini"``.
187
+ enabled_names:
188
+ List of tool names to include.
189
+ """
190
+ definitions = [
191
+ self._definitions[n] for n in enabled_names if n in self._definitions
192
+ ]
193
+
194
+ if provider == "openai":
195
+ return self._to_openai(definitions)
196
+ elif provider == "claude":
197
+ return self._to_claude(definitions)
198
+ elif provider == "gemini":
199
+ return self._to_gemini(definitions)
200
+ else:
201
+ raise ValueError(f"Unknown LLM provider: {provider}")
202
+
203
+ @staticmethod
204
+ def _to_openai(definitions: list[dict]) -> list[dict]:
205
+ tools = []
206
+ for d in definitions:
207
+ tools.append({
208
+ "type": "function",
209
+ "function": {
210
+ "name": d["name"],
211
+ "description": d.get("description", ""),
212
+ "parameters": copy.deepcopy(d.get("parameters", {})),
213
+ },
214
+ })
215
+ return tools
216
+
217
+ @staticmethod
218
+ def _to_claude(definitions: list[dict]) -> list[dict]:
219
+ tools = []
220
+ for d in definitions:
221
+ tools.append({
222
+ "name": d["name"],
223
+ "description": d.get("description", ""),
224
+ "input_schema": copy.deepcopy(d.get("parameters", {})),
225
+ })
226
+ return tools
227
+
228
+ @staticmethod
229
+ def _to_gemini(definitions: list[dict]) -> list[dict]:
230
+ declarations = []
231
+ for d in definitions:
232
+ declarations.append({
233
+ "name": d["name"],
234
+ "description": d.get("description", ""),
235
+ "parameters": copy.deepcopy(d.get("parameters", {})),
236
+ })
237
+ return [{"function_declarations": declarations}]
238
+
239
+ # ------------------------------------------------------------------
240
+ # Context / summary helpers
241
+ # ------------------------------------------------------------------
242
+
243
+ def build_tools_summary(self, enabled_names: list[str]) -> str:
244
+ """Build a human-readable summary of enabled tools for the LLM system prompt."""
245
+ lines: list[str] = []
246
+ for name in enabled_names:
247
+ defn = self._definitions.get(name)
248
+ if not defn:
249
+ continue
250
+ desc = defn.get("description", "")
251
+ skill = defn.get("skill", {})
252
+ summary = skill.get("summary", desc)
253
+ lines.append(f"- **{name}**: {summary}")
254
+ if not lines:
255
+ return ""
256
+ return "## 可用工具\n\n" + "\n".join(lines)
257
+
258
+ def get_permission_files(
259
+ self, tool_name: str, user_phone: str, contact_phone: str
260
+ ) -> list[Path]:
261
+ """Return the list of permission file paths (user-level, contact-level) for a tool.
262
+
263
+ Files may or may not exist — the handler decides the semantics.
264
+ """
265
+ from storage.identity import user_dir, contact_dir
266
+
267
+ files: list[Path] = []
268
+ u_perm = user_dir(user_phone) / "permissions" / f"{tool_name}.yaml"
269
+ files.append(u_perm)
270
+ c_perm = contact_dir(user_phone, contact_phone) / "permissions" / f"{tool_name}.yaml"
271
+ files.append(c_perm)
272
+ return files
273
+
274
+ def build_context(
275
+ self,
276
+ tool_name: str,
277
+ engine: Any,
278
+ is_owner: bool,
279
+ ) -> dict[str, Any]:
280
+ """Build the context dict passed to a tool handler's ``execute()``."""
281
+ user_phone = engine.task_info.get("user_phone", "")
282
+ contact_phone = engine.task_info.get("phone_number", "")
283
+
284
+ return {
285
+ "task_info": engine.task_info,
286
+ "webhook": engine.webhook,
287
+ "store": None, # lazy-imported by handler if needed
288
+ "identity": None, # lazy-imported by handler if needed
289
+ "engine": engine,
290
+ "data_dir": cfg.data_dir(),
291
+ "root_dir": cfg.root_dir(),
292
+ "is_owner": is_owner,
293
+ "permission_files": self.get_permission_files(
294
+ tool_name, user_phone, contact_phone
295
+ ),
296
+ }
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # Module-level singleton
301
+ # ---------------------------------------------------------------------------
302
+
303
+ _registry: ToolRegistry | None = None
304
+
305
+
306
+ def get_registry() -> ToolRegistry:
307
+ """Return the global ToolRegistry singleton, initializing if needed."""
308
+ global _registry
309
+ if _registry is None:
310
+ _registry = ToolRegistry(cfg.data_dir() / "tools")
311
+ _registry.load_all()
312
+ return _registry
313
+
314
+
315
+ def init_registry() -> ToolRegistry:
316
+ """Explicitly (re-)initialize the global registry. Called at startup."""
317
+ global _registry
318
+ _registry = ToolRegistry(cfg.data_dir() / "tools")
319
+ _registry.load_all()
320
+ logger.info("ToolRegistry initialized with %d tools", len(_registry.list_tools()))
321
+ return _registry