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,306 @@
1
+ """The runner: a world's hosted apps kept running as the gateway wants them, one pod each."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import logging
6
+ import signal
7
+ import socket
8
+ import time
9
+ from collections.abc import Mapping, Sequence
10
+ from dataclasses import dataclass, field
11
+
12
+ import httpx
13
+ import uvicorn
14
+ from starlette.applications import Starlette
15
+ from starlette.requests import Request
16
+ from starlette.responses import Response
17
+ from starlette.routing import Route
18
+
19
+ from pinecall.domain.errors import GatewayRefused, SettingsRefused, UpstreamFailed
20
+ from pinecall.domain.names import PRODUCTION, Env
21
+ from pinecall.process.settings import Settings
22
+ from pinecall.runner._kube import Cluster, Container, Engine, Launch, in_cluster
23
+ from pinecall.runner._plan import (
24
+ Failed,
25
+ Revive,
26
+ Start,
27
+ Step,
28
+ Stop,
29
+ WentLive,
30
+ crashes_within,
31
+ label_of,
32
+ planned,
33
+ )
34
+ from pinecall.wire.rest.hosting import (
35
+ AppEnvironment,
36
+ AppLogs,
37
+ RunnerHeartbeatRequest,
38
+ RunnerHeartbeatResponse,
39
+ RunnerReport,
40
+ WantedApp,
41
+ )
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ BEAT_S = 5.0
47
+
48
+
49
+ # What a failure says of the process's own output: its last lines.
50
+ LAST_LINES = 20
51
+
52
+
53
+ # What a person asking for an app's logs is sent: its process's last lines.
54
+ SENT_LINES = 300
55
+
56
+
57
+ LONGEST_WHY = 2000
58
+
59
+
60
+ START = ("./node_modules/.bin/pinecall", "start")
61
+
62
+
63
+ NO_KEY = "PINECALL_RUNNER_KEY: a runner knocks its gateway with its world's runner key"
64
+
65
+
66
+ NOT_THE_RELEASE = "release {release}'s sources are not the ones uploaded: sha256 differs"
67
+
68
+
69
+ START_FAILED = "the cluster could not start the release:\n{output}"
70
+
71
+
72
+ # A release is (org, name, release): what the gateway numbers and what a pod carries.
73
+ type Release = tuple[str, str, int]
74
+
75
+
76
+ @dataclass
77
+ class Runner:
78
+ """A world's runner: what it holds between two beats, and the steps it has in hand."""
79
+
80
+ cluster: Cluster
81
+ gateway: httpx.AsyncClient
82
+ reports: list[RunnerReport] = field(default_factory=list[RunnerReport])
83
+ logs: list[AppLogs] = field(default_factory=list[AppLogs])
84
+ # When each host's process exited and was run again, so one that keeps exiting is given up.
85
+ crashes: dict[str, list[float]] = field(default_factory=dict[str, list[float]])
86
+ # One task per app with steps under way; an app with one is left alone this beat.
87
+ working: dict[str, asyncio.Task[None]] = field(default_factory=dict[str, asyncio.Task[None]])
88
+ # The releases a pod of this world may fetch, by their digest: read off the gateway, checked.
89
+ sources: dict[str, bytes] = field(default_factory=dict[str, bytes])
90
+ digests: dict[Release, str] = field(default_factory=dict[Release, str])
91
+
92
+ async def beat(self, name: str) -> RunnerHeartbeatResponse:
93
+ """Send what happened since the last beat; the gateway answers what should be running."""
94
+ reports, self.reports = self.reports, []
95
+ logs, self.logs = self.logs, []
96
+ body = RunnerHeartbeatRequest(runner=name, reports=reports, logs=logs)
97
+ try:
98
+ answer = await self.gateway.post("/v1/runner/heartbeat", json=body.written())
99
+ except httpx.HTTPError:
100
+ self.reports, self.logs = reports + self.reports, logs + self.logs
101
+ raise
102
+ if not answer.is_success:
103
+ self.reports, self.logs = reports + self.reports, logs + self.logs
104
+ raise GatewayRefused(answer.text, answered=answer.status_code)
105
+ return RunnerHeartbeatResponse.model_validate(answer.json())
106
+
107
+ async def reconcile(self, wanted: RunnerHeartbeatResponse, now: float) -> None:
108
+ """Make the pods what the gateway wants; the long steps go on past this beat."""
109
+ # Finished first: a step that ends after the listing would be planned again on a stale one.
110
+ self._finished()
111
+ containers = await self.cluster.pods(wanted.world)
112
+ for label, steps in planned(wanted.apps, containers, self.crashes, now).items():
113
+ if label not in self.working:
114
+ self.working[label] = asyncio.create_task(self._done(wanted.world, steps, now))
115
+ await self._logs_asked_for(wanted.apps, containers)
116
+ self._swept(wanted.apps, containers)
117
+
118
+ async def close(self) -> None:
119
+ """Leave: every step under way is cut short; the pods go on without the runner."""
120
+ for task in self.working.values():
121
+ task.cancel()
122
+ await asyncio.gather(*self.working.values(), return_exceptions=True)
123
+ self.working.clear()
124
+
125
+ def _finished(self) -> None:
126
+ for label, task in list(self.working.items()):
127
+ if task.done():
128
+ del self.working[label]
129
+ if not task.cancelled() and task.exception() is not None:
130
+ logger.error("%s: its steps failed", label, exc_info=task.exception())
131
+
132
+ async def _done(self, world: Env, steps: Sequence[Step], now: float) -> None:
133
+ for step in steps:
134
+ match step:
135
+ case Start(app):
136
+ await self._started(world, app, app.host, app.release)
137
+ case Revive(app, container):
138
+ await self._revived(world, app, container, now)
139
+ case Stop(container):
140
+ await self.cluster.stop(container.name)
141
+ logger.info("stopped %s", container.name)
142
+ case WentLive(app):
143
+ self._report(app, "live", "")
144
+ case Failed(app, why, container):
145
+ output = await self.cluster.logs(container.name, LAST_LINES)
146
+ self._report(app, "failed", why.format(output=_tail(output)))
147
+
148
+ async def _started(self, world: Env, app: WantedApp, host: str, release: int) -> None:
149
+ try:
150
+ digest = await self._fetched(app, release)
151
+ environment = await self._environment(app)
152
+ except UpstreamFailed as failure:
153
+ self._report(app, "failed", str(failure))
154
+ return
155
+ launch = Launch(
156
+ world=world,
157
+ app=label_of(app),
158
+ host=host,
159
+ release=release,
160
+ sha256=digest,
161
+ command=[*START, "--prod"] if world == PRODUCTION else list(START),
162
+ )
163
+ try:
164
+ await self.cluster.start(launch, environment)
165
+ except UpstreamFailed as failure:
166
+ self._report(app, "failed", START_FAILED.format(output=str(failure)))
167
+ return
168
+ logger.info("started %s release %d as %s", label_of(app), release, host)
169
+
170
+ # Its last lines are kept as the app's logs before the pod goes, so the exit is read.
171
+ async def _revived(self, world: Env, app: WantedApp, container: Container, now: float) -> None:
172
+ output = await self.cluster.logs(container.name, SENT_LINES)
173
+ self.logs.append(AppLogs(org=app.org, name=app.name, host=container.name, lines=output))
174
+ logger.warning(
175
+ "%s: %s exited, run again:\n%s", label_of(app), container.name, _tail(output)
176
+ )
177
+ self.crashes[container.name] = [
178
+ *crashes_within(self.crashes.get(container.name, []), now),
179
+ now,
180
+ ]
181
+ await self.cluster.stop(container.name, grace_s=0)
182
+ await self._started(world, app, container.name, container.release)
183
+
184
+ # The wanted release is checked against the digest the gateway kept when it was uploaded; an
185
+ # older one, revived, is the one the gateway still holds under its number.
186
+ async def _fetched(self, app: WantedApp, release: int) -> str:
187
+ known = self.digests.get((app.org, app.name, release))
188
+ if known is not None and known in self.sources:
189
+ return known
190
+ answer = await self.gateway.get(
191
+ f"/v1/runner/apps/{app.org}/{app.name}/releases/{release}/source"
192
+ )
193
+ if not answer.is_success:
194
+ raise UpstreamFailed(f"the release's sources: {answer.status_code} {answer.text}")
195
+ digest = hashlib.sha256(answer.content).hexdigest()
196
+ if release == app.release and digest != app.sha256:
197
+ raise UpstreamFailed(NOT_THE_RELEASE.format(release=release))
198
+ self.sources[digest] = answer.content
199
+ self.digests[(app.org, app.name, release)] = digest
200
+ return digest
201
+
202
+ async def _environment(self, app: WantedApp) -> Mapping[str, str]:
203
+ answer = await self.gateway.get(f"/v1/runner/apps/{app.org}/{app.name}/environment")
204
+ if not answer.is_success:
205
+ raise UpstreamFailed(f"the app's environment: {answer.status_code} {answer.text}")
206
+ return AppEnvironment.model_validate(answer.json()).environment
207
+
208
+ # The host asked for, else the one still serving while it installs.
209
+ async def _logs_asked_for(
210
+ self, apps: Sequence[WantedApp], containers: Sequence[Container]
211
+ ) -> None:
212
+ for app in apps:
213
+ ours = [each.name for each in containers if each.app == label_of(app)]
214
+ host = app.host if app.host in ours else next(iter(ours), None)
215
+ if not app.logs_wanted or host is None:
216
+ continue
217
+ output = await self.cluster.logs(host, SENT_LINES)
218
+ self.logs.append(AppLogs(org=app.org, name=app.name, host=host, lines=output))
219
+
220
+ # A release no pod runs and nobody wants is no longer anybody's to fetch.
221
+ def _swept(self, apps: Sequence[WantedApp], containers: Sequence[Container]) -> None:
222
+ in_use: set[Release] = {(app.org, app.name, app.release) for app in apps}
223
+ for each in containers:
224
+ org, _, name = each.app.partition("/")
225
+ in_use.add((org, name, each.release))
226
+ for release in [each for each in self.digests if each not in in_use]:
227
+ del self.digests[release]
228
+ kept = set(self.digests.values())
229
+ for digest in [each for each in self.sources if each not in kept]:
230
+ del self.sources[digest]
231
+
232
+ def _report(self, app: WantedApp, state: str, why: str) -> None:
233
+ if state == "failed":
234
+ logger.warning("%s release %d failed: %s", label_of(app), app.release, why)
235
+ self.reports.append(
236
+ RunnerReport.model_validate(
237
+ {
238
+ "org": app.org,
239
+ "name": app.name,
240
+ "host": app.host,
241
+ "state": state,
242
+ "why": why[-LONGEST_WHY:],
243
+ }
244
+ )
245
+ )
246
+
247
+
248
+ def sources_app(runner: Runner) -> Starlette:
249
+ """The one door a pod of this world knocks: its release's sources, by their digest."""
250
+
251
+ async def served(request: Request) -> Response:
252
+ found = runner.sources.get(str(request.path_params["sha256"]))
253
+ if found is None:
254
+ return Response(status_code=404)
255
+ return Response(found, media_type="application/gzip")
256
+
257
+ return Starlette(routes=[Route("/sources/{sha256}", served)])
258
+
259
+
260
+ async def run(settings: Settings) -> int:
261
+ """Keep the world's hosted apps running until told to stop; the apps go on without it."""
262
+ if settings.runner_key is None:
263
+ raise SettingsRefused(NO_KEY)
264
+ stop = asyncio.Event()
265
+ loop = asyncio.get_running_loop()
266
+ for each in (signal.SIGTERM, signal.SIGINT):
267
+ loop.add_signal_handler(each, stop.set)
268
+ engine = Engine(
269
+ image=settings.runner_image,
270
+ runtime_class=settings.runner_runtime_class,
271
+ namespace=settings.runner_namespace,
272
+ sources_url=settings.runner_sources_url,
273
+ )
274
+ headers = {"Authorization": f"Bearer {settings.runner_key}"}
275
+ async with (
276
+ httpx.AsyncClient(base_url=settings.gateway_url, headers=headers, timeout=30.0) as gateway,
277
+ in_cluster(engine).http as kube,
278
+ ):
279
+ runner = Runner(cluster=Cluster(http=kube, engine=engine), gateway=gateway)
280
+ server = uvicorn.Server(
281
+ uvicorn.Config(
282
+ sources_app(runner),
283
+ host=settings.runner_listen,
284
+ port=settings.runner_port,
285
+ log_level="warning",
286
+ )
287
+ )
288
+ serving = asyncio.create_task(server.serve())
289
+ name = socket.gethostname().split(".")[0]
290
+ while not stop.is_set():
291
+ try:
292
+ await runner.reconcile(await runner.beat(name), time.time())
293
+ except (UpstreamFailed, httpx.HTTPError):
294
+ logger.warning("runner: this beat did nothing", exc_info=True)
295
+ try:
296
+ await asyncio.wait_for(stop.wait(), BEAT_S)
297
+ except TimeoutError:
298
+ continue
299
+ await runner.close()
300
+ server.should_exit = True
301
+ await serving
302
+ return 0
303
+
304
+
305
+ def _tail(output: str) -> str:
306
+ return "\n".join(output.strip().splitlines()[-LAST_LINES:])
File without changes
@@ -0,0 +1,141 @@
1
+ """The livekit Agent the session runs: the prompt, the ears' filter, and the hooks around a turn."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ from collections.abc import AsyncIterable, AsyncIterator, Callable
6
+ from contextlib import AbstractAsyncContextManager
7
+ from typing import override
8
+
9
+ from livekit import rtc
10
+ from livekit.agents import (
11
+ llm,
12
+ stt,
13
+ )
14
+ from livekit.agents.types import TimedString
15
+ from livekit.agents.voice import Agent, AgentSession, ModelSettings
16
+
17
+ from pinecall.session import _prompt, tools
18
+ from pinecall.session._livekit import transcript_of
19
+ from pinecall.session._prompt import Blocks
20
+ from pinecall.wire.events import ErrorEvent
21
+
22
+ type Heard = AsyncIterator[stt.SpeechEvent]
23
+ type Declared = list[llm.Tool | llm.Toolset]
24
+ type Thought = AsyncIterator[llm.ChatChunk | str]
25
+ type Words = AsyncIterator[str | TimedString]
26
+ # What plays while the caller waits: the hold melody after its grace, or nothing.
27
+ type Waiting = Callable[[], AbstractAsyncContextManager[None]]
28
+
29
+
30
+ LLM_TIMEOUT = "llm_timeout"
31
+
32
+
33
+ MODEL_SILENT = "the model said nothing within {seconds:g}s, so the caller was asked to say it again"
34
+
35
+
36
+ # How long a turn waits for the model's first word when the agent sets no deadline: past it the
37
+ # caller hears MODEL_LATE instead of a silence a model stuck for 20 s would leave them in.
38
+ LLM_TIMEOUT_S = 12.0
39
+
40
+
41
+ # What the caller hears when the model is past the deadline, by the agent's language; English
42
+ # where the agent's language has no sentence here.
43
+ MODEL_LATE = {
44
+ "es": "Disculpe, me demoré. ¿Me lo repite, por favor?",
45
+ "en": "Sorry, that took me too long. Could you say that again?",
46
+ "pt": "Desculpe, demorei. Pode repetir, por favor?",
47
+ }
48
+
49
+
50
+ class CallAgent(Agent):
51
+ """livekit's Agent reading the call's prompt blocks, lookups and tools."""
52
+
53
+ def __init__(
54
+ self,
55
+ live: AgentSession[None],
56
+ blocks: Blocks,
57
+ lookups: tools.Lookups,
58
+ declared: Declared,
59
+ waiting: Waiting,
60
+ ) -> None:
61
+ """The agent with the static blocks as its instructions and every tool of the call."""
62
+ super().__init__(instructions=blocks.instructions, tools=declared) # pyright: ignore[reportUnknownMemberType]
63
+ self.live = live
64
+ self.call = lookups.call
65
+ self.blocks = blocks
66
+ self.lookups = lookups
67
+ self.waiting = waiting
68
+
69
+ # Runs before the request and livekit times it, so the lookups here are the ones already
70
+ # running (started on an interim) or quick ones. No speech handle exists yet.
71
+ @override
72
+ async def on_user_turn_completed(
73
+ self, turn_ctx: llm.ChatContext, new_message: llm.ChatMessage
74
+ ) -> None:
75
+ """Collect this turn's lookups and write the ones that did not run."""
76
+ for skipped in await self.lookups.turn_ended(new_message.text_content or ""):
77
+ self.call.writing.write("error", skipped)
78
+
79
+ # Lookups and dynamic blocks go into this request only, so the cached prefix (instructions
80
+ # and history) stays the same bytes from turn to turn. The wait for the first token plays the
81
+ # melody as a tool's does, and the agent's deadline cuts it, livekit's retries with it.
82
+ @override
83
+ async def llm_node(
84
+ self, chat_ctx: llm.ChatContext, tools: list[llm.Tool], model_settings: ModelSettings
85
+ ) -> Thought:
86
+ """The model run on the history, this turn's lookups, and the dynamic blocks."""
87
+ params = _prompt.request(chat_ctx, self.blocks, self.lookups.items)
88
+ if self.call.context.run is not None:
89
+ self.call.requests.append(_prompt.as_asked(params, tools))
90
+ deadline = self.call.config.llm_timeout_s or LLM_TIMEOUT_S
91
+ thought = Agent.default.llm_node(self, params, tools, model_settings)
92
+ waited = asyncio.timeout(deadline)
93
+ async with contextlib.aclosing(thought):
94
+ try:
95
+ async with self.waiting(), waited:
96
+ first = await anext(thought, None)
97
+ except TimeoutError:
98
+ # A vendor's own TimeoutError is livekit's to handle, as it was before.
99
+ if not waited.expired():
100
+ raise
101
+ why = MODEL_SILENT.format(seconds=deadline)
102
+ self.call.writing.write(
103
+ "error", ErrorEvent(code=LLM_TIMEOUT, message=why, recoverable=True)
104
+ )
105
+ yield _late_in(self.call.config.language)
106
+ return
107
+ if isinstance(first, llm.ChatChunk | str):
108
+ yield first
109
+ async for chunk in thought:
110
+ if isinstance(chunk, llm.ChatChunk | str):
111
+ yield chunk
112
+
113
+ # Runs on what was played, so an interrupted reply's transcript stops where its audio did; a
114
+ # written call plays everything. Aligned speech yields one timed string per word.
115
+ @override
116
+ async def transcription_node(
117
+ self, text: AsyncIterable[str | TimedString], model_settings: ModelSettings
118
+ ) -> Words:
119
+ """Each played piece of the reply, written as it plays, then passed on."""
120
+ async for delta in Agent.default.transcription_node(self, text, model_settings):
121
+ if str(delta):
122
+ self.call.writing.write("agent.transcript", transcript_of(self.live, delta))
123
+ yield delta
124
+
125
+ # Before the turn detector: the last point to drop a backchannel at no cost of a model.
126
+ @override
127
+ async def stt_node(
128
+ self, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
129
+ ) -> Heard:
130
+ """What the ears heard, but agreement said over the agent's voice."""
131
+ async for event in Agent.default.stt_node(self, audio, model_settings):
132
+ if not self._agreement(event):
133
+ yield event
134
+
135
+ def _agreement(self, event: stt.SpeechEvent) -> bool:
136
+ text = event.alternatives[0].text if event.alternatives else ""
137
+ return self.call.agent_speaking and self.call.turn_policy.is_a_backchannel(text)
138
+
139
+
140
+ def _late_in(language: str | None) -> str:
141
+ return MODEL_LATE.get((language or "en")[:2].lower(), MODEL_LATE["en"])
@@ -0,0 +1,101 @@
1
+ """What the ears are told: the words that only agree, the pause before a barge-in, the keyterms."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+ from pinecall.domain.agent import AgentConfig, Turn
7
+ from pinecall.domain.names import JsonObject
8
+
9
+ # livekit counts no words by default; one word is as often a cough or an echo.
10
+ MIN_WORDS = 2
11
+
12
+
13
+ # Silence before an interruption is judged false: livekit's 2 s sound like a dropped call.
14
+ FALSE_INTERRUPTION_TIMEOUT_S = 1.0
15
+
16
+
17
+ # A turn made of these alone is somebody agreeing, and does not take the floor. livekit's own
18
+ # backchannel detector is hosted only. Per language: "vale" takes the floor in English.
19
+ BACKCHANNELS: dict[str, frozenset[str]] = {
20
+ "es": frozenset(
21
+ {
22
+ "aha", "ajá", "ah", "ajam", "bien", "bueno", "claro", "dale", "eh", "em", "exacto",
23
+ "hm", "hmm", "mm", "mmm", "ok", "okay", "perfecto", "sí", "si", "vale", "ya", "uh",
24
+ "uhu", "uhum",
25
+ }
26
+ ),
27
+ "en": frozenset(
28
+ {
29
+ "aha", "ah", "hm", "hmm", "mm", "mmm", "mhm", "ok", "okay", "yeah", "yes", "yep",
30
+ "right", "sure", "uh", "uh-huh", "gotcha", "exactly",
31
+ }
32
+ ),
33
+ "pt": frozenset({"aham", "ah", "sim", "tá", "certo", "claro", "ok", "beleza", "hum", "uhum"}),
34
+ "ca": frozenset({"sí", "vale", "d'acord", "clar", "entesos", "ok", "mm", "aha", "eh"}),
35
+ } # fmt: skip
36
+
37
+
38
+ _A_WORD = re.compile(r"[^\W_]+(?:['\u2019-][^\W_]+)*")
39
+
40
+
41
+ # Keyterms are names, not sentences: longer text dilutes them. The cap is ours, since the ears'
42
+ # vendors either document none or count the terms against the model's budget.
43
+ LONGEST_TERM = 40
44
+
45
+
46
+ MOST_WORDS = 4
47
+
48
+
49
+ MOST_TERMS = 50
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class TurnPolicy:
54
+ """What the caller must say to take the floor, in the agent's language."""
55
+
56
+ backchannels: frozenset[str]
57
+ min_words: int = MIN_WORDS
58
+ false_interruption_s: float = FALSE_INTERRUPTION_TIMEOUT_S
59
+ # How long the caller speaks over the agent before it stops; None leaves livekit's own.
60
+ min_speech_s: float | None = None
61
+
62
+ def is_a_backchannel(self, text: str) -> bool:
63
+ """Whether every word said is somebody agreeing."""
64
+ words: list[str] = [str(found.group(0)).lower() for found in _A_WORD.finditer(text)]
65
+ return bool(words) and all(word in self.backchannels for word in words)
66
+
67
+
68
+ def policy_for(language: str | None, turn: Turn | None = None) -> TurnPolicy:
69
+ """The policy of the agent's language (`es-ES` is `es`) and its turn knobs; else every list."""
70
+ tag = (language or "").split("-", 1)[0].lower()
71
+ words = BACKCHANNELS.get(tag) or frozenset[str]().union(*BACKCHANNELS.values())
72
+ declared = turn or Turn()
73
+ return TurnPolicy(
74
+ backchannels=words,
75
+ min_words=MIN_WORDS
76
+ if declared.min_interruption_words is None
77
+ else declared.min_interruption_words,
78
+ min_speech_s=None
79
+ if declared.min_interruption_ms is None
80
+ else declared.min_interruption_ms / 1000,
81
+ )
82
+
83
+
84
+ # The declared words first, so the cap drops the names found in the state before them. One
85
+ # level deep catches `patient = {name, phone}`; a term needs a letter, which leaves out numbers.
86
+ def keyterms(config: AgentConfig, state: JsonObject) -> list[str]:
87
+ """The words the ears are told to expect: the agent's own, then names the state holds."""
88
+ found: list[object] = []
89
+ for value in state.values():
90
+ found += list(value.values()) if isinstance(value, dict) else [value]
91
+ names = [name for name in (_a_name(item) for item in found) if name]
92
+ return list(dict.fromkeys(term for term in (*config.hears, *names) if term))[:MOST_TERMS]
93
+
94
+
95
+ def _a_name(value: object) -> str:
96
+ if not isinstance(value, str):
97
+ return ""
98
+ name = value.strip()
99
+ if not name or len(name) > LONGEST_TERM or len(name.split()) > MOST_WORDS:
100
+ return ""
101
+ return name if any(letter.isalpha() for letter in name) else ""