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
pinecall/cli/_load.py ADDED
@@ -0,0 +1,433 @@
1
+ """`load`: synthetic calls held against a gateway exactly as workers hold them, and measured."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import math
6
+ import re
7
+ import sys
8
+ import time
9
+ from collections import Counter
10
+ from collections.abc import Callable, Sequence
11
+ from dataclasses import dataclass, field
12
+ from datetime import UTC, datetime
13
+ from functools import partial
14
+ from itertools import pairwise
15
+ from pathlib import Path
16
+
17
+ import httpx
18
+ from pydantic import TypeAdapter
19
+
20
+ from pinecall.domain.call import CallContext, Route, new_call_id
21
+ from pinecall.domain.errors import DeclarationRefused, GatewayRefused
22
+ from pinecall.domain.names import SANDBOX
23
+ from pinecall.fleet.client import SEALED_WITHIN_S, TIMEOUT_S, GatewayClient
24
+ from pinecall.process.settings import Settings
25
+ from pinecall.session.call import Writing
26
+ from pinecall.wire.events import GATEWAY_WRITES, CallEnded, event_of
27
+ from pinecall.wire.frames import Entry, WireModel
28
+ from pinecall.wire.rest.calls import BatchedEntry, OpenCallRequest, SealCallRequest
29
+
30
+ # What the replayer never sends: the gateway's own kinds, and a tool's round trip, which the
31
+ # gateway writes too (the one the worker writes itself, end_call's, is skipped with them).
32
+ GATEWAY_KINDS = GATEWAY_WRITES | {"tool.call", "tool.result"}
33
+
34
+ ENDED = "call.ended"
35
+
36
+ # The channel needs no number: the load names no carrier.
37
+ CHANNEL = "web"
38
+
39
+ CALLER = "load"
40
+
41
+ OUTCOME = "load"
42
+
43
+ # Between a refusal and the next call, so a gateway that refuses every open is not hammered.
44
+ REFUSED_PAUSE_S = 1.0
45
+
46
+ LAG_EVERY_S = 0.1
47
+
48
+ # Over this, the generator measures its own loop and not the gateway.
49
+ LAG_WARNING_MS = 50.0
50
+
51
+ # Past the run's end, what a call still writing and sealing is given before it is cut.
52
+ GRACE_S = 2 * SEALED_WITHIN_S
53
+
54
+ # A call's writer, once everything it queued was answered or the grace cut the call, stops this
55
+ # soon; nothing is left to send but what a cut call never will.
56
+ LET_GO_S = 0.1
57
+
58
+ UNREACHABLE = "unreachable"
59
+
60
+ A_CALL_IN_A_PATH = re.compile(r"call_[0-9a-f]+")
61
+
62
+ NO_KEY = "PINECALL_WORKER_KEY is unset: the load knocks with the sandbox fleet's key"
63
+
64
+ NOT_A_SCRIPT = "--script {path}: {why}"
65
+
66
+ NOT_ENOUGH = "--calls {calls}: at least one"
67
+
68
+ _ENTRIES: TypeAdapter[list[Entry]] = TypeAdapter(list[Entry])
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Step:
73
+ """One entry of the script: how long after the call opened it is written, and what it is."""
74
+
75
+ at_s: float
76
+ type: str
77
+ event: WireModel
78
+ ephemeral: bool
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class Script:
83
+ """What one synthetic call writes: the worker's entries in order, then its call.ended."""
84
+
85
+ steps: tuple[Step, ...]
86
+ ending: Step
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Plan:
91
+ """A run: the org and agent the calls are opened for, the script, and the curve."""
92
+
93
+ org: str
94
+ agent: str
95
+ script: Script
96
+ calls: int
97
+ ramp_s: float
98
+ hold_s: float
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class Window:
103
+ """When a run reaches its top and when it ends, on the monotonic clock."""
104
+
105
+ top_at: float
106
+ ends_at: float
107
+
108
+
109
+ @dataclass
110
+ class Tally:
111
+ """What a run counted while it ran: calls, entries, latencies, refusals, logs, the loop."""
112
+
113
+ opened: int = 0
114
+ sealed: int = 0
115
+ held: int = 0
116
+ most_held: int = 0
117
+ sent: int = 0
118
+ durable: int = 0
119
+ at_top: int = 0
120
+ # From an entry queued to the log holding it: what the call waits, as a worker's does.
121
+ append_ms: list[float] = field(default_factory=list[float])
122
+ batches: list[int] = field(default_factory=list[int])
123
+ seal_ms: list[float] = field(default_factory=list[float])
124
+ refusals: Counter[str] = field(default_factory=Counter[str])
125
+ verified: int = 0
126
+ wrong: int = 0
127
+ unread: Counter[str] = field(default_factory=Counter[str])
128
+ lag_ms: list[float] = field(default_factory=list[float])
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class Run:
133
+ """What every call of a run shares: how a slot knocks, the plan, the tally and the window."""
134
+
135
+ knock: Callable[[], GatewayClient]
136
+ plan: Plan
137
+ tally: Tally
138
+ window: Window
139
+
140
+
141
+ def load_verb(verb: argparse.ArgumentParser) -> None:
142
+ """`load --org --agent --script --calls --ramp --minutes`."""
143
+ verb.add_argument("--org", required=True, help="the org the calls are opened for")
144
+ verb.add_argument("--agent", required=True, help="the agent the calls are opened for")
145
+ verb.add_argument("--script", required=True, help="a call's log as JSON, replayed per call")
146
+ verb.add_argument("--calls", required=True, type=int, help="how many at once at the top")
147
+ verb.add_argument("--ramp", required=True, type=float, help="seconds to reach them, linearly")
148
+ verb.add_argument("--minutes", required=True, type=float, help="how long the top is held")
149
+ verb.set_defaults(run=loaded)
150
+
151
+
152
+ def loaded(settings: Settings, args: argparse.Namespace) -> int:
153
+ """Hold the calls against the gateway, then print what was measured."""
154
+ if settings.worker_key is None:
155
+ raise DeclarationRefused(NO_KEY)
156
+ if args.calls < 1:
157
+ raise DeclarationRefused(NOT_ENOUGH.format(calls=args.calls))
158
+ plan = Plan(
159
+ org=args.org,
160
+ agent=args.agent,
161
+ script=script_of(_entries_in(Path(args.script))),
162
+ calls=args.calls,
163
+ ramp_s=args.ramp,
164
+ hold_s=args.minutes * 60,
165
+ )
166
+ tally = asyncio.run(_measured(settings.gateway_url, settings.worker_key, plan))
167
+ sys.stdout.writelines(f"{line}\n" for line in report_of(tally, plan.hold_s))
168
+ return 0
169
+
170
+
171
+ def written_by_worker(kind: str) -> bool:
172
+ """Whether a worker writes this kind of entry, rather than the gateway itself."""
173
+ return kind not in GATEWAY_KINDS
174
+
175
+
176
+ # ts may step back between two entries of a real log: a step is never earlier than the last.
177
+ def script_of(entries: Sequence[Entry]) -> Script:
178
+ """The worker's entries of a call's log, timed and read as the wire's; call.ended if absent."""
179
+ first = entries[0].ts
180
+ steps: list[Step] = []
181
+ ending: Step | None = None
182
+ at_s = 0.0
183
+ for entry in entries:
184
+ at_s = max(at_s, entry.ts - first)
185
+ if not written_by_worker(entry.type):
186
+ continue
187
+ step = Step(at_s, entry.type, event_of(entry), entry.ephemeral)
188
+ if entry.type == ENDED:
189
+ ending = step
190
+ else:
191
+ steps.append(step)
192
+ if ending is None:
193
+ over = CallEnded(
194
+ reason="caller_hung_up", ended_by="caller", ended_at=first + at_s, duration_s=at_s
195
+ )
196
+ ending = Step(at_s, ENDED, over, ephemeral=False)
197
+ return Script(tuple(steps), ending)
198
+
199
+
200
+ def starts_of(calls: int, ramp_s: float) -> list[float]:
201
+ """When each slot starts, in seconds from the run's start: evenly along the ramp."""
202
+ return [ramp_s * slot / calls for slot in range(calls)]
203
+
204
+
205
+ def percentile(samples: Sequence[float], share: float) -> float | None:
206
+ """The nearest-rank percentile of the samples (share 0.99 for p99); None for no sample."""
207
+ if not samples:
208
+ return None
209
+ ordered = sorted(samples)
210
+ return ordered[max(math.ceil(share * len(ordered)) - 1, 0)]
211
+
212
+
213
+ def rising(sent: Sequence[Entry]) -> bool:
214
+ """Whether the seqs the gateway gave a call's entries rise strictly, in the order sent."""
215
+ return all(before.seq < after.seq for before, after in pairwise(sent))
216
+
217
+
218
+ # The kinds sent are compared: a duplicate from a retry is an entry of a sent kind at a new seq.
219
+ def log_holds(sent: Sequence[Entry], read: Sequence[Entry]) -> bool:
220
+ """Whether a log read back holds every durable entry sent, once each, in order, and no other."""
221
+ kinds = {entry.type for entry in sent}
222
+ written = [(entry.seq, entry.type) for entry in sent if not entry.ephemeral]
223
+ return [(entry.seq, entry.type) for entry in read if entry.type in kinds] == written
224
+
225
+
226
+ async def run_load(knock: Callable[[], GatewayClient], plan: Plan) -> Tally:
227
+ """Hold the plan's calls, each slot on a client of its own, and count what happened."""
228
+ tally = Tally()
229
+ began = time.monotonic()
230
+ window = Window(top_at=began + plan.ramp_s, ends_at=began + plan.ramp_s + plan.hold_s)
231
+ stopped = asyncio.Event()
232
+ try:
233
+ async with (
234
+ asyncio.timeout(plan.ramp_s + plan.hold_s + GRACE_S),
235
+ asyncio.TaskGroup() as group,
236
+ ):
237
+ group.create_task(_lag(tally, stopped))
238
+ run = Run(knock, plan, tally, window)
239
+ slots = [
240
+ group.create_task(_slot(run, start)) for start in starts_of(plan.calls, plan.ramp_s)
241
+ ]
242
+ await asyncio.wait(slots)
243
+ stopped.set()
244
+ except TimeoutError:
245
+ # Past the grace, a call still writing is cut; the report says how many were open.
246
+ pass
247
+ return tally
248
+
249
+
250
+ def report_of(tally: Tally, hold_s: float) -> list[str]:
251
+ """What a run measured, a line each, as a person and a script both read it."""
252
+ per_second = "-" if hold_s <= 0 else f"{tally.at_top / hold_s:.1f}"
253
+ lag = percentile(tally.lag_ms, 0.99)
254
+ lines = [
255
+ f"calls opened: {tally.opened}",
256
+ f"calls sealed: {tally.sealed}",
257
+ f"calls open at the end: {tally.held}",
258
+ f"most held at once: {tally.most_held}",
259
+ f"entries sent: {tally.sent} ({tally.durable} durable)",
260
+ f"entries a second at the top: {per_second}",
261
+ f"append ms: {_percentiles(tally.append_ms, (0.5, 0.95, 0.99))}",
262
+ f"entries per batch: {_percentiles([float(n) for n in tally.batches], (0.5, 0.99))}",
263
+ f"seal ms: {_percentiles(tally.seal_ms, (0.5, 0.99))}",
264
+ f"refusals: {_counted(tally.refusals)}",
265
+ f"logs verified: {tally.verified}",
266
+ f"logs found wrong: {tally.wrong}",
267
+ f"logs unread: {sum(tally.unread.values())} (refused: {_counted(tally.unread)})",
268
+ f"loop lag ms: p99 {_ms(lag)}",
269
+ ]
270
+ if lag is not None and lag > LAG_WARNING_MS:
271
+ lines.append(
272
+ f"warning: this generator's own loop lagged {lag:.1f} ms at p99, over "
273
+ f"{LAG_WARNING_MS:.0f}: it measured itself, not the gateway; run fewer calls a process"
274
+ )
275
+ return lines
276
+
277
+
278
+ # A worker's job is a process with a client of its own, so a slot has one too: a pool shared by
279
+ # every call is searched whole on each request, and the generator would measure that search.
280
+ async def _measured(url: str, key: str, plan: Plan) -> Tally:
281
+ headers = {"Authorization": f"Bearer {key}"}
282
+ # Built once: every client would otherwise load the machine's certificates for itself.
283
+ trusted = httpx.create_ssl_context()
284
+
285
+ def knock() -> GatewayClient:
286
+ http = httpx.AsyncClient(base_url=url, headers=headers, timeout=TIMEOUT_S, verify=trusted)
287
+ return GatewayClient(http)
288
+
289
+ return await run_load(knock, plan)
290
+
291
+
292
+ def _entries_in(path: Path) -> list[Entry]:
293
+ try:
294
+ entries = _ENTRIES.validate_json(path.read_bytes())
295
+ except (OSError, ValueError) as unread:
296
+ raise DeclarationRefused(NOT_A_SCRIPT.format(path=path, why=unread)) from None
297
+ if not entries:
298
+ raise DeclarationRefused(NOT_A_SCRIPT.format(path=path, why="the log holds no entry"))
299
+ return entries
300
+
301
+
302
+ async def _lag(tally: Tally, stopped: asyncio.Event) -> None:
303
+ while not stopped.is_set():
304
+ before = time.monotonic()
305
+ await asyncio.sleep(LAG_EVERY_S)
306
+ tally.lag_ms.append((time.monotonic() - before - LAG_EVERY_S) * 1000)
307
+
308
+
309
+ # A refusal ends that call, never the slot: the slot opens a fresh call after a pause.
310
+ async def _slot(run: Run, start_s: float) -> None:
311
+ await asyncio.sleep(start_s)
312
+ client = run.knock()
313
+ try:
314
+ while time.monotonic() < run.window.ends_at:
315
+ try:
316
+ await _call(run, client)
317
+ except GatewayRefused as refused:
318
+ run.tally.refusals[_status(refused)] += 1
319
+ await asyncio.sleep(REFUSED_PAUSE_S)
320
+ finally:
321
+ await client.aclose()
322
+
323
+
324
+ async def _call(run: Run, client: GatewayClient) -> None:
325
+ plan, tally = run.plan, run.tally
326
+ context = CallContext(
327
+ call=new_call_id(),
328
+ channel=CHANNEL,
329
+ direction="inbound",
330
+ caller=CALLER,
331
+ route=Route(org=plan.org, agent=plan.agent, channel=CHANNEL, env=SANDBOX),
332
+ today=datetime.now(UTC).date(),
333
+ )
334
+ await client.open(OpenCallRequest(agent=plan.agent, context=context))
335
+ tally.opened += 1
336
+ tally.held += 1
337
+ tally.most_held = max(tally.most_held, tally.held)
338
+ try:
339
+ sent = await _replayed(run, client, context.call)
340
+ started = time.monotonic()
341
+ await client.sealed(context.call, SealCallRequest(usage=[], outcome=OUTCOME))
342
+ tally.seal_ms.append((time.monotonic() - started) * 1000)
343
+ tally.sealed += 1
344
+ finally:
345
+ tally.held -= 1
346
+ await _verified(run, client, context.call, sent)
347
+
348
+
349
+ # At the script's own times from the open, queued on the worker's own writer, which sends what
350
+ # is queued as one batch while the last is out; a call the run's end overtakes skips to its
351
+ # call.ended. A refusal ends the call once what it queued has been answered.
352
+ async def _replayed(run: Run, client: GatewayClient, call: str) -> list[Entry]:
353
+ script, ends_at = run.plan.script, run.window.ends_at
354
+
355
+ async def batched(entries: list[BatchedEntry], *, after: int) -> list[Entry]:
356
+ run.tally.batches.append(len(entries))
357
+ return await client.append_many(call, entries, after=after)
358
+
359
+ writing = Writing(batched, call)
360
+ writing.open()
361
+ opened_at = time.monotonic()
362
+ written: list[asyncio.Future[Entry]] = []
363
+ try:
364
+ for step in (*script.steps, script.ending):
365
+ due = opened_at + step.at_s
366
+ if due >= ends_at and step is not script.ending:
367
+ continue
368
+ await asyncio.sleep(max(min(due, ends_at) - time.monotonic(), 0.0))
369
+ written.append(_queued(run, writing, step))
370
+ answered = await asyncio.gather(*written, return_exceptions=True)
371
+ finally:
372
+ await writing.close(LET_GO_S)
373
+ sent: list[Entry] = []
374
+ for result in answered:
375
+ if isinstance(result, BaseException):
376
+ raise result
377
+ sent.append(result)
378
+ return sent
379
+
380
+
381
+ def _queued(run: Run, writing: Writing, step: Step) -> asyncio.Future[Entry]:
382
+ queued_at = time.monotonic()
383
+ written = writing.write(step.type, step.event, ephemeral=step.ephemeral)
384
+ written.add_done_callback(partial(_landed, run, queued_at))
385
+ return written
386
+
387
+
388
+ def _landed(run: Run, queued_at: float, written: asyncio.Future[Entry]) -> None:
389
+ if written.cancelled() or written.exception() is not None:
390
+ return
391
+ tally, done = run.tally, time.monotonic()
392
+ tally.append_ms.append((done - queued_at) * 1000)
393
+ tally.sent += 1
394
+ tally.durable += 0 if written.result().ephemeral else 1
395
+ if run.window.top_at <= done <= run.window.ends_at:
396
+ tally.at_top += 1
397
+
398
+
399
+ # Read through the log's own door as a reader pages it; a key that may not read is counted apart.
400
+ async def _verified(run: Run, client: GatewayClient, call: str, sent: list[Entry]) -> None:
401
+ tally = run.tally
402
+ if not rising(sent):
403
+ tally.wrong += 1
404
+ return
405
+ try:
406
+ read = [entry async for entry in client.since(call, 0)]
407
+ except GatewayRefused as refused:
408
+ tally.unread[_status(refused)] += 1
409
+ return
410
+ if log_holds(sent, read):
411
+ tally.verified += 1
412
+ else:
413
+ tally.wrong += 1
414
+
415
+
416
+ # By status and door, the call's id taken out: which request was refused is what a run under
417
+ # gateways killed must say (`unreachable POST /v1/calls`), not a count of statuses alone.
418
+ def _status(refused: GatewayRefused) -> str:
419
+ status = UNREACHABLE if refused.answered is None else str(refused.answered)
420
+ door = A_CALL_IN_A_PATH.sub("{call}", str(refused).split(": ", 1)[0])
421
+ return f"{status} {door}"
422
+
423
+
424
+ def _percentiles(samples: Sequence[float], shares: Sequence[float]) -> str:
425
+ return " ".join(f"p{round(share * 100)} {_ms(percentile(samples, share))}" for share in shares)
426
+
427
+
428
+ def _ms(value: float | None) -> str:
429
+ return "-" if value is None else f"{value:.1f}"
430
+
431
+
432
+ def _counted(counts: Counter[str]) -> str:
433
+ return " ".join(f"{name}={count}" for name, count in sorted(counts.items())) or "none"