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,58 @@
1
+ """A person alone in a room is offered a worker; one an agent joined, or that ended, is let go."""
2
+
3
+ from livekit import api
4
+ from livekit.protocol.agent_dispatch import AgentDispatch
5
+ from livekit.protocol.webhook import WebhookEvent
6
+
7
+ from pinecall.channels import offers, rooms
8
+ from pinecall.channels.offers import Offering
9
+ from pinecall.channels.telephony.hand_over import handed_in
10
+ from pinecall.domain.names import Env
11
+
12
+ JOINED = "participant_joined"
13
+
14
+ LEFT = "participant_left"
15
+
16
+ FINISHED = "room_finished"
17
+
18
+
19
+ # The SIP rule and a visitor's token dispatch to the fleet by its plain name, which no worker holds
20
+ # (each registers as <fleet>/<worker>), so LiveKit starts no job: the dispatch stays on the room
21
+ # with the call's metadata, and the gateway reads it there. A caller joining sends the first; a
22
+ # worker handing its caller to another fleet dispatches there and leaves, which sends the other.
23
+ # The newest dispatch decides: a call that ended, or a supervisor joining a live call, has the
24
+ # gateway's own dispatch, to a worker by its name, as the newest, and is left alone. The room is
25
+ # asked of the LiveKit of the world that sent the event. A ring production handed to the sandbox
26
+ # over SIP is made the developer's by what its leg, the person joining, carries.
27
+ async def arrived(offering: Offering, world: Env, event: WebhookEvent) -> str | None:
28
+ """Offer the room a person is alone in to a worker; the name it went to, or None."""
29
+ if not _a_person_may_be_alone(event):
30
+ return None
31
+ room = event.room.name
32
+ server = offering.servers[world]
33
+ if not await rooms.left_alone(server, room):
34
+ return None
35
+ newest = _newest(await server.agent_dispatch.list_dispatch(room))
36
+ if newest is None or "/" in newest.agent_name or newest.state.jobs:
37
+ return None
38
+ dispatch = handed_in(rooms.read_dispatch(newest.metadata), event.participant.attributes)
39
+ return await offering.offer(room, newest.agent_name, dispatch)
40
+
41
+
42
+ # A dispatch creates the room it names again if it is gone: a room offered after its caller hung
43
+ # up, or after the worker sent to say the sentence closed it, would be a job on an empty room.
44
+ async def settled(offering: Offering, event: WebhookEvent) -> None:
45
+ """Let a room go that an agent joined or that ended: nothing is left to offer it to."""
46
+ agent_in = event.event == JOINED and event.participant.kind == api.ParticipantInfo.Kind.AGENT
47
+ if event.room.name and (agent_in or event.event == FINISHED):
48
+ await offers.forgotten(offering.pool, event.room.name)
49
+
50
+
51
+ def _a_person_may_be_alone(event: WebhookEvent) -> bool:
52
+ agent = event.participant.kind == api.ParticipantInfo.Kind.AGENT
53
+ joined = event.event == JOINED and not agent
54
+ return bool(event.room.name) and (joined or (event.event == LEFT and agent))
55
+
56
+
57
+ def _newest(dispatches: list[AgentDispatch]) -> AgentDispatch | None:
58
+ return max(dispatches, key=lambda dispatch: dispatch.state.created_at, default=None)
@@ -0,0 +1,43 @@
1
+ """The sweep: every few seconds, a room nobody opened in time is offered again, by any gateway."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+
7
+ import psycopg
8
+ from livekit import api
9
+
10
+ from pinecall.channels import offers
11
+ from pinecall.channels.offers import OFFERED_AGAIN_AFTER_S, Offering
12
+ from pinecall.domain.errors import Conflict, NotAvailable
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ SWEPT_EVERY_S = 3.0
17
+
18
+ # Past any offer: a caller who waited this long has hung up, and the SIP leg is gone.
19
+ FORGOTTEN_AFTER_S = 120.0
20
+
21
+ AT_MOST = 100
22
+
23
+
24
+ async def swept(offering: Offering) -> list[str]:
25
+ """Offer again every room nobody opened in time; the rooms offered."""
26
+ now = time.time()
27
+ await offers.aged_out(offering.pool, now - FORGOTTEN_AFTER_S)
28
+ due = await offers.due(offering.pool, now - OFFERED_AGAIN_AFTER_S, AT_MOST)
29
+ return [offer.room for offer in due if await offering.offered(offer) is not None]
30
+
31
+
32
+ # A pass that fails is said, and the next one runs: the sweep never stops, a database or an SFU
33
+ # that did not answer included.
34
+ async def sweep_forever(offering: Offering) -> None:
35
+ """A pass every few seconds."""
36
+ while True:
37
+ try:
38
+ await swept(offering)
39
+ except (Conflict, NotAvailable, api.TwirpError, OSError, psycopg.Error):
40
+ logger.warning(
41
+ "the offers' sweep failed; the next is in %.0f s", SWEPT_EVERY_S, exc_info=True
42
+ )
43
+ await asyncio.sleep(SWEPT_EVERY_S)
File without changes
@@ -0,0 +1,160 @@
1
+ """The reaper: it seals the calls nothing runs any more, a dead worker's and a silent chat's."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from collections.abc import Mapping
7
+
8
+ import psycopg
9
+ from livekit import api
10
+
11
+ from pinecall.channels.rooms import room_closed, rooms_with_an_agent
12
+ from pinecall.domain.errors import Conflict, NotAvailable
13
+ from pinecall.domain.names import Env
14
+ from pinecall.gateway._served import Serving
15
+ from pinecall.gateway.ending.seal import LEASED_S, summed_up
16
+ from pinecall.log import queries
17
+ from pinecall.wire.events import CallEnded
18
+ from pinecall.wire.parts import EndReason
19
+ from pinecall.wire.rest.calls import SealCallRequest
20
+ from pinecall.wire.scores import CallScore
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ NOT_JUDGED_REAPED = (
25
+ "the worker holding this call went away before it could end it, and the platform sealed the "
26
+ "log: there was no session left to judge"
27
+ )
28
+
29
+
30
+ NOTHING_SAID = "no reply"
31
+
32
+
33
+ # Well over livekit's empty_timeout (60 s): the empty room is the signal, this is the margin.
34
+ QUIET_S = 5 * 60.0
35
+
36
+
37
+ REAPED_EVERY_S = 60.0
38
+
39
+
40
+ AT_MOST = 100
41
+
42
+
43
+ # How long a WhatsApp thread waits for its contact before it is closed.
44
+ A_THREAD_WAITS_S = 2 * 60 * 60.0
45
+
46
+
47
+ REAPED = "sealed %s: no agent is in its room and it has said nothing for %.0f s"
48
+
49
+
50
+ # A killed job writes no call.ended, and nothing else would close its log. A call's room is
51
+ # asked of its world's LiveKit; a call no scope claimed, of every one.
52
+ async def reaped(serving: Serving, servers: Mapping[Env, api.LiveKitAPI], now: float) -> list[str]:
53
+ """Seal the quiet calls nothing runs any more, and say which."""
54
+ sealed_now: list[str] = []
55
+ quiet = await queries.unsealed_spoken(serving.connections.pool, now - QUIET_S, limit=AT_MOST)
56
+ existing: set[str] = set()
57
+ for server, calls in _rooms_by_livekit(servers, quiet).items():
58
+ existing |= await rooms_with_an_agent(server, calls)
59
+ for orphan in quiet:
60
+ if orphan.call not in existing and await _finished(serving, orphan, "drained"):
61
+ # The room goes too, so whoever is still in it hears the call end.
62
+ for server in _livekits_of(servers, orphan):
63
+ await room_closed(server, orphan.call)
64
+ logger.warning(REAPED, orphan.call, now - orphan.last_at)
65
+ sealed_now.append(orphan.call)
66
+ for orphan in await queries.unsealed_written(
67
+ serving.connections.pool, now - QUIET_S, limit=AT_MOST
68
+ ):
69
+ # A written call waits for its caller as long as its channel would.
70
+ patience = A_THREAD_WAITS_S if orphan.channel == "whatsapp" else QUIET_S
71
+ # Its session may run on another gateway, which says so: it is that gateway's to end.
72
+ runs = serving.live.calls.get(orphan.call) is not None
73
+ if (
74
+ runs
75
+ or serving.live.owners.text_elsewhere(orphan.call)
76
+ or now - orphan.last_at < patience
77
+ ):
78
+ continue
79
+ if await _finished(serving, orphan, "timeout"):
80
+ sealed_now.append(orphan.call)
81
+ return sealed_now
82
+
83
+
84
+ # A pass that fails is said, and the next one runs: the reaper never stops.
85
+ async def reap_forever(serving: Serving, servers: Mapping[Env, api.LiveKitAPI]) -> None:
86
+ """A pass now, and one every minute."""
87
+ while True:
88
+ try:
89
+ await reaped(serving, servers, time.time())
90
+ await let_go(serving)
91
+ except (Conflict, NotAvailable, api.TwirpError, OSError, psycopg.Error):
92
+ logger.warning(
93
+ "the reaper's pass failed; the next is in %.0f s", REAPED_EVERY_S, exc_info=True
94
+ )
95
+ await asyncio.sleep(REAPED_EVERY_S)
96
+
97
+
98
+ # A call sealed on another gateway ends its readers there; here it is only let go of, so it no
99
+ # longer counts against its org's calls at once, and idle calls first seen here go with it.
100
+ async def let_go(serving: Serving) -> list[str]:
101
+ """Stop serving the calls sealed elsewhere, and the ones first seen here that went idle."""
102
+ serving.live.idle(time.monotonic())
103
+ gone = await queries.sealed_among(serving.connections.pool, list(serving.live.calls))
104
+ for call in gone:
105
+ serving.logs.forget(call)
106
+ serving.live.close(call)
107
+ return sorted(gone)
108
+
109
+
110
+ def _rooms_by_livekit(
111
+ servers: Mapping[Env, api.LiveKitAPI], quiet: list[queries.Unsealed]
112
+ ) -> dict[api.LiveKitAPI, list[str]]:
113
+ by_livekit: dict[api.LiveKitAPI, list[str]] = {}
114
+ for orphan in quiet:
115
+ for server in _livekits_of(servers, orphan):
116
+ by_livekit.setdefault(server, []).append(orphan.call)
117
+ return by_livekit
118
+
119
+
120
+ # Both worlds hold the one client when they share a LiveKit: it is asked once.
121
+ def _livekits_of(
122
+ servers: Mapping[Env, api.LiveKitAPI], orphan: queries.Unsealed
123
+ ) -> list[api.LiveKitAPI]:
124
+ if orphan.env is not None:
125
+ return [servers[orphan.env]]
126
+ return list(dict.fromkeys(servers.values()))
127
+
128
+
129
+ # Duration ends at the last entry, not now: reaping late bills no extra minutes.
130
+ async def _finished(serving: Serving, orphan: queries.Unsealed, reason: EndReason) -> bool:
131
+ store = serving.logs.store
132
+ if not await store.lease_seal(orphan.call, LEASED_S):
133
+ return False
134
+ log = serving.logs.writing(orphan.call, orphan.agent)
135
+ written_types = {item.type for item in await store.whole(orphan.call)}
136
+ try:
137
+ if "call.ended" not in written_types:
138
+ ended = CallEnded(
139
+ reason=reason,
140
+ ended_by="platform",
141
+ ended_at=orphan.last_at,
142
+ duration_s=max(orphan.last_at - orphan.started_at, 0.0),
143
+ )
144
+ await log.append("call.ended", ended.written())
145
+ if "call.summary" not in written_types:
146
+ await summed_up(
147
+ serving.connections.pool,
148
+ store,
149
+ log,
150
+ SealCallRequest(usage=[], outcome=NOTHING_SAID),
151
+ )
152
+ score = CallScore(judges=[], judge_calls=0, not_judged=NOT_JUDGED_REAPED)
153
+ await log.append("call.score", score.written())
154
+ except Conflict:
155
+ # Another gateway sealed it first: nothing is left to do.
156
+ return False
157
+ finally:
158
+ serving.logs.forget(orphan.call)
159
+ serving.live.close(orphan.call)
160
+ return True
@@ -0,0 +1,312 @@
1
+ """How a call ends: what it taught memory, what it cost, how it was judged, and its log sealed."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Sequence
6
+
7
+ import psycopg
8
+
9
+ from pinecall.domain.agent import AgentConfig, Model
10
+ from pinecall.domain.errors import NotAvailable, PinecallError, QuotaExhausted
11
+ from pinecall.evals import dataset, judges
12
+ from pinecall.evals.compliance import Compliance, Panel
13
+ from pinecall.gateway._call_setup import exhausted, keys_of
14
+ from pinecall.gateway._served import Served, Serving
15
+ from pinecall.gateway.calls.serving import now_of
16
+ from pinecall.log import drift, facts, queries
17
+ from pinecall.log.logs import Log
18
+ from pinecall.log.reduce import phone_legs, reduce
19
+ from pinecall.log.store import Store
20
+ from pinecall.postgres.pool import Pool
21
+ from pinecall.process.connections import Connections
22
+ from pinecall.providers import catalog, credentials, prices
23
+ from pinecall.providers.catalog import Providers
24
+ from pinecall.providers.credentials import Keyring, thinking
25
+ from pinecall.retrieval import extraction, lookups, memory
26
+ from pinecall.retrieval.extraction import MemoryWrite
27
+ from pinecall.tenancy import admission, consents, disclosure, orgs, policy, spend, vault
28
+ from pinecall.tenancy.judges import StoredJudge, for_call
29
+ from pinecall.wire.events import CallEnded, CallSummary, ErrorEvent, MemoryOps, SpendUnusual
30
+ from pinecall.wire.frames import Entry
31
+ from pinecall.wire.parts import MemoryOp
32
+ from pinecall.wire.rest.calls import SealCallRequest
33
+ from pinecall.wire.scores import CallScore
34
+ from pinecall.wire.state import AgentTurn
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ JUDGING_OFF = "this org's calls are not judged at hang-up: POST /v1/evals/judge/{call} judges one"
39
+
40
+ A_RUN_JUDGES_IT = "an eval run opened this call, and its own judges scored it in the run's matrix"
41
+
42
+ NO_JUDGE = "this box's providers configuration names no judge model"
43
+
44
+ NO_CEILING = "the providers row gives the judge a ceiling of zero, so no judge model may be asked"
45
+
46
+ JUDGING_BROKE = "judging this call failed: {broke}"
47
+
48
+
49
+ REMEMBER_FAILED = "the call was not written into memory: {why}"
50
+
51
+
52
+ # What a hang-up waits for the one model call that writes memory, unless the settings say.
53
+ REMEMBER_BUDGET_S = 8.0
54
+
55
+ # How long one gateway holds the right to seal a call, renewed at a third of it while it seals:
56
+ # a gateway that dies sealing lets the call go within these seconds, and a knock waiting on it
57
+ # takes the seal over. Held 120 s unrenewed, a worker gave up (30 s) on a call whose sealing gateway
58
+ # died, and the reaper sealed it later with no usage: measured with a gateway killed every minute.
59
+ LEASED_S = 15.0
60
+ LOOKED_AGAIN_S = 0.5
61
+
62
+ # How long a knock waits on another gateway's seal before it says so: the worker asks again.
63
+ WAITED_AT_MOST_S = 25.0
64
+
65
+ SEALED_ELSEWHERE = (
66
+ "call {call} is being sealed by another gateway, which has not finished: ask again"
67
+ )
68
+
69
+
70
+ # One end for every call: a worker's, a written one's, and one the reaper finishes. A call is
71
+ # sealed once: a second knock waits for the first and finds the call gone, and a seal that broke
72
+ # after its summary goes on from the score, so no call is remembered or billed twice. Across
73
+ # gateways the head's lease does what the lock does here: a knock that finds it taken waits for
74
+ # the log to say sealed.
75
+ async def sealed(
76
+ serving: Serving, served: Served, sealing: SealCallRequest, *, lent: Sequence[str] = ()
77
+ ) -> None:
78
+ """Remember, price the call, write its summary and its score, seal the log, let it go."""
79
+ async with served.sealing:
80
+ if served.call not in serving.live.calls:
81
+ return
82
+ store = serving.logs.store
83
+ if not await store.lease_seal(served.call, LEASED_S) and await _sealed_elsewhere(
84
+ serving, served
85
+ ):
86
+ return
87
+ renewing = asyncio.create_task(_renewed(store, served.call))
88
+ try:
89
+ entries = await store.whole(served.call)
90
+ if all(entry.type != "call.summary" for entry in entries):
91
+ await _priced(serving, served, sealing, lent)
92
+ score = await _scored(serving, served)
93
+ await served.log.append("call.score", score.written())
94
+ except BaseException:
95
+ await store.release_seal(served.call)
96
+ raise
97
+ finally:
98
+ renewing.cancel()
99
+ await asyncio.gather(renewing, return_exceptions=True)
100
+ await drifted(serving.connections.pool, served.call, entries, score)
101
+ await _kept_as_case(serving, served, score)
102
+ await _watched(serving, served)
103
+ serving.logs.forget(served.call)
104
+ serving.live.close(served.call)
105
+
106
+
107
+ # Between call.ended and call.summary, on the org's keys as they are now. A refusal at the cap goes
108
+ # on the agent's log and the call's says an empty remember; a break is an entry, and it seals.
109
+ async def remembered(serving: Serving, served: Served) -> MemoryWrite | None:
110
+ """What the call taught its contact's memory, written; None when the agent keeps nothing."""
111
+ pool = serving.connections.pool
112
+ entries = await serving.logs.store.whole(served.call)
113
+ heard = lookups.heard_in(served.context, served.config, entries, at=now_of(serving))
114
+ if heard is None or serving.embedder is None:
115
+ return None
116
+ try:
117
+ await admission.admit_memory(
118
+ pool,
119
+ served.scope.org,
120
+ served.scope.env,
121
+ kept=await memory.kept(pool, served.scope.org, served.scope.env),
122
+ )
123
+ except QuotaExhausted as refused:
124
+ await exhausted(serving.logs, served.scope.org, served.agent, refused)
125
+ op = MemoryOp(op="remember", contact=heard.contact, facts=[], took_ms=0.0)
126
+ await served.log.append("memory.ops", MemoryOps(ops=[op]).written())
127
+ return MemoryWrite(op=op, usage=None)
128
+ budget = serving.connections.settings.remember_budget_s or REMEMBER_BUDGET_S
129
+ try:
130
+ async with asyncio.timeout(budget):
131
+ configured = await catalog.providers(pool)
132
+ keys = await keys_of(pool, serving.connections.vault, served.scope)
133
+ model = thinking(served.config, configured, keys)
134
+ written = await extraction.remember(pool, serving.embedder, model, served.scope, heard)
135
+ except Exception as broke:
136
+ # Memory is a courtesy to the next call; this one ends all the same.
137
+ logger.warning("call %s was not written into memory", served.call, exc_info=True)
138
+ why = REMEMBER_FAILED.format(why=str(broke) or type(broke).__name__)
139
+ failed = ErrorEvent(code="remember_failed", message=why, recoverable=True)
140
+ await served.log.append("error", failed.written())
141
+ return None
142
+ await served.log.append("memory.ops", MemoryOps(ops=[written.op]).written())
143
+ return written
144
+
145
+
146
+ # The seal never fails on a judge: a call that could not be judged says why and seals all the same.
147
+ async def judged_call(
148
+ connections: Connections,
149
+ entries: Sequence[Entry],
150
+ declared: AgentConfig | None,
151
+ own: Sequence[StoredJudge],
152
+ org_facts: Compliance | None,
153
+ ) -> CallScore:
154
+ """The hang-up panel, compliance and the agent's own judges over a finished call."""
155
+ call = next((entry.call for entry in entries if entry.call is not None), "")
156
+ try:
157
+ configured = await catalog.providers(connections.pool)
158
+ judge = await judge_of(connections, configured)
159
+ judged_by = Panel(own=[stored.judge for stored in own], compliance=org_facts)
160
+ return await judges.at_hangup(entries, declared, judged_by, judge, configured=configured)
161
+ except PinecallError as broke:
162
+ logger.warning("call %s: nothing judged it", call, exc_info=True)
163
+ return CallScore(judges=[], judge_calls=0, not_judged=JUDGING_BROKE.format(broke=broke))
164
+
165
+
166
+ # The org as the call spoke for it: its name, the opening sentence it set, and an opt-out it took.
167
+ async def compliance_of(
168
+ pool: Pool, org: str, call: str, declared: AgentConfig | None
169
+ ) -> Compliance:
170
+ """What the compliance judges read beyond the call's log."""
171
+ found = await orgs.find(pool, org)
172
+ name = org if found is None else found.name
173
+ kept = (await policy.policy_of(pool, org)).policy
174
+ language = None if declared is None else declared.language
175
+ opening = disclosure.disclosure_of(kept, name, language)
176
+ return Compliance(
177
+ org=name, disclosure=opening, opted_out=await consents.opted_out_on(pool, call)
178
+ )
179
+
180
+
181
+ # Always the box's key, never an org's: judging is the platform's measure, the same for all.
182
+ async def judge_of(connections: Connections, configured: Providers) -> judges.JudgeModel:
183
+ """The judge model on the box's key, or None and the sentence that says why there is none."""
184
+ if configured.judge is None:
185
+ return judges.JudgeModel(None, 0.0, NO_JUDGE)
186
+ if configured.judge.ceiling_usd <= 0:
187
+ return judges.JudgeModel(None, 0.0, NO_CEILING)
188
+ box = await vault.box_credentials(connections.pool, connections.vault)
189
+ named = configured.judge.llm
190
+ declared = Model(provider=named.vendor, model=named.model or "")
191
+ stage = credentials.stage("llm", declared, configured, Keyring(box=box))
192
+ return judges.JudgeModel(stage, configured.judge.ceiling_usd)
193
+
194
+
195
+ # Drift is a measure of the day, not of the call: a fold that breaks is logged and the call seals
196
+ # all the same, and `pinecall-runtime drift rebuild` counts it later.
197
+ async def drifted(pool: Pool, call: str, entries: Sequence[Entry], score: CallScore) -> None:
198
+ """Count the call's stages and verdicts into its day's drift, or log why not."""
199
+ try:
200
+ await drift.fold(pool, call, entries, score)
201
+ except psycopg.Error:
202
+ logger.warning("call %s was not counted into its day's drift", call, exc_info=True)
203
+
204
+
205
+ async def summed_up(pool: Pool, store: Store, log: Log, sealing: SealCallRequest) -> None:
206
+ """call.summary: how the call ended, what it used, what that cost."""
207
+ entries = await store.whole(log.name)
208
+ ended = next((entry for entry in reversed(entries) if entry.type == "call.ended"), None)
209
+ over = None if ended is None else CallEnded.model_validate(ended.data)
210
+ state = reduce(entries)
211
+ duration = 0.0 if over is None else over.duration_s
212
+ configured = await catalog.providers(pool)
213
+ summary = CallSummary(
214
+ reason="error" if over is None else over.reason,
215
+ outcome=sealing.outcome,
216
+ duration_s=duration,
217
+ turns=sum(1 for turn in state.turns if isinstance(turn, AgentTurn)),
218
+ usage=sealing.usage,
219
+ cost=prices.cost(sealing.usage, configured, legs=phone_legs(entries), seconds=duration),
220
+ recording=sealing.recording,
221
+ )
222
+ # Once per log: two gateways sealing one call (a lease lapsed under a stalled seal) price it
223
+ # once.
224
+ await log.append_first("call.summary", summary.written())
225
+
226
+
227
+ async def _priced(
228
+ serving: Serving, served: Served, sealing: SealCallRequest, lent: Sequence[str]
229
+ ) -> None:
230
+ written = await remembered(serving, served)
231
+ # The memory model's tokens are the call's: they are billed with it.
232
+ if written is not None and written.usage is not None:
233
+ sealing = sealing.model_copy(update={"usage": [*sealing.usage, written.usage]})
234
+ await summed_up(serving.connections.pool, serving.logs.store, served.log, sealing)
235
+ if lent:
236
+ await facts.lent(serving.connections.pool, served.call, lent)
237
+
238
+
239
+ async def _scored(serving: Serving, served: Served) -> CallScore:
240
+ pool = serving.connections.pool
241
+ if served.context.run is not None:
242
+ return CallScore(judges=[], judge_calls=0, not_judged=A_RUN_JUDGES_IT)
243
+ if not await orgs.judged(pool, served.scope.org):
244
+ return CallScore(judges=[], judge_calls=0, not_judged=JUDGING_OFF.format(call=served.call))
245
+ entries = await serving.logs.store.whole(served.call)
246
+ own = await for_call(pool, served.scope.org, served.agent)
247
+ org_facts = await compliance_of(pool, served.scope.org, served.call, served.config)
248
+ return await judged_call(serving.connections, entries, served.config, own, org_facts)
249
+
250
+
251
+ # True once another gateway sealed it; False when its lease ran out (that gateway died sealing)
252
+ # and this one took it over, so the caller seals.
253
+ async def _sealed_elsewhere(serving: Serving, served: Served) -> bool:
254
+ store = serving.logs.store
255
+ loop = asyncio.get_running_loop()
256
+ deadline = loop.time() + WAITED_AT_MOST_S
257
+ while not await store.sealed(served.call):
258
+ if await store.lease_seal(served.call, LEASED_S):
259
+ return False
260
+ if loop.time() > deadline:
261
+ raise NotAvailable(SEALED_ELSEWHERE.format(call=served.call))
262
+ await asyncio.sleep(LOOKED_AGAIN_S)
263
+ serving.logs.forget(served.call)
264
+ serving.live.close(served.call)
265
+ return True
266
+
267
+
268
+ async def _renewed(store: Store, call: str) -> None:
269
+ while True:
270
+ await asyncio.sleep(LEASED_S / 3)
271
+ await store.renew_seal(call, LEASED_S)
272
+
273
+
274
+ # A call a judge broke on waits in the org's inbox as a case (evals/dataset.py). Like drift, it
275
+ # is kept after the call: a write that breaks is logged and the call seals all the same.
276
+ async def _kept_as_case(serving: Serving, served: Served, score: CallScore) -> None:
277
+ """Keep the call as a pending case of the org's dataset when one of its judges broke."""
278
+ if score.passed is not False:
279
+ return
280
+ pool = serving.connections.pool
281
+ try:
282
+ entries = await serving.logs.store.whole(served.call)
283
+ kept = await queries.scope_of_call(pool, served.call)
284
+ version = None if kept is None else kept.versions.config
285
+ born = dataset.Born(served.scope.org, served.scope.env, served.agent, version)
286
+ await dataset.kept_at_hangup(pool, entries, score, born)
287
+ except psycopg.Error:
288
+ logger.warning("call %s was not kept as a case", served.call, exc_info=True)
289
+
290
+
291
+ # The org's spend is watched once the summary priced the call: today against its own trailing
292
+ # weeks, said once a day on the agent's log and held up on /metrics for the alert. A check that
293
+ # breaks is logged and the call seals all the same.
294
+ async def _watched(serving: Serving, served: Served) -> None:
295
+ """Say once a day, on the agent's log, that the org spends more today than it usually does."""
296
+ pool, org = serving.connections.pool, served.scope.org
297
+ at = serving.logs.store.clock()
298
+ try:
299
+ found = await spend.unusual(pool, org, at)
300
+ serving.counters.spending(org, None if found is None else found.multiple)
301
+ if found is None or await spend.said_today(pool, org, at):
302
+ return
303
+ text = SpendUnusual(
304
+ org=org,
305
+ day=found.day,
306
+ today_usd=found.today_usd,
307
+ usual_usd=found.usual_usd,
308
+ multiple=found.multiple,
309
+ )
310
+ await serving.logs.agent(served.agent).append("spend.unusual", text.written())
311
+ except psycopg.Error:
312
+ logger.warning("call %s: the org's spend was not looked at", served.call, exc_info=True)