pinecall-runtime 0.1.6__py3-none-any.whl

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 (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
pinecall/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,45 @@
1
+ """Which worker of a fleet a call is offered to: one heard lately, with a seat free, the freest."""
2
+
3
+ from collections.abc import Collection, Mapping
4
+
5
+ from pinecall.fleet.roster import REFUSED_AT, accepting_of
6
+ from pinecall.wire.rest.fleet import WorkerStatus
7
+
8
+ # Two heartbeats and a margin: a worker unheard this long may be gone, and LiveKit would keep a
9
+ # dead one for 15 minutes; it is not offered a call until it is heard again.
10
+ HEARD_WITHIN_S = 12.0
11
+
12
+
13
+ def chosen(
14
+ seats: Collection[WorkerStatus],
15
+ in_flight: Mapping[str, int],
16
+ now: float,
17
+ not_these: Collection[str] = (),
18
+ ) -> WorkerStatus | None:
19
+ """The worker to offer a call to, or None when no worker of the fleet has a seat."""
20
+ candidates = [
21
+ seat
22
+ for seat in accepting_of(seats, now)
23
+ if seat.agent_name is not None
24
+ and seat.agent_name not in not_these
25
+ and now - seat.seen_at <= HEARD_WITHIN_S
26
+ and free_seats(seat, in_flight) > 0
27
+ ]
28
+ return max(candidates, key=lambda seat: (free_seats(seat, in_flight), -seat.load), default=None)
29
+
30
+
31
+ # A gateway that just started rebuilds its roster from the next heartbeats, five seconds: until a
32
+ # worker of the fleet is heard, nobody can say the fleet is full.
33
+ def any_heard(seats: Collection[WorkerStatus], now: float) -> bool:
34
+ """Whether any worker of the fleet was heard lately, whatever it is doing."""
35
+ return any(now - seat.seen_at <= HEARD_WITHIN_S for seat in seats)
36
+
37
+
38
+ # A worker gated on its CPU has no count of seats: one call at a time is offered to it, under
39
+ # LiveKit's line.
40
+ def free_seats(seat: WorkerStatus, in_flight: Mapping[str, int]) -> int:
41
+ """The seats a worker has free, its calls and the ones offered to it not yet opened taken."""
42
+ offered = in_flight.get(seat.agent_name or "", 0)
43
+ if seat.max_jobs is None:
44
+ return 1 if seat.load < REFUSED_AT and offered == 0 else 0
45
+ return seat.max_jobs - seat.active - offered
@@ -0,0 +1,219 @@
1
+ """Who takes a call: offered to a worker the gateway chose, then another, then the overflow."""
2
+
3
+ import logging
4
+ import time
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+
8
+ from livekit import api
9
+
10
+ from pinecall.channels import rooms
11
+ from pinecall.channels._chooser import HEARD_WITHIN_S, any_heard, chosen, free_seats
12
+ from pinecall.channels.rooms import Dispatch
13
+ from pinecall.domain.names import Env
14
+ from pinecall.fleet import worlds
15
+ from pinecall.fleet.roster import Roster, overflow_name
16
+ from pinecall.postgres.pool import Pool
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # LiveKit waits 10 s on a worker that does not answer before it gives a job up; a live worker opens
21
+ # its call in about 2 s. A room no worker opened 12 s after its offer is offered to another.
22
+ OFFERED_AGAIN_AFTER_S = 12.0
23
+
24
+ # Three offers, then the overflow's sentence: 36 s is the longest a caller waits on our account.
25
+ OFFERS = 3
26
+
27
+ # A fleet none of whose workers was heard is one the gateway knows nothing of yet (it just
28
+ # started): the room waits for the next sweep, up to as long as a worker takes to be heard.
29
+ WAITS_FOR_A_WORKER_S = HEARD_WITHIN_S
30
+
31
+ # The rooms counted as waiting for a worker: the ones a caller joined in the last ten minutes.
32
+ WAITING_COUNTED_S = 600.0
33
+
34
+ # Every room is done with by its third offer and a sweep after it: one kept a minute is a room no
35
+ # gateway is sweeping.
36
+ UNSWEPT_AFTER_S = 60.0
37
+
38
+ UNSWEPT = "{n} rooms a caller joined over a minute ago were never let go: no gateway sweeps them"
39
+
40
+ # The first sight stands: a second webhook for the same room changes nothing.
41
+ OPENED = """
42
+ INSERT INTO offers (room, fleet, dispatch, seen_at)
43
+ VALUES (%(room)s, %(fleet)s, %(dispatch)s, %(now)s)
44
+ ON CONFLICT (room) DO NOTHING
45
+ RETURNING room
46
+ """
47
+
48
+ # Taken only by the gateway that read this count: two sweeping the same room offer it once.
49
+ CLAIMED = """
50
+ UPDATE offers SET worker = %(worker)s, offers = offers + 1, offered_at = %(now)s
51
+ WHERE room = %(room)s AND offers = %(offers)s
52
+ RETURNING room
53
+ """
54
+
55
+ DUE = """
56
+ SELECT room, fleet, dispatch, seen_at, worker, offers FROM offers
57
+ WHERE offered_at IS NULL OR offered_at < %(before)s
58
+ ORDER BY seen_at LIMIT %(limit)s
59
+ """
60
+
61
+ IN_FLIGHT = """
62
+ SELECT worker, count(*) AS offered FROM offers
63
+ WHERE worker IS NOT NULL AND offered_at >= %(since)s
64
+ GROUP BY worker
65
+ """
66
+
67
+ WAITING = """
68
+ SELECT fleet, count(*) AS waiting FROM offers WHERE seen_at >= %(since)s GROUP BY fleet
69
+ """
70
+
71
+ FORGOTTEN = "DELETE FROM offers WHERE room = %(room)s"
72
+
73
+ AGED = "DELETE FROM offers WHERE seen_at < %(before)s"
74
+
75
+ KEPT_SINCE = "SELECT count(*) AS kept FROM offers WHERE seen_at < %(before)s"
76
+
77
+ OFFERED = "room %s: offered to %s: %s"
78
+
79
+ NOT_YET = "room %s: %s; offered at the next sweep"
80
+
81
+ OFFERED_AGAIN = "room %s: no worker opened it in %.0f s; offered to %s (offer %d): %s"
82
+
83
+ TO_THE_OVERFLOW = "room %s: %s; the overflow says the sentence"
84
+
85
+ NO_WORLD = "room %s: fleet %s is no world's, so no LiveKit holds its room; not offered"
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Offer:
90
+ """A room waiting for a worker: its fleet, its call's dispatch, and whom it was offered to."""
91
+
92
+ room: str
93
+ fleet: str
94
+ dispatch: str
95
+ seen_at: float
96
+ worker: str | None
97
+ offers: int
98
+
99
+
100
+ # What a process holds to send a call to a worker: the offers' table, each world's LiveKit, and
101
+ # the fleet's workers as their heartbeats say. Every door that sends a call into a room goes
102
+ # through it. A room lives on the LiveKit of its fleet's world, which the fleets row says.
103
+ @dataclass(frozen=True)
104
+ class Offering:
105
+ """The gateway's dispatcher: a room offered to the worker it chose, by the worker's name."""
106
+
107
+ pool: Pool
108
+ servers: Mapping[Env, api.LiveKitAPI]
109
+ roster: Roster
110
+
111
+ async def offer(self, room: str, fleet: str, dispatch: Dispatch) -> str | None:
112
+ """Keep the room and offer it; the name it went to, or None if kept already or not yet."""
113
+ written = rooms.written(dispatch)
114
+ now = time.time()
115
+ if not await opened(self.pool, room, fleet, written, now):
116
+ return None
117
+ return await self.offered(
118
+ Offer(room=room, fleet=fleet, dispatch=written, seen_at=now, worker=None, offers=0)
119
+ )
120
+
121
+ async def offered(self, offer: Offer) -> str | None:
122
+ """Offer the room to the worker it should go to now; the name, or None if not offered."""
123
+ now = time.time()
124
+ world = worlds.world_of(await worlds.fleets(self.pool), offer.fleet)
125
+ if world is None:
126
+ logger.warning(NO_WORLD, offer.room, offer.fleet)
127
+ return None
128
+ target, why = await _target(self.pool, self.roster, offer, now)
129
+ if target is None:
130
+ logger.info(NOT_YET, offer.room, why)
131
+ return None
132
+ if not await claimed(self.pool, offer, target, now):
133
+ return None
134
+ dispatch = rooms.read_dispatch(offer.dispatch)
135
+ await rooms.dispatched(self.servers[world], offer.room, target, dispatch)
136
+ if target == overflow_name(offer.fleet):
137
+ await forgotten(self.pool, offer.room)
138
+ logger.warning(TO_THE_OVERFLOW, offer.room, why)
139
+ elif offer.offers > 0:
140
+ logger.warning(
141
+ OFFERED_AGAIN, offer.room, OFFERED_AGAIN_AFTER_S, target, offer.offers + 1, why
142
+ )
143
+ else:
144
+ logger.info(OFFERED, offer.room, target, why)
145
+ return target
146
+
147
+
148
+ async def opened(pool: Pool, room: str, fleet: str, dispatch: str, now: float) -> bool:
149
+ """Keep a room a caller joined; False when it was kept already."""
150
+ values = {"room": room, "fleet": fleet, "dispatch": dispatch, "now": now}
151
+ async with pool.connection() as connection:
152
+ return await (await connection.execute(OPENED, values)).fetchone() is not None
153
+
154
+
155
+ async def claimed(pool: Pool, offer: Offer, worker: str, now: float) -> bool:
156
+ """Say the room is offered to this worker; False when another gateway offered it first."""
157
+ values = {"room": offer.room, "worker": worker, "offers": offer.offers, "now": now}
158
+ async with pool.connection() as connection:
159
+ return await (await connection.execute(CLAIMED, values)).fetchone() is not None
160
+
161
+
162
+ async def due(pool: Pool, before: float, limit: int) -> list[Offer]:
163
+ """The rooms no worker opened and nobody offered since `before`, oldest first."""
164
+ async with pool.connection() as connection:
165
+ rows = await (await connection.execute(DUE, {"before": before, "limit": limit})).fetchall()
166
+ return [Offer(**row) for row in rows]
167
+
168
+
169
+ async def in_flight(pool: Pool, since: float) -> dict[str, int]:
170
+ """Each worker's rooms offered since `since` that it has not opened."""
171
+ async with pool.connection() as connection:
172
+ rows = await (await connection.execute(IN_FLIGHT, {"since": since})).fetchall()
173
+ return {row["worker"]: row["offered"] for row in rows}
174
+
175
+
176
+ async def waiting(pool: Pool, since: float) -> dict[str, int]:
177
+ """Each fleet's rooms with a caller seen since `since` that no worker opened."""
178
+ async with pool.connection() as connection:
179
+ rows = await (await connection.execute(WAITING, {"since": since})).fetchall()
180
+ return {row["fleet"]: row["waiting"] for row in rows}
181
+
182
+
183
+ async def forgotten(pool: Pool, room: str) -> None:
184
+ """The room is done with: its call opened, the overflow sent, or the room gone."""
185
+ async with pool.connection() as connection:
186
+ await connection.execute(FORGOTTEN, {"room": room})
187
+
188
+
189
+ async def aged_out(pool: Pool, before: float) -> None:
190
+ """Rooms first seen before `before` are past any offer: their callers are gone."""
191
+ async with pool.connection() as connection:
192
+ await connection.execute(AGED, {"before": before})
193
+
194
+
195
+ async def examined(pool: Pool, now: float) -> str | None:
196
+ """What doctor says of the offers: the rooms kept past every offer, or None."""
197
+ async with pool.connection() as connection:
198
+ row = await (
199
+ await connection.execute(KEPT_SINCE, {"before": now - UNSWEPT_AFTER_S})
200
+ ).fetchone()
201
+ kept = 0 if row is None else row["kept"]
202
+ return UNSWEPT.format(n=kept) if kept else None
203
+
204
+
205
+ # The worker chosen; the overflow when the offers ran out or the workers heard have no seat; None
206
+ # while no worker of the fleet was heard yet. And why, so the log says it.
207
+ async def _target(pool: Pool, roster: Roster, offer: Offer, now: float) -> tuple[str | None, str]:
208
+ if offer.offers >= OFFERS:
209
+ return overflow_name(offer.fleet), f"no worker opened it in {OFFERS} offers"
210
+ flying = await in_flight(pool, now - OFFERED_AGAIN_AFTER_S)
211
+ tried = () if offer.worker is None else (offer.worker,)
212
+ seats = roster.of(offer.fleet, now)
213
+ seat = chosen(seats, flying, now, not_these=tried)
214
+ if seat is not None and seat.agent_name is not None:
215
+ free = free_seats(seat, flying)
216
+ return seat.agent_name, f"{free} seats free, heard {now - seat.seen_at:.0f} s ago"
217
+ if not any_heard(seats, now) and now - offer.seen_at < WAITS_FOR_A_WORKER_S:
218
+ return None, f"no worker of the fleet heard in {HEARD_WITHIN_S:.0f} s"
219
+ return overflow_name(offer.fleet), "no worker of the fleet has a seat"
@@ -0,0 +1,176 @@
1
+ """The dispatch to a world's fleet, the rooms an agent is in, and LiveKit's signed events."""
2
+
3
+ import json
4
+ from collections.abc import Collection, Mapping
5
+
6
+ from google.protobuf.json_format import ParseDict, ParseError
7
+ from livekit import api
8
+ from livekit.protocol.agent_dispatch import RoomAgentDispatch
9
+ from livekit.protocol.room import RoomConfiguration
10
+ from livekit.protocol.webhook import WebhookEvent
11
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
12
+
13
+ from pinecall.domain.errors import DeclarationRefused, NotAllowed
14
+ from pinecall.domain.names import Direction, Env, Json, JsonObject
15
+
16
+ NOT_A_ROOM_CONFIG = "room_config is not a LiveKit RoomConfiguration: {reason}"
17
+
18
+ # Room names ride one request's query, so they are params for in batches.
19
+ ROOMS_A_REQUEST = 100
20
+
21
+ # livekit's code for a room that is not there.
22
+ ROOM_GONE = "not_found"
23
+
24
+ UNSIGNED = "the event carries no signature of this box's LiveKit key: {reason}"
25
+
26
+ # The seats a person sits in: a browser's, and a phone's leg.
27
+ A_PERSON = frozenset({api.ParticipantInfo.Kind.STANDARD, api.ParticipantInfo.Kind.SIP})
28
+
29
+
30
+ # `trunk` names the carrier account the leg is dialled through; the worker asks the gateway for
31
+ # its inline configuration, so no secret rides a dispatch.
32
+ class Dialling(BaseModel):
33
+ """The leg an outbound job places: trunk, far end, the number shown, the media's ceiling."""
34
+
35
+ model_config = ConfigDict(frozen=True, extra="ignore")
36
+
37
+ trunk: str
38
+ to: str
39
+ shown: str
40
+ max_duration_s: int = 0
41
+
42
+
43
+ # Written by the gateway alone, signed into a token or sent by the server API, so the worker
44
+ # trusts it: the scope a call runs in, and on an outbound call the ceiling of its leg.
45
+ class Dispatch(BaseModel):
46
+ """What a dispatch tells the job it starts: whose call, which agent, and how it arrived."""
47
+
48
+ model_config = ConfigDict(frozen=True, extra="ignore", populate_by_name=True)
49
+
50
+ agent: str | None = None
51
+ org: str | None = None
52
+ env: Env | None = None
53
+ holder: str | None = None
54
+ direction: Direction = "inbound"
55
+ caller: str | None = None
56
+ # An opaque id the tenant's backend knows the visitor by; never a number or a name.
57
+ contact: str | None = None
58
+ # The token's scope: a dispatch that carries one spends its token once.
59
+ scope: str | None = None
60
+ # What the tenant's backend sealed into the token.
61
+ metadata: JsonObject = Field(default_factory=dict[str, Json])
62
+ dial: Dialling | None = None
63
+ # The app socket that must serve the call (a spoken golden run).
64
+ app: str | None = None
65
+ # A simulated caller and its rules, frozen at dispatch so a later edit does not move them.
66
+ run: str | None = None
67
+ persona: str | None = None
68
+ accepts_when: str | None = None
69
+ declines_when: str | None = None
70
+ diverted_from: Env | None = None
71
+ # The state the call opens in, when a golden or a persona asked for one.
72
+ state: JsonObject = Field(default_factory=dict[str, Json])
73
+ # The call's worker went away mid-call: the job tells the caller once and closes the room.
74
+ worker_gone: bool = False
75
+ # How many entries the log took from the worker that went: where the told job's writer follows.
76
+ entries_written: int = 0
77
+
78
+
79
+ def written(dispatch: Dispatch) -> str:
80
+ """The dispatch as a job's metadata: compact JSON, absent fields absent."""
81
+ data = dispatch.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
82
+ return json.dumps(data, separators=(",", ":"))
83
+
84
+
85
+ # A job with no metadata or with somebody else's (a room nobody dispatched) reads as empty.
86
+ def read_dispatch(metadata: str) -> Dispatch:
87
+ """The dispatch a job was started with."""
88
+ if not metadata:
89
+ return Dispatch()
90
+ try:
91
+ return Dispatch.model_validate_json(metadata)
92
+ except ValidationError:
93
+ return Dispatch()
94
+
95
+
96
+ # livekit creates the dispatch from the token when the join creates the room, so the browser
97
+ # cannot change the agent, the org or the world it reaches.
98
+ def room_dispatch(fleet: str, dispatch: Dispatch) -> RoomConfiguration:
99
+ """The room configuration a visitor's token carries: one dispatch, to the world's fleet."""
100
+ return RoomConfiguration(
101
+ agents=[RoomAgentDispatch(agent_name=fleet, metadata=written(dispatch))]
102
+ )
103
+
104
+
105
+ # livekit creates the room if it does not exist; its name is the call id the worker opens.
106
+ async def dispatched(server: api.LiveKitAPI, room: str, agent: str, dispatch: Dispatch) -> None:
107
+ """Send the workers registered under this name into a room, with the call's dispatch."""
108
+ await server.agent_dispatch.create_dispatch(
109
+ api.CreateAgentDispatchRequest(room=room, agent_name=agent, metadata=written(dispatch))
110
+ )
111
+
112
+
113
+ # A stock livekit client names the agent as room_config.agents[0].agent_name, in either spelling.
114
+ def client_named_agent(room_config: Mapping[str, object] | None) -> str | None:
115
+ """The agent a livekit client put in the room configuration, or None."""
116
+ if not room_config:
117
+ return None
118
+ try:
119
+ parsed = ParseDict(dict(room_config), RoomConfiguration(), ignore_unknown_fields=True)
120
+ except ParseError as malformed:
121
+ raise DeclarationRefused(NOT_A_ROOM_CONFIG.format(reason=malformed)) from malformed
122
+ agents = list(parsed.agents)
123
+ return (agents[0].agent_name or None) if agents else None
124
+
125
+
126
+ # A room is a live call only while an agent is in it: a browser tab or a supervisor keeps a
127
+ # room open long after its job is gone.
128
+ async def rooms_with_an_agent(server: api.LiveKitAPI, names: Collection[str]) -> set[str]:
129
+ """The rooms of these that exist with an agent in them."""
130
+ wanted = list(names)
131
+ served: set[str] = set()
132
+ for start in range(0, len(wanted), ROOMS_A_REQUEST):
133
+ batch = wanted[start : start + ROOMS_A_REQUEST]
134
+ rooms = await server.room.list_rooms(api.ListRoomsRequest(names=batch))
135
+ for room in rooms.rooms:
136
+ seats = await server.room.list_participants(api.ListParticipantsRequest(room=room.name))
137
+ if any(seat.kind == api.ParticipantInfo.Kind.AGENT for seat in seats.participants):
138
+ served.add(room.name)
139
+ return served
140
+
141
+
142
+ async def room_closed(server: api.LiveKitAPI, name: str) -> None:
143
+ """Delete the room, which hangs up everyone still in it; a room already gone is fine."""
144
+ try:
145
+ await server.room.delete_room(api.DeleteRoomRequest(room=name))
146
+ except api.TwirpError as refused:
147
+ if refused.code != ROOM_GONE:
148
+ raise
149
+
150
+
151
+ # A room gone is nobody left in it.
152
+ async def seats_in(server: api.LiveKitAPI, name: str) -> list[api.ParticipantInfo]:
153
+ """Who is in the room: nobody when it is gone."""
154
+ try:
155
+ seats = await server.room.list_participants(api.ListParticipantsRequest(room=name))
156
+ except api.TwirpError as refused:
157
+ if refused.code != ROOM_GONE:
158
+ raise
159
+ return []
160
+ return list(seats.participants)
161
+
162
+
163
+ async def left_alone(server: api.LiveKitAPI, name: str) -> bool:
164
+ """Whether a person is still in the room and no agent is."""
165
+ kinds = {seat.kind for seat in await seats_in(server, name)}
166
+ return api.ParticipantInfo.Kind.AGENT not in kinds and bool(kinds & A_PERSON)
167
+
168
+
169
+ # livekit signs each event with a key of the server's: a token whose sha256 claim is the body's.
170
+ # The library raises a bare Exception for a body that does not match its token.
171
+ def livekit_event(body: str, token: str, key: str, secret: str) -> WebhookEvent:
172
+ """The event of a body LiveKit signed with this key, or NotAllowed."""
173
+ try:
174
+ return api.WebhookReceiver(api.TokenVerifier(key, secret)).receive(body, token)
175
+ except Exception as refused:
176
+ raise NotAllowed(UNSIGNED.format(reason=refused)) from refused