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
@@ -0,0 +1,23 @@
1
+ """A call a gateway learns of from another: served here from what was kept when it opened."""
2
+
3
+ import time
4
+
5
+ from pinecall.gateway._served import Served, Serving
6
+ from pinecall.gateway.calls.serving import first_seen
7
+ from pinecall.log import openings, queries
8
+
9
+
10
+ # None for a call nobody claimed, one sealed, or one opened by a release that kept no opening.
11
+ async def known_here(serving: Serving, call: str) -> Served | None:
12
+ """The call as this gateway serves it, first seen here from its opening when need be."""
13
+ served = serving.live.calls.get(call)
14
+ if served is not None:
15
+ return served
16
+ pool = serving.connections.pool
17
+ kept = await queries.scope_of_call(pool, call)
18
+ if kept is None or kept.scope is None or kept.sealed:
19
+ return None
20
+ opening = await openings.opening_of(pool, call)
21
+ if opening is None:
22
+ return None
23
+ return first_seen(serving, opening.context, opening.config, kept, time.monotonic())
@@ -0,0 +1,87 @@
1
+ """Who runs each written call and holds each WhatsApp thread, across the gateways of a box."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pydantic import TypeAdapter
6
+
7
+ from pinecall.process.shared import Shared
8
+ from pinecall.process.signal import Signal
9
+
10
+ # Each gateway says the written calls it runs and the threads it holds here, merged by each.
11
+ OWNED_CHANNEL = "owned"
12
+
13
+ # Where a gateway hands a WhatsApp message on to the one holding its contact's thread.
14
+ THREAD_CHANNEL = "thread:{gateway}"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Owned:
19
+ """One gateway's share: the written calls whose sessions run there, the threads it holds."""
20
+
21
+ texts: tuple[str, ...] = ()
22
+ threads: tuple[str, ...] = ()
23
+
24
+
25
+ # A session and a thread live where they were opened: another gateway takes one up only once its
26
+ # owner fell silent, when the owner is forgotten (process/shared.py).
27
+ class Owners:
28
+ """This gateway's written calls and threads, and every other gateway's as it says them."""
29
+
30
+ def __init__(self, signal: Signal) -> None:
31
+ """Nothing run and nothing held yet."""
32
+ self.shared = Shared(signal, OWNED_CHANNEL, _OWNED, Owned(), self._merged)
33
+ self.texts: set[str] = set()
34
+ self.threads: set[str] = set()
35
+ self._texts_elsewhere: set[str] = set()
36
+ # Each thread another live gateway holds, and that gateway's name on the signal.
37
+ self.threads_elsewhere: dict[str, str] = {}
38
+
39
+ @property
40
+ def id(self) -> str:
41
+ """This gateway's name on the signal."""
42
+ return self.shared.id
43
+
44
+ async def start(self) -> None:
45
+ """Hear the other gateways, and say what runs here."""
46
+ await self.shared.start()
47
+
48
+ async def close(self) -> None:
49
+ """Stop hearing and saying."""
50
+ await self.shared.close()
51
+
52
+ def running(self, call: str, *, here: bool) -> None:
53
+ """A written call's session started here, or ended."""
54
+ if (call in self.texts) == here:
55
+ return
56
+ if here:
57
+ self.texts.add(call)
58
+ else:
59
+ self.texts.discard(call)
60
+ self._say()
61
+
62
+ def holding(self, thread: str, *, here: bool) -> None:
63
+ """A contact's thread opened here, or closed."""
64
+ if (thread in self.threads) == here:
65
+ return
66
+ if here:
67
+ self.threads.add(thread)
68
+ else:
69
+ self.threads.discard(thread)
70
+ self._say()
71
+
72
+ def text_elsewhere(self, call: str) -> bool:
73
+ """Whether a live gateway other than this one runs the written call."""
74
+ return call in self._texts_elsewhere
75
+
76
+ def _say(self) -> None:
77
+ self.shared.put(Owned(texts=tuple(sorted(self.texts)), threads=tuple(sorted(self.threads))))
78
+
79
+ def _merged(self) -> None:
80
+ heard = self.shared.theirs.items()
81
+ self._texts_elsewhere = {call for _, share in heard for call in share.share.texts}
82
+ self.threads_elsewhere = {
83
+ thread: gateway for gateway, share in heard for thread in share.share.threads
84
+ }
85
+
86
+
87
+ _OWNED: TypeAdapter[Owned] = TypeAdapter(Owned)
@@ -0,0 +1,33 @@
1
+ """A bound call's pump: what any gateway writes to it, sent down its app socket in seq order."""
2
+
3
+ import logging
4
+ from collections.abc import Awaitable, Callable, Sequence
5
+
6
+ from pinecall.log.logs import Log
7
+ from pinecall.wire.frames import Entry
8
+
9
+ type Send = Callable[[Entry], Awaitable[None]]
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # What a stream says of itself, never sent to an app.
14
+ MARKERS = frozenset({"log.caught_up", "log.gap"})
15
+
16
+
17
+ # The app runs the call's tools and holds its state: it is sent them as they were written, from
18
+ # `after`; `then` goes down right after the first entry (the tools still waiting, after
19
+ # call.attached). The stream follows the log through the relay, so an entry another gateway
20
+ # wrote reaches the socket here, and it ends when the call does.
21
+ async def pumped(log: Log, after: int, send: Send, then: Sequence[Entry]) -> None:
22
+ """Send the call's entries to its socket until the call ends or the socket stops taking them."""
23
+ waiting = list(then)
24
+ try:
25
+ async for entry in log.stream(after=after):
26
+ if entry.type in MARKERS:
27
+ continue
28
+ await send(log.opened(entry))
29
+ for tool_call in waiting:
30
+ await send(log.opened(tool_call))
31
+ waiting = []
32
+ except (OSError, RuntimeError):
33
+ logger.warning("an app socket stopped taking its call's entries", exc_info=True)
@@ -0,0 +1,118 @@
1
+ """A call served here, as a door reaches it: its socket, set up, first seen, looked up, claimed."""
2
+
3
+ import asyncio
4
+ from dataclasses import replace
5
+ from datetime import UTC, datetime
6
+ from functools import partial
7
+ from typing import Literal
8
+
9
+ from pinecall.domain.agent import AgentConfig
10
+ from pinecall.domain.call import CallContext
11
+ from pinecall.domain.names import CHANNELS_WITH_A_NUMBER, JsonObject
12
+ from pinecall.domain.scope import Scope
13
+ from pinecall.gateway._served import Served, Serving
14
+ from pinecall.gateway._sockets import Registration, SocketId, Sockets, orgs_own
15
+ from pinecall.log import queries
16
+ from pinecall.log.logs import Log, arrival_entry
17
+ from pinecall.log.private import Privacy
18
+ from pinecall.log.queries import CallScope
19
+ from pinecall.retrieval import lookups
20
+ from pinecall.retrieval.lookups import OnTheCall
21
+ from pinecall.session.tools import ToolCalls
22
+ from pinecall.tenancy import admission
23
+ from pinecall.tenancy.codes import Codes
24
+ from pinecall.wire.events import CallClaimed
25
+ from pinecall.wire.rest.calls import LookupRequest
26
+
27
+
28
+ # A call opened with a key goes to that key's scope; one that rang, to its phone owner, or the line.
29
+ def serving_agent(
30
+ sockets: Sockets, scope: Scope, agent: str, app: SocketId | None, context: CallContext
31
+ ) -> Registration | None:
32
+ """The socket a new call of the agent reaches."""
33
+ if app is None and context.route.channel in CHANNELS_WITH_A_NUMBER:
34
+ return sockets.taking(orgs_own(scope), agent, context.caller)
35
+ return sockets.serving(scope, agent, app)
36
+
37
+
38
+ def served_call(
39
+ serving: Serving,
40
+ owner: SocketId | None,
41
+ context: CallContext,
42
+ config: AgentConfig,
43
+ scope: Scope,
44
+ ) -> Served:
45
+ """Serve the call to its socket, or park it for the next one; before its first entry."""
46
+ log = serving.logs.writing(context.call, config.slug)
47
+ log.privacy = Privacy(serving.connections.vault, config)
48
+ served = Served(
49
+ agent=config.slug,
50
+ scope=scope,
51
+ app=owner,
52
+ log=log,
53
+ commands=asyncio.Queue(),
54
+ context=context,
55
+ config=config,
56
+ tools=ToolCalls(
57
+ config,
58
+ log,
59
+ partial(queries.tool_answered, serving.connections.pool, context.call),
60
+ partial(queries.tool_called, serving.connections.pool, context.call),
61
+ ),
62
+ )
63
+ serving.live.serve(served)
64
+ return serving.live.calls[context.call]
65
+
66
+
67
+ # Parked and not counted: the socket holding its agent and the quota are its opener's.
68
+ def first_seen(
69
+ serving: Serving, context: CallContext, config: AgentConfig, kept: CallScope, now: float
70
+ ) -> Served:
71
+ """Serve a call another gateway opened, from what was kept when it opened."""
72
+ serving.live.idle(now)
73
+ served = served_call(
74
+ serving, None, context, config, kept.scope or Scope(context.route.org, context.env)
75
+ )
76
+ served = replace(served, opened_here=False, opened_by=kept.opened_by)
77
+ serving.live.calls[served.call] = served
78
+ serving.live.seen[served.call] = now
79
+ return served
80
+
81
+
82
+ async def opened(log: Log, context: CallContext, agent: str) -> None:
83
+ """call.ringing for an inbound call; an outbound one has its call.dialing already."""
84
+ if context.direction == "outbound":
85
+ return
86
+ kind, data = arrival_entry(context, context.route.number or agent)
87
+ await log.append(kind, data)
88
+
89
+
90
+ # The quotas are read per lookup, so a plan changed mid-call applies from the next turn.
91
+ async def looked_up(serving: Serving, served: Served, request: LookupRequest) -> JsonObject:
92
+ """Recall or search for a call served here, written on its log, answered as the model reads."""
93
+ pool = serving.connections.pool
94
+ quotas = await admission.quotas_of(pool, served.scope.org, served.scope.env)
95
+ on_the_call = OnTheCall(
96
+ scope=served.scope,
97
+ context=served.context,
98
+ config=served.config,
99
+ log=served.log,
100
+ now=now_of(serving),
101
+ )
102
+ return await lookups.lookup(pool, serving.embedder, on_the_call, request, quotas=quotas)
103
+
104
+
105
+ async def claim_code(
106
+ codes: Codes, served: Served, code: str, *, via: Literal["keypad", "agent"]
107
+ ) -> bool:
108
+ """Tie the call to the page showing the code; False when no page waits on it."""
109
+ issued = await codes.claim(served.scope.env, served.agent, code, served.call)
110
+ if issued is None:
111
+ return False
112
+ await served.log.append("call.claimed", CallClaimed(code=code, via=via).written())
113
+ return True
114
+
115
+
116
+ def now_of(serving: Serving) -> datetime:
117
+ """This moment by the store's clock, the one every entry of a call is stamped with."""
118
+ return datetime.fromtimestamp(serving.logs.store.clock(), UTC)
@@ -0,0 +1,367 @@
1
+ """The WhatsApp threads this gateway keeps open, one per contact; their doors are api/threads.py."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+
7
+ import httpx
8
+ from pydantic import TypeAdapter
9
+
10
+ from pinecall.channels import routes, whatsapp
11
+ from pinecall.channels.whatsapp import IDLE_S, WINDOW_S, Inbound, Waiting
12
+ from pinecall.domain.call import CallContext, Contact, Route, new_call_id, today_in
13
+ from pinecall.domain.errors import NotAvailable, PinecallError, QuotaExhausted, UpstreamFailed
14
+ from pinecall.domain.scope import Scope
15
+ from pinecall.gateway._call_setup import exhausted
16
+ from pinecall.gateway._served import Serving
17
+ from pinecall.gateway._sockets import Registration, Sockets
18
+ from pinecall.gateway._text_calls import open_text, resume_text, tokens_of
19
+ from pinecall.gateway.calls.owners import THREAD_CHANNEL
20
+ from pinecall.log import inbox
21
+ from pinecall.postgres.pool import Pool, box_task
22
+ from pinecall.session import text
23
+ from pinecall.session.session import Session
24
+ from pinecall.tenancy import admission
25
+ from pinecall.wire.events import ErrorEvent
26
+ from pinecall.wire.frames import Entry
27
+
28
+ # One conversation: the number written to, and who wrote.
29
+ type Door = tuple[str, str, str, str]
30
+
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ NOT_TEXT = "whatsapp: a %s to %s was dropped: only text is read"
36
+
37
+
38
+ NOBODY_AT = "whatsapp: nobody answers %s: `pinecall numbers import %s --channel whatsapp` routes it"
39
+
40
+
41
+ NOT_ANSWERED = "whatsapp: agent %s cannot answer at %s: %s"
42
+
43
+
44
+ KEPT = "whatsapp: nobody holds agent %s, so a message to %s waits for somebody"
45
+
46
+
47
+ EXPIRED = "whatsapp: a message to agent %s outlived Meta's window unanswered"
48
+
49
+
50
+ AGAIN = "whatsapp: message %s delivered again, dropped: %s"
51
+
52
+
53
+ TURN_FAILED = "whatsapp: a turn of call %s failed and was not answered"
54
+
55
+
56
+ NOT_SENT = "whatsapp_not_sent"
57
+
58
+
59
+ HANDED = "whatsapp: a message for %s was handed to the gateway holding its thread"
60
+
61
+ NOT_HANDED = "whatsapp: a message handed on by another gateway was not read: %s"
62
+
63
+ # How long the listener for messages handed on waits for the signal before listening again.
64
+ HANDED_RETRY_S = 1.0
65
+
66
+ _INBOUND: TypeAdapter[Inbound] = TypeAdapter(Inbound)
67
+
68
+ # However the call ends, the conversation is over with it: a written call a supervisor ends
69
+ # only says so, and its session is closed here.
70
+ OVER = {"call.ended", "supervisor.ended"}
71
+
72
+
73
+ @dataclass
74
+ class OpenThread:
75
+ """One contact's open conversation: its session, the token it replies with, what is unread."""
76
+
77
+ session: Session
78
+ inbound: Inbound
79
+ http: httpx.AsyncClient
80
+ token: str
81
+ # A None is the call over: the desk ended it, or it was hung up some other way.
82
+ heard: asyncio.Queue[str | None] = field(default_factory=asyncio.Queue[str | None])
83
+ served: asyncio.Task[None] | None = None
84
+
85
+ # The model's turns, the agent's `say` and a supervisor's reach the contact on one path.
86
+ async def replied(self, entry: Entry) -> None:
87
+ """Send a durable turn.agent to the contact; Meta's refusal is an error on the call."""
88
+ if entry.type in OVER:
89
+ self.heard.put_nowait(None)
90
+ return
91
+ answer = entry.data.get("text")
92
+ if (
93
+ entry.type != "turn.agent"
94
+ or entry.ephemeral
95
+ or not isinstance(answer, str)
96
+ or not answer
97
+ ):
98
+ return
99
+ try:
100
+ await whatsapp.send_text(self.http, self.token, self.inbound, answer)
101
+ except (UpstreamFailed, httpx.HTTPError) as refused:
102
+ error = ErrorEvent(code=NOT_SENT, message=str(refused), recoverable=True)
103
+ # Queued, not awaited: the queue is the one appending the entry this tap runs on.
104
+ self.session.call.writing.write("error", error)
105
+
106
+
107
+ class Threads:
108
+ """The open WhatsApp threads of this process, keyed by org, world, number and contact."""
109
+
110
+ def __init__(self, serving: Serving, sockets: Sockets) -> None:
111
+ """No conversation open and nothing waiting until `loaded` reads the logs."""
112
+ self.serving = serving
113
+ self.sockets = sockets
114
+ self.idle_s: float = IDLE_S
115
+ self.open: dict[Door, OpenThread] = {}
116
+ self.waiting: list[Waiting] = []
117
+ self.answering_now: set[asyncio.Task[None]] = set()
118
+ self.handed: asyncio.Task[None] | None = None
119
+
120
+ async def start(self) -> None:
121
+ """Take the messages other gateways hand on for the threads held here."""
122
+ self.handed = box_task(self._handed_here())
123
+
124
+ # Meta delivers a message again when it thinks it unanswered. The row is claimed before the
125
+ # message is read, so of two deliveries at once only one reads it; it is marked read once the
126
+ # message is queued or kept, and a reading that fails gives the claim back, so Meta's next
127
+ # delivery reads it again. A process that dies mid-reading leaves a claim its lease expires.
128
+ async def received(self, inbound: Inbound) -> bool:
129
+ """Read the message once, onto its contact's conversation; False while another reads it."""
130
+ route = await _route_of(self.serving.connections.pool, inbound)
131
+ if route is None:
132
+ return True
133
+ pool = self.serving.connections.pool
134
+ now = self.serving.logs.store.clock()
135
+ claim = await whatsapp.claimed(pool, route.org, inbound.message_id, now)
136
+ if claim != "new":
137
+ logger.info(AGAIN, inbound.message_id, claim)
138
+ return claim == "seen"
139
+ try:
140
+ await self._heard(route, inbound)
141
+ except Exception:
142
+ await whatsapp.released(pool, route.org, inbound.message_id)
143
+ raise
144
+ await whatsapp.read(pool, route.org, inbound.message_id, now)
145
+ return True
146
+
147
+ # Returns once the message is queued or kept: Meta sends a slow webhook again.
148
+ # A contact's thread is held by one gateway: a message landing on another is handed to it,
149
+ # and taken here only when nobody holds it, or its holder no longer listens.
150
+ async def _heard(self, route: Route, inbound: Inbound) -> None:
151
+ scope = Scope(route.org, route.env)
152
+ registration = self.sockets.taking(scope, route.agent, inbound.caller)
153
+ door = _door(registration.scope if registration else scope, inbound)
154
+ thread = self.open.get(door)
155
+ holder = self.serving.live.owners.threads_elsewhere.get("|".join(door))
156
+ if thread is None and holder is not None and await self._handed_to(holder, inbound):
157
+ logger.info(HANDED, inbound.number)
158
+ return
159
+ if thread is None and registration is None:
160
+ self.waiting.append(await whatsapp.kept(self.serving.logs, route, inbound))
161
+ logger.warning(KEPT, route.agent, inbound.number)
162
+ return
163
+ if thread is None and registration is not None:
164
+ thread = await self._opened(registration, route, inbound)
165
+ if thread is not None:
166
+ thread.heard.put_nowait(inbound.text)
167
+
168
+ # Off the app socket's own loop: opening a conversation may wait on the model.
169
+ def answering(self, registration: Registration) -> None:
170
+ """Answer the messages that waited for this agent, now that somebody holds it."""
171
+ if not any(waiter.agent == registration.slug for waiter in self.waiting):
172
+ return
173
+ task = asyncio.create_task(self._answered(registration))
174
+ self.answering_now.add(task)
175
+ task.add_done_callback(self.answering_now.discard)
176
+
177
+ # Oldest first, so one contact's messages reach their conversation in order.
178
+ async def _answered(self, registration: Registration) -> None:
179
+ now = self.serving.logs.store.clock()
180
+ for waiting in [waiter for waiter in self.waiting if waiter.agent == registration.slug]:
181
+ if waiting.env != registration.scope.env:
182
+ continue
183
+ self.waiting.remove(waiting)
184
+ if now - waiting.received_at > WINDOW_S:
185
+ logger.warning(EXPIRED, waiting.agent)
186
+ await whatsapp.taken(self.serving.logs, waiting, None)
187
+ continue
188
+ # Claimed and read when it was kept: it is heard, not received again.
189
+ route = await _route_of(self.serving.connections.pool, waiting.inbound)
190
+ if route is not None:
191
+ await self._heard(route, waiting.inbound)
192
+ opened = self.open.get(_door(registration.scope, waiting.inbound))
193
+ await whatsapp.taken(
194
+ self.serving.logs,
195
+ waiting,
196
+ None if opened is None else opened.session.call.context.call,
197
+ )
198
+
199
+ async def loaded(self) -> None:
200
+ """What was waiting when this process started, read back off the agents' logs."""
201
+ self.waiting = await whatsapp.waiting_in(self.serving.logs.store)
202
+
203
+ async def closed(self) -> None:
204
+ """Stop answering: the threads stay open in their logs for the next process."""
205
+ serving = [value.served for value in self.open.values() if value.served is not None]
206
+ serving += list(self.answering_now)
207
+ serving += [] if self.handed is None else [self.handed]
208
+ for task in serving:
209
+ task.cancel()
210
+ await asyncio.gather(*serving, return_exceptions=True)
211
+ self.open.clear()
212
+
213
+ async def _opened(
214
+ self, registration: Registration, route: Route, inbound: Inbound
215
+ ) -> OpenThread | None:
216
+ token = await whatsapp.meta_token_for(
217
+ self.serving.connections.pool,
218
+ self.serving.connections.vault,
219
+ route.org,
220
+ inbound.phone_number_id,
221
+ )
222
+ if token is None:
223
+ logger.warning(NOT_ANSWERED, route.agent, inbound.number, "no Meta token here")
224
+ return None
225
+ try:
226
+ taken = await self._taken_up(registration, inbound)
227
+ session = taken or await open_text(
228
+ self.serving, registration, self._context(registration, route, inbound)
229
+ )
230
+ except PinecallError as refused:
231
+ logger.warning(NOT_ANSWERED, route.agent, inbound.number, refused)
232
+ return None
233
+ thread = OpenThread(
234
+ session=session, inbound=inbound, http=self.serving.connections.http, token=token
235
+ )
236
+ # Before the session starts, so every turn the contact is owed reaches them.
237
+ served = self.serving.live.calls[session.call.context.call]
238
+ served.log.tapped(thread.replied)
239
+ if taken is None:
240
+ await session.start()
241
+ door = _door(registration.scope, inbound)
242
+ self.open[door] = thread
243
+ self.serving.live.owners.holding("|".join(door), here=True)
244
+ thread.served = asyncio.create_task(self._served(door, thread))
245
+ return thread
246
+
247
+ async def _handed_to(self, holder: str, inbound: Inbound) -> bool:
248
+ channel = THREAD_CHANNEL.format(gateway=holder)
249
+ try:
250
+ return (
251
+ await self.serving.live.signal.published(channel, _INBOUND.dump_json(inbound)) > 0
252
+ )
253
+ except NotAvailable:
254
+ return False
255
+
256
+ # Claimed and read by the gateway it landed on: here it is only heard.
257
+ async def _handed_here(self) -> None:
258
+ signal = self.serving.live.signal
259
+ channel = THREAD_CHANNEL.format(gateway=self.serving.live.owners.id)
260
+ while True:
261
+ try:
262
+ listening = await signal.subscribe(channel)
263
+ except NotAvailable:
264
+ await asyncio.sleep(HANDED_RETRY_S)
265
+ continue
266
+ try:
267
+ async for data in listening:
268
+ await self._taken_on(_INBOUND.validate_json(data))
269
+ finally:
270
+ listening.close()
271
+ await asyncio.sleep(HANDED_RETRY_S)
272
+
273
+ async def _taken_on(self, inbound: Inbound) -> None:
274
+ route = await _route_of(self.serving.connections.pool, inbound)
275
+ if route is None:
276
+ return
277
+ try:
278
+ await self._heard(route, inbound)
279
+ except PinecallError as refused:
280
+ logger.warning(NOT_HANDED, refused)
281
+
282
+ # A conversation this process never saw is taken up from its log while it has idle time left.
283
+ async def _taken_up(self, registration: Registration, inbound: Inbound) -> Session | None:
284
+ newest = await inbox.calls_with(
285
+ self.serving.connections.pool,
286
+ registration.scope,
287
+ registration.slug,
288
+ inbound.caller,
289
+ limit=1,
290
+ )
291
+ if not newest:
292
+ return None
293
+ entries = await self.serving.logs.store.whole(newest[0])
294
+ if not entries or self.serving.logs.store.clock() - entries[-1].ts > self.idle_s:
295
+ return None
296
+ return await resume_text(
297
+ self.serving, registration, newest[0], self.serving.connections.settings.timezone
298
+ )
299
+
300
+ def _context(self, registration: Registration, route: Route, inbound: Inbound) -> CallContext:
301
+ answering = Route(
302
+ org=route.org,
303
+ agent=registration.slug,
304
+ channel="whatsapp",
305
+ number=route.number,
306
+ env=route.env,
307
+ )
308
+ return CallContext(
309
+ call=new_call_id(),
310
+ channel="whatsapp",
311
+ direction="inbound",
312
+ caller=inbound.caller,
313
+ route=answering,
314
+ today=today_in(self.serving.connections.settings.timezone),
315
+ contact=Contact(phone=inbound.caller, name=inbound.name),
316
+ holder=registration.scope.holder or None,
317
+ )
318
+
319
+ # The idle period starts once every message is answered: a slow turn is never cut off.
320
+ async def _served(self, door: Door, thread: OpenThread) -> None:
321
+ session = thread.session
322
+ scope = session.call.context.route
323
+ try:
324
+ while True:
325
+ try:
326
+ answer = await asyncio.wait_for(thread.heard.get(), timeout=self.idle_s)
327
+ except TimeoutError:
328
+ await text.end(session, "timeout", "platform")
329
+ return
330
+ if answer is None:
331
+ await session.close()
332
+ return
333
+ try:
334
+ await admission.admit_turn(
335
+ self.serving.connections.pool,
336
+ Scope(scope.org, scope.env),
337
+ turns=session.call.turns,
338
+ tokens=tokens_of(session.usage),
339
+ at=self.serving.logs.store.clock(),
340
+ )
341
+ except QuotaExhausted as refused:
342
+ await exhausted(self.serving.logs, scope.org, session.call.config.slug, refused)
343
+ await text.end(session, "timeout", "platform")
344
+ return
345
+ try:
346
+ await text.hears(session, answer)
347
+ except PinecallError:
348
+ logger.warning(TURN_FAILED, session.call.context.call, exc_info=True)
349
+ finally:
350
+ self.open.pop(door, None)
351
+ self.serving.live.owners.holding("|".join(door), here=False)
352
+ self.serving.live.close(session.call.context.call)
353
+ self.serving.logs.forget(session.call.context.call)
354
+
355
+
356
+ def _door(scope: Scope, inbound: Inbound) -> Door:
357
+ return (scope.org, scope.env, inbound.number, inbound.wa_id)
358
+
359
+
360
+ async def _route_of(pool: Pool, inbound: Inbound) -> Route | None:
361
+ if inbound.kind != "text" or not inbound.text:
362
+ logger.warning(NOT_TEXT, inbound.kind or "message", inbound.number)
363
+ return None
364
+ route = await routes.at(pool, "whatsapp", inbound.number)
365
+ if route is None:
366
+ logger.warning(NOBODY_AT, inbound.number, inbound.number)
367
+ return route
File without changes