10xgraph-api 0.7.0__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 (203) hide show
  1. 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
  2. 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
  3. 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
  4. 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
  5. 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
  6. 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
  7. agentflow_cli/__init__.py +69 -0
  8. tenxgraph_api/__init__.py +23 -0
  9. tenxgraph_api/cli/__init__.py +6 -0
  10. tenxgraph_api/cli/capabilities.py +127 -0
  11. tenxgraph_api/cli/commands/__init__.py +49 -0
  12. tenxgraph_api/cli/commands/api.py +262 -0
  13. tenxgraph_api/cli/commands/audit.py +213 -0
  14. tenxgraph_api/cli/commands/build.py +305 -0
  15. tenxgraph_api/cli/commands/config.py +71 -0
  16. tenxgraph_api/cli/commands/demo.py +93 -0
  17. tenxgraph_api/cli/commands/eval.py +909 -0
  18. tenxgraph_api/cli/commands/init.py +531 -0
  19. tenxgraph_api/cli/commands/skills.py +532 -0
  20. tenxgraph_api/cli/commands/test.py +113 -0
  21. tenxgraph_api/cli/commands/version.py +47 -0
  22. tenxgraph_api/cli/config_editor/__init__.py +15 -0
  23. tenxgraph_api/cli/config_editor/schema.py +566 -0
  24. tenxgraph_api/cli/config_editor/server.py +240 -0
  25. tenxgraph_api/cli/config_editor/static/app.css +2 -0
  26. tenxgraph_api/cli/config_editor/static/app.js +3 -0
  27. tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
  28. tenxgraph_api/cli/config_editor/static/index.html +20 -0
  29. tenxgraph_api/cli/config_editor/store.py +120 -0
  30. tenxgraph_api/cli/config_editor/validation.py +377 -0
  31. tenxgraph_api/cli/constants.py +112 -0
  32. tenxgraph_api/cli/context.py +46 -0
  33. tenxgraph_api/cli/core/__init__.py +0 -0
  34. tenxgraph_api/cli/core/animations.py +550 -0
  35. tenxgraph_api/cli/core/config.py +279 -0
  36. tenxgraph_api/cli/core/output.py +625 -0
  37. tenxgraph_api/cli/core/prompts.py +220 -0
  38. tenxgraph_api/cli/core/screen.py +287 -0
  39. tenxgraph_api/cli/core/steps.py +621 -0
  40. tenxgraph_api/cli/core/theme.py +242 -0
  41. tenxgraph_api/cli/core/validation.py +251 -0
  42. tenxgraph_api/cli/exceptions.py +129 -0
  43. tenxgraph_api/cli/logger.py +84 -0
  44. tenxgraph_api/cli/main.py +936 -0
  45. tenxgraph_api/cli/templates/__init__.py +0 -0
  46. tenxgraph_api/cli/templates/defaults.py +844 -0
  47. tenxgraph_api/cli/templates/dev/.env.example +1 -0
  48. tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
  49. tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
  50. tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
  51. tenxgraph_api/cli/templates/prod/.env.example +75 -0
  52. tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
  53. tenxgraph_api/cli/templates/prod/.python-version +1 -0
  54. tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
  55. tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
  56. tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
  57. tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
  58. tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
  59. tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
  60. tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
  61. tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
  62. tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
  63. tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
  64. tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
  65. tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
  66. tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
  67. tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
  68. tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
  69. tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
  70. tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
  71. tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
  72. tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
  73. tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
  74. tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
  75. tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
  76. tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
  77. tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
  78. tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
  79. tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
  80. tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
  81. tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
  82. tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
  83. tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
  84. tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
  85. tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
  86. tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
  87. tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
  88. tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
  89. tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
  90. tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
  91. tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
  92. tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
  93. tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
  94. tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
  95. tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
  96. tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
  97. tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
  98. tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
  99. tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
  100. tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
  101. tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
  102. tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
  103. tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
  104. tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
  105. tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
  106. tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
  107. tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
  108. tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
  109. tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
  110. tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
  111. tenxgraph_api/py.typed +0 -0
  112. tenxgraph_api/src/__init__.py +0 -0
  113. tenxgraph_api/src/app/__init__.py +13 -0
  114. tenxgraph_api/src/app/core/__init__.py +15 -0
  115. tenxgraph_api/src/app/core/auth/__init__.py +0 -0
  116. tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
  117. tenxgraph_api/src/app/core/auth/authorization.py +379 -0
  118. tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
  119. tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
  120. tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
  121. tenxgraph_api/src/app/core/auth/permissions.py +354 -0
  122. tenxgraph_api/src/app/core/auth/request_config.py +130 -0
  123. tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
  124. tenxgraph_api/src/app/core/config/__init__.py +0 -0
  125. tenxgraph_api/src/app/core/config/graph_config.py +532 -0
  126. tenxgraph_api/src/app/core/config/media_settings.py +72 -0
  127. tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
  128. tenxgraph_api/src/app/core/config/settings.py +202 -0
  129. tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
  130. tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
  131. tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
  132. tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
  133. tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
  134. tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
  135. tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
  136. tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
  137. tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
  138. tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
  139. tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
  140. tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
  141. tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
  142. tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
  143. tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
  144. tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
  145. tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
  146. tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
  147. tenxgraph_api/src/app/core/utils/__init__.py +14 -0
  148. tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
  149. tenxgraph_api/src/app/loader.py +516 -0
  150. tenxgraph_api/src/app/main.py +180 -0
  151. tenxgraph_api/src/app/routers/__init__.py +4 -0
  152. tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
  153. tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
  154. tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
  155. tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
  156. tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
  157. tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
  158. tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
  159. tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
  160. tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
  161. tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
  162. tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
  163. tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
  164. tenxgraph_api/src/app/routers/evals/router.py +38 -0
  165. tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
  166. tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
  167. tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
  168. tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
  169. tenxgraph_api/src/app/routers/graph/router.py +821 -0
  170. tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
  171. tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
  172. tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
  173. tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
  174. tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
  175. tenxgraph_api/src/app/routers/media/__init__.py +506 -0
  176. tenxgraph_api/src/app/routers/media/router.py +242 -0
  177. tenxgraph_api/src/app/routers/media/schemas.py +39 -0
  178. tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
  179. tenxgraph_api/src/app/routers/ping/router.py +28 -0
  180. tenxgraph_api/src/app/routers/setup_router.py +60 -0
  181. tenxgraph_api/src/app/routers/store/__init__.py +4 -0
  182. tenxgraph_api/src/app/routers/store/router.py +200 -0
  183. tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
  184. tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
  185. tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
  186. tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
  187. tenxgraph_api/src/app/tasks/__init__.py +0 -0
  188. tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
  189. tenxgraph_api/src/app/utils/__init__.py +12 -0
  190. tenxgraph_api/src/app/utils/callable_helper.py +26 -0
  191. tenxgraph_api/src/app/utils/media/__init__.py +15 -0
  192. tenxgraph_api/src/app/utils/media/extractor.py +172 -0
  193. tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
  194. tenxgraph_api/src/app/utils/parse_output.py +17 -0
  195. tenxgraph_api/src/app/utils/response_helper.py +129 -0
  196. tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
  197. tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
  198. tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
  199. tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
  200. tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
  201. tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
  202. tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
  203. tenxgraph_api/src/app/worker.py +91 -0
