@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.
Files changed (77) hide show
  1. package/core/event_hub/entry.py +305 -26
  2. package/core/event_hub/hub.py +8 -0
  3. package/core/event_hub/server.py +80 -17
  4. package/core/kite_log.py +241 -0
  5. package/core/launcher/entry.py +978 -284
  6. package/core/launcher/process_manager.py +456 -46
  7. package/core/registry/entry.py +272 -3
  8. package/core/registry/server.py +339 -289
  9. package/core/registry/store.py +10 -4
  10. package/extensions/agents/__init__.py +1 -0
  11. package/extensions/agents/assistant/__init__.py +1 -0
  12. package/extensions/agents/assistant/entry.py +380 -0
  13. package/extensions/agents/assistant/module.md +22 -0
  14. package/extensions/agents/assistant/server.py +236 -0
  15. package/extensions/channels/__init__.py +1 -0
  16. package/extensions/channels/acp_channel/__init__.py +1 -0
  17. package/extensions/channels/acp_channel/entry.py +380 -0
  18. package/extensions/channels/acp_channel/module.md +22 -0
  19. package/extensions/channels/acp_channel/server.py +236 -0
  20. package/extensions/event_hub_bench/entry.py +664 -379
  21. package/extensions/event_hub_bench/module.md +2 -1
  22. package/extensions/services/backup/__init__.py +1 -0
  23. package/extensions/services/backup/entry.py +380 -0
  24. package/extensions/services/backup/module.md +22 -0
  25. package/extensions/services/backup/server.py +244 -0
  26. package/extensions/services/model_service/__init__.py +1 -0
  27. package/extensions/services/model_service/entry.py +380 -0
  28. package/extensions/services/model_service/module.md +22 -0
  29. package/extensions/services/model_service/server.py +236 -0
  30. package/extensions/services/watchdog/entry.py +460 -147
  31. package/extensions/services/watchdog/module.md +3 -0
  32. package/extensions/services/watchdog/monitor.py +128 -13
  33. package/extensions/services/watchdog/server.py +75 -13
  34. package/extensions/services/web/__init__.py +1 -0
  35. package/extensions/services/web/config.yaml +149 -0
  36. package/extensions/services/web/entry.py +487 -0
  37. package/extensions/services/web/module.md +24 -0
  38. package/extensions/services/web/routes/__init__.py +1 -0
  39. package/extensions/services/web/routes/routes_call.py +189 -0
  40. package/extensions/services/web/routes/routes_config.py +512 -0
  41. package/extensions/services/web/routes/routes_contacts.py +98 -0
  42. package/extensions/services/web/routes/routes_devlog.py +99 -0
  43. package/extensions/services/web/routes/routes_phone.py +81 -0
  44. package/extensions/services/web/routes/routes_sms.py +48 -0
  45. package/extensions/services/web/routes/routes_stats.py +17 -0
  46. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  47. package/extensions/services/web/routes/schemas.py +216 -0
  48. package/extensions/services/web/server.py +332 -0
  49. package/extensions/services/web/static/css/style.css +1064 -0
  50. package/extensions/services/web/static/index.html +1445 -0
  51. package/extensions/services/web/static/js/app.js +4671 -0
  52. package/extensions/services/web/vendor/__init__.py +1 -0
  53. package/extensions/services/web/vendor/bluetooth/__init__.py +0 -0
  54. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  55. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  56. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  57. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  58. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  59. package/extensions/services/web/vendor/config.py +139 -0
  60. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  61. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  62. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  63. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  64. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  65. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  66. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  67. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  68. package/extensions/services/web/vendor/storage/identity.py +312 -0
  69. package/extensions/services/web/vendor/storage/store.py +507 -0
  70. package/extensions/services/web/vendor/task/__init__.py +0 -0
  71. package/extensions/services/web/vendor/task/manager.py +864 -0
  72. package/extensions/services/web/vendor/task/models.py +45 -0
  73. package/extensions/services/web/vendor/task/webhook.py +263 -0
  74. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  75. package/extensions/services/web/vendor/tools/registry.py +321 -0
  76. package/main.py +230 -90
  77. package/package.json +1 -1
