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