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.
Files changed (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. 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()))