@@ -0,0 +1,864 @@
1
+ """Task manager — orchestrates the complete lifecycle of outgoing and incoming calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from typing import Any
10
+
11
+ from .. import config as cfg
12
+ from ..storage import store
13
+ from ..storage import identity
14
+ from .models import CallDirection, CallResult, EndedReason, TaskStatus
15
+ from .webhook import WebhookClient
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _now_iso() -> str:
21
+ return datetime.now(timezone.utc).isoformat()
22
+
23
+
24
+ class TaskManager:
25
+ """Central coordinator for call tasks.
26
+
27
+ Manages the lifecycle of both outgoing and incoming calls, bridging the
28
+ Bluetooth telephony layer, the AI conversation engine, and webhook
29
+ notifications.
30
+
31
+ Parameters
32
+ ----------
33
+ bt_manager:
34
+ An instance of :class:`bluetooth.manager.BluetoothManager` that
35
+ provides access to the telephony and audio sub-systems.
36
+ """
37
+
38
+ def __init__(self, bt_manager: Any) -> None:
39
+ self.bt_manager = bt_manager
40
+ self.webhook = WebhookClient()
41
+ self.active_tasks: dict[str, dict[str, Any]] = {}
42
+ self._engines: dict[str, Any] = {} # task_id -> ConversationEngine
43
+ self._background_tasks: dict[str, asyncio.Task[None]] = {}
44
+
45
+ # ------------------------------------------------------------------
46
+ # Public API
47
+ # ------------------------------------------------------------------
48
+
49
+ async def create_call_task(self, request: Any) -> dict[str, Any]:
50
+ """Create a new **outgoing** call task.
51
+
52
+ Workflow
53
+ --------
54
+ 1. Generate ``task_id`` (UUID4) and persist the task record.
55
+ 2. If ``phone_number`` is already provided, jump to step 4.
56
+ 3. If only ``purpose`` is given:
57
+ a. Use LLM to search the contact list for the best match.
58
+ b. If ``require_confirmation`` is true, send a
59
+ ``contact_confirmation`` webhook, set status to *confirming*,
60
+ and return immediately so the caller can confirm.
61
+ c. Otherwise, use the best match directly.
62
+ 4. Launch ``_run_call`` as a background coroutine.
63
+ 5. Return ``{task_id, status}``.
64
+ """
65
+ # Accept both Pydantic model and plain dict
66
+ if hasattr(request, "model_dump"):
67
+ req = request.model_dump()
68
+ elif isinstance(request, dict):
69
+ req = request
70
+ else:
71
+ req = dict(request)
72
+ task_id = str(uuid.uuid4())
73
+
74
+ task: dict[str, Any] = {
75
+ "task_id": task_id,
76
+ "phone_number": req.get("phone_number"),
77
+ "contact_name": req.get("contact_name"),
78
+ "user_phone": identity.get_user_phone(),
79
+ "purpose": req.get("purpose", ""),
80
+ "system_prompt": req.get("system_prompt", ""),
81
+ "webhook_url": req.get("webhook_url", ""),
82
+ "max_duration_seconds": req.get(
83
+ "max_duration_seconds",
84
+ cfg.get("call.max_duration_seconds", 300),
85
+ ),
86
+ "language": req.get("language", "zh"),
87
+ "play_text": req.get("play_text"),
88
+ "require_confirmation": req.get("require_confirmation", False),
89
+ "direction": CallDirection.OUTGOING.value,
90
+ "status": TaskStatus.QUEUED.value,
91
+ "result": None,
92
+ "ended_reason": None,
93
+ "duration_seconds": 0,
94
+ "summary": "",
95
+ "has_recording": False,
96
+ "started_at": _now_iso(),
97
+ "ended_at": None,
98
+ }
99
+
100
+ # Persist initial record
101
+ await store.save_task(task)
102
+ self.active_tasks[task_id] = task
103
+
104
+ # --- Contact resolution (when phone_number is absent) -----------
105
+ if not task["phone_number"] and task["purpose"]:
106
+ matched = await self._find_contact_for_purpose(task["purpose"])
107
+ if matched:
108
+ task["contact_name"] = matched.get("name")
109
+ task["contact_info"] = {
110
+ "id": matched.get("id"),
111
+ "company": matched.get("company"),
112
+ "phone": matched.get("phone"),
113
+ }
114
+
115
+ if task["require_confirmation"]:
116
+ task["status"] = TaskStatus.CONFIRMING.value
117
+ await store.update_task(task_id, {"status": task["status"]})
118
+ # Fire-and-forget the webhook; the caller will confirm
119
+ # through POST /api/call/{task_id}/confirm later.
120
+ asyncio.create_task(
121
+ self.webhook.request_contact_confirmation(task, matched)
122
+ )
123
+ return {"task_id": task_id, "status": task["status"]}
124
+
125
+ # No confirmation needed — use the match directly.
126
+ task["phone_number"] = matched.get("phone")
127
+ await store.update_task(task_id, {
128
+ "phone_number": task["phone_number"],
129
+ "contact_name": task["contact_name"],
130
+ })
131
+ else:
132
+ # Could not find a matching contact.
133
+ task["status"] = TaskStatus.FAILED.value
134
+ task["result"] = CallResult.ERROR.value
135
+ task["ended_reason"] = EndedReason.ERROR.value
136
+ task["ended_at"] = _now_iso()
137
+ await store.update_task(task_id, {
138
+ "status": task["status"],
139
+ "result": task["result"],
140
+ "ended_reason": task["ended_reason"],
141
+ "ended_at": task["ended_at"],
142
+ })
143
+ self.active_tasks.pop(task_id, None)
144
+ return {"task_id": task_id, "status": task["status"]}
145
+
146
+ if not task["phone_number"]:
147
+ task["status"] = TaskStatus.FAILED.value
148
+ task["result"] = CallResult.ERROR.value
149
+ task["ended_reason"] = EndedReason.ERROR.value
150
+ task["ended_at"] = _now_iso()
151
+ await store.update_task(task_id, {
152
+ "status": task["status"],
153
+ "result": task["result"],
154
+ "ended_reason": task["ended_reason"],
155
+ "ended_at": task["ended_at"],
156
+ })
157
+ self.active_tasks.pop(task_id, None)
158
+ return {"task_id": task_id, "status": task["status"]}
159
+
160
+ # --- Start the call in the background --------------------------
161
+ bg = asyncio.create_task(self._run_call(task_id))
162
+ self._background_tasks[task_id] = bg
163
+ bg.add_done_callback(lambda _t: self._background_tasks.pop(task_id, None))
164
+
165
+ return {"task_id": task_id, "status": task["status"]}
166
+
167
+ async def confirm_task(self, task_id: str, request: Any) -> dict[str, Any]:
168
+ """Handle a confirmation response for a task.
169
+
170
+ Two flavours:
171
+ - **Contact confirmation** — ``action="confirm"`` with an optional
172
+ ``phone_number`` override. Triggers the outgoing call.
173
+ - **Incoming-call answer** — ``action="answer"`` or ``"reject"``.
174
+ """
175
+ # Accept both Pydantic model and plain dict
176
+ if hasattr(request, "model_dump"):
177
+ req = request.model_dump()
178
+ elif isinstance(request, dict):
179
+ req = request
180
+ else:
181
+ req = dict(request)
182
+
183
+ task = self.active_tasks.get(task_id)
184
+ if not task:
185
+ task = await store.get_task(task_id)
186
+ if not task:
187
+ return {"error": "task_not_found"}
188
+
189
+ action = req.get("action", "confirm")
190
+
191
+ # --- Contact confirmation (outgoing) ---------------------------
192
+ if action == "confirm":
193
+ phone_number = req.get("phone_number") or task.get("phone_number")
194
+ if not phone_number:
195
+ # Try from contact_info
196
+ phone_number = (task.get("contact_info") or {}).get("phone")
197
+ if not phone_number:
198
+ return {"error": "phone_number_required"}
199
+
200
+ task["phone_number"] = phone_number
201
+ task["status"] = TaskStatus.QUEUED.value
202
+ if req.get("system_prompt"):
203
+ task["system_prompt"] = req["system_prompt"]
204
+ if req.get("purpose"):
205
+ task["purpose"] = req["purpose"]
206
+
207
+ await store.update_task(task_id, {
208
+ "phone_number": phone_number,
209
+ "status": task["status"],
210
+ "system_prompt": task.get("system_prompt", ""),
211
+ "purpose": task.get("purpose", ""),
212
+ })
213
+ self.active_tasks[task_id] = task
214
+
215
+ # Resolve any pending webhook future
216
+ self.webhook.resolve_confirmation(task_id, "confirmed")
217
+
218
+ # Launch the call
219
+ bg = asyncio.create_task(self._run_call(task_id))
220
+ self._background_tasks[task_id] = bg
221
+ bg.add_done_callback(lambda _t: self._background_tasks.pop(task_id, None))
222
+
223
+ return {"task_id": task_id, "status": task["status"]}
224
+
225
+ # --- Incoming-call answer / reject -----------------------------
226
+ if action == "answer":
227
+ if req.get("system_prompt"):
228
+ task["system_prompt"] = req["system_prompt"]
229
+ if req.get("purpose"):
230
+ task["purpose"] = req["purpose"]
231
+
232
+ task["status"] = TaskStatus.ACTIVE.value
233
+ await store.update_task(task_id, {"status": task["status"]})
234
+ self.active_tasks[task_id] = task
235
+
236
+ # Resolve the pending incoming-call confirmation future
237
+ self.webhook.resolve_confirmation(task_id, "answer")
238
+
239
+ return {"task_id": task_id, "status": task["status"]}
240
+
241
+ if action == "reject":
242
+ task["status"] = TaskStatus.CANCELLED.value
243
+ task["result"] = CallResult.REJECTED.value
244
+ task["ended_reason"] = EndedReason.CANCELLED.value
245
+ task["ended_at"] = _now_iso()
246
+ await store.update_task(task_id, {
247
+ "status": task["status"],
248
+ "result": task["result"],
249
+ "ended_reason": task["ended_reason"],
250
+ "ended_at": task["ended_at"],
251
+ })
252
+ self.active_tasks.pop(task_id, None)
253
+
254
+ # Resolve the pending incoming-call confirmation future
255
+ self.webhook.resolve_confirmation(task_id, "reject")
256
+
257
+ return {"task_id": task_id, "status": task["status"]}
258
+
259
+ return {"error": f"unknown_action: {action}"}
260
+
261
+ async def hangup_task(self, task_id: str) -> dict[str, Any]:
262
+ """Force-hangup a task that is currently in progress."""
263
+ task = self.active_tasks.get(task_id)
264
+ if not task:
265
+ task = await store.get_task(task_id)
266
+ if not task:
267
+ return {"error": "task_not_found"}
268
+
269
+ # Stop the conversation engine if running
270
+ engine = self._engines.pop(task_id, None)
271
+ if engine is not None:
272
+ try:
273
+ await engine.stop()
274
+ except Exception:
275
+ logger.exception("Error stopping conversation engine for %s", task_id)
276
+
277
+ # Instruct telephony to hang up
278
+ try:
279
+ if hasattr(self.bt_manager, "telephony") and self.bt_manager.telephony:
280
+ await self.bt_manager.telephony.hangup()
281
+ except Exception:
282
+ logger.exception("Error sending HFP hangup for %s", task_id)
283
+
284
+ # Cancel background task
285
+ bg = self._background_tasks.pop(task_id, None)
286
+ if bg and not bg.done():
287
+ bg.cancel()
288
+
289
+ task["status"] = TaskStatus.COMPLETED.value
290
+ task["result"] = CallResult.SUCCESS.value
291
+ task["ended_reason"] = EndedReason.AI_HANGUP.value
292
+ task["ended_at"] = _now_iso()
293
+
294
+ if task.get("started_at"):
295
+ try:
296
+ start = datetime.fromisoformat(task["started_at"])
297
+ end = datetime.fromisoformat(task["ended_at"])
298
+ task["duration_seconds"] = int((end - start).total_seconds())
299
+ except (ValueError, TypeError):
300
+ pass
301
+
302
+ await store.update_task(task_id, {
303
+ "status": task["status"],
304
+ "result": task["result"],
305
+ "ended_reason": task["ended_reason"],
306
+ "ended_at": task["ended_at"],
307
+ "duration_seconds": task.get("duration_seconds", 0),
308
+ })
309
+ self.active_tasks.pop(task_id, None)
310
+
311
+ # Notify via webhook
312
+ asyncio.create_task(self.webhook.notify_call_completed(task))
313
+
314
+ return {"task_id": task_id, "status": task["status"]}
315
+
316
+ async def send_message(self, task_id: str, message: str) -> dict[str, Any]:
317
+ """Inject a caller message into an active conversation.
318
+
319
+ This also resolves any pending ``in_call_confirmation`` webhook
320
+ future so that the conversation engine can resume.
321
+ """
322
+ task = self.active_tasks.get(task_id)
323
+ if not task:
324
+ return {"error": "task_not_found"}
325
+
326
+ # Resolve pending webhook confirmation (if any)
327
+ self.webhook.resolve_confirmation(task_id, message)
328
+
329
+ # Forward to the conversation engine
330
+ engine = self._engines.get(task_id)
331
+ if engine is not None:
332
+ try:
333
+ await engine.inject_message(message)
334
+ except Exception:
335
+ logger.exception("Error injecting message into engine for %s", task_id)
336
+
337
+ # Persist the message as a call event
338
+ call_dir_path = task.get("call_dir")
339
+ if call_dir_path:
340
+ from pathlib import Path
341
+ await identity.save_call_message(Path(call_dir_path), {
342
+ "role": "caller",
343
+ "text": message,
344
+ "timestamp": _now_iso(),
345
+ })
346
+
347
+ return {"task_id": task_id, "status": "message_sent"}
348
+
349
+ # ------------------------------------------------------------------
350
+ # Incoming call handling
351
+ # ------------------------------------------------------------------
352
+
353
+ async def handle_incoming_call(self, phone_number: str) -> None:
354
+ """Called by the telephony layer when an incoming call is detected.
355
+
356
+ Workflow
357
+ --------
358
+ 1. Look up the caller in the contact list.
359
+ 2. Create a task record with ``direction=incoming``.
360
+ 3. Send an ``incoming_call`` webhook notification.
361
+ 4. Wait for the caller to confirm (answer/reject) or timeout.
362
+ 5. Answer or reject the call based on the response / default policy.
363
+ """
364
+ # 1. Contact lookup
365
+ contact = await store.find_contact_by_phone(phone_number)
366
+ contact_name = contact["name"] if contact else None
367
+ contact_info: dict[str, Any] | None = None
368
+ if contact:
369
+ contact_info = {
370
+ "company": contact.get("company"),
371
+ "title": contact.get("title"),
372
+ "notes": contact.get("notes"),
373
+ }
374
+
375
+ # 2. Create task
376
+ task_id = str(uuid.uuid4())
377
+ task: dict[str, Any] = {
378
+ "task_id": task_id,
379
+ "phone_number": phone_number,
380
+ "contact_name": contact_name,
381
+ "contact_info": contact_info,
382
+ "user_phone": identity.get_user_phone(),
383
+ "purpose": "",
384
+ "system_prompt": "",
385
+ "webhook_url": cfg.get("webhook.default_url", ""),
386
+ "direction": CallDirection.INCOMING.value,
387
+ "status": TaskStatus.ALERTING.value,
388
+ "result": None,
389
+ "ended_reason": None,
390
+ "duration_seconds": 0,
391
+ "summary": "",
392
+ "has_recording": False,
393
+ "started_at": _now_iso(),
394
+ "ended_at": None,
395
+ }
396
+ await store.save_task(task)
397
+ self.active_tasks[task_id] = task
398
+
399
+ # 3. Webhook notification
400
+ confirmation = await self.webhook.notify_incoming_call(task)
401
+
402
+ # 4. Decide whether to answer
403
+ default_action = cfg.get("call.incoming_default_action", "reject")
404
+ action = "reject"
405
+ if confirmation and confirmation.get("response") == "answer":
406
+ action = "answer"
407
+ elif confirmation is None:
408
+ action = default_action
409
+
410
+ # 5. Answer or reject
411
+ if action == "answer":
412
+ task["status"] = TaskStatus.ACTIVE.value
413
+ await store.update_task(task_id, {"status": task["status"]})
414
+
415
+ try:
416
+ if hasattr(self.bt_manager, "telephony") and self.bt_manager.telephony:
417
+ await self.bt_manager.telephony.answer()
418
+ except Exception:
419
+ logger.exception("Error answering incoming call %s", task_id)
420
+ task["status"] = TaskStatus.FAILED.value
421
+ task["result"] = CallResult.ERROR.value
422
+ task["ended_reason"] = EndedReason.ERROR.value
423
+ task["ended_at"] = _now_iso()
424
+ await store.update_task(task_id, {
425
+ "status": task["status"],
426
+ "result": task["result"],
427
+ "ended_reason": task["ended_reason"],
428
+ "ended_at": task["ended_at"],
429
+ })
430
+ self.active_tasks.pop(task_id, None)
431
+ return
432
+
433
+ # Start the call pipeline in the background
434
+ bg = asyncio.create_task(self._run_active_call(task_id))
435
+ self._background_tasks[task_id] = bg
436
+ bg.add_done_callback(lambda _t: self._background_tasks.pop(task_id, None))
437
+ else:
438
+ # Reject the call
439
+ try:
440
+ if hasattr(self.bt_manager, "telephony") and self.bt_manager.telephony:
441
+ await self.bt_manager.telephony.hangup()
442
+ except Exception:
443
+ logger.exception("Error rejecting incoming call %s", task_id)
444
+
445
+ task["status"] = TaskStatus.CANCELLED.value
446
+ task["result"] = CallResult.REJECTED.value
447
+ task["ended_reason"] = EndedReason.CANCELLED.value
448
+ task["ended_at"] = _now_iso()
449
+ await store.update_task(task_id, {
450
+ "status": task["status"],
451
+ "result": task["result"],
452
+ "ended_reason": task["ended_reason"],
453
+ "ended_at": task["ended_at"],
454
+ })
455
+ self.active_tasks.pop(task_id, None)
456
+
457
+ # ------------------------------------------------------------------
458
+ # Internal — run a complete outgoing call
459
+ # ------------------------------------------------------------------
460
+
461
+ async def _run_call(self, task_id: str) -> None:
462
+ """Background coroutine that runs a complete **outgoing** call.
463
+
464
+ Steps
465
+ -----
466
+ 1. Verify Bluetooth is connected.
467
+ 2. Dial the number via telephony.
468
+ 3. Wait for the remote party to answer (poll call state).
469
+ 4. Start the audio pipeline.
470
+ 5. Create and start the ``ConversationEngine``.
471
+ 6. Wait for completion (or max duration).
472
+ 7. Generate a call summary via LLM.
473
+ 8. Persist all records.
474
+ 9. Send ``call_completed`` webhook.
475
+ """
476
+ task = self.active_tasks.get(task_id)
477
+ if not task:
478
+ logger.error("_run_call: task %s not found", task_id)
479
+ return
480
+
481
+ try:
482
+ # 1. Bluetooth readiness check
483
+ task["status"] = TaskStatus.CONNECTING.value
484
+ await store.update_task(task_id, {"status": task["status"]})
485
+
486
+ if not self._is_bluetooth_connected():
487
+ logger.warning("Bluetooth not connected for task %s", task_id)
488
+ await self._fail_task(task_id, CallResult.ERROR, EndedReason.ERROR)
489
+ return
490
+
491
+ # 2. Dial the number
492
+ task["status"] = TaskStatus.DIALING.value
493
+ await store.update_task(task_id, {"status": task["status"]})
494
+
495
+ try:
496
+ if hasattr(self.bt_manager, "telephony") and self.bt_manager.telephony:
497
+ await self.bt_manager.telephony.dial(task["phone_number"])
498
+ else:
499
+ logger.error("Telephony subsystem unavailable for task %s", task_id)
500
+ await self._fail_task(task_id, CallResult.ERROR, EndedReason.ERROR)
501
+ return
502
+ except Exception:
503
+ logger.exception("Dial failed for task %s", task_id)
504
+ await self._fail_task(task_id, CallResult.ERROR, EndedReason.ERROR)
505
+ return
506
+
507
+ # 3. Wait for the remote party to answer
508
+ task["status"] = TaskStatus.ALERTING.value
509
+ await store.update_task(task_id, {"status": task["status"]})
510
+
511
+ call_answered = await self._wait_for_answer(task_id)
512
+ if not call_answered:
513
+ # _wait_for_answer already updated the task status
514
+ return
515
+
516
+ # 4–6. Run the active call phase
517
+ await self._run_active_call(task_id)
518
+
519
+ except asyncio.CancelledError:
520
+ logger.info("Call task %s was cancelled", task_id)
521
+ await self._fail_task(task_id, CallResult.ERROR, EndedReason.CANCELLED)
522
+ except Exception:
523
+ logger.exception("Unexpected error in _run_call for %s", task_id)
524
+ await self._fail_task(task_id, CallResult.ERROR, EndedReason.ERROR)
525
+
526
+ async def _run_active_call(self, task_id: str) -> None:
527
+ """Run the audio + AI pipeline once a call is connected (active).
528
+
529
+ Shared between outgoing and incoming call flows.
530
+ """
531
+ task = self.active_tasks.get(task_id)
532
+ if not task:
533
+ return
534
+
535
+ task["status"] = TaskStatus.ACTIVE.value
536
+ await store.update_task(task_id, {"status": task["status"]})
537
+
538
+ # Create identity-based call directory
539
+ user_phone = task.get("user_phone", "")
540
+ contact_phone = task.get("phone_number", "")
541
+ started_at = task.get("started_at", _now_iso())
542
+ if user_phone and contact_phone:
543
+ # Ensure contact profile.yaml exists (auto-creates on first call,
544
+ # pulls owner name from config user.owners if available)
545
+ identity.ensure_contact_profile(
546
+ user_phone, contact_phone,
547
+ contact_name=task.get("contact_name"),
548
+ )
549
+ call_path = identity.ensure_call_dir(user_phone, contact_phone, started_at)
550
+ task["call_dir"] = str(call_path)
551
+ await store.update_task(task_id, {"call_dir": str(call_path)})
552
+ else:
553
+ task["call_dir"] = None
554
+
555
+ engine = None
556
+ try:
557
+ # 4. Start audio pipeline
558
+ audio_stream = None
559
+ if hasattr(self.bt_manager, "audio") and self.bt_manager.audio:
560
+ audio_stream = await self.bt_manager.audio.start()
561
+
562
+ # 5. Create conversation engine
563
+ try:
564
+ from conversation.engine import ConversationEngine
565
+ engine = ConversationEngine(
566
+ audio_pipeline=audio_stream,
567
+ task_info=task,
568
+ webhook_client=self.webhook,
569
+ )
570
+ self._engines[task_id] = engine
571
+ await engine.start() # blocks until conversation ends
572
+ except ImportError:
573
+ logger.warning(
574
+ "ConversationEngine not available; call %s will run "
575
+ "without AI (telephony only)",
576
+ task_id,
577
+ )
578
+ except Exception:
579
+ logger.exception("Failed to start ConversationEngine for %s", task_id)
580
+
581
+ ended_reason = EndedReason.NORMAL
582
+
583
+ except asyncio.CancelledError:
584
+ ended_reason = EndedReason.CANCELLED
585
+ except Exception:
586
+ logger.exception("Error during active call %s", task_id)
587
+ ended_reason = EndedReason.ERROR
588
+ finally:
589
+ # Clean up engine
590
+ if engine:
591
+ try:
592
+ await engine.stop()
593
+ except Exception:
594
+ pass
595
+ self._engines.pop(task_id, None)
596
+
597
+ # Stop audio pipeline
598
+ if hasattr(self.bt_manager, "audio") and self.bt_manager.audio:
599
+ try:
600
+ await self.bt_manager.audio.stop()
601
+ except Exception:
602
+ pass
603
+
604
+ # 7. Generate summary
605
+ summary = ""
606
+ try:
607
+ call_dir_str = task.get("call_dir")
608
+ transcript = []
609
+ if call_dir_str:
610
+ from pathlib import Path
611
+ transcript = await identity.load_call_messages(Path(call_dir_str))
612
+ if transcript:
613
+ summary = await self._generate_summary(task, transcript)
614
+ except Exception:
615
+ logger.exception("Failed to generate summary for %s", task_id)
616
+
617
+ # 8. Persist final records
618
+ task["status"] = TaskStatus.COMPLETED.value
619
+ task["result"] = CallResult.SUCCESS.value
620
+ task["ended_reason"] = ended_reason.value if isinstance(ended_reason, EndedReason) else ended_reason
621
+ task["ended_at"] = _now_iso()
622
+ task["summary"] = summary
623
+ task["has_recording"] = cfg.get("audio.record_calls", True)
624
+
625
+ if task.get("started_at"):
626
+ try:
627
+ start = datetime.fromisoformat(task["started_at"])
628
+ end = datetime.fromisoformat(task["ended_at"])
629
+ task["duration_seconds"] = int((end - start).total_seconds())
630
+ except (ValueError, TypeError):
631
+ pass
632
+
633
+ # Save session.json and summary.md to identity directory
634
+ call_dir_path = task.get("call_dir")
635
+ if call_dir_path:
636
+ from pathlib import Path
637
+ cd = Path(call_dir_path)
638
+ try:
639
+ session_info = {
640
+ "task_id": task_id,
641
+ "direction": task.get("direction", ""),
642
+ "phone_number": task.get("phone_number", ""),
643
+ "contact_name": task.get("contact_name", ""),
644
+ "started_at": task.get("started_at", ""),
645
+ "ended_at": task.get("ended_at", ""),
646
+ "duration_seconds": task.get("duration_seconds", 0),
647
+ "result": task.get("result", ""),
648
+ "llm_provider": cfg.get("llm.active_provider", ""),
649
+ "llm_model": cfg.get(f"llm.providers.{cfg.get('llm.active_provider', 'openai')}.model", ""),
650
+ "asr_provider": cfg.get("asr.provider", ""),
651
+ "tts_provider": cfg.get("tts.provider", ""),
652
+ "system_prompt": task.get("system_prompt", ""),
653
+ }
654
+ await identity.save_session_info(cd, session_info)
655
+ except Exception:
656
+ logger.exception("Failed to save session info for %s", task_id)
657
+
658
+ if summary:
659
+ try:
660
+ await identity.save_call_summary(cd, summary)
661
+ except Exception:
662
+ logger.exception("Failed to save identity summary for %s", task_id)
663
+
664
+ await store.update_task(task_id, {
665
+ "status": task["status"],
666
+ "result": task["result"],
667
+ "ended_reason": task["ended_reason"],
668
+ "ended_at": task["ended_at"],
669
+ "duration_seconds": task.get("duration_seconds", 0),
670
+ "summary": summary,
671
+ "has_recording": task["has_recording"],
672
+ })
673
+
674
+ self.active_tasks.pop(task_id, None)
675
+
676
+ # 9. Webhook notification
677
+ call_dir_str = task.get("call_dir")
678
+ if call_dir_str:
679
+ from pathlib import Path
680
+ task["transcript"] = await identity.load_call_messages(Path(call_dir_str))
681
+ else:
682
+ task["transcript"] = []
683
+ await self.webhook.notify_call_completed(task)
684
+
685
+ # ------------------------------------------------------------------
686
+ # Internal — helpers
687
+ # ------------------------------------------------------------------
688
+
689
+ def _is_bluetooth_connected(self) -> bool:
690
+ """Check whether the Bluetooth HFP connection is up."""
691
+ if hasattr(self.bt_manager, "connected"):
692
+ return bool(self.bt_manager.connected)
693
+ return True # assume connected if we cannot check
694
+
695
+ async def _wait_for_answer(self, task_id: str, poll_interval: float = 0.5) -> bool:
696
+ """Poll the telephony layer until the call is answered or fails.
697
+
698
+ Updates the task status on failure and returns ``False`` in that case.
699
+ """
700
+ max_ring_seconds = 60
701
+ elapsed = 0.0
702
+
703
+ while elapsed < max_ring_seconds:
704
+ if not hasattr(self.bt_manager, "telephony") or not self.bt_manager.telephony:
705
+ await asyncio.sleep(poll_interval)
706
+ elapsed += poll_interval
707
+ continue
708
+
709
+ state = None
710
+ try:
711
+ state = await self.bt_manager.telephony.get_call_state()
712
+ except Exception:
713
+ pass
714
+
715
+ if state == "active":
716
+ return True
717
+ if state in ("disconnected", "idle", None) and elapsed > 2:
718
+ # Call ended before being answered
719
+ await self._fail_task(task_id, CallResult.NO_ANSWER, EndedReason.REMOTE_HANGUP)
720
+ return False
721
+ if state == "busy":
722
+ await self._fail_task(task_id, CallResult.BUSY, EndedReason.REMOTE_HANGUP)
723
+ return False
724
+
725
+ await asyncio.sleep(poll_interval)
726
+ elapsed += poll_interval
727
+
728
+ # Timed out — no answer
729
+ await self._fail_task(task_id, CallResult.NO_ANSWER, EndedReason.TIMEOUT)
730
+ return False
731
+
732
+ async def _fail_task(
733
+ self,
734
+ task_id: str,
735
+ result: CallResult,
736
+ ended_reason: EndedReason,
737
+ ) -> None:
738
+ """Mark a task as failed and persist the update."""
739
+ task = self.active_tasks.get(task_id)
740
+ if not task:
741
+ task = await store.get_task(task_id) or {}
742
+
743
+ task["status"] = TaskStatus.FAILED.value
744
+ task["result"] = result.value
745
+ task["ended_reason"] = ended_reason.value
746
+ task["ended_at"] = _now_iso()
747
+
748
+ await store.update_task(task_id, {
749
+ "status": task["status"],
750
+ "result": task["result"],
751
+ "ended_reason": task["ended_reason"],
752
+ "ended_at": task["ended_at"],
753
+ })
754
+
755
+ self.active_tasks.pop(task_id, None)
756
+
757
+ # Notify webhook
758
+ asyncio.create_task(self.webhook.notify_call_completed(task))
759
+
760
+ async def _find_contact_for_purpose(self, purpose: str) -> dict[str, Any] | None:
761
+ """Use the configured LLM to find the best contact match for *purpose*.
762
+
763
+ Loads all contacts, builds a prompt asking the LLM to pick the best
764
+ match, and returns the selected contact dict (with an extra
765
+ ``confidence`` key) or ``None`` if no reasonable match is found.
766
+ """
767
+ import json
768
+
769
+ contacts, _ = await store.list_contacts(page=1, page_size=500)
770
+ if not contacts:
771
+ logger.info("No contacts in address book; cannot match for purpose")
772
+ return None
773
+
774
+ # Build a compact contact list for the LLM
775
+ contacts_text = "\n".join(
776
+ f"- id={c.get('id')} name={c.get('name')} phone={c.get('phone')} "
777
+ f"company={c.get('company', '')} title={c.get('title', '')} "
778
+ f"notes={c.get('notes', '')} tags={c.get('tags', [])}"
779
+ for c in contacts
780
+ )
781
+
782
+ prompt = (
783
+ "You are a contact-matching assistant. Given the following purpose "
784
+ "and a list of contacts, select the single best matching contact.\n\n"
785
+ f"Purpose: {purpose}\n\n"
786
+ f"Contacts:\n{contacts_text}\n\n"
787
+ "Respond with ONLY a JSON object: "
788
+ '{"id": "<contact_id>", "confidence": <0.0-1.0>}\n'
789
+ "If no contact is a reasonable match, respond: "
790
+ '{"id": null, "confidence": 0}'
791
+ )
792
+
793
+ try:
794
+ from conversation.llm import create_llm_provider
795
+
796
+ provider = create_llm_provider()
797
+ result = await provider.generate(
798
+ messages=[{"role": "user", "content": prompt}],
799
+ )
800
+ response_text = result.get("content", "")
801
+
802
+ result_json = json.loads(response_text)
803
+ contact_id = result_json.get("id")
804
+ confidence = result_json.get("confidence", 0)
805
+
806
+ if not contact_id or confidence < 0.3:
807
+ logger.info("LLM contact match confidence too low (%.2f)", confidence)
808
+ return None
809
+
810
+ matched = await store.get_contact(contact_id)
811
+ if matched:
812
+ matched["confidence"] = confidence
813
+ return matched
814
+
815
+ except ImportError:
816
+ logger.warning("LLM module not available; falling back to text search")
817
+ # Fallback: simple keyword search
818
+ results = await store.search_contacts(purpose)
819
+ if results:
820
+ results[0]["confidence"] = 0.5
821
+ return results[0]
822
+ except (json.JSONDecodeError, KeyError, TypeError):
823
+ logger.exception("Failed to parse LLM contact match response")
824
+ except Exception:
825
+ logger.exception("Error during LLM contact matching")
826
+
827
+ return None
828
+
829
+ async def _generate_summary(
830
+ self,
831
+ task: dict[str, Any],
832
+ transcript: list[dict[str, Any]],
833
+ ) -> str:
834
+ """Use the LLM to generate a concise call summary in Markdown."""
835
+ transcript_text = "\n".join(
836
+ f"[{entry.get('role', '?')}] {entry.get('text', '')}"
837
+ for entry in transcript
838
+ )
839
+
840
+ prompt = (
841
+ "Summarize the following phone call transcript concisely. "
842
+ "Include: key topics discussed, decisions made, action items, "
843
+ "and the overall outcome.\n\n"
844
+ f"Call purpose: {task.get('purpose', 'N/A')}\n"
845
+ f"Contact: {task.get('contact_name', 'Unknown')}\n"
846
+ f"Direction: {task.get('direction', 'unknown')}\n\n"
847
+ f"Transcript:\n{transcript_text}\n\n"
848
+ "Respond in the same language as the transcript."
849
+ )
850
+
851
+ try:
852
+ from conversation.llm import create_llm_provider
853
+
854
+ provider = create_llm_provider()
855
+ result = await provider.generate(
856
+ messages=[{"role": "user", "content": prompt}],
857
+ )
858
+ return result.get("content", "")
859
+ except ImportError:
860
+ logger.warning("LLM module not available; cannot generate summary")
861
+ return ""
862
+ except Exception:
863
+ logger.exception("Failed to generate call summary")
864
+ return ""