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,690 @@
1
+ """One call on livekit's AgentSession, spoken or written, and every entry the call writes."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ import time
7
+ from collections.abc import AsyncGenerator
8
+
9
+ from livekit.agents import (
10
+ NOT_GIVEN,
11
+ AgentStateChangedEvent,
12
+ APIStatusError,
13
+ CloseEvent,
14
+ CloseReason,
15
+ ConversationItemAddedEvent,
16
+ RunContext,
17
+ SessionUsageUpdatedEvent,
18
+ StopResponse,
19
+ ToolError,
20
+ UserInputTranscribedEvent,
21
+ UserStateChangedEvent,
22
+ get_job_context,
23
+ llm,
24
+ metrics,
25
+ stt,
26
+ utils,
27
+ )
28
+ from livekit.agents import ErrorEvent as ComponentFailed
29
+ from livekit.agents.beta.tools import EndCallTool
30
+ from livekit.agents.voice import AgentSession
31
+ from livekit.agents.voice.room_io import RoomOptions
32
+
33
+ from pinecall.domain.agent import block_hash
34
+ from pinecall.domain.errors import DeclarationRefused, NotAllowed, PinecallError
35
+ from pinecall.domain.names import JsonObject
36
+ from pinecall.log.logs import started_entry
37
+ from pinecall.providers.build import Ears, Speaking, Thinking
38
+ from pinecall.session import _prompt, _recording, room, tools
39
+ from pinecall.session._agent import CallAgent
40
+ from pinecall.session._hearing import keyterms
41
+ from pinecall.session._livekit import (
42
+ block_of,
43
+ end_of_utterance,
44
+ forever,
45
+ given_or_unset,
46
+ hearing_again,
47
+ nothing_after,
48
+ silence,
49
+ usage_of,
50
+ )
51
+ from pinecall.session._prompt import A_RELEASE, A_WHISPER, Blocks
52
+ from pinecall.session.call import ToolUse
53
+ from pinecall.session.hold import HoldMusic
54
+ from pinecall.wire import events as wire
55
+ from pinecall.wire import metrics as measured
56
+ from pinecall.wire.commands import (
57
+ AgentReply,
58
+ AgentSay,
59
+ CallAttention,
60
+ CallCallback,
61
+ CallEvent,
62
+ CallHangup,
63
+ CallHold,
64
+ CallLog,
65
+ CallTransfer,
66
+ CallUnhold,
67
+ EndVerb,
68
+ PromptSet,
69
+ ReleaseVerb,
70
+ SayVerb,
71
+ SessionConfigure,
72
+ StateSet,
73
+ SupervisorVerb,
74
+ TakeoverVerb,
75
+ ToolsSet,
76
+ TransferVerb,
77
+ WhisperVerb,
78
+ )
79
+ from pinecall.wire.frames import WireModel
80
+ from pinecall.wire.parts import EndedBy, EndReason, Supervisor, ToolResult
81
+
82
+ # What a session built and measures: the model, and on a voice call the ears and the voice.
83
+ type Built = tuple[Thinking | Ears | Speaking, ...]
84
+
85
+
86
+ logger = logging.getLogger(__name__)
87
+
88
+
89
+ # livekit's max_tool_steps: the step after a tool round is forced to answer in words, so the agent
90
+ # does not keep talking after it answered.
91
+ ONE_ANSWER_PER_TOOL = 1
92
+
93
+
94
+ # livekit's close reason misreads what our code knows: a cold transfer reads as the caller
95
+ # hanging up, end_call as a drain. The reason recorded wins; this is what is left.
96
+ HOW_IT_ENDED: dict[CloseReason, tuple[EndReason, EndedBy]] = {
97
+ CloseReason.PARTICIPANT_DISCONNECTED: ("caller_hung_up", "caller"),
98
+ CloseReason.ERROR: ("error", "platform"),
99
+ CloseReason.JOB_SHUTDOWN: ("drained", "platform"),
100
+ CloseReason.USER_INITIATED: ("drained", "platform"),
101
+ CloseReason.TASK_COMPLETED: ("agent_hung_up", "agent"),
102
+ }
103
+
104
+
105
+ A_PLATFORM_ERROR: tuple[EndReason, EndedBy] = ("error", "platform")
106
+
107
+
108
+ NOTHING_SAID = "no reply"
109
+
110
+
111
+ # What the session waits for the log after the call ended: past it, the gateway's reaper
112
+ # seals what this could not.
113
+ SEAL_S = 20.0
114
+
115
+
116
+ # The goodbye comes in the same turn as the call to end: a reply generated after it says odd
117
+ # things, so nothing is generated.
118
+ SAY_GOODBYE_FIRST = (
119
+ "Say your goodbye in the same reply in which you call this, before the call: nothing you "
120
+ "say after it is heard, and nothing is generated for you."
121
+ )
122
+
123
+
124
+ END_CALL = "end_call"
125
+
126
+
127
+ ALREADY_HELD = "supervisor.verb: {id} already holds the line; they release it, or nobody does"
128
+
129
+
130
+ NOBODY_HOLDS = "supervisor.verb: nobody holds the line, so there is nothing to release"
131
+
132
+
133
+ ALREADY_WAITING = "call.attention: the caller is already waiting for a person"
134
+
135
+
136
+ # The app's tool timeout must outlast the wait it asks for; the runtime does not extend it.
137
+ NOBODY_TOOK_IT = "nobody took the line within {wait_s:g}s"
138
+
139
+
140
+ NOT_HERE = "{command} is not something this call does"
141
+
142
+
143
+ LISTENED = (
144
+ "user_input_transcribed",
145
+ "user_state_changed",
146
+ "agent_state_changed",
147
+ "conversation_item_added",
148
+ "session_usage_updated",
149
+ "error",
150
+ "close",
151
+ )
152
+
153
+
154
+ class Session:
155
+ """One call's AgentSession and the call it writes: its events, commands, time and end."""
156
+
157
+ def __init__(self, live: AgentSession[None], built: Built, lookups: tools.Lookups) -> None:
158
+ """The session of a call nobody has spoken on, its agent holding every tool."""
159
+ self.call = lookups.call
160
+ self.live = live
161
+ self.built = built
162
+ self.lookups = lookups
163
+ self.blocks = Blocks(self.call.config.prompt, self.call.config.knowledge or "")
164
+ self.agent = CallAgent(live, self.blocks, lookups, self._declared(), self._music)
165
+ self.started_at = time.time()
166
+ # When the person last said something: what a written call in a room is kept alive by.
167
+ self.last_heard = time.monotonic()
168
+ # Why the call ended, when our code knows: it wins over livekit's close reason.
169
+ self.ended: tuple[EndReason, EndedBy] | None = None
170
+ self.closed_for: CloseReason | None = None
171
+ self.closed = False
172
+ # Set once close() wrote call.ended and sealed; the chat door closes its socket on it.
173
+ self.over = asyncio.Event()
174
+ self.closing: asyncio.Task[None] | None = None
175
+ self.dead_end = False
176
+ self.language: str | None = None
177
+ self.usage: list[measured.ModelUsage] = []
178
+ self.on_hold = False
179
+ self.waiting: asyncio.Task[None] | None = None
180
+ # Given at start; a written call has neither.
181
+ self.room: room.CallRoom | None = None
182
+ self.hold: HoldMusic | None = None
183
+ # The one participant the session listens to: the caller's leg, or the talk seat.
184
+ self.seat: str | None = None
185
+ self.recorder: _recording.Recorder | None = None
186
+
187
+ # ── the session's life ──
188
+
189
+ # call.started comes first, so it is written before livekit starts and says anything.
190
+ async def start(
191
+ self,
192
+ *,
193
+ where: room.CallRoom | None = None,
194
+ hold: HoldMusic | None = None,
195
+ seat: str | None = None,
196
+ opening: str | None = None,
197
+ worker: str | None = None,
198
+ ) -> None:
199
+ """Open a new call: its first entries, the session, the opening, the greeting."""
200
+ self.room = where
201
+ self.hold = hold
202
+ self.seat = seat
203
+ self.call.writing.open()
204
+ context = self.call.context
205
+ door = context.route.number or self.call.config.slug
206
+ # A written call is given no room.
207
+ medium = "text" if where is None else "voice"
208
+ started = wire.CallStarted.model_validate(
209
+ started_entry(context, door, self.started_at, worker, medium=medium)
210
+ )
211
+ await self.call.writing.write("call.started", started)
212
+ knowledge = _prompt.knowledge_changed(self.blocks)
213
+ if knowledge is not None:
214
+ await self.call.writing.write("prompt.changed", knowledge)
215
+ await self._opened(llm.ChatContext.empty())
216
+ if opening is not None:
217
+ self.live.say(opening, allow_interruptions=False)
218
+ greeting = _prompt.greeting_for(self.call.config.greeting, context.run)
219
+ if greeting is not None and greeting.say is not None:
220
+ self.live.say(
221
+ greeting.say, allow_interruptions=given_or_unset(greeting.allow_interruptions)
222
+ )
223
+ elif greeting is not None and greeting.reply is not None:
224
+ interruptible = given_or_unset(greeting.allow_interruptions)
225
+ self.live.generate_reply(instructions=greeting.reply, allow_interruptions=interruptible)
226
+
227
+ # The caller is in the middle of the conversation: no call.started, no greeting.
228
+ async def resume(self, history: llm.ChatContext) -> None:
229
+ """Open a call taken up again, with the history it had."""
230
+ self.call.writing.open()
231
+ await self._opened(history)
232
+
233
+ # In a worker the job's shutdown closes the session; a written call in the gateway has no
234
+ # job, so the hang-up closes it itself.
235
+ def hang_up(self, reason: EndReason, by: EndedBy = "agent", *, at_once: bool = False) -> None:
236
+ """End the call for this reason: the session, and the job that runs it."""
237
+ self.ended = (reason, by)
238
+ self.live.shutdown(drain=not at_once)
239
+ job = get_job_context(required=False)
240
+ if job is not None:
241
+ job.shutdown(reason=reason)
242
+ return
243
+ self.closing = asyncio.create_task(self.close())
244
+
245
+ # The session closes first: its close writes the last turn, which comes before call.ended.
246
+ # The log is flushed before the seal, since the gateway reads the call back from it.
247
+ async def close(self) -> None:
248
+ """Write call.ended and hand the rest to the seal, within the seal's budget."""
249
+ if self.closed:
250
+ return
251
+ self.closed = True
252
+ await self.live.aclose()
253
+ if self.recorder is not None:
254
+ await self.recorder.close(self.hold)
255
+ for name in LISTENED:
256
+ self.live.off(name, self._heard) # pyright: ignore[reportUnknownMemberType]
257
+ for component in self.built:
258
+ component.off("metrics_collected", self._measured) # pyright: ignore[reportUnknownMemberType]
259
+ if self.waiting is not None:
260
+ await utils.aio.cancel_and_wait(self.waiting)
261
+ await self.lookups.close()
262
+ reason, by = self._how_it_ended()
263
+ ended_at = time.time()
264
+ ended = wire.CallEnded(
265
+ reason=reason, ended_by=by, ended_at=ended_at, duration_s=ended_at - self.started_at
266
+ )
267
+ try:
268
+ await self.call.writing.write("call.ended", ended)
269
+ await self.call.writing.flushed(SEAL_S)
270
+ await self.call.platform.seal(self.usage, self.call.last_said or NOTHING_SAID)
271
+ finally:
272
+ await self.call.writing.close(SEAL_S)
273
+ self.over.set()
274
+
275
+ # ── the app's commands ──
276
+
277
+ async def apply(self, command: WireModel) -> None:
278
+ """What one command of the app does to this call."""
279
+ if await self._written(command) or await self._spoken(command):
280
+ return
281
+ if isinstance(command, SupervisorVerb):
282
+ await self.supervise(command)
283
+ return
284
+ await self._in_the_room(command)
285
+
286
+ async def _written(self, command: WireModel) -> bool:
287
+ match command:
288
+ case PromptSet():
289
+ await self.set_prompt(command.name, command.text)
290
+ case ToolsSet():
291
+ await self.call.set_tools(command)
292
+ case StateSet():
293
+ await self.call.set_state(command)
294
+ self._tell_the_ears(command.state)
295
+ case CallEvent():
296
+ await self.call.receives(command.name, command.data, source="app")
297
+ case CallLog():
298
+ await self.call.log_custom(command)
299
+ case CallCallback():
300
+ await self.call.call_back(command)
301
+ case SessionConfigure():
302
+ await self.call.configure(command)
303
+ case _:
304
+ return False
305
+ return True
306
+
307
+ async def _spoken(self, command: WireModel) -> bool:
308
+ match command:
309
+ case AgentSay():
310
+ self.live.say(
311
+ command.text, allow_interruptions=given_or_unset(command.allow_interruptions)
312
+ )
313
+ case AgentReply():
314
+ self.live.generate_reply(
315
+ instructions=command.instructions,
316
+ allow_interruptions=given_or_unset(command.allow_interruptions),
317
+ )
318
+ case CallHangup():
319
+ self.hang_up("agent_hung_up")
320
+ case CallHold():
321
+ await self.hold_the_line()
322
+ case CallUnhold():
323
+ await self.give_the_line_back()
324
+ case CallAttention():
325
+ await self.ask_for_a_person(command)
326
+ case _:
327
+ return False
328
+ return True
329
+
330
+ # Only a change of the static text rewrites the instructions: identical bytes would still
331
+ # cost a cache write. The dynamic blocks are read by each request.
332
+ async def set_prompt(self, name: str, text: str) -> None:
333
+ """Rewrite one block; the log keeps its hash."""
334
+ if self.blocks.set(name, text):
335
+ await self.agent.update_instructions(self.blocks.instructions)
336
+ changed = wire.PromptChanged(name=name, hash=block_hash(text), chars=len(text))
337
+ await self.call.writing.write("prompt.changed", changed)
338
+
339
+ # ── the line ──
340
+
341
+ async def hold_the_line(self) -> None:
342
+ """The agent neither speaks nor hears, the melody plays, and the log says so."""
343
+ if self.on_hold:
344
+ return
345
+ self.on_hold = True
346
+ await silence(self.live)
347
+ if self.hold is not None:
348
+ self.hold.began()
349
+ await self.call.writing.write("call.line", wire.CallLine(held=True, muted=False))
350
+
351
+ async def give_the_line_back(self) -> None:
352
+ """The melody stops and the agent hears and speaks again."""
353
+ if not self.on_hold:
354
+ return
355
+ self._melody_ends()
356
+ hearing_again(self.live)
357
+ await self.call.writing.write("call.line", wire.CallLine(held=False, muted=False))
358
+
359
+ async def stop_the_melody(self) -> None:
360
+ """The melody stops and the line is written free; whoever took it keeps it."""
361
+ if not self.on_hold:
362
+ return
363
+ self._melody_ends()
364
+ await self.call.writing.write("call.line", wire.CallLine(held=False, muted=False))
365
+
366
+ async def ask_for_a_person(self, wanted: CallAttention) -> None:
367
+ """Hold the caller for a supervisor, for as long as the app asked, once at a time."""
368
+ if self.call.waiting_for_a_person:
369
+ raise DeclarationRefused(ALREADY_WAITING)
370
+ self.call.waiting_for_a_person = True
371
+ params = wire.AttentionRequested(reason=wanted.reason, wait_s=wanted.wait_s)
372
+ await self.call.writing.write("attention.requested", params)
373
+ await self.hold_the_line()
374
+ self.waiting = asyncio.create_task(self._nobody_came(wanted.wait_s))
375
+
376
+ # ── a supervisor ──
377
+
378
+ async def supervise(self, command: SupervisorVerb) -> None:
379
+ """One of a supervisor's six verbs, written before it acts."""
380
+ by, verb = command.by, command.verb
381
+ match verb:
382
+ case SayVerb():
383
+ await self.call.writing.write(
384
+ "supervisor.said", wire.SupervisorSaid(by=by, text=verb.text)
385
+ )
386
+ self.live.say(verb.text, allow_interruptions=True)
387
+ case WhisperVerb():
388
+ await self.call.writing.write(
389
+ "supervisor.whispered", wire.SupervisorWhispered(by=by, text=verb.text)
390
+ )
391
+ note = A_WHISPER.format(text=verb.text)
392
+ await self._noted(note)
393
+ if self.call.taken_by is None:
394
+ self.live.generate_reply(instructions=note)
395
+ case TakeoverVerb():
396
+ await self._take_over(by)
397
+ case ReleaseVerb():
398
+ await self._release(by)
399
+ case EndVerb():
400
+ await self.call.writing.write(
401
+ "supervisor.ended", wire.SupervisorEnded(by=by, reason=verb.reason)
402
+ )
403
+ self.hang_up("supervisor_ended", "supervisor", at_once=True)
404
+ case TransferVerb():
405
+ await self._in_the_room(verb, by=by)
406
+
407
+ def _melody_ends(self) -> None:
408
+ self.on_hold = False
409
+ if self.hold is not None:
410
+ self.hold.ended()
411
+
412
+ async def _take_over(self, by: Supervisor) -> None:
413
+ if self.call.taken_by is not None:
414
+ raise DeclarationRefused(ALREADY_HELD.format(id=self.call.taken_by.id))
415
+ await self.call.writing.write("supervisor.took_over", wire.SupervisorTookOver(by=by))
416
+ if self.call.waiting_for_a_person:
417
+ await self._answered(wire.AttentionAnswered(ok=True, by=by))
418
+ await self.stop_the_melody()
419
+ await silence(self.live)
420
+ self.call.taken_by = by
421
+
422
+ async def _release(self, by: Supervisor) -> None:
423
+ if self.call.taken_by is None:
424
+ raise DeclarationRefused(NOBODY_HOLDS)
425
+ await self.call.writing.write("supervisor.released", wire.SupervisorReleased(by=by))
426
+ hearing_again(self.live)
427
+ self.call.taken_by = None
428
+ await self._noted(A_RELEASE)
429
+ self.live.generate_reply(instructions=A_RELEASE)
430
+
431
+ async def _nobody_came(self, wait_s: float) -> None:
432
+ await asyncio.sleep(wait_s)
433
+ failed = NOBODY_TOOK_IT.format(wait_s=wait_s)
434
+ await self._answered(wire.AttentionAnswered(ok=False, by=None, error=failed), waited=True)
435
+ await self.give_the_line_back()
436
+
437
+ async def _answered(self, answer: wire.AttentionAnswered, *, waited: bool = False) -> None:
438
+ self.call.waiting_for_a_person = False
439
+ if self.waiting is not None and not waited:
440
+ self.waiting.cancel()
441
+ self.waiting = None
442
+ await self.call.writing.write("attention.answered", answer)
443
+
444
+ async def _in_the_room(self, command: WireModel, *, by: Supervisor | None = None) -> None:
445
+ if self.room is None:
446
+ raise NotAllowed(NOT_HERE.format(command=type(command).__name__))
447
+ if isinstance(command, TransferVerb | CallTransfer):
448
+ await self._transfer(self.room, CallTransfer(to=command.to, mode=command.mode), by=by)
449
+ return
450
+ await self.room.apply(command)
451
+
452
+ # A transfer however it was asked has one outcome entry; a supervisor's is written first,
453
+ # with the mode resolved. A cold one removes the caller, which livekit reads as a hang-up.
454
+ async def _transfer(
455
+ self, where: room.CallRoom, wanted: CallTransfer, *, by: Supervisor | None
456
+ ) -> None:
457
+ if by is not None:
458
+ mode = await where.mode_of(wanted)
459
+ params = wire.SupervisorTransferred(by=by, to=wanted.to, mode=mode)
460
+ await self.call.writing.write("supervisor.transferred", params)
461
+ wanted = CallTransfer(to=wanted.to, mode=mode)
462
+ transferred = await where.transfer(wanted, self.live)
463
+ await self.call.writing.write("call.transferred", transferred)
464
+ if not transferred.ok:
465
+ return
466
+ self.ended = ("transferred", "agent")
467
+ if transferred.mode == "warm":
468
+ await silence(self.live)
469
+ where.when_the_person_leaves(
470
+ f"{room.LEG_PREFIX}{wanted.to}", lambda: self.hang_up("transferred")
471
+ )
472
+
473
+ # ── what the session tells us ──
474
+
475
+ def _heard(self, event: object) -> None:
476
+ match event:
477
+ case UserInputTranscribedEvent():
478
+ self._transcribed(event)
479
+ case UserStateChangedEvent():
480
+ self.call.writing.write("user.state", wire.UserStateChanged(state=event.new_state))
481
+ case AgentStateChangedEvent():
482
+ self.call.agent_speaking = event.new_state == "speaking"
483
+ if self.hold is not None:
484
+ self.hold.floor(speaking=self.call.agent_speaking)
485
+ self.call.writing.write(
486
+ "agent.state", wire.AgentStateChanged(state=event.new_state)
487
+ )
488
+ case ConversationItemAddedEvent():
489
+ self._turn(event.item)
490
+ case SessionUsageUpdatedEvent():
491
+ self.usage = [usage_of(used) for used in event.usage.model_usage]
492
+ case ComponentFailed():
493
+ self._failed(event)
494
+ case CloseEvent():
495
+ self.closed_for = event.reason
496
+ case _:
497
+ return
498
+
499
+ # Interims also start the lookups, so recall and search run while the caller still talks.
500
+ def _transcribed(self, event: UserInputTranscribedEvent) -> None:
501
+ self.language = event.language or self.language
502
+ text = wire.UserTranscript(
503
+ text=event.transcript, final=event.is_final, language=event.language
504
+ )
505
+ self.call.writing.write("user.transcript", text)
506
+ if not event.is_final:
507
+ self.lookups.heard_so_far(event.transcript)
508
+
509
+ # Metrics are whole by the time an item is added, so the turns are written here.
510
+ def _turn(self, item: object) -> None:
511
+ if not isinstance(item, llm.ChatMessage) or item.role not in {"user", "assistant"}:
512
+ return
513
+ speech = self.live.current_speech.id if self.live.current_speech else item.id
514
+ text = item.text_content or ""
515
+ if item.role == "assistant":
516
+ self.call.turns += 1
517
+ self.call.last_said = text or self.call.last_said
518
+ agent = wire.AgentTurnEnded(
519
+ speech_id=speech,
520
+ item_id=item.id,
521
+ text=text,
522
+ interrupted=item.interrupted,
523
+ metrics=measured.AgentTurnMetrics.model_validate(dict(item.metrics)),
524
+ )
525
+ self.call.writing.write("turn.agent", agent)
526
+ return
527
+ self.last_heard = time.monotonic()
528
+ eou = end_of_utterance(item.metrics, speech)
529
+ if eou is not None:
530
+ self.call.writing.write("metrics.eou", eou)
531
+ user = wire.UserTurnEnded(
532
+ speech_id=speech,
533
+ item_id=item.id,
534
+ text=text,
535
+ language=self.language,
536
+ transcript_confidence=item.transcript_confidence,
537
+ metrics=measured.UserTurnMetrics.model_validate(dict(item.metrics)),
538
+ )
539
+ self.call.writing.write("turn.user", user)
540
+
541
+ # livekit closes only after three unrecoverable errors. A refusal that can never succeed (a
542
+ # bad key, a voice that does not exist) ends the call on the first.
543
+ def _failed(self, event: ComponentFailed) -> None:
544
+ if self.dead_end:
545
+ return
546
+ data = str(event.error)
547
+ wrapped = getattr(event.error, "error", None)
548
+ if not (isinstance(wrapped, APIStatusError) and forever(wrapped)):
549
+ recoverable = bool(getattr(event.error, "recoverable", False))
550
+ failed = wire.ErrorEvent(code="component_failed", message=data, recoverable=recoverable)
551
+ self.call.writing.write("error", failed)
552
+ return
553
+ self.dead_end = True
554
+ dead = wire.ErrorEvent(code="component_dead_end", message=data, recoverable=False)
555
+ self.call.writing.write("error", dead)
556
+ self.hang_up("error", "platform")
557
+
558
+ # Deferred a tick: livekit stamps the speech id on the same object from a listener of its
559
+ # own, and the order of listeners is not defined. Streaming ears report usage at their own
560
+ # cadence; those ticks ride the stream and the total reaches the summary.
561
+ def _measured(self, block: metrics.AgentMetrics) -> None:
562
+ asyncio.get_running_loop().call_soon(self._write_block, block)
563
+
564
+ def _write_block(self, block: metrics.AgentMetrics) -> None:
565
+ written = block_of(block)
566
+ if written is None:
567
+ return
568
+ kind, model = written
569
+ tick = isinstance(block, metrics.STTMetrics) and block.streamed and not block.acquire_time
570
+ self.call.writing.write(kind, model, ephemeral=True if tick else None)
571
+
572
+ # ── what the agent is handed ──
573
+
574
+ def _declared(self) -> list[llm.Tool | llm.Toolset]:
575
+ config = self.call.config
576
+ declared: list[llm.Tool | llm.Toolset] = [
577
+ *tools.as_livekit_tools(config.tools, self._run_app_tool),
578
+ *tools.as_livekit_tools(tools.platform_tools(config), self._run_lookup),
579
+ ]
580
+ if config.hangup is not None:
581
+ declared.append(
582
+ EndCallTool(
583
+ extra_description=f"{config.hangup.when}\n{SAY_GOODBYE_FIRST}".strip(),
584
+ # Hidden while the agent greets, or models hang up before the caller speaks.
585
+ ignore_on_enter=True,
586
+ # Deleting the room ends the caller's SIP leg, or they hear a silent line.
587
+ delete_room=True,
588
+ end_instructions=None,
589
+ on_tool_called=self._ended_by_the_model,
590
+ on_tool_completed=nothing_after,
591
+ )
592
+ )
593
+ return declared
594
+
595
+ # The model's words and its tool call arrive in one answer: its announcement plays before the
596
+ # tool runs. The read-back is said inside the tool and awaited, so it is in the history the
597
+ # reply is built from, or the model says it again in its own words.
598
+ async def _run_app_tool(self, use: ToolUse, context: RunContext[None]) -> str:
599
+ spec = self.call.config.tools_by_name[use.name]
600
+ await tools.admitted(self.call, use.name)
601
+ await context.wait_for_playout()
602
+ # The gateway writes the tool's round trip: the turn that called it is in the log first.
603
+ await self.call.writing.flushed(SEAL_S)
604
+ self.call.cause = wire.StateCauseTool(kind="tool", tool=use.name, call_id=use.call_id)
605
+ try:
606
+ async with self._music():
607
+ result = await self.call.platform.tool(use, context.speech_handle.id)
608
+ # The gateway's refusal (the app gone, a guard) is the model's to read, in its own turn.
609
+ except PinecallError as refused:
610
+ raise ToolError(str(refused)) from refused
611
+ finally:
612
+ if self.call.cause == wire.StateCauseTool(
613
+ kind="tool", tool=use.name, call_id=use.call_id
614
+ ):
615
+ self.call.cause = None
616
+ text = tools.result_text(result)
617
+ if result.error is not None:
618
+ raise ToolError(text)
619
+ if spec.confirm:
620
+ await self.live.say(tools.read_back(spec.confirm, use.arguments, result))
621
+ # A supervisor took the line while the tool ran: the result stays, the reply does not.
622
+ if self.call.a_person_has_the_line:
623
+ raise StopResponse
624
+ return text
625
+
626
+ async def _run_lookup(self, use: ToolUse, _context: RunContext[None]) -> str:
627
+ return await self.lookups.called(use)
628
+
629
+ # end_call closes the session as livekit's own initiative, which reads as a drain: the reason
630
+ # is written down first, and the tool with it, since it never reaches the app.
631
+ async def _ended_by_the_model(self, event: llm.Toolset.ToolCalledEvent) -> None:
632
+ self.ended = ("agent_hung_up", "agent")
633
+ context: RunContext[None] = event.ctx # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
634
+ call_id = context.function_call.call_id
635
+ speech = context.speech_handle.id
636
+ called = wire.ToolCall(call_id=call_id, name=END_CALL, arguments={}, speech_id=speech)
637
+ await self.call.writing.write("tool.call", called)
638
+ await self.call.writing.write("tool.result", ToolResult(call_id=call_id, name=END_CALL))
639
+
640
+ @contextlib.asynccontextmanager
641
+ async def _music(self) -> AsyncGenerator[None]:
642
+ if self.hold is not None:
643
+ self.hold.began()
644
+ try:
645
+ yield
646
+ finally:
647
+ if self.hold is not None:
648
+ self.hold.ended()
649
+
650
+ async def _opened(self, history: llm.ChatContext) -> None:
651
+ for name in LISTENED:
652
+ self.live.on(name, self._heard) # pyright: ignore[reportUnknownMemberType]
653
+ on = NOT_GIVEN if self.room is None else self.room.room
654
+ # The caller is pinned before livekit subscribes, or it links the first seat of a kind it
655
+ # accepts, a supervisor's as soon as a caller's. A written call in a room hears no audio.
656
+ # record is said: left unset, livekit asks its cloud; the call's own recorder is ours.
657
+ spoken = any(isinstance(built_one, stt.STT) for built_one in self.built)
658
+ options = RoomOptions(
659
+ participant_identity=given_or_unset(self.seat),
660
+ audio_input=NOT_GIVEN if spoken else False,
661
+ audio_output=NOT_GIVEN if spoken else False,
662
+ )
663
+ await self.live.start( # pyright: ignore[reportUnknownMemberType]
664
+ self.agent, room=on, room_options=options, record=False
665
+ )
666
+ self.recorder = await _recording.recorded(self.live, self.call.recording, self.room)
667
+ for component in self.built:
668
+ component.on("metrics_collected", self._measured) # pyright: ignore[reportUnknownMemberType]
669
+ given = history.copy()
670
+ given.items[:0] = list(tools.date_pair(self.call.context.today))
671
+ await self.agent.update_chat_ctx(given, exclude_invalid_function_calls=False)
672
+
673
+ # A note joins the history as livekit appends: copy, add, update. Never a static block,
674
+ # which would spend the cache.
675
+ async def _noted(self, note: str) -> None:
676
+ options = self.agent.chat_ctx.copy()
677
+ options.add_message(role="system", content=note)
678
+ await self.agent.update_chat_ctx(options, exclude_invalid_function_calls=False)
679
+
680
+ # Names the state holds (a patient's) join the declared words; livekit replaces the
681
+ # session's keyterms in place.
682
+ def _tell_the_ears(self, state: JsonObject) -> None:
683
+ ears = next((built_one for built_one in self.built if isinstance(built_one, stt.STT)), None)
684
+ if ears is not None and ears.capabilities.keyterms:
685
+ self.live.update_options(keyterms=keyterms(self.call.config, state))
686
+
687
+ def _how_it_ended(self) -> tuple[EndReason, EndedBy]:
688
+ if self.ended is not None:
689
+ return self.ended
690
+ return A_PLATFORM_ERROR if self.closed_for is None else HOW_IT_ENDED[self.closed_for]