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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- 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
|