@@ -0,0 +1,821 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
7
+ from fastapi.logger import logger
8
+ from fastapi.responses import StreamingResponse
9
+ from injectq import InjectQ
10
+ from injectq.integrations import InjectAPI
11
+ from pydantic import ValidationError
12
+ from tenxgraph.core.realtime.base import ErrorEvent
13
+ from tenxgraph.core.realtime.queue import LiveInputQueue
14
+ from tenxgraph.core.state import StreamChunk, StreamEvent
15
+
16
+ from tenxgraph_api.src.app.core.auth.authorization import AuthorizationBackend
17
+ from tenxgraph_api.src.app.core.auth.permissions import (
18
+ RequirePermission,
19
+ ws_bearer_subprotocol,
20
+ )
21
+ from tenxgraph_api.src.app.core.auth.request_config import normalize_thread_id
22
+ from tenxgraph_api.src.app.routers.graph.realtime_guard import (
23
+ realtime_connection_guard,
24
+ ws_identity_still_valid,
25
+ ws_run_allowed,
26
+ )
27
+ from tenxgraph_api.src.app.routers.graph.schemas.graph_schemas import (
28
+ FixGraphRequestSchema,
29
+ GraphInputSchema,
30
+ GraphInvokeOutputSchema,
31
+ GraphSchema,
32
+ GraphStopSchema,
33
+ GraphToolsSchema,
34
+ ObservabilitySchema,
35
+ WsGraphInputSchema,
36
+ )
37
+ from tenxgraph_api.src.app.routers.graph.services.graph_service import GraphService
38
+ from tenxgraph_api.src.app.utils import success_response
39
+ from tenxgraph_api.src.app.utils.swagger_helper import generate_swagger_responses
40
+
41
+
42
+ # Grace period (seconds) to let the model's final response drain to the client after the
43
+ # client side ends (``close`` control frame or disconnect). Closing the input queue makes
44
+ # the live agent finish its turn and stop once the provider goes idle, so this normally
45
+ # returns well within the window; it only bounds a provider that never goes idle.
46
+ REALTIME_DRAIN_TIMEOUT = 30.0
47
+
48
+ # ``config.thread_id`` value a WebSocket client sends to start a fresh thread.
49
+ WS_NEW_THREAD = "new"
50
+
51
+ # Bound the upstream audio queue. WebSocket frames bypass RequestSizeLimitMiddleware (it is
52
+ # HTTP-only), so the realtime path must guard memory itself. At ~50 input frames/sec a depth
53
+ # of 1000 is ~20s of buffered audio; if the provider send stalls past that the oldest frames
54
+ # are dropped (logged) instead of growing memory without bound -- the audio was already behind.
55
+ REALTIME_INPUT_QUEUE_MAXSIZE = 1000
56
+
57
+ # Hard cap on a single binary (PCM16) input frame. Normal client chunks are tens of KB
58
+ # (16kHz PCM16 = 32KB/s); 1 MiB is ~30s of audio in one frame, far beyond any real chunk.
59
+ # Oversized frames are dropped rather than enqueued (again, the middleware does not see them).
60
+ REALTIME_MAX_FRAME_BYTES = 1024 * 1024
61
+
62
+
63
+ router = APIRouter(
64
+ tags=["Graph"],
65
+ )
66
+
67
+
68
+ async def _bind_ws_container() -> None:
69
+ """Set the active InjectQ container for a WebSocket handshake.
70
+
71
+ ``setup_fastapi`` installs its container-activation via Starlette middleware,
72
+ which runs for HTTP scopes only — WebSocket handshakes bypass it, so every
73
+ ``InjectAPI`` dependency (here and inside ``RequirePermission``) would raise
74
+ "No InjectQ container in current request context". Declaring this dependency
75
+ *before* the ``InjectAPI`` ones makes FastAPI resolve it first, so the
76
+ ContextVar is set by the time the container-backed deps resolve.
77
+
78
+ Idempotent and cheap; harmless if the container is already active.
79
+ """
80
+ try:
81
+ from injectq.integrations.fastapi import _request_container
82
+
83
+ _request_container.set(InjectQ.get_instance())
84
+ except Exception: # pragma: no cover - defensive: never block the handshake here
85
+ logger.exception("Failed to bind InjectQ container for WebSocket scope")
86
+
87
+
88
+ @router.post(
89
+ "/v1/graph/invoke",
90
+ summary="Invoke graph execution",
91
+ responses=generate_swagger_responses(GraphInvokeOutputSchema),
92
+ description="Execute the graph with the provided input and return the final result",
93
+ openapi_extra={},
94
+ )
95
+ async def invoke_graph(
96
+ request: Request,
97
+ graph_input: GraphInputSchema,
98
+ service: GraphService = InjectAPI(GraphService),
99
+ user: dict[str, Any] = Depends(RequirePermission("graph", "invoke")),
100
+ ):
101
+ """
102
+ Invoke the graph with the provided input and return the final result.
103
+ """
104
+ logger.info(f"Graph invoke request received with {len(graph_input.messages)} messages")
105
+
106
+ result: GraphInvokeOutputSchema = await service.invoke_graph(
107
+ graph_input,
108
+ user,
109
+ )
110
+
111
+ logger.info("Graph invoke completed successfully")
112
+
113
+ return success_response(
114
+ result,
115
+ request,
116
+ )
117
+
118
+
119
+ @router.post(
120
+ "/v1/graph/stream",
121
+ summary="Stream graph execution",
122
+ description="Execute the graph with streaming output for real-time results",
123
+ responses=generate_swagger_responses(StreamChunk),
124
+ openapi_extra={},
125
+ )
126
+ async def stream_graph(
127
+ graph_input: GraphInputSchema,
128
+ service: GraphService = InjectAPI(GraphService),
129
+ user: dict[str, Any] = Depends(RequirePermission("graph", "stream")),
130
+ ):
131
+ """
132
+ Stream the graph execution with real-time output.
133
+ """
134
+ logger.info(f"Graph stream request received with {len(graph_input.messages)} messages")
135
+
136
+ result = service.stream_graph(
137
+ graph_input,
138
+ user,
139
+ )
140
+
141
+ return StreamingResponse(
142
+ result,
143
+ media_type="text/event-stream",
144
+ headers={
145
+ "Cache-Control": "no-cache, no-transform",
146
+ "Connection": "keep-alive",
147
+ "X-Accel-Buffering": "no", # Disable nginx buffering
148
+ "Content-Encoding": "identity", # Disable any content encoding (bypasses GZip)
149
+ },
150
+ )
151
+
152
+
153
+ @router.get(
154
+ "/v1/graph",
155
+ summary="Invoke graph execution",
156
+ responses=generate_swagger_responses(GraphSchema),
157
+ description="Execute the graph with the provided input and return the final result",
158
+ openapi_extra={},
159
+ )
160
+ async def graph_details(
161
+ request: Request,
162
+ service: GraphService = InjectAPI(GraphService),
163
+ user: dict[str, Any] = Depends(RequirePermission("graph", "read")),
164
+ ):
165
+ """
166
+ Invoke the graph with the provided input and return the final result.
167
+ """
168
+ logger.info("Graph getting details")
169
+
170
+ result: GraphSchema = await service.graph_details()
171
+
172
+ logger.info("Graph invoke completed successfully")
173
+
174
+ return success_response(
175
+ result,
176
+ request,
177
+ )
178
+
179
+
180
+ @router.get(
181
+ "/v1/graph/tools",
182
+ summary="List graph tools",
183
+ responses=generate_swagger_responses(GraphToolsSchema),
184
+ description=(
185
+ "List the tools exposed by every ToolNode in the graph, grouped by node. "
186
+ "Each tool is tagged with its source (local, mcp, or remote)."
187
+ ),
188
+ openapi_extra={},
189
+ )
190
+ async def graph_tools(
191
+ request: Request,
192
+ service: GraphService = InjectAPI(GraphService),
193
+ user: dict[str, Any] = Depends(RequirePermission("graph", "read")),
194
+ ):
195
+ """List the tools exposed by the graph's tool nodes."""
196
+ logger.info("Graph getting tools")
197
+
198
+ result: GraphToolsSchema = await service.get_tools()
199
+
200
+ logger.info("Graph tools listed successfully")
201
+
202
+ return success_response(
203
+ result,
204
+ request,
205
+ )
206
+
207
+
208
+ @router.get(
209
+ "/v1/observability/{thread_id}",
210
+ summary="Get observability trace for a thread",
211
+ responses=generate_swagger_responses(ObservabilitySchema),
212
+ description=(
213
+ "Reconstruct a run trace (spans, events, token cost) for a thread from the "
214
+ "events its runs emitted. Returns the latest run by default, or ?run_id=..."
215
+ ),
216
+ openapi_extra={},
217
+ )
218
+ async def observability(
219
+ request: Request,
220
+ thread_id: str,
221
+ run_id: str | None = None,
222
+ service: GraphService = InjectAPI(GraphService),
223
+ user: dict[str, Any] = Depends(RequirePermission("graph", "read")),
224
+ ):
225
+ """Return the reconstructed observability trace for a thread."""
226
+ logger.info(f"Observability requested for thread {thread_id}")
227
+
228
+ result: ObservabilitySchema = await service.get_observability(thread_id, run_id)
229
+
230
+ return success_response(
231
+ result,
232
+ request,
233
+ )
234
+
235
+
236
+ @router.get(
237
+ "/v1/graph:StateSchema",
238
+ summary="Invoke graph execution",
239
+ responses=generate_swagger_responses(GraphSchema),
240
+ description="Execute the graph with the provided input and return the final result",
241
+ openapi_extra={},
242
+ )
243
+ async def state_schema(
244
+ request: Request,
245
+ service: GraphService = InjectAPI(GraphService),
246
+ user: dict[str, Any] = Depends(RequirePermission("graph", "read")),
247
+ ):
248
+ """
249
+ Invoke the graph with the provided input and return the final result.
250
+ """
251
+ logger.info("Graph getting details")
252
+
253
+ result: dict = await service.get_state_schema()
254
+
255
+ logger.info("Graph invoke completed successfully")
256
+
257
+ return success_response(
258
+ result,
259
+ request,
260
+ )
261
+
262
+
263
+ @router.post(
264
+ "/v1/graph/stop",
265
+ summary="Stop graph execution",
266
+ description="Stop the currently running graph execution for a specific thread",
267
+ responses=generate_swagger_responses(dict), # type: ignore
268
+ openapi_extra={},
269
+ )
270
+ async def stop_graph(
271
+ request: Request,
272
+ stop_request: GraphStopSchema,
273
+ service: GraphService = InjectAPI(GraphService),
274
+ user: dict[str, Any] = Depends(RequirePermission("graph", "stop")),
275
+ ):
276
+ """
277
+ Stop the graph execution for a specific thread.
278
+
279
+ Args:
280
+ stop_request: Request containing thread_id and optional config
281
+
282
+ Returns:
283
+ Status information about the stop operation
284
+ """
285
+ logger.info(f"Graph stop request received for thread: {stop_request.thread_id}")
286
+
287
+ result = await service.stop_graph(stop_request.thread_id, user, stop_request.config)
288
+
289
+ logger.info(f"Graph stop completed for thread {stop_request.thread_id}")
290
+
291
+ return success_response(
292
+ result,
293
+ request,
294
+ )
295
+
296
+
297
+ @router.post(
298
+ "/v1/graph/fix",
299
+ summary="Fix graph state by removing messages with empty tool calls",
300
+ description=(
301
+ "Fix the graph state by identifying and removing messages that have tool "
302
+ "calls with empty content. This is useful for cleaning up incomplete "
303
+ "tool call messages that may have failed or been interrupted."
304
+ ),
305
+ responses=generate_swagger_responses(dict), # type: ignore
306
+ openapi_extra={},
307
+ )
308
+ async def fix_graph(
309
+ request: Request,
310
+ fix_request: FixGraphRequestSchema,
311
+ service: GraphService = InjectAPI(GraphService),
312
+ user: dict[str, Any] = Depends(RequirePermission("graph", "fix")),
313
+ ):
314
+ """
315
+ Fix the graph execution state for a specific thread.
316
+
317
+ This endpoint removes messages with empty tool call content from the state.
318
+ Tool calls with empty content typically indicate interrupted or failed tool
319
+ executions that should be cleaned up.
320
+
321
+ Args:
322
+ request: HTTP request object
323
+ fix_request: Request containing thread_id and optional config
324
+ service: Injected GraphService instance
325
+ user: Current authenticated user
326
+
327
+ Returns:
328
+ Status information about the fix operation, including:
329
+ - success: Whether the operation was successful
330
+ - message: Descriptive message about the operation
331
+ - removed_count: Number of messages that were removed
332
+ - state: Updated state after fixing (or original if no changes)
333
+
334
+ Raises:
335
+ HTTPException: If the fix operation fails or if no state is found
336
+ for the given thread_id
337
+ """
338
+ logger.info(f"Graph fix request received for thread: {fix_request.thread_id}")
339
+
340
+ result = await service.fix_graph(
341
+ fix_request.thread_id,
342
+ user,
343
+ fix_request.config,
344
+ )
345
+
346
+ logger.info(f"Graph fix completed for thread {fix_request.thread_id}")
347
+
348
+ return success_response(
349
+ result,
350
+ request,
351
+ )
352
+
353
+
354
+ # ──────────────────────────────────────────────────────────────────────────────
355
+ # WebSocket endpoint
356
+ # ──────────────────────────────────────────────────────────────────────────────
357
+
358
+
359
+ async def _ws_run_gate(websocket: WebSocket, user: dict[str, Any]) -> str:
360
+ """Per-run checks for a WebSocket: ``"run"``, ``"skip"`` (rate limited) or ``"close"``.
361
+
362
+ The handshake guard runs once, so a socket that stays open must be re-checked before each
363
+ run: the token may have expired or been revoked, and every run counts against the limit.
364
+ """
365
+ if not ws_identity_still_valid(websocket, user):
366
+ logger.info("WebSocket credential no longer valid; closing")
367
+ await websocket.send_text(
368
+ StreamChunk(
369
+ event=StreamEvent.ERROR,
370
+ data={"reason": "Session expired. Reconnect with a valid token."},
371
+ ).model_dump_json()
372
+ )
373
+ await websocket.close(code=1008)
374
+ return "close"
375
+
376
+ retry_after = await ws_run_allowed(websocket)
377
+ if retry_after is not None:
378
+ await websocket.send_text(
379
+ StreamChunk(
380
+ event=StreamEvent.ERROR,
381
+ data={
382
+ "reason": f"Rate limit exceeded. Retry after {retry_after}s.",
383
+ "retry_after_seconds": retry_after,
384
+ },
385
+ ).model_dump_json()
386
+ )
387
+ return "skip"
388
+ return "run"
389
+
390
+
391
+ def _ws_run_thread_id(ws_input: WsGraphInputSchema) -> str | None:
392
+ """Resolve the thread a WebSocket run targets, and make the run use exactly that thread.
393
+
394
+ Normalises ``config.thread_id`` the way ``GraphService`` does. ``"new"`` (or a missing
395
+ or blank id) means "start a fresh thread": the key is removed so the service generates
396
+ one, instead of running on a literal thread named ``"new"`` shared by every client.
397
+ """
398
+ config = dict(ws_input.config or {})
399
+ thread_id = _resolve_ws_thread(config)
400
+ ws_input.config = config
401
+ return thread_id
402
+
403
+
404
+ def _resolve_ws_thread(frame: dict[str, Any]) -> str | None:
405
+ """Normalise ``frame["thread_id"]`` in place; ``"new"``, blank or missing removes it."""
406
+ thread_id = normalize_thread_id(frame.get("thread_id"))
407
+ if thread_id == WS_NEW_THREAD:
408
+ thread_id = None
409
+ if thread_id is None:
410
+ frame.pop("thread_id", None)
411
+ else:
412
+ frame["thread_id"] = thread_id
413
+ return thread_id
414
+
415
+
416
+ async def _ws_thread_authorized(
417
+ authz: AuthorizationBackend,
418
+ user: dict[str, Any],
419
+ thread_id: str | None,
420
+ action: str,
421
+ ) -> bool:
422
+ """Owner-check ``thread_id`` for a WebSocket run.
423
+
424
+ Returns ``True`` when the run may proceed. Ownership is only enforced when auth is
425
+ configured (``GraphConfig.auth_config``); an unauthenticated dev graph and fresh
426
+ (``"new"``/absent) threads always pass. If the config cannot be read, the check runs
427
+ anyway: a lookup failure must not switch authorization off.
428
+ """
429
+ if not thread_id or thread_id == WS_NEW_THREAD:
430
+ return True
431
+
432
+ from injectq import InjectQ
433
+
434
+ has_auth = True
435
+ try:
436
+ from tenxgraph_api.src.app.core.config.graph_config import GraphConfig
437
+
438
+ cfg = InjectQ.get_instance().get(GraphConfig)
439
+ has_auth = bool(cfg and cfg.auth_config())
440
+ except Exception as exc:
441
+ logger.warning("Could not resolve GraphConfig for WS auth check; enforcing it: %s", exc)
442
+
443
+ if not has_auth:
444
+ return True
445
+
446
+ return await authz.authorize(user, "graph", action, resource_id=str(thread_id))
447
+
448
+
449
+ @router.websocket("/v1/graph/ws")
450
+ async def websocket_graph(
451
+ websocket: WebSocket,
452
+ _bind: None = Depends(_bind_ws_container),
453
+ _guard: None = Depends(realtime_connection_guard),
454
+ service: GraphService = InjectAPI(GraphService),
455
+ user: dict[str, Any] = Depends(RequirePermission("graph", "stream")),
456
+ authz: AuthorizationBackend = InjectAPI(AuthorizationBackend),
457
+ ):
458
+ """
459
+ WebSocket endpoint for streaming graph execution.
460
+
461
+ One connection, one run at a time. The client drives the protocol:
462
+
463
+ Fresh run
464
+ ---------
465
+ Client → Server : WsGraphInputSchema { invoke_type:"fresh", messages:[…], config:{} }
466
+ Server → Client : StreamChunk JSON messages (same format as POST /v1/graph/stream)
467
+ Server → Client : StreamChunk { event:"updates", data:{status:"done"} }
468
+
469
+ Resume after remote tool call
470
+ -----------------------------
471
+ (client detects the remote-tool-call chunk in the stream, executes the tool)
472
+ Client → Server : WsGraphInputSchema { invoke_type:"resume", tool_result:[…],
473
+ config:{thread_id:"<id>"} }
474
+ Server → Client : StreamChunk JSON messages (graph resumes from checkpoint)
475
+ Server → Client : StreamChunk { event:"updates", data:{status:"done"} }
476
+
477
+ The server does not inspect chunks for tool-call detection — that is the
478
+ client library's responsibility. invoke_type is only used for validation
479
+ and logging.
480
+
481
+ Authentication
482
+ --------------
483
+ Bearer token via the ``Authorization`` header, the ``10xgraph-bearer``
484
+ Sec-WebSocket-Protocol (browser-safe), or the ``?token=`` query fallback —
485
+ identical to the HTTP stream route. Handshakes are subject to the global rate
486
+ limit and the ``websocket.max_connections`` cap.
487
+
488
+ Every run on the socket counts against the same rate limit (an error chunk with
489
+ ``retry_after_seconds`` is sent when over it), and the token is re-verified before each
490
+ run.
491
+
492
+ Close codes
493
+ -----------
494
+ 1000 normal closure (client disconnected cleanly)
495
+ 1008 rejected: this graph is a live (realtime) agent — use ``/v1/graph/live``;
496
+ or the token expired / was revoked since the socket opened
497
+ 1011 unexpected server error
498
+ 1013 rejected: rate limit or connection cap exceeded (try again later)
499
+ """
500
+ await websocket.accept(subprotocol=ws_bearer_subprotocol(websocket))
501
+ logger.info("WebSocket graph connection accepted")
502
+
503
+ if hasattr(authz, "dependency"):
504
+ from injectq import InjectQ
505
+
506
+ try:
507
+ authz = InjectQ.get_instance().get(AuthorizationBackend)
508
+ except Exception:
509
+ from tenxgraph_api.src.app.core.auth.authorization import DefaultAuthorizationBackend
510
+
511
+ authz = DefaultAuthorizationBackend()
512
+
513
+ # Wrong agent type for this endpoint: a live (realtime) graph cannot be driven over
514
+ # the turn-based stream socket. Reject up front with a clear error instead of failing
515
+ # mid-run when the graph refuses invoke/stream.
516
+ if service.is_live_agent:
517
+ logger.warning("Rejected /v1/graph/ws connection: graph is a live agent")
518
+ with contextlib.suppress(Exception):
519
+ await websocket.send_text(
520
+ StreamChunk(
521
+ event=StreamEvent.ERROR,
522
+ data={
523
+ "reason": "This graph is a live (realtime) agent; connect to "
524
+ "/v1/graph/live instead of /v1/graph/ws.",
525
+ },
526
+ ).model_dump_json()
527
+ )
528
+ with contextlib.suppress(Exception):
529
+ await websocket.close(code=1008)
530
+ return
531
+
532
+ try:
533
+ while True:
534
+ try:
535
+ data = await websocket.receive_json()
536
+ except WebSocketDisconnect:
537
+ logger.info("WebSocket client disconnected")
538
+ break
539
+
540
+ try:
541
+ ws_input = WsGraphInputSchema(**data)
542
+ except Exception as e:
543
+ logger.warning("WebSocket received invalid input: %s", e)
544
+ await websocket.send_text(
545
+ StreamChunk(
546
+ event=StreamEvent.ERROR,
547
+ data={"reason": f"Invalid request: {e}"},
548
+ ).model_dump_json()
549
+ )
550
+ continue
551
+
552
+ # The handshake checks happen once; these apply to every run on the socket.
553
+ gate = await _ws_run_gate(websocket, user)
554
+ if gate == "close":
555
+ break
556
+ if gate == "skip":
557
+ continue
558
+
559
+ thread_id = _ws_run_thread_id(ws_input)
560
+ if not await _ws_thread_authorized(authz, user, thread_id, "stream"):
561
+ logger.warning(
562
+ f"WebSocket authorization failed for user {user.get('user_id')} "
563
+ f"on thread {thread_id}"
564
+ )
565
+ await websocket.send_text(
566
+ StreamChunk(
567
+ event=StreamEvent.ERROR,
568
+ data={"reason": f"Not authorized to stream thread {thread_id}"},
569
+ ).model_dump_json()
570
+ )
571
+ continue
572
+
573
+ logger.info(
574
+ "WebSocket graph run: invoke_type=%s, thread_id=%s",
575
+ ws_input.invoke_type,
576
+ thread_id,
577
+ )
578
+
579
+ graph_input = ws_input.to_graph_input()
580
+ async for chunk_json in service.stream_graph(graph_input, user=user):
581
+ await websocket.send_text(chunk_json)
582
+
583
+ await websocket.send_text(
584
+ StreamChunk(
585
+ event=StreamEvent.UPDATES,
586
+ data={"status": "done"},
587
+ ).model_dump_json()
588
+ )
589
+
590
+ except WebSocketDisconnect:
591
+ logger.info("WebSocket client disconnected")
592
+ except Exception as e:
593
+ logger.error("WebSocket graph connection error: %s", e)
594
+ with contextlib.suppress(Exception):
595
+ await websocket.close(code=1011)
596
+
597
+
598
+ def _client_error(exc: Exception) -> str:
599
+ """The error text a client may see: the exception in development, generic in production.
600
+
601
+ A ValueError here can come from anywhere under the session (storage, provider SDK), not
602
+ only from the init frame, and its text may name internal hosts, paths or queries.
603
+ """
604
+ from tenxgraph_api.src.app.core.config.settings import get_settings
605
+ from tenxgraph_api.src.app.core.exceptions.handle_errors import _sanitize_error_message
606
+
607
+ is_production = get_settings().MODE == "production"
608
+ return _sanitize_error_message(str(exc), "VALIDATION_ERROR", is_production)
609
+
610
+
611
+ def _realtime_event_json(event: Any) -> str:
612
+ """Serialize a non-audio RealtimeEvent to a JSON text frame for the client."""
613
+ try:
614
+ payload = event.model_dump(mode="json")
615
+ except Exception as e:
616
+ logger.warning("Realtime event serialization failed (%s): %s", type(event).__name__, e)
617
+ payload = {"type": getattr(event, "type", "unknown")}
618
+ return json.dumps(payload)
619
+
620
+
621
+ @router.websocket("/v1/graph/live")
622
+ async def realtime_graph_ws( # noqa: PLR0912, PLR0915
623
+ websocket: WebSocket,
624
+ _bind: None = Depends(_bind_ws_container),
625
+ _guard: None = Depends(realtime_connection_guard),
626
+ service: GraphService = InjectAPI(GraphService),
627
+ user: dict[str, Any] = Depends(RequirePermission("graph", "stream")),
628
+ authz: AuthorizationBackend = InjectAPI(AuthorizationBackend),
629
+ ):
630
+ """Realtime (audio-to-audio) WebSocket bridge over ``CompiledGraph.arealtime``.
631
+
632
+ Protocol (provider-neutral; the client never sees Gemini vs OpenAI)
633
+ ------------------------------------------------------------------
634
+ First frame : JSON control ``{model, thread_id?, voice?, modalities?, vad?, ...}``
635
+ Upstream : binary frame = PCM16 input audio
636
+ JSON control = ``{type:"activity_start"|"activity_end"|"text"|"close", ...}``
637
+ Downstream : binary frame = PCM16 model audio (``audio_delta``)
638
+ JSON text frame = every other event (transcripts, turn_complete,
639
+ interrupted, tool_call, session/go_away, error)
640
+
641
+ Auth: ``RequirePermission("graph","stream")`` — bearer via the ``Authorization`` header,
642
+ the ``10xgraph-bearer`` Sec-WebSocket-Protocol (browser-safe), or the ``?token=`` query
643
+ fallback. Handshakes are subject to the global rate limit and the
644
+ ``websocket.max_connections`` cap (rejected with close code 1013). A non-live
645
+ (turn-based) graph is rejected up front with close code 1008 — use ``/v1/graph/ws``.
646
+ """
647
+ await websocket.accept(subprotocol=ws_bearer_subprotocol(websocket))
648
+ logger.info("Realtime WebSocket connection accepted")
649
+
650
+ if hasattr(authz, "dependency"):
651
+ from injectq import InjectQ
652
+
653
+ try:
654
+ authz = InjectQ.get_instance().get(AuthorizationBackend)
655
+ except Exception:
656
+ from tenxgraph_api.src.app.core.auth.authorization import DefaultAuthorizationBackend
657
+
658
+ authz = DefaultAuthorizationBackend()
659
+
660
+ # Wrong agent type for this endpoint: the realtime bridge requires a graph rooted at a
661
+ # LiveAgent. Reject a turn-based graph up front with a normalized fatal error instead
662
+ # of accepting the init frame and failing later inside ``arealtime``.
663
+ if not service.is_live_agent:
664
+ logger.warning("Rejected /v1/graph/live connection: graph is not a live agent")
665
+ with contextlib.suppress(Exception):
666
+ await websocket.send_text(
667
+ _realtime_event_json(
668
+ ErrorEvent(
669
+ code="not_live",
670
+ message="This graph is not a live (realtime) agent; use the "
671
+ "turn-based /v1/graph/ws endpoint instead of /v1/graph/live.",
672
+ fatal=True,
673
+ )
674
+ )
675
+ )
676
+ with contextlib.suppress(Exception):
677
+ await websocket.close(code=1008)
678
+ return
679
+
680
+ try:
681
+ init = await websocket.receive_json()
682
+ except WebSocketDisconnect:
683
+ logger.info("Realtime client disconnected before init")
684
+ return
685
+ except Exception as e:
686
+ logger.warning("Realtime init frame invalid: %s", e)
687
+ with contextlib.suppress(Exception):
688
+ await websocket.close(code=1003)
689
+ return
690
+
691
+ if isinstance(init, dict):
692
+ # Same rules as /v1/graph/ws: "new" starts a fresh thread, never a shared one.
693
+ thread_id = _resolve_ws_thread(init)
694
+ if not await _ws_thread_authorized(authz, user, thread_id, "stream"):
695
+ logger.warning(
696
+ f"Realtime WebSocket authorization failed for user {user.get('user_id')} "
697
+ f"on thread {thread_id}"
698
+ )
699
+ with contextlib.suppress(Exception):
700
+ await websocket.send_text(
701
+ _realtime_event_json(
702
+ ErrorEvent(
703
+ code="not_authorized",
704
+ message=f"Not authorized to stream thread {thread_id}",
705
+ fatal=True,
706
+ )
707
+ )
708
+ )
709
+ with contextlib.suppress(Exception):
710
+ await websocket.close(code=1008)
711
+ return
712
+
713
+ if not isinstance(init, dict):
714
+ logger.warning("Realtime init frame must be a JSON object, got %s", type(init).__name__)
715
+ with contextlib.suppress(Exception):
716
+ await websocket.close(code=1003)
717
+ return
718
+
719
+ queue = LiveInputQueue(maxsize=REALTIME_INPUT_QUEUE_MAXSIZE)
720
+
721
+ async def upstream() -> None:
722
+ """Pump client frames into the input queue until close/disconnect."""
723
+ try:
724
+ while True:
725
+ message = await websocket.receive()
726
+ if message.get("type") == "websocket.disconnect":
727
+ break
728
+ data = message.get("bytes")
729
+ if data is not None:
730
+ if len(data) > REALTIME_MAX_FRAME_BYTES:
731
+ logger.warning(
732
+ "Realtime upstream: dropping oversized audio frame (%d bytes > %d)",
733
+ len(data),
734
+ REALTIME_MAX_FRAME_BYTES,
735
+ )
736
+ continue
737
+ queue.send_audio(data)
738
+ continue
739
+ text = message.get("text")
740
+ if text is None:
741
+ continue
742
+ try:
743
+ control = json.loads(text)
744
+ except json.JSONDecodeError:
745
+ logger.warning("Realtime upstream: non-JSON text frame ignored")
746
+ continue
747
+ ctype = control.get("type")
748
+ if ctype == "text":
749
+ queue.send_text(control.get("text", ""))
750
+ elif ctype == "activity_start":
751
+ queue.send_activity_start()
752
+ elif ctype == "activity_end":
753
+ queue.send_activity_end()
754
+ elif ctype == "close":
755
+ break
756
+ except WebSocketDisconnect:
757
+ logger.info("Realtime client disconnected (upstream)")
758
+ finally:
759
+ queue.close()
760
+
761
+ async def downstream() -> None:
762
+ """Stream normalized events back: audio as binary, everything else as JSON."""
763
+ try:
764
+ async for event in service.realtime_graph(queue, init, user):
765
+ if getattr(event, "type", None) == "audio_delta":
766
+ await websocket.send_bytes(event.data)
767
+ else:
768
+ await websocket.send_text(_realtime_event_json(event))
769
+ except (ValidationError, ValueError) as e:
770
+ # Bad session config from the init frame (e.g. an invalid ``modalities`` value)
771
+ # is a client error, not a server fault. Send a normalized, fatal error event
772
+ # so the client can show why instead of seeing an opaque 1011 close.
773
+ logger.warning("Realtime session config rejected: %s", e)
774
+ with contextlib.suppress(Exception):
775
+ await websocket.send_text(
776
+ _realtime_event_json(
777
+ ErrorEvent(code="invalid_config", message=_client_error(e), fatal=True)
778
+ )
779
+ )
780
+
781
+ up_task = asyncio.create_task(upstream())
782
+ down_task = asyncio.create_task(downstream())
783
+ try:
784
+ _done, pending = await asyncio.wait(
785
+ {up_task, down_task}, return_when=asyncio.FIRST_COMPLETED
786
+ )
787
+
788
+ # If the client side finished first (sent ``close`` or disconnected) while the
789
+ # model is still responding, give downstream a bounded grace period to drain the
790
+ # session's final events instead of cutting them off. Closing the input queue ends
791
+ # the live agent's turn and stops it once the provider goes idle, so this returns
792
+ # promptly; on a real disconnect the next send fails fast and downstream ends.
793
+ if down_task in pending:
794
+ queue.close()
795
+ await asyncio.wait({down_task}, timeout=REALTIME_DRAIN_TIMEOUT)
796
+
797
+ # Cancel whatever is still running: downstream that overran the grace window, or
798
+ # upstream once the session ended.
799
+ for task in (up_task, down_task):
800
+ if not task.done():
801
+ task.cancel()
802
+ with contextlib.suppress(asyncio.CancelledError, Exception):
803
+ await task
804
+
805
+ # asyncio.wait never re-raises; surface any failure from the finished task(s)
806
+ # (e.g. arealtime rejecting a non-live graph, or a provider/checkpointer error).
807
+ for task in (up_task, down_task):
808
+ if task.done() and not task.cancelled():
809
+ task.result()
810
+ except WebSocketDisconnect:
811
+ # The client going away mid-session is a normal termination, not a server fault;
812
+ # don't log it as an error or attempt a 1011 close on an already-closed socket.
813
+ logger.info("Realtime WebSocket client disconnected")
814
+ except Exception as e:
815
+ logger.error("Realtime WebSocket error: %s", e)
816
+ with contextlib.suppress(Exception):
817
+ await websocket.close(code=1011)
818
+ finally:
819
+ queue.close()
820
+ with contextlib.suppress(Exception):
821
+ await websocket.close()