@agentunion/kite 1.0.7 → 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/core/event_hub/entry.py +305 -26
- package/core/event_hub/hub.py +8 -0
- package/core/event_hub/server.py +80 -17
- package/core/kite_log.py +241 -0
- package/core/launcher/entry.py +978 -284
- package/core/launcher/process_manager.py +456 -46
- package/core/registry/entry.py +272 -3
- package/core/registry/server.py +339 -289
- package/core/registry/store.py +10 -4
- 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/extensions/event_hub_bench/entry.py +664 -379
- package/extensions/event_hub_bench/module.md +2 -1
- 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 -147
- 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/__init__.py +0 -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 +230 -90
- package/package.json +1 -1
|
@@ -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()
|
|
File without changes
|
|
@@ -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
|