monkeybot 2.1.1__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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Realtime error taxonomy.
|
|
2
|
+
|
|
3
|
+
Every error in the realtime loop is either (a) surfaced to the client as a typed
|
|
4
|
+
``RealtimeError`` event and the session is closed, or (b) logged and treated as
|
|
5
|
+
non-fatal (e.g. a malformed client frame). Close/send failures must be logged, never
|
|
6
|
+
silently discarded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RealtimeError(Exception):
|
|
13
|
+
"""Base class for all realtime gateway errors."""
|
|
14
|
+
|
|
15
|
+
client_visible: bool = True
|
|
16
|
+
close_code: int = 1011
|
|
17
|
+
log_level: str = "error"
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, details: str | None = None) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.message = message
|
|
22
|
+
self.details = details
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProviderConnectionError(RealtimeError):
|
|
26
|
+
"""Failed to establish the provider realtime session."""
|
|
27
|
+
|
|
28
|
+
close_code = 1011
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProviderStreamError(RealtimeError):
|
|
32
|
+
"""The provider stream failed mid-session."""
|
|
33
|
+
|
|
34
|
+
close_code = 1011
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ClientProtocolError(RealtimeError):
|
|
38
|
+
"""Malformed or unexpected client frame."""
|
|
39
|
+
|
|
40
|
+
close_code = 1008
|
|
41
|
+
log_level = "warning"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AudioFormatError(RealtimeError):
|
|
45
|
+
"""Client and gateway audio formats do not match."""
|
|
46
|
+
|
|
47
|
+
close_code = 1008
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SubagentDispatchError(RealtimeError):
|
|
51
|
+
"""A subagent/task dispatch failed. Non-fatal; surfaced to the client but session continues."""
|
|
52
|
+
|
|
53
|
+
client_visible = True
|
|
54
|
+
close_code = 0 # do not close the WebSocket
|
|
55
|
+
log_level = "warning"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GatewayInternalError(RealtimeError):
|
|
59
|
+
"""Unexpected gateway internal error."""
|
|
60
|
+
|
|
61
|
+
close_code = 1011
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GuardrailError(RealtimeError):
|
|
65
|
+
"""Session closed due to a guardrail trigger (idle, max duration, etc.)."""
|
|
66
|
+
|
|
67
|
+
close_code = 1001
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConcurrencyLimitError(RealtimeError):
|
|
71
|
+
"""Realtime concurrency limit reached; upgrade rejected."""
|
|
72
|
+
|
|
73
|
+
close_code = 1013 # Try Again Later
|
|
74
|
+
log_level = "warning"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SessionConflictError(RealtimeError):
|
|
78
|
+
"""A live WebSocket already exists for this session_id."""
|
|
79
|
+
|
|
80
|
+
close_code = 1008
|
|
81
|
+
log_level = "warning"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Realtime session guardrails: max duration, idle timeout, max response turn.
|
|
2
|
+
|
|
3
|
+
Guardrails are enforced by a background task that runs alongside the main session loop.
|
|
4
|
+
They are non-blocking and only raise a :class:`GuardrailError` when a threshold is
|
|
5
|
+
crossed. The session loop then closes the session gracefully. The WebSocket ping/pong
|
|
6
|
+
keepalive is handled by the underlying Starlette/FastAPI implementation; this module only
|
|
7
|
+
encloses the application-level idle timer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from monkeybot.core.config.realtime_config import RealtimeConfig
|
|
17
|
+
from monkeybot.core.logging_utils import kv
|
|
18
|
+
|
|
19
|
+
from .errors import GuardrailError
|
|
20
|
+
from .session import RealtimeConnectionState
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("monkeybot.gateway.realtime.guardrails")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def run_guardrails(
|
|
26
|
+
state: RealtimeConnectionState,
|
|
27
|
+
config: RealtimeConfig,
|
|
28
|
+
*,
|
|
29
|
+
poll_interval_sec: float = 5.0,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Background task enforcing session duration, idle timeout, and response-turn limits.
|
|
32
|
+
|
|
33
|
+
Raises :class:`GuardrailError` with the guardrail reason when a limit is hit. The
|
|
34
|
+
caller is responsible for canceling this task after the error is raised.
|
|
35
|
+
"""
|
|
36
|
+
max_duration = config.session.max_duration_sec
|
|
37
|
+
idle_timeout = config.session.idle_timeout_sec
|
|
38
|
+
max_response_turn = config.session.max_response_turn_sec
|
|
39
|
+
|
|
40
|
+
while True:
|
|
41
|
+
await asyncio.sleep(poll_interval_sec)
|
|
42
|
+
if state._closed or state.state == "closing":
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
now = time.monotonic()
|
|
46
|
+
|
|
47
|
+
# Max duration hard cap
|
|
48
|
+
if now - state.opened_at >= max_duration:
|
|
49
|
+
logger.info(
|
|
50
|
+
"realtime guardrail triggered: max_duration %s",
|
|
51
|
+
kv(session_id=state.session_id, max_duration_sec=max_duration),
|
|
52
|
+
)
|
|
53
|
+
raise GuardrailError(
|
|
54
|
+
f"Session exceeded max_duration_sec={max_duration}",
|
|
55
|
+
details="max_duration_exceeded",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Idle timeout: no client or provider activity.
|
|
59
|
+
if now - state.last_activity_at >= idle_timeout:
|
|
60
|
+
logger.info(
|
|
61
|
+
"realtime guardrail triggered: idle_timeout %s",
|
|
62
|
+
kv(
|
|
63
|
+
session_id=state.session_id,
|
|
64
|
+
idle_timeout_sec=idle_timeout,
|
|
65
|
+
idle_sec=round(now - state.last_activity_at, 1),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
raise GuardrailError(
|
|
69
|
+
f"No activity for idle_timeout_sec={idle_timeout}",
|
|
70
|
+
details="idle_timeout",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Max response turn: one model turn cannot exceed the limit.
|
|
74
|
+
if state.state in ("thinking", "speaking", "tool_running"):
|
|
75
|
+
turn_start = state.metrics.current_turn_started_at if state.metrics else None
|
|
76
|
+
if turn_start is not None and now - turn_start >= max_response_turn:
|
|
77
|
+
logger.info(
|
|
78
|
+
"realtime guardrail triggered: max_response_turn %s",
|
|
79
|
+
kv(
|
|
80
|
+
session_id=state.session_id,
|
|
81
|
+
max_response_turn_sec=max_response_turn,
|
|
82
|
+
turn_sec=round(now - turn_start, 1),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
raise GuardrailError(
|
|
86
|
+
f"Model turn exceeded max_response_turn_sec={max_response_turn}",
|
|
87
|
+
details="max_response_turn_exceeded",
|
|
88
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Realtime session registry and concurrency guardrails."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from monkeybot.core.config.realtime_config import RealtimeConfig
|
|
11
|
+
from monkeybot.core.logging_utils import kv
|
|
12
|
+
|
|
13
|
+
from .session import RealtimeConnectionState
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("monkeybot.gateway.realtime.manager")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RealtimeSessionManager:
|
|
20
|
+
"""In-process manager for realtime sessions.
|
|
21
|
+
|
|
22
|
+
Mirrors the existing SSE ``SessionRegistry`` pattern: one instance per process.
|
|
23
|
+
Multi-instance deployments require sticky routing or an external pub/sub (out of
|
|
24
|
+
scope for v1, per the realtime design doc).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
config: RealtimeConfig
|
|
28
|
+
_sessions: dict[str, RealtimeConnectionState] = field(default_factory=dict)
|
|
29
|
+
_sem: asyncio.Semaphore = field(init=False)
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
self._sem = asyncio.Semaphore(self.config.session.max_concurrent_sessions)
|
|
33
|
+
|
|
34
|
+
async def acquire_slot(self, session_id: str) -> bool:
|
|
35
|
+
"""Acquire a concurrency slot. Returns True if granted, False if at limit."""
|
|
36
|
+
if self._sem.locked():
|
|
37
|
+
logger.warning(
|
|
38
|
+
"realtime concurrency limit reached %s",
|
|
39
|
+
kv(
|
|
40
|
+
session_id=session_id,
|
|
41
|
+
max_concurrent=self.config.session.max_concurrent_sessions,
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
return False
|
|
45
|
+
await self._sem.acquire()
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
def release_slot(self, session_id: str) -> None:
|
|
49
|
+
self._sem.release()
|
|
50
|
+
logger.debug(
|
|
51
|
+
"realtime concurrency slot released %s",
|
|
52
|
+
kv(session_id=session_id),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def register(self, session_id: str, state: RealtimeConnectionState) -> None:
|
|
56
|
+
"""Register a live connection. Raises if ``session_id`` is already active."""
|
|
57
|
+
if session_id in self._sessions:
|
|
58
|
+
raise ValueError(f"Realtime session already active: {session_id}")
|
|
59
|
+
self._sessions[session_id] = state
|
|
60
|
+
|
|
61
|
+
def get(self, session_id: str) -> RealtimeConnectionState | None:
|
|
62
|
+
return self._sessions.get(session_id)
|
|
63
|
+
|
|
64
|
+
def remove(self, session_id: str, state: RealtimeConnectionState | None = None) -> None:
|
|
65
|
+
"""Remove a session. When ``state`` is given, only remove that exact connection."""
|
|
66
|
+
current = self._sessions.get(session_id)
|
|
67
|
+
if current is None:
|
|
68
|
+
return
|
|
69
|
+
if state is not None and current is not state:
|
|
70
|
+
return
|
|
71
|
+
self._sessions.pop(session_id, None)
|
|
72
|
+
|
|
73
|
+
def snapshot_metrics(self) -> dict[str, Any]:
|
|
74
|
+
return {
|
|
75
|
+
"active_sessions": len(self._sessions),
|
|
76
|
+
"max_concurrent_sessions": self.config.session.max_concurrent_sessions,
|
|
77
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Realtime session and turn metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from monkeybot.core.logging_utils import kv
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("monkeybot.gateway.realtime.metrics")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RealtimeMetrics:
|
|
17
|
+
"""Accumulated metrics for one realtime session."""
|
|
18
|
+
|
|
19
|
+
session_id: str
|
|
20
|
+
request_id: str
|
|
21
|
+
opened_at: float = field(default_factory=time.monotonic)
|
|
22
|
+
closed_at: float | None = None
|
|
23
|
+
close_reason: str = "unknown"
|
|
24
|
+
user_audio_sec: float = 0.0
|
|
25
|
+
model_audio_sec: float = 0.0
|
|
26
|
+
interrupt_count: int = 0
|
|
27
|
+
turn_count: int = 0
|
|
28
|
+
input_tokens: int = 0
|
|
29
|
+
output_tokens: int = 0
|
|
30
|
+
last_prompt_tokens: int = 0
|
|
31
|
+
turn_latencies_ms: list[float] = field(default_factory=list)
|
|
32
|
+
turn_model_durations_ms: list[float] = field(default_factory=list)
|
|
33
|
+
turn_tool_counts: list[int] = field(default_factory=list)
|
|
34
|
+
_current_turn_started_at: float | None = None
|
|
35
|
+
_current_turn_first_output_at: float | None = None
|
|
36
|
+
_current_turn_tool_count: int = 0
|
|
37
|
+
|
|
38
|
+
def mark_user_audio_sec(self, duration: float) -> None:
|
|
39
|
+
self.user_audio_sec += duration
|
|
40
|
+
|
|
41
|
+
def mark_model_audio_sec(self, duration: float) -> None:
|
|
42
|
+
self.model_audio_sec += duration
|
|
43
|
+
|
|
44
|
+
def mark_interrupt(self) -> None:
|
|
45
|
+
self.interrupt_count += 1
|
|
46
|
+
|
|
47
|
+
def mark_usage(self, *, input_tokens: int = 0, output_tokens: int = 0) -> None:
|
|
48
|
+
"""Accumulate session totals; keep last turn's prompt size for the context meter."""
|
|
49
|
+
inp = max(0, input_tokens)
|
|
50
|
+
out = max(0, output_tokens)
|
|
51
|
+
self.input_tokens += inp
|
|
52
|
+
self.output_tokens += out
|
|
53
|
+
# Provider usage events report per-turn (or current-context) prompt size.
|
|
54
|
+
# The CLI context ring must use that latest value, not the session sum.
|
|
55
|
+
self.last_prompt_tokens = inp
|
|
56
|
+
|
|
57
|
+
def to_usage_payload(self, *, context_window_tokens: int = 200_000) -> dict[str, Any]:
|
|
58
|
+
"""Shape compatible with ``GET /sessions/{id}/usage`` / ``parse_usage_response``.
|
|
59
|
+
|
|
60
|
+
``input_tokens`` / ``output_tokens`` are session cumulatives.
|
|
61
|
+
``last_prompt_tokens`` / ``estimated_prompt_tokens`` are the most recent
|
|
62
|
+
provider-reported prompt size (realtime has no separate preflight count).
|
|
63
|
+
"""
|
|
64
|
+
return {
|
|
65
|
+
"input_tokens": self.input_tokens,
|
|
66
|
+
"output_tokens": self.output_tokens,
|
|
67
|
+
"cost_usd": 0.0,
|
|
68
|
+
"last_prompt_tokens": self.last_prompt_tokens,
|
|
69
|
+
"estimated_prompt_tokens": self.last_prompt_tokens,
|
|
70
|
+
"context_window_tokens": context_window_tokens,
|
|
71
|
+
"summarization_threshold_tokens": max(1, int(context_window_tokens * 0.85)),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def start_turn(self, now: float | None = None) -> None:
|
|
75
|
+
"""Call when a user turn boundary is finalized (model is about to respond)."""
|
|
76
|
+
self._current_turn_started_at = now or time.monotonic()
|
|
77
|
+
self._current_turn_first_output_at = None
|
|
78
|
+
self._current_turn_tool_count = 0
|
|
79
|
+
|
|
80
|
+
def mark_first_output(self, now: float | None = None) -> None:
|
|
81
|
+
"""Call when the first assistant output delta arrives."""
|
|
82
|
+
if self._current_turn_started_at is None:
|
|
83
|
+
return
|
|
84
|
+
now = now or time.monotonic()
|
|
85
|
+
self._current_turn_first_output_at = now
|
|
86
|
+
self.turn_latencies_ms.append(
|
|
87
|
+
round((now - self._current_turn_started_at) * 1000, 2)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def mark_tool_in_turn(self) -> None:
|
|
91
|
+
self._current_turn_tool_count += 1
|
|
92
|
+
|
|
93
|
+
def end_turn(self, now: float | None = None) -> None:
|
|
94
|
+
"""Call when the assistant turn boundary is finalized."""
|
|
95
|
+
self.turn_count += 1
|
|
96
|
+
self.turn_tool_counts.append(self._current_turn_tool_count)
|
|
97
|
+
if self._current_turn_first_output_at is not None:
|
|
98
|
+
now = now or time.monotonic()
|
|
99
|
+
self.turn_model_durations_ms.append(
|
|
100
|
+
round((now - self._current_turn_first_output_at) * 1000, 2)
|
|
101
|
+
)
|
|
102
|
+
self._current_turn_started_at = None
|
|
103
|
+
self._current_turn_first_output_at = None
|
|
104
|
+
self._current_turn_tool_count = 0
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def current_turn_started_at(self) -> float | None:
|
|
108
|
+
return self._current_turn_started_at
|
|
109
|
+
|
|
110
|
+
def close(self, reason: str, now: float | None = None) -> None:
|
|
111
|
+
self.close_reason = reason
|
|
112
|
+
self.closed_at = now or time.monotonic()
|
|
113
|
+
|
|
114
|
+
def as_dict(self) -> dict[str, Any]:
|
|
115
|
+
duration = 0.0
|
|
116
|
+
if self.closed_at is not None:
|
|
117
|
+
duration = round(self.closed_at - self.opened_at, 3)
|
|
118
|
+
return {
|
|
119
|
+
"event": "realtime_session_summary",
|
|
120
|
+
"session_id": self.session_id,
|
|
121
|
+
"request_id": self.request_id,
|
|
122
|
+
"realtime_session_duration_sec": duration,
|
|
123
|
+
"realtime_session_user_audio_sec": round(self.user_audio_sec, 3),
|
|
124
|
+
"realtime_session_model_audio_sec": round(self.model_audio_sec, 3),
|
|
125
|
+
"realtime_session_interrupt_count": self.interrupt_count,
|
|
126
|
+
"realtime_session_turn_count": self.turn_count,
|
|
127
|
+
"realtime_session_input_tokens": self.input_tokens,
|
|
128
|
+
"realtime_session_output_tokens": self.output_tokens,
|
|
129
|
+
"realtime_session_close_reason": self.close_reason,
|
|
130
|
+
"realtime_turn_latency_ms_avg": round(sum(self.turn_latencies_ms) / len(self.turn_latencies_ms), 2)
|
|
131
|
+
if self.turn_latencies_ms
|
|
132
|
+
else 0.0,
|
|
133
|
+
"realtime_turn_model_duration_ms_avg": round(
|
|
134
|
+
sum(self.turn_model_durations_ms) / len(self.turn_model_durations_ms), 2
|
|
135
|
+
)
|
|
136
|
+
if self.turn_model_durations_ms
|
|
137
|
+
else 0.0,
|
|
138
|
+
"realtime_turn_tool_count_total": sum(self.turn_tool_counts),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def emit_summary(self) -> None:
|
|
142
|
+
if self.closed_at is None:
|
|
143
|
+
self.close("unknown")
|
|
144
|
+
logger.info("realtime session summary %s", kv(**self.as_dict()))
|