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,647 @@
1
+ """The eval doors: suites and their runs, a call replayed or judged, the simulated caller."""
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import time
6
+ from collections.abc import AsyncGenerator
7
+ from contextlib import asynccontextmanager
8
+ from dataclasses import dataclass, field
9
+ from typing import Annotated, Never
10
+
11
+ from fastapi import APIRouter, Path, Query
12
+ from livekit import rtc
13
+ from livekit.agents import llm, tts
14
+ from pydantic import BaseModel, Field
15
+
16
+ from pinecall.channels import rooms
17
+ from pinecall.domain.agent import AgentConfig, Model, Voice
18
+ from pinecall.domain.call import CallContext, Route, new_call_id, today_in
19
+ from pinecall.domain.errors import (
20
+ Conflict,
21
+ DeclarationRefused,
22
+ NotAllowed,
23
+ NotAvailable,
24
+ NotFound,
25
+ QuotaExhausted,
26
+ )
27
+ from pinecall.domain.names import PRODUCTION, THE_WIDGET
28
+ from pinecall.domain.scope import Scope
29
+ from pinecall.evals import checks, dataset, goldens, runs, spoken
30
+ from pinecall.evals.callers import Spending, heard_in, improvise_line
31
+ from pinecall.evals.case import Case, case_of
32
+ from pinecall.evals.judges import CaseJudge, hangup_judges
33
+ from pinecall.fleet import worlds
34
+ from pinecall.gateway import _deps
35
+ from pinecall.gateway._call_setup import exhausted, keys_of, tuned
36
+ from pinecall.gateway._deps import EvalsKey, GatewayDep, ScopeDep, check_paced
37
+ from pinecall.gateway._gateway import Gateway
38
+ from pinecall.gateway._sockets import NO_AGENT, Registration
39
+ from pinecall.gateway._text_calls import TextSetup, open_text_as
40
+ from pinecall.gateway.ending.seal import compliance_of, drifted, judge_of, judged_call
41
+ from pinecall.log import queries
42
+ from pinecall.log.store import Claim
43
+ from pinecall.providers import catalog, credentials
44
+ from pinecall.providers.build import Running, llm_of, tts_of
45
+ from pinecall.providers.catalog import Providers
46
+ from pinecall.providers.credentials import Keyring, thinking
47
+ from pinecall.providers.declared import model_of
48
+ from pinecall.tenancy import judges, personas, scopes, tokens
49
+ from pinecall.tenancy.keys import check_agent
50
+ from pinecall.tenancy.scopes import Picked
51
+ from pinecall.wire.parts import ModelConfig
52
+ from pinecall.wire.rest.evals import (
53
+ CallerPersona,
54
+ EvalRunList,
55
+ EvalRunResponse,
56
+ Golden,
57
+ NextLineRequest,
58
+ NextLineResponse,
59
+ OpenedCall,
60
+ PlaceVoiceCallRequest,
61
+ PlaceVoiceCallResponse,
62
+ ReplayCallRequest,
63
+ ReplayCallResponse,
64
+ RunSuiteRequest,
65
+ ScoreRow,
66
+ )
67
+ from pinecall.wire.scores import CallScore
68
+
69
+ router = APIRouter()
70
+
71
+
72
+ A_SCREENFUL = 20
73
+
74
+
75
+ NO_SUCH_RUN = "no eval run {id} in this key's org and world"
76
+
77
+
78
+ STILL_GOING = "call {call} is still going: it is judged when it hangs up"
79
+
80
+
81
+ ALREADY_JUDGED = (
82
+ "call {call} was judged at hang-up: ?again=true judges it again, and pays for it again"
83
+ )
84
+
85
+
86
+ # Without the app the rest would run against a bare model: no view, no tools.
87
+ THE_APP_LEFT = (
88
+ "the app detached after {done} of {total} goldens ({slug}): the socket holding the agent "
89
+ "closed, and a conversation with no app behind it drives a model with no view and no tools"
90
+ )
91
+
92
+
93
+ # The worker builds a spoken session from the declaration: a column per model cannot hold.
94
+ ONE_MODEL_OUT_LOUD = (
95
+ "--voice runs the model the agent declares: the worker builds the session, so a run cannot "
96
+ "put a golden through a second model out loud. Drop --model, or drop --voice."
97
+ )
98
+
99
+
100
+ # A dispatch carries no date: the worker's clock is the real day's.
101
+ NO_PINNED_DAY_OUT_LOUD = (
102
+ "golden {name} pins today={day}, and a spoken call runs on the real day: the worker seeds its "
103
+ "clock when the room opens. Run this golden without --voice."
104
+ )
105
+
106
+
107
+ NO_LINE = "the simulated call could not be held: {broke}"
108
+
109
+
110
+ # A call id names a room, a log and a recording: a simulated caller is put on a new one alone.
111
+ NOT_A_NEW_CALL = (
112
+ "call {call} exists already: a simulated caller is placed on a call id nobody opened"
113
+ )
114
+
115
+
116
+ CASES_IN_THE_SANDBOX = (
117
+ "cases are real callers' words played again: they run in the sandbox, against the app a "
118
+ "developer holds there, never through production's, whose tools act for real"
119
+ )
120
+
121
+
122
+ CASES_WRITTEN = "cases are played as written calls: drop --voice, or play only goldens out loud"
123
+
124
+
125
+ # Every call of a run is a simulated caller and an agent the box may pay for: goldens and cases,
126
+ # each played once under every model.
127
+ CALLS_A_RUN = 200
128
+
129
+
130
+ TOO_MANY_CALLS = (
131
+ "a run plays 200 calls at most, and {played} goldens and cases under {models} models are "
132
+ "{total}: run fewer, or fewer models"
133
+ )
134
+
135
+
136
+ NO_SUCH_VERSION = "no version {version} of {slug} in the scope of the app that holds it"
137
+
138
+
139
+ # A caller's line is a model call: a call of forty turns says forty, and several run side by side.
140
+ LINES_A_MINUTE = 120
141
+
142
+
143
+ TOO_MANY_LINES = "more than 120 caller lines this minute from this org: try again in a minute"
144
+
145
+
146
+ # The worker writes call.summary and call.score after the caller leaves.
147
+ A_SEAL_MAY_TAKE_S = 25.0
148
+
149
+
150
+ NEVER_SEALED = (
151
+ "the spoken call {call} never sealed within {seconds:.0f}s: there is nothing to judge"
152
+ )
153
+
154
+
155
+ # Longer than any run, so the caller's token never ends a call first.
156
+ A_CALL_MAY_LAST_S = 15 * 60.0
157
+
158
+
159
+ class RunListQuery(BaseModel):
160
+ """What a list of runs asks for: one agent's or every one, since when, how many."""
161
+
162
+ agent: str | None = None
163
+ since: float = Field(0.0, ge=0)
164
+ limit: int = Field(A_SCREENFUL, ge=1, le=_deps.LONGEST_LIST)
165
+
166
+
167
+ class JudgeQuery(BaseModel):
168
+ """Whether a call judged already is judged again, and paid for again."""
169
+
170
+ again: bool = False
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class Suite:
175
+ """One run as it goes: the socket it drives, what its calls run on, what it opened, judged."""
176
+
177
+ body: RunSuiteRequest
178
+ registration: Registration
179
+ setup: TextSetup
180
+ configured: Providers
181
+ keys: Keyring
182
+ run: EvalRunResponse
183
+ calls: list[OpenedCall] = field(default_factory=list[OpenedCall])
184
+ cells: list[ScoreRow] = field(default_factory=list[ScoreRow])
185
+
186
+ @property
187
+ def now(self) -> EvalRunResponse:
188
+ """The run as it stands: the calls opened and the matrix of the cells judged."""
189
+ opened = self.run.model_copy(update={"calls": list(self.calls)})
190
+ return runs.judged(opened, self.cells) if self.cells else opened
191
+
192
+
193
+ # One per agent at a time: a run drives the app that answers the agent's real calls.
194
+ @router.post("/v1/evals/run", response_model_exclude_unset=True)
195
+ async def run_suite(
196
+ body: RunSuiteRequest, key: EvalsKey, scope: ScopeDep, gateway: GatewayDep
197
+ ) -> EvalRunResponse:
198
+ """Every golden under every model through the app that holds the agent, judged and stored."""
199
+ check_agent(key.bearer, body.agent)
200
+ registration = gateway.sockets.serving(scope, body.agent, body.app)
201
+ if registration is None:
202
+ raise NotFound(NO_AGENT.format(slug=body.agent))
203
+ if body.voice:
204
+ _refuse_out_loud(body)
205
+ if body.cases or body.dataset:
206
+ body = await _with_cases(gateway, body, scope)
207
+ _refuse_past_the_ceiling(body)
208
+ suite = await _suite_of(gateway, body, registration)
209
+ pool, where = gateway.connections.pool, registration.scope
210
+ async with gateway.evals.alone(suite.run.id, body.agent):
211
+ await runs.put(pool, where, suite.run)
212
+ why = await _judged_suite(gateway, suite, key.org)
213
+ done = runs.finished(suite.now) if why is None else runs.stopped(suite.now, why)
214
+ await runs.put(pool, where, done)
215
+ return done
216
+
217
+
218
+ @router.get("/v1/evals/runs", response_model_exclude_unset=True)
219
+ async def list_runs(
220
+ key: EvalsKey, scope: ScopeDep, gateway: GatewayDep, query: Annotated[RunListQuery, Query()]
221
+ ) -> EvalRunList:
222
+ """The runs of the key's org in its world, newest first."""
223
+ found = await runs.listed(
224
+ gateway.connections.pool,
225
+ Scope(key.org, scope.env),
226
+ agent=query.agent,
227
+ since=query.since,
228
+ limit=query.limit,
229
+ )
230
+ return EvalRunList(runs=found)
231
+
232
+
233
+ @router.get("/v1/evals/runs/{id}", response_model_exclude_unset=True)
234
+ async def get_run(
235
+ run: Annotated[str, Path(alias="id")], key: EvalsKey, scope: ScopeDep, gateway: GatewayDep
236
+ ) -> EvalRunResponse:
237
+ """One run; another org's, or one nobody ran, is the same 404."""
238
+ found = await runs.of(gateway.connections.pool, Scope(key.org, scope.env), run)
239
+ if found is None:
240
+ raise NotFound(NO_SUCH_RUN.format(id=run))
241
+ return found
242
+
243
+
244
+ # Another org's call and nobody's are one 404: neither is told apart from a typo.
245
+ @router.post("/v1/evals/replay/{call}")
246
+ async def replay_call(
247
+ call: str,
248
+ key: EvalsKey,
249
+ scope: ScopeDep,
250
+ gateway: GatewayDep,
251
+ body: ReplayCallRequest | None = None,
252
+ ) -> ReplayCallResponse:
253
+ """The six code checks over a finished call, the barge-ins it answered among them."""
254
+ declared = await _deps.check_readable(gateway, _deps.Reader(acting=key, scope=scope), call)
255
+ entries = await gateway.logs.store.whole(call)
256
+ if not entries:
257
+ raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
258
+ wanted = body or ReplayCallRequest()
259
+ irreversible = (
260
+ None
261
+ if declared is None
262
+ else frozenset(tool.name for tool in declared.tools if tool.side_effect == "irreversible")
263
+ )
264
+ verdicts = checks.replay(
265
+ entries, banned=wanted.banned, budget=wanted.budget, irreversible=irreversible
266
+ )
267
+ return ReplayCallResponse(
268
+ call=call,
269
+ agent=entries[0].agent,
270
+ passed=not any(verdict.status == "broken" for verdict in verdicts),
271
+ verdicts=verdicts,
272
+ )
273
+
274
+
275
+ # Hang-up judging run later: the org judged nothing then, the judge failed, or a judge again.
276
+ @router.post("/v1/evals/judge/{call}", response_model_exclude_unset=True)
277
+ async def judge_call(
278
+ call: str,
279
+ key: EvalsKey,
280
+ scope: ScopeDep,
281
+ gateway: GatewayDep,
282
+ query: Annotated[JudgeQuery, Query()],
283
+ ) -> CallScore:
284
+ """Judge a finished call, write the verdict on its log, and answer it."""
285
+ declared = await _deps.check_readable(gateway, _deps.Reader(acting=key, scope=scope), call)
286
+ store = gateway.logs.store
287
+ entries = await store.whole(call)
288
+ if not entries:
289
+ raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
290
+ if not any(entry.type == "call.ended" for entry in entries):
291
+ raise Conflict(STILL_GOING.format(call=call))
292
+ if not query.again and any(
293
+ entry.type == "call.score" and entry.data.get("passed") is not None for entry in entries
294
+ ):
295
+ raise Conflict(ALREADY_JUDGED.format(call=call))
296
+ own = await judges.for_call(gateway.connections.pool, key.org, entries[0].agent)
297
+ org_facts = await compliance_of(gateway.connections.pool, key.org, call, declared)
298
+ score = await judged_call(gateway.connections, entries, declared, own, org_facts)
299
+ await store.rescored(call, entries[0].agent, score.written())
300
+ await drifted(gateway.connections.pool, call, entries, score)
301
+ return score
302
+
303
+
304
+ # The gateway holds the vendors' keys: the org's own when it brought one, else the box's lent.
305
+ @router.post("/v1/evals/caller")
306
+ async def next_line(
307
+ body: NextLineRequest, _key: EvalsKey, scope: ScopeDep, gateway: GatewayDep
308
+ ) -> NextLineResponse:
309
+ """The persona's next line on the call so far, improvised by its model."""
310
+ await check_paced(gateway, f"{scope.org} evals/caller", LINES_A_MINUTE, TOO_MANY_LINES)
311
+ async with _caller_model(gateway, scope, body.persona) as model:
312
+ return (await improvise_line(model, body)).answer
313
+
314
+
315
+ # A persona is the agent's: a name nobody wrote for this agent is refused; one sent without a
316
+ # name is played as sent.
317
+ @router.post("/v1/evals/voice")
318
+ async def place_voice_call(
319
+ body: PlaceVoiceCallRequest, key: EvalsKey, scope: ScopeDep, gateway: GatewayDep
320
+ ) -> PlaceVoiceCallResponse:
321
+ """Dispatch the agent into a room, play the persona as a spoken caller, and hang up."""
322
+ check_agent(key.bearer, body.agent)
323
+ pool = gateway.connections.pool
324
+ persona = body.persona
325
+ if persona.name and await personas.persona(pool, key.org, body.agent, persona.name) is None:
326
+ raise NotFound(personas.NOBODY.format(name=persona.name, agent=body.agent))
327
+ await _a_new_call(gateway, body.call, body.agent, scope)
328
+ line = spoken.Line(interferer_db=body.interferer_db, packet_loss=body.packet_loss)
329
+ dispatch = rooms.Dispatch(
330
+ agent=body.agent,
331
+ org=scope.org,
332
+ env=scope.env,
333
+ holder=scope.holder or None,
334
+ caller=spoken.A_SIMULATED_CALLER,
335
+ persona=persona.name or None,
336
+ accepts_when=persona.accepts_when or None,
337
+ declines_when=persona.declines_when or None,
338
+ state=dict(body.state),
339
+ )
340
+ spending = Spending(await catalog.providers(pool))
341
+ stopped = False
342
+ async with _caller_model(gateway, scope, persona) as model:
343
+ speech = tts_of(await _caller_voice(gateway, scope, body.agent, persona))
344
+
345
+ # Asked before each line: the line that crosses the ceiling is the last one said.
346
+ async def improvised(turns_left: int) -> tuple[str, bool]:
347
+ nonlocal stopped
348
+ entries = await gateway.logs.store.whole(body.call)
349
+ if spoken.is_call_over(entries):
350
+ return "", True
351
+ if spending.is_over:
352
+ stopped = True
353
+ return "", True
354
+ request = NextLineRequest(
355
+ persona=persona, heard=heard_in(entries), turns_left=turns_left
356
+ )
357
+ next_one = await improvise_line(model, request)
358
+ spending.count(next_one, speech)
359
+ return next_one.answer.say, next_one.answer.hangup
360
+
361
+ placed = spoken.SpokenLine("", "", body.call, body.turns, line)
362
+ try:
363
+ turns = await _on_the_line(gateway, dispatch, speech, placed, improvised)
364
+ finally:
365
+ await speech.aclose()
366
+ return PlaceVoiceCallResponse(
367
+ call=body.call,
368
+ turns=turns,
369
+ line=spoken.described(line),
370
+ caller_cost_usd=spending.usd,
371
+ stopped_at_ceiling=stopped,
372
+ )
373
+
374
+
375
+ # ── a suite ──
376
+
377
+
378
+ # The run's calls run on what a real caller's would: the config tuned where the app holds it.
379
+ async def _suite_of(gateway: Gateway, body: RunSuiteRequest, registration: Registration) -> Suite:
380
+ pool, where = gateway.connections.pool, registration.scope
381
+ configured = await catalog.providers(pool)
382
+ if body.version is not None:
383
+ candidate = await scopes.tuning_at(pool, where, body.agent, body.version)
384
+ if candidate is None or candidate.holder != where.holder:
385
+ raise NotFound(NO_SUCH_VERSION.format(version=body.version, slug=body.agent))
386
+ picked = Picked(version=body.version)
387
+ config, versions = await tuned(pool, registration.config, where, configured, picked)
388
+ keys = await keys_of(pool, gateway.connections.vault, where)
389
+ setup = TextSetup(config, versions, thinking(config, configured, keys))
390
+ return Suite(body, registration, setup, configured, keys, runs.new_run(body.agent))
391
+
392
+
393
+ # Real callers' words are played in written calls in the sandbox: never through the live app,
394
+ # whose tools act for real, and never out loud.
395
+ async def _with_cases(gateway: Gateway, body: RunSuiteRequest, scope: Scope) -> RunSuiteRequest:
396
+ if scope.env == PRODUCTION:
397
+ raise NotAllowed(CASES_IN_THE_SANDBOX)
398
+ if body.voice:
399
+ raise DeclarationRefused(CASES_WRITTEN)
400
+ played = await dataset.picked(
401
+ gateway.connections.pool, scope.org, body.agent, body.cases, every=body.dataset
402
+ )
403
+ return body.model_copy(update={"goldens": [*body.goldens, *played]})
404
+
405
+
406
+ async def _judged_suite(gateway: Gateway, suite: Suite, org: str) -> str | None:
407
+ judge = (await judge_of(gateway.connections, suite.configured)).running
408
+ model = None if judge is None else llm_of(judge)
409
+ try:
410
+ async with asyncio.timeout(runs.A_RUN_MAY_TAKE_S):
411
+ return await _every_golden(gateway, suite, model)
412
+ except QuotaExhausted as refused:
413
+ await exhausted(gateway.logs, org, suite.body.agent, refused)
414
+ return str(refused)
415
+ except TimeoutError:
416
+ return runs.TOOK_TOO_LONG.format(minutes=runs.A_RUN_MAY_TAKE_S // 60)
417
+ finally:
418
+ if model is not None:
419
+ await model.aclose()
420
+
421
+
422
+ # The row is written before each call (so it can be tailed) and after it is judged.
423
+ async def _every_golden(gateway: Gateway, suite: Suite, judge: llm.LLM[Never] | None) -> str | None:
424
+ body, registration = suite.body, suite.registration
425
+ total = len(body.goldens) * max(len(body.models), 1)
426
+ for model in body.models or [None]:
427
+ column = runs.column_of(model)
428
+ setup = _column_setup(suite, model)
429
+ for golden in body.goldens:
430
+ if not _holds(gateway, registration):
431
+ return THE_APP_LEFT.format(done=len(suite.cells), total=total, slug=body.agent)
432
+ opened = OpenedCall(golden=golden.name, model=column, call=new_call_id())
433
+ suite.calls.append(opened)
434
+ await runs.put(gateway.connections.pool, registration.scope, suite.now)
435
+ played = await (
436
+ _said_out_loud(gateway, suite, golden, opened)
437
+ if body.voice
438
+ else _written(gateway, suite, golden, opened, setup)
439
+ )
440
+ if played is None:
441
+ return THE_APP_LEFT.format(done=len(suite.cells), total=total, slug=body.agent)
442
+ entries = await gateway.logs.store.whole(opened.call)
443
+ case = case_of(entries, setup.config)
444
+ panel = await _panel_of(gateway, suite, case, opened) if golden.expect.judges else []
445
+ scores = await runs.score(goldens.golden_judges(golden, case, panel), case, judge)
446
+ requests = None if body.voice else played.requests
447
+ suite.cells.append(runs.cell_of(opened, case, scores, requests))
448
+ await runs.put(gateway.connections.pool, registration.scope, suite.now)
449
+ return None
450
+
451
+
452
+ # The panel a real call of the agent meets at hang-up: a golden's `expect.judges` picks from it.
453
+ async def _panel_of(
454
+ gateway: Gateway, suite: Suite, case: Case, opened: OpenedCall
455
+ ) -> list[CaseJudge]:
456
+ pool, scope = gateway.connections.pool, suite.registration.scope
457
+ own = await judges.for_call(pool, scope.org, suite.body.agent)
458
+ org_facts = await compliance_of(pool, scope.org, opened.call, suite.setup.config)
459
+ return hangup_judges(case, [stored.judge for stored in own], org_facts)
460
+
461
+
462
+ # A column runs the agent's own model, or the one named, on the same tuned config and keys.
463
+ def _column_setup(suite: Suite, model: ModelConfig | None) -> TextSetup:
464
+ config = suite.setup.config
465
+ if model is not None:
466
+ declared = Model(provider=model.provider, model=model.model, temperature=model.temperature)
467
+ config = dataclasses.replace(config, llm=declared)
468
+ return dataclasses.replace(
469
+ suite.setup, config=config, model=thinking(config, suite.configured, suite.keys)
470
+ )
471
+
472
+
473
+ async def _written(
474
+ gateway: Gateway, suite: Suite, golden: Golden, opened: OpenedCall, setup: TextSetup
475
+ ) -> goldens.Played | None:
476
+ registration = suite.registration
477
+ scope = registration.scope
478
+ context = CallContext(
479
+ call=opened.call,
480
+ channel=THE_WIDGET,
481
+ direction="inbound",
482
+ caller=f"web_{new_call_id()[5:17]}",
483
+ route=Route(org=scope.org, agent=registration.slug, channel=THE_WIDGET, env=scope.env),
484
+ today=golden.today or today_in(gateway.connections.settings.timezone),
485
+ run=suite.run.id,
486
+ holder=scope.holder or None,
487
+ state=dict(golden.state),
488
+ )
489
+ session = await open_text_as(
490
+ gateway.serving,
491
+ registration,
492
+ context,
493
+ dataclasses.replace(setup, recalled=tuple(golden.memory)),
494
+ )
495
+ heard = await gateway.logs.writing(opened.call, registration.slug).followed()
496
+ try:
497
+ played = await goldens.drive(
498
+ session, golden, heard, is_held=lambda: _holds(gateway, registration)
499
+ )
500
+ finally:
501
+ heard.close()
502
+ return played if played.held else None
503
+
504
+
505
+ async def _said_out_loud(
506
+ gateway: Gateway, suite: Suite, golden: Golden, opened: OpenedCall
507
+ ) -> goldens.Played | None:
508
+ registration, body = suite.registration, suite.body
509
+ scope = registration.scope
510
+ lines = [line for line in golden.input if line]
511
+ dispatch = rooms.Dispatch(
512
+ agent=registration.slug,
513
+ org=scope.org,
514
+ env=scope.env,
515
+ holder=scope.holder or None,
516
+ caller=spoken.A_SIMULATED_CALLER,
517
+ app=registration.owner,
518
+ run=suite.run.id,
519
+ state=dict(golden.state),
520
+ )
521
+
522
+ async def scripted(turns_left: int) -> tuple[str, bool]:
523
+ return (lines[len(lines) - turns_left], False) if turns_left <= len(lines) else ("", False)
524
+
525
+ speech = tts_of(await _caller_voice(gateway, scope, registration.slug, None))
526
+ line = spoken.Line(interferer_db=body.interferer_db, packet_loss=body.packet_loss)
527
+ placed = spoken.SpokenLine("", "", opened.call, len(lines), line)
528
+ try:
529
+ await _on_the_line(gateway, dispatch, speech, placed, scripted)
530
+ finally:
531
+ await speech.aclose()
532
+ await _until_sealed(gateway, opened.call)
533
+ return goldens.Played(held=True, requests=())
534
+
535
+
536
+ # The head is claimed in the caller's scope before the room is offered, so the worker opens it
537
+ # there or not at all (api/calls.py _unclaimed_or_in), and another org's call can never be named.
538
+ async def _a_new_call(gateway: Gateway, call: str, agent: str, scope: Scope) -> None:
539
+ if await queries.scope_of_call(gateway.connections.pool, call) is not None:
540
+ raise Conflict(NOT_A_NEW_CALL.format(call=call))
541
+ await gateway.logs.store.claim(call, agent, scope.org, Claim(scope))
542
+
543
+
544
+ # ── the caller ──
545
+
546
+
547
+ @asynccontextmanager
548
+ async def _caller_model(
549
+ gateway: Gateway, scope: Scope, persona: CallerPersona
550
+ ) -> AsyncGenerator[llm.LLM[Never]]:
551
+ pool = gateway.connections.pool
552
+ configured = await catalog.providers(pool)
553
+ declared = model_of(persona.llm or None, "llm", in_use=configured.defaults["llm"].vendor)
554
+ keys = await keys_of(pool, gateway.connections.vault, scope)
555
+ model = llm_of(credentials.stage("llm", declared, configured, keys))
556
+ try:
557
+ yield model
558
+ finally:
559
+ await model.aclose()
560
+
561
+
562
+ # The caller speaks in the agent's language and never in the agent's own voice.
563
+ async def _caller_voice(
564
+ gateway: Gateway, scope: Scope, agent: str, persona: CallerPersona | None
565
+ ) -> Running:
566
+ pool = gateway.connections.pool
567
+ configured = await catalog.providers(pool)
568
+ holding = gateway.sockets.of(scope, agent)
569
+ config = (
570
+ AgentConfig(slug=agent)
571
+ if holding is None or holding.scope.org != scope.org
572
+ else (await tuned(pool, holding.config, holding.scope, configured))[0]
573
+ )
574
+ in_use = configured.defaults["tts"].vendor
575
+ named = None if persona is None else model_of(persona.tts or None, "tts", in_use=in_use)
576
+ vendor = in_use if named is None else named.provider
577
+ agents_voice = None if config.voice is None else config.voice.voice_id
578
+ chosen = (persona.voice if persona is not None else None) or spoken.pick_caller_voice(
579
+ configured.voices, vendor, agents_voice, config.language
580
+ )
581
+ model = None if named is None else named.model or None
582
+ wanted = Voice(provider=vendor, model=model, voice_id=chosen)
583
+ keys = await keys_of(pool, gateway.connections.vault, scope)
584
+ stage = credentials.stage("tts", wanted, configured, keys)
585
+ return dataclasses.replace(stage, voice=chosen, language=config.language)
586
+
587
+
588
+ # The room is deleted whatever happens: that ends the agent's job, and its job seals the log.
589
+ async def _on_the_line(
590
+ gateway: Gateway,
591
+ dispatch: rooms.Dispatch,
592
+ speech: tts.TTS[Never],
593
+ line: spoken.SpokenLine,
594
+ next_line: spoken.NextLine,
595
+ ) -> int:
596
+ connections = gateway.connections
597
+ world = dispatch.env or "sandbox"
598
+ fleet = worlds.fleet_of(await worlds.fleets(connections.pool), world)
599
+ visitor = tokens.Visitor(
600
+ expires_at=time.time() + A_CALL_MAY_LAST_S, identity=spoken.A_SIMULATED_CALLER
601
+ )
602
+ token = tokens.room_token(gateway.signer, line.call, "talk", visitor)
603
+ joined = dataclasses.replace(line, url=connections.settings.livekit_url_of(world), token=token)
604
+ try:
605
+ await gateway.offering.offer(line.call, fleet, dispatch)
606
+ return await spoken.run_spoken(joined, speech, gateway.logs, next_line)
607
+ except (TimeoutError, rtc.ConnectError) as broke:
608
+ raise NotAvailable(NO_LINE.format(broke=broke)) from broke
609
+ finally:
610
+ await rooms.room_closed(connections.servers[world], line.call)
611
+
612
+
613
+ async def _until_sealed(gateway: Gateway, call: str) -> None:
614
+ log = gateway.logs.reading(call)
615
+ subscription = await log.followed()
616
+ try:
617
+ async with asyncio.timeout(A_SEAL_MAY_TAKE_S):
618
+ while not await gateway.logs.store.sealed(call):
619
+ await anext(subscription, None)
620
+ except TimeoutError as never:
621
+ raise NotAvailable(NEVER_SEALED.format(call=call, seconds=A_SEAL_MAY_TAKE_S)) from never
622
+ finally:
623
+ subscription.close()
624
+
625
+
626
+ def _refuse_past_the_ceiling(body: RunSuiteRequest) -> None:
627
+ models = max(len(body.models), 1)
628
+ total = len(body.goldens) * models
629
+ if total > CALLS_A_RUN:
630
+ raise DeclarationRefused(
631
+ TOO_MANY_CALLS.format(played=len(body.goldens), models=models, total=total)
632
+ )
633
+
634
+
635
+ def _refuse_out_loud(body: RunSuiteRequest) -> None:
636
+ if body.models:
637
+ raise DeclarationRefused(ONE_MODEL_OUT_LOUD)
638
+ for golden in body.goldens:
639
+ if golden.today is not None:
640
+ raise DeclarationRefused(
641
+ NO_PINNED_DAY_OUT_LOUD.format(name=golden.name, day=golden.today)
642
+ )
643
+
644
+
645
+ def _holds(gateway: Gateway, registration: Registration) -> bool:
646
+ holding = gateway.sockets.on(registration.owner, registration.scope.env, registration.slug)
647
+ return holding is not None