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,195 @@
1
+ """What livekit hands the session, read as ours: usage, transcripts, blocks, names, switches."""
2
+
3
+ import contextlib
4
+ import time
5
+ from collections.abc import Callable, Iterable, Mapping
6
+
7
+ from livekit.agents import (
8
+ NOT_GIVEN,
9
+ APIStatusError,
10
+ NotGivenOr,
11
+ StopResponse,
12
+ llm,
13
+ metrics,
14
+ stt,
15
+ tts,
16
+ utils,
17
+ )
18
+ from livekit.agents.metrics.base import AvatarMetrics
19
+ from livekit.agents.types import TimedString
20
+ from livekit.agents.voice import AgentSession
21
+ from pydantic import BaseModel
22
+
23
+ from pinecall.providers.build import Modality
24
+ from pinecall.session.call import Writing
25
+ from pinecall.wire import events as wire
26
+ from pinecall.wire import metrics as measured
27
+ from pinecall.wire.frames import WireModel
28
+
29
+ type Adapter = llm.FallbackAdapter | stt.FallbackAdapter | tts.FallbackAdapter
30
+
31
+
32
+ # Errors of the request itself; 408, 429 and 5xx are transient and livekit retries them.
33
+ FOREVER = frozenset({400, 401, 402, 403, 404, 422})
34
+
35
+
36
+ # The WebSocket close for a policy violation: a vendor that refuses a voice or a key sends it,
37
+ # and livekit's 4xx check misses it.
38
+ POLICY_VIOLATION = 1008
39
+
40
+
41
+ # Each block is read by the wire's fields, under livekit's own names.
42
+ BLOCKS: tuple[tuple[type[metrics.AgentMetrics], str, type[WireModel]], ...] = (
43
+ (metrics.LLMMetrics, "metrics.llm", measured.LLMMetrics),
44
+ (metrics.STTMetrics, "metrics.stt", measured.STTMetrics),
45
+ (metrics.TTSMetrics, "metrics.tts", measured.TTSMetrics),
46
+ (metrics.VADMetrics, "metrics.vad", measured.VADMetrics),
47
+ (metrics.EOUMetrics, "metrics.eou", measured.EOUMetrics),
48
+ (metrics.InterruptionMetrics, "metrics.interruption", measured.InterruptionMetrics),
49
+ (metrics.RealtimeModelMetrics, "metrics.realtime", measured.RealtimeModelMetrics),
50
+ (metrics.EOTInferenceMetrics, "metrics.eot", measured.EOTInferenceMetrics),
51
+ (AvatarMetrics, "metrics.avatar", measured.AvatarMetrics),
52
+ )
53
+
54
+
55
+ def given_or_unset[T](value: T | None) -> NotGivenOr[T]:
56
+ """The value, or livekit's NOT_GIVEN when it is None."""
57
+ return NOT_GIVEN if value is None else value
58
+
59
+
60
+ def transcript_of(live: AgentSession[None], delta: str | TimedString) -> wire.AgentTranscript:
61
+ """What the agent said, as the wire's transcript entry, with the word timings livekit gave."""
62
+ speech = live.current_speech.id if live.current_speech else ""
63
+ timed: dict[str, float] = {}
64
+ if isinstance(delta, TimedString):
65
+ if utils.is_given(delta.start_time):
66
+ timed["start"] = delta.start_time
67
+ if utils.is_given(delta.end_time):
68
+ timed["end"] = delta.end_time
69
+ return wire.AgentTranscript(speech_id=speech, text=str(delta), final=False, **timed)
70
+
71
+
72
+ # interrupt() raises when nothing is playing or the session has stopped.
73
+ async def silence(live: AgentSession[None]) -> None:
74
+ """Stop the agent's speech now, and wait for livekit to say it stopped."""
75
+ with contextlib.suppress(RuntimeError):
76
+ await live.interrupt(force=True)
77
+ live.output.set_audio_enabled(False)
78
+ live.input.set_audio_enabled(False)
79
+
80
+
81
+ # The ears first, then the voice, so the agent never speaks before it can hear.
82
+ def hearing_again(live: AgentSession[None]) -> None:
83
+ """Let the caller's audio reach the ears again after a pause."""
84
+ live.input.set_audio_enabled(True)
85
+ live.output.set_audio_enabled(True)
86
+
87
+
88
+ async def nothing_after(_: llm.Toolset.ToolCompletedEvent) -> None:
89
+ """Speak nothing after a tool finished: the app speaks when it wants to."""
90
+ raise StopResponse
91
+
92
+
93
+ def forever(error: APIStatusError) -> bool:
94
+ """Whether a vendor's HTTP error will fail the same way every time it is retried."""
95
+ return error.status_code == POLICY_VIOLATION or error.status_code in FOREVER
96
+
97
+
98
+ def usage_of(used: metrics.ModelUsage) -> measured.ModelUsage:
99
+ """The usage of one model livekit measured, as the wire writes it."""
100
+ match used:
101
+ case metrics.LLMModelUsage():
102
+ return _read_as(measured.LLMModelUsage, used)
103
+ case metrics.TTSModelUsage():
104
+ return _read_as(measured.TTSModelUsage, used)
105
+ case metrics.STTModelUsage():
106
+ return _read_as(measured.STTModelUsage, used)
107
+ case metrics.InterruptionModelUsage():
108
+ return _read_as(measured.InterruptionModelUsage, used)
109
+ case _:
110
+ return _read_as(measured.EOTModelUsage, used)
111
+
112
+
113
+ def block_of(block: metrics.AgentMetrics) -> tuple[str, WireModel] | None:
114
+ """The metrics livekit measured for one step, as the entry type and model the log keeps."""
115
+ for livekits, kind, ours in BLOCKS:
116
+ if isinstance(block, livekits):
117
+ return kind, _read_as(ours, block)
118
+ return None
119
+
120
+
121
+ # livekit emits the end-of-utterance block on the session only, so it is built again from the
122
+ # user turn's report, as livekit builds it.
123
+ def end_of_utterance(report: Mapping[str, object], speech: str) -> measured.EOUMetrics | None:
124
+ """The end-of-turn metrics livekit reports, as the wire's; None when a field is missing."""
125
+ delays = ("end_of_turn_delay", "transcription_delay", "on_user_turn_completed_delay")
126
+ if not any(key in report for key in delays):
127
+ return None
128
+ # Said, or written() drops the default and the SDKs refuse the entry.
129
+ return measured.EOUMetrics.model_validate(
130
+ {
131
+ "type": "eou_metrics",
132
+ "timestamp": time.time(),
133
+ "end_of_utterance_delay": report.get("end_of_turn_delay", 0.0),
134
+ "transcription_delay": report.get("transcription_delay", 0.0),
135
+ "on_user_turn_completed_delay": report.get("on_user_turn_completed_delay", 0.0),
136
+ "speech_id": speech,
137
+ }
138
+ )
139
+
140
+
141
+ # livekit's adapter says when a vendor goes down or comes back (`<stage>_availability_changed`,
142
+ # the vendor under the stage's own name), and names the next to serve. Its emitter is untyped:
143
+ # it is read as an object.
144
+ def switches_written(writing: Writing, built: Iterable[object]) -> None:
145
+ """Write vendor.switched each time a stage's fallback adapter loses or regains a vendor."""
146
+ for component in built:
147
+ match component:
148
+ case llm.FallbackAdapter():
149
+ stage: Modality = "llm"
150
+ case stt.FallbackAdapter():
151
+ stage = "stt"
152
+ case tts.FallbackAdapter():
153
+ stage = "tts"
154
+ case _:
155
+ continue
156
+ listen: object = getattr(component, "on", None)
157
+ if callable(listen):
158
+ listen(f"{stage}_availability_changed", _switch(writing, stage, component))
159
+
160
+
161
+ def switch_of(stage: Modality, event: object, adapter: Adapter) -> wire.VendorSwitched | None:
162
+ """The entry for one vendor of a stage lost or back, and the one serving from now."""
163
+ changed: object = getattr(event, stage, None)
164
+ available: object = getattr(event, "available", None)
165
+ if not isinstance(changed, (llm.LLM, stt.STT, tts.TTS)) or not isinstance(available, bool):
166
+ return None
167
+ return wire.VendorSwitched(
168
+ stage=stage,
169
+ vendor=changed.provider,
170
+ model=changed.model,
171
+ available=available,
172
+ serving=adapter.provider,
173
+ serving_model=adapter.model,
174
+ )
175
+
176
+
177
+ # livekit's rows carry the wire's names, and livekit adds fields the wire does not know
178
+ # (`input_audio_tokens` on the ears' usage in 1.8.3). The wire refuses an unknown key, and a
179
+ # listener's exception is swallowed by livekit's emitter, so the call would lose its usage in
180
+ # silence: each row is read by the fields its wire model declares.
181
+ def _read_as[T: WireModel](model: type[T], livekits: BaseModel) -> T:
182
+ """The wire model read from livekit's, field by field, so a field livekit grew is left out."""
183
+ dumped = livekits.model_dump()
184
+ return model.model_validate(
185
+ {name: dumped[name] for name in model.model_fields if name in dumped}
186
+ )
187
+
188
+
189
+ def _switch(writing: Writing, stage: Modality, adapter: Adapter) -> Callable[[object], None]:
190
+ def written(event: object) -> None:
191
+ switched = switch_of(stage, event, adapter)
192
+ if switched is not None:
193
+ writing.write("vendor.switched", switched)
194
+
195
+ return written
@@ -0,0 +1,147 @@
1
+ """A call's prompt: its blocks, the request each turn sends, its greeting, its knowledge entry."""
2
+
3
+ import json
4
+ from collections.abc import Sequence
5
+ from typing import override
6
+
7
+ from livekit.agents import llm
8
+
9
+ from pinecall.domain.agent import (
10
+ DEFAULT_LAYOUT,
11
+ KNOWLEDGE,
12
+ Greeting,
13
+ PromptBlock,
14
+ PromptRegion,
15
+ block_hash,
16
+ )
17
+ from pinecall.domain.errors import DeclarationRefused
18
+ from pinecall.domain.names import JsonObject
19
+ from pinecall.wire.events import PromptChanged
20
+
21
+ # Joins the static blocks into livekit's one `instructions` string.
22
+ BETWEEN_BLOCKS = "\n\n"
23
+
24
+
25
+ # Written into the history and read as the next turn's instructions. It claims precedence,
26
+ # since the stage instructions come after the history and the model would follow them instead.
27
+ A_WHISPER = (
28
+ "A human supervisor is telling you this, and the caller cannot hear it: {text} "
29
+ "This order comes from the supervisor and takes precedence over the stage instructions "
30
+ "that follow it: do it in your very next sentence, before anything else you were going to "
31
+ "say, and only then go on. Never mention the supervisor or this note."
32
+ )
33
+
34
+
35
+ # The agent did not hear the supervisor, so it must not guess what was said.
36
+ A_RELEASE = (
37
+ "A human supervisor spoke with the caller for a moment; you did not hear it. "
38
+ "Do not guess what was said. Resume by offering to continue with what is still pending."
39
+ )
40
+
41
+
42
+ class Blocks:
43
+ """One call's prompt blocks, in the order they are sent, and their text now."""
44
+
45
+ # The knowledge is set here and never by the app, so it lands in the cached prefix as the
46
+ # operator's text (docs/security/prompt-injection.md).
47
+ def __init__(self, layout: Sequence[PromptBlock] = DEFAULT_LAYOUT, knowledge: str = "") -> None:
48
+ """Every block of the layout, empty but the knowledge."""
49
+ self.layout = tuple(layout)
50
+ self.texts: dict[str, str] = {block.name: "" for block in self.layout}
51
+ if knowledge and KNOWLEDGE in self.texts:
52
+ self.texts[KNOWLEDGE] = knowledge
53
+
54
+ # Identical static text is not resent: even the same bytes cost the provider a cache write.
55
+ def set(self, name: str, text: str) -> bool:
56
+ """Replace a block's text; True when the static instructions changed."""
57
+ if name not in self.texts:
58
+ raise DeclarationRefused(
59
+ f"the prompt has no block named {name!r}: "
60
+ f"this agent's blocks are {', '.join(self.texts)}"
61
+ )
62
+ before = self.instructions
63
+ self.texts[name] = text
64
+ return self.instructions != before
65
+
66
+ @property
67
+ def instructions(self) -> str:
68
+ """The static blocks written so far, joined, in layout order."""
69
+ return BETWEEN_BLOCKS.join(self.of("static"))
70
+
71
+ def of(self, region: PromptRegion) -> tuple[str, ...]:
72
+ """A region's blocks that have text, in layout order."""
73
+ return tuple(
74
+ self.texts[block.name]
75
+ for block in self.layout
76
+ if block.region == region and self.texts[block.name]
77
+ )
78
+
79
+
80
+ class Request(llm.ChatContext):
81
+ """One turn's request, whose static blocks reach the provider one system block each."""
82
+
83
+ def __init__(self, items: list[llm.ChatItem], static: tuple[str, ...]) -> None:
84
+ """The request's items, and the static blocks it keeps apart."""
85
+ super().__init__(items)
86
+ self.static = static
87
+
88
+ # livekit's formatters keep only the first system item as the preamble
89
+ # (_provider_format/utils.py); a format that carries system messages apart (the one that
90
+ # caches up to its last block among them) gets one per static block instead.
91
+ @override
92
+ def to_provider_format( # pyright: ignore[reportIncompatibleMethodOverride]
93
+ self, format: str, **kwargs: object
94
+ ) -> tuple[list[dict[str, object]], object]:
95
+ """The request as the provider's formatter builds it, the static blocks kept apart."""
96
+ formatted = super().to_provider_format(format, **kwargs) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
97
+ if self.static and hasattr(formatted[1], "system_messages"):
98
+ formatted[1].system_messages = list(self.static)
99
+ return formatted # pyright: ignore[reportUnknownVariableType]
100
+
101
+
102
+ # Always a new context: livekit reuses its own across a turn's tool steps, so additions would
103
+ # pile up. The order is the one prompt-injection.md states: static blocks, history, the view.
104
+ def request(
105
+ history: llm.ChatContext, blocks: Blocks, lookups: Sequence[llm.ChatItem] = ()
106
+ ) -> Request:
107
+ """One request: the history with this turn's lookups spliced in, then the dynamic blocks."""
108
+ items = _ahead_of_the_caller(history.items, lookups)
109
+ items += [llm.ChatMessage(role="system", content=[text]) for text in blocks.of("dynamic")]
110
+ return Request(items, blocks.of("static"))
111
+
112
+
113
+ # The log keeps the prompt's hashes alone; a run keeps what was sent, to reproduce a failure.
114
+ def as_asked(sent: Request, tools: Sequence[llm.Tool]) -> JsonObject:
115
+ """One request as a failing golden reproduces it: the static blocks, the tools, the items."""
116
+ schemas = llm.ToolContext(list(tools)).parse_function_tools("openai")
117
+ items: object = sent.to_dict().get("items", [])
118
+ return json.loads(
119
+ json.dumps({"system": list(sent.static), "tools": schemas, "messages": items})
120
+ )
121
+
122
+
123
+ # An eval run starts in the middle of a conversation, so it never greets.
124
+ def greeting_for(greeting: Greeting | None, run: str | None) -> Greeting | None:
125
+ """The declared greeting, or none when an eval run opened the call."""
126
+ return None if run is not None else greeting
127
+
128
+
129
+ # The app never sends prompt.set for this block, so without this entry the log would not show
130
+ # that the file reached the model.
131
+ def knowledge_changed(blocks: Blocks) -> PromptChanged | None:
132
+ """The entry saying the knowledge file reached the prompt, when there is one."""
133
+ text = blocks.texts.get(KNOWLEDGE, "")
134
+ return PromptChanged(name=KNOWLEDGE, hash=block_hash(text), chars=len(text)) if text else None
135
+
136
+
137
+ # Lookups go before the caller's newest message (livekit appends it after
138
+ # on_user_turn_completed); placed after it, models skip the next tool. They are spliced into
139
+ # the request only, never into the history. No trailing user message (a tool step, agent.reply):
140
+ # they go at the end.
141
+ def _ahead_of_the_caller(
142
+ items: Sequence[llm.ChatItem], lookups: Sequence[llm.ChatItem]
143
+ ) -> list[llm.ChatItem]:
144
+ last = items[-1] if items else None
145
+ if not lookups or not (isinstance(last, llm.ChatMessage) and last.role == "user"):
146
+ return [*items, *lookups]
147
+ return [*items[:-1], *lookups, last]
@@ -0,0 +1,237 @@
1
+ """A call's audio recorded by its own session: the caller left, every other voice right."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from collections.abc import Callable, Iterable, Iterator
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import av
11
+ import numpy as np
12
+ from av.audio.stream import AudioStream
13
+ from livekit import rtc
14
+ from livekit.agents import AgentSession
15
+ from livekit.agents.voice.recorder_io import RecorderIO
16
+
17
+ from pinecall.session.hold import VOLUME, HoldMusic, drained
18
+ from pinecall.session.room import CallRoom
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # livekit's recorder wraps the session's audio in and out and places both on one timeline: the
23
+ # caller on the left, the agent on the right, encoded in a thread of this process. Egress did the
24
+ # same work in a process of its own per call (~0.12 vCPU, ~80 MB), and a track egress per voice
25
+ # costs more still (~0.1 vCPU and ~170 MB a track, the load lab 2026-10-02): the voices are already
26
+ # decoded here, so the recording is made here. A supervisor who takes over and the far end of a
27
+ # transfer are the room's, not the session's: each is heard from its own track while it speaks and
28
+ # spooled beside the file, and with the hold melody (from its clip, where it sounded) they are laid
29
+ # in on the right when the file is closed: the file decoded and encoded once more, a stretch at a
30
+ # time. That costs about a fiftieth of the call's length on one core (ten minutes in ~13 s on a
31
+ # laptop's), within the worker's sealing budget for the calls a phone line takes; a call with no
32
+ # other voice and no hold is never touched again. The session closes the recorder before the seal,
33
+ # so the file is whole when the summary names it.
34
+
35
+ # The agent's voice comes at 24 kHz and a phone at 8: 48 keeps nothing more (~10 % of a worker's
36
+ # cores less, the load lab).
37
+ RATE = 24000
38
+
39
+ # Opus takes whole frames of 20 ms (480 samples at RATE).
40
+ OPUS_FRAME = 480
41
+
42
+ # A sum of voices can pass full scale; it is clipped, not wrapped.
43
+ FULL_SCALE = 1.0
44
+
45
+ # Who in the room speaks into the recording besides the session's own two.
46
+ OTHERS = frozenset({"supervisor", "sip"})
47
+
48
+ LOST = "a voice in the room of %s is not in its recording"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Sound:
53
+ """One sound laid on the right: the sample it starts at, how many, and any stretch of it."""
54
+
55
+ start: int
56
+ length: int
57
+ stretch: Callable[[int, int], np.ndarray]
58
+
59
+
60
+ class Recorder:
61
+ """The call's file: livekit's recorder for its two sides, the room's other voices beside."""
62
+
63
+ def __init__(self, recorder: RecorderIO, audio: Path, where: CallRoom | None) -> None:
64
+ """A recording that started now and heard nobody else yet."""
65
+ self.recorder = recorder
66
+ self.audio = audio
67
+ self.where = where
68
+ self.others: list[tuple[float, Path]] = []
69
+ self.tasks: set[asyncio.Task[None]] = set()
70
+
71
+ def listen(self) -> None:
72
+ """Hear every other voice the room has, and each one that joins from now on."""
73
+ if self.where is None:
74
+ return
75
+ room = self.where.room
76
+ room.on("track_subscribed", self._subscribed) # pyright: ignore[reportUnknownMemberType]
77
+ for seat in room.remote_participants.values():
78
+ for publication in seat.track_publications.values():
79
+ if publication.track is not None:
80
+ self._subscribed(publication.track, publication, seat)
81
+
82
+ async def close(self, melody: HoldMusic | None) -> None:
83
+ """Finish the file; lay in the other voices and the melody when there were any."""
84
+ if self.where is not None:
85
+ self.where.room.off("track_subscribed", self._subscribed) # pyright: ignore[reportUnknownMemberType]
86
+ for task in self.tasks:
87
+ task.cancel()
88
+ await asyncio.gather(*self.tasks, return_exceptions=True)
89
+ await self.recorder.aclose()
90
+ began = self.recorder.recording_started_at
91
+ played = [] if melody is None else melody.sounded()
92
+ if began is None or (not self.others and not played):
93
+ return
94
+ spans = [(start - began, end - began) for start, end in played]
95
+ clip = None if melody is None else melody.clip
96
+ await asyncio.to_thread(laid_in, self.audio, self.others, (clip, spans))
97
+
98
+ def _subscribed(
99
+ self, track: rtc.Track, _publication: rtc.TrackPublication, seat: rtc.RemoteParticipant
100
+ ) -> None:
101
+ if self.where is None or not isinstance(track, rtc.RemoteAudioTrack):
102
+ return
103
+ if self.where.kind_of(seat, dict(seat.attributes)) not in OTHERS:
104
+ return
105
+ task = asyncio.create_task(self._heard(track))
106
+ self.tasks.add(task)
107
+ task.add_done_callback(self.tasks.discard)
108
+
109
+ # The track's frames come continuous (the jitter buffer fills a silence), so one run a voice,
110
+ # spooled as it comes and placed where its first frame arrived. A run that breaks is said and
111
+ # kept as far as it got: the recording is the call's, with or without that voice.
112
+ async def _heard(self, track: rtc.RemoteAudioTrack) -> None:
113
+ began = self.recorder.recording_started_at or time.time()
114
+ spool = self.audio.with_name(f"{track.sid}.pcm")
115
+ stream = rtc.AudioStream(track, sample_rate=RATE, num_channels=1)
116
+ try:
117
+ with spool.open("wb") as sink:
118
+ async for event in stream:
119
+ if sink.tell() == 0:
120
+ self.others.append((time.time() - event.frame.duration - began, spool))
121
+ sink.write(event.frame.data)
122
+ except Exception:
123
+ logger.warning(LOST, self.audio.parent.name, exc_info=True)
124
+ finally:
125
+ await stream.aclose()
126
+
127
+
128
+ # A stretch of silence laid in past the file's end, at a time.
129
+ STRETCH = RATE
130
+
131
+
132
+ async def recorded(
133
+ live: AgentSession[None], audio: Path | None, where: CallRoom | None
134
+ ) -> Recorder | None:
135
+ """Record the call into the file from now on; None when it keeps no audio."""
136
+ caller, agent = live.input.audio, live.output.audio
137
+ if audio is None or caller is None or agent is None:
138
+ return None
139
+ recorder = RecorderIO(agent_session=live, sample_rate=RATE)
140
+ live.input.audio = recorder.record_input(caller)
141
+ live.output.audio = recorder.record_output(agent)
142
+ await recorder.start(output_path=audio)
143
+ recording = Recorder(recorder, audio, where)
144
+ recording.listen()
145
+ return recording
146
+
147
+
148
+ # Blocking, for a thread: the file decoded a frame at a time, the sounds added on the right where
149
+ # each falls, encoded as it goes, put in its place. Nothing of the call's length is held at once.
150
+ def laid_in(
151
+ audio: Path,
152
+ others: list[tuple[float, Path]],
153
+ melody: tuple[Path | None, list[tuple[float, float]]],
154
+ ) -> None:
155
+ """Add the other voices and the melody to the recording's right channel, each at its time."""
156
+ sounds = [*(_voice(at_s, pcm) for at_s, pcm in others), *_tune(*melody)]
157
+ part = audio.with_name(f"{audio.name}.part")
158
+ encoded(_mixed(_decoded(audio, "stereo"), sounds), part)
159
+ part.replace(audio)
160
+ for _, pcm in others:
161
+ pcm.unlink(missing_ok=True)
162
+
163
+
164
+ def encoded(stretches: Iterable[np.ndarray], out: Path) -> None:
165
+ """Write stretches of two channels of floats at RATE as one stereo Ogg Opus."""
166
+ fifo = av.AudioFifo()
167
+ with av.open(str(out), mode="w", format="ogg") as sink:
168
+ stream: AudioStream = sink.add_stream("libopus", rate=RATE) # pyright: ignore[reportUnknownMemberType]
169
+ stream.layout = "stereo"
170
+ for stretch in stretches:
171
+ frame = av.AudioFrame.from_ndarray(
172
+ np.ascontiguousarray(stretch), format="fltp", layout="stereo"
173
+ )
174
+ frame.sample_rate = RATE
175
+ frame.pts = None
176
+ fifo.write(frame)
177
+ drained(fifo, stream, sink, final=False)
178
+ drained(fifo, stream, sink, final=True)
179
+ for packet in stream.encode(None): # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
180
+ sink.mux(packet) # pyright: ignore[reportUnknownMemberType]
181
+
182
+
183
+ def _voice(at_s: float, pcm: Path) -> Sound:
184
+ samples = np.memmap(pcm, dtype="<i2", mode="r")
185
+
186
+ def stretch(at: int, count: int) -> np.ndarray:
187
+ return samples[at : at + count].astype(np.float32) / 32768
188
+
189
+ return Sound(round(at_s * RATE), len(samples), stretch)
190
+
191
+
192
+ # The melody loops from the start of each span, as the player played it.
193
+ def _tune(clip: Path | None, spans: list[tuple[float, float]]) -> list[Sound]:
194
+ pieces = [] if clip is None else list(_decoded(clip, "mono"))
195
+ if not pieces or not spans:
196
+ return []
197
+ tune = np.concatenate(pieces, axis=1)[0] * VOLUME
198
+
199
+ def stretch(at: int, count: int) -> np.ndarray:
200
+ return np.take(tune, np.arange(at, at + count) % len(tune))
201
+
202
+ return [
203
+ Sound(round(start_s * RATE), max(round((end_s - start_s) * RATE), 0), stretch)
204
+ for start_s, end_s in spans
205
+ ]
206
+
207
+
208
+ def _mixed(stretches: Iterator[np.ndarray], sounds: list[Sound]) -> Iterator[np.ndarray]:
209
+ cursor = 0
210
+ end = max((sound.start + sound.length for sound in sounds), default=0)
211
+ for stretch in stretches:
212
+ yield _laid(stretch, cursor, sounds)
213
+ cursor += stretch.shape[1]
214
+ while cursor < end:
215
+ count = min(STRETCH, end - cursor)
216
+ yield _laid(np.zeros((2, count), dtype=np.float32), cursor, sounds)
217
+ cursor += count
218
+
219
+
220
+ def _laid(stretch: np.ndarray, cursor: int, sounds: list[Sound]) -> np.ndarray:
221
+ until = cursor + stretch.shape[1]
222
+ for sound in sounds:
223
+ low, high = max(sound.start, cursor), min(sound.start + sound.length, until)
224
+ if low < high:
225
+ stretch[1, low - cursor : high - cursor] += sound.stretch(low - sound.start, high - low)
226
+ np.clip(stretch, -FULL_SCALE, FULL_SCALE, out=stretch)
227
+ return stretch
228
+
229
+
230
+ def _decoded(audio: Path, layout: str) -> Iterator[np.ndarray]:
231
+ resampler = av.AudioResampler(format="fltp", layout=layout, rate=RATE)
232
+ with av.open(str(audio)) as source:
233
+ for frame in source.decode(audio=0):
234
+ for piece in resampler.resample(frame):
235
+ yield piece.to_ndarray()
236
+ for piece in resampler.resample(None):
237
+ yield piece.to_ndarray()