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,1219 @@
1
+ from collections.abc import AsyncIterable
2
+ from datetime import datetime
3
+ from typing import Any
4
+ from uuid import uuid4
5
+
6
+ from fastapi import HTTPException
7
+ from injectq import InjectQ, inject, singleton
8
+ from pydantic import BaseModel
9
+ from tenxgraph.core.exceptions.media_exceptions import UnsupportedMediaInputError
10
+ from tenxgraph.core.graph import CompiledGraph
11
+ from tenxgraph.core.state import AgentState, Message, StreamChunk, StreamEvent
12
+ from tenxgraph.storage.checkpointer import BaseCheckpointer
13
+ from tenxgraph.utils.thread_info import ThreadInfo
14
+
15
+ from tenxgraph_api.src.app.core import logger
16
+ from tenxgraph_api.src.app.core.auth.request_config import (
17
+ client_config,
18
+ normalize_thread_id,
19
+ tool_result_ids,
20
+ unanswered_tool_calls,
21
+ )
22
+ from tenxgraph_api.src.app.core.config.graph_config import GraphConfig
23
+ from tenxgraph_api.src.app.core.utils.log_sanitizer import sanitize_for_logging
24
+ from tenxgraph_api.src.app.routers.graph.schemas.graph_schemas import (
25
+ GraphInputSchema,
26
+ GraphInvokeOutputSchema,
27
+ GraphSchema,
28
+ GraphToolsSchema,
29
+ ObservabilitySchema,
30
+ ObsEventSchema,
31
+ ObsRunSchema,
32
+ ObsSpanSchema,
33
+ ObsTokenUsageSchema,
34
+ ToolNodeSchema,
35
+ ToolSchema,
36
+ )
37
+ from tenxgraph_api.src.app.routers.graph.services.multimodal_preprocessor import (
38
+ preprocess_multimodal_messages,
39
+ )
40
+ from tenxgraph_api.src.app.utils import DefaultThreadNameGenerator, ThreadNameGenerator
41
+ from tenxgraph_api.src.app.utils.telemetry_store import TelemetryStore
42
+
43
+
44
+ def _run_tool_names(config: dict[str, Any]) -> set[str]:
45
+ """Names of the per-run client tools in ``config["remote_tools"]`` (server-owned key)."""
46
+ names: set[str] = set()
47
+ for item in config.get("remote_tools") or []:
48
+ if isinstance(item, dict):
49
+ spec = item.get("function") if isinstance(item.get("function"), dict) else item
50
+ if isinstance(spec.get("name"), str):
51
+ names.add(spec["name"])
52
+ return names
53
+
54
+
55
+ def _reraise_framework_errors(exc: Exception) -> None:
56
+ """Re-raise typed errors that have dedicated handlers instead of masking them.
57
+
58
+ ``HTTPException`` (already carrying the right status) and the framework's typed
59
+ exceptions have registered handlers in ``handle_errors.py`` that map them to the
60
+ correct 4xx/5xx. Swallowing them into a generic 500 turns client-caused failures
61
+ (recursion-limit exhaustion, bad tool args) into server errors.
62
+
63
+ The tenxgraph exceptions are imported lazily here: a top-level import of the
64
+ ``tenxgraph.core.exceptions`` package triggers a circular import through the core
65
+ media modules during test collection.
66
+ """
67
+ if isinstance(exc, HTTPException):
68
+ raise exc
69
+ from tenxgraph.core.exceptions import (
70
+ GraphError,
71
+ GraphRecursionError,
72
+ MetricsError,
73
+ NodeError,
74
+ SchemaVersionError,
75
+ SerializationError,
76
+ StorageError,
77
+ TransientStorageError,
78
+ )
79
+
80
+ typed = (
81
+ GraphError,
82
+ GraphRecursionError,
83
+ NodeError,
84
+ StorageError,
85
+ TransientStorageError,
86
+ SchemaVersionError,
87
+ SerializationError,
88
+ MetricsError,
89
+ )
90
+ if isinstance(exc, typed):
91
+ raise exc
92
+
93
+
94
+ # A taken generated id is retried with a fresh one; each retry is another prediction the
95
+ # claimer would have had to get right.
96
+ MAX_THREAD_ID_ATTEMPTS = 5
97
+
98
+
99
+ @singleton
100
+ class GraphService:
101
+ """
102
+ Service class for graph-related operations.
103
+
104
+ This class acts as an intermediary between the controllers and the
105
+ CompiledGraph, facilitating graph execution operations.
106
+ """
107
+
108
+ @inject
109
+ def __init__(
110
+ self,
111
+ graph: CompiledGraph,
112
+ checkpointer: BaseCheckpointer,
113
+ config: GraphConfig,
114
+ thread_name_generator: ThreadNameGenerator | None = None,
115
+ ):
116
+ """
117
+ Initializes the GraphService with a CompiledGraph instance.
118
+
119
+ Args:
120
+ graph (CompiledGraph): An instance of CompiledGraph for
121
+ graph execution operations.
122
+ """
123
+ self._graph = graph
124
+ self.config = config
125
+ self.checkpointer = checkpointer
126
+ self.thread_name_generator = thread_name_generator
127
+
128
+ # Telemetry store is optional (bound in the loader). Resolve lazily so unit
129
+ # tests that construct GraphService directly don't require the binding.
130
+ self._telemetry: TelemetryStore | None = None
131
+
132
+ # Lazy import to avoid circular dependency
133
+ self._media_service = None
134
+
135
+ @property
136
+ def is_live_agent(self) -> bool:
137
+ """Whether the configured graph is a realtime (live) agent.
138
+
139
+ A live graph must be driven over the ``/v1/graph/live`` realtime WebSocket; a
140
+ non-live (turn-based) graph must use ``/v1/graph/ws``. The WebSocket handlers use
141
+ this to reject the wrong agent type up front.
142
+
143
+ Prefers the public ``CompiledGraph.is_realtime()`` (newer core releases) and falls
144
+ back to the internal ``_find_live_nodes()`` probe on releases that predate it.
145
+ """
146
+ is_realtime = getattr(self._graph, "is_realtime", None)
147
+ if callable(is_realtime):
148
+ return bool(is_realtime())
149
+ find_live_nodes = getattr(self._graph, "_find_live_nodes", None)
150
+ if callable(find_live_nodes):
151
+ return bool(find_live_nodes())
152
+ return False
153
+
154
+ @property
155
+ def media_service(self):
156
+ if self._media_service is None:
157
+ try:
158
+ container = InjectQ.get_instance()
159
+ from tenxgraph_api.src.app.routers.media import MediaService
160
+
161
+ self._media_service = container.try_get(MediaService)
162
+ except Exception:
163
+ self._media_service = None
164
+ return self._media_service
165
+
166
+ async def _save_thread_name(
167
+ self,
168
+ config: dict[str, Any],
169
+ thread_id: int,
170
+ messages: list[str],
171
+ ) -> str:
172
+ """
173
+ Save the generated thread name to the database.
174
+ """
175
+ if not self.thread_name_generator:
176
+ thread_name = await DefaultThreadNameGenerator().generate_name([])
177
+ logger.debug("No thread name generator configured, using default generator.")
178
+ return thread_name
179
+
180
+ thread_name = await self.thread_name_generator.generate_name(messages)
181
+
182
+ res = await self.checkpointer.aput_thread(
183
+ config,
184
+ ThreadInfo(thread_id=thread_id, thread_name=thread_name),
185
+ )
186
+ if res:
187
+ logger.info(f"Generated thread name: {thread_name} for thread_id: {thread_id}")
188
+
189
+ return thread_name
190
+
191
+ async def _save_thread(self, config: dict[str, Any], thread_id: str):
192
+ """
193
+ Save the generated thread name to the database.
194
+ """
195
+ return await self.checkpointer.aput_thread(
196
+ config,
197
+ ThreadInfo(thread_id=thread_id),
198
+ )
199
+
200
+ def _extract_context_info(
201
+ self, raw_state, result: dict[str, Any]
202
+ ) -> tuple[list[Message] | None, str | None]:
203
+ """Extract context and context_summary from result or state."""
204
+ context: list[Message] | None = result.get("context")
205
+ context_summary: str | None = result.get("context_summary")
206
+
207
+ # If not found, try reading from state (supports both dict and model)
208
+ if not context_summary and raw_state is not None:
209
+ try:
210
+ if isinstance(raw_state, dict):
211
+ context_summary = raw_state.get("context_summary")
212
+ else:
213
+ context_summary = getattr(raw_state, "context_summary", None)
214
+ except Exception:
215
+ context_summary = None
216
+
217
+ if not context and raw_state is not None:
218
+ try:
219
+ if isinstance(raw_state, dict):
220
+ context = raw_state.get("context")
221
+ else:
222
+ context = getattr(raw_state, "context", None)
223
+ except Exception:
224
+ context = None
225
+
226
+ return context, context_summary
227
+
228
+ @property
229
+ def telemetry(self) -> TelemetryStore | None:
230
+ """The bound TelemetryStore, if any (resolved lazily, cached)."""
231
+ if getattr(self, "_telemetry", None) is None:
232
+ try:
233
+ self._telemetry = InjectQ.get_instance().try_get(TelemetryStore)
234
+ except Exception:
235
+ self._telemetry = None
236
+ return self._telemetry
237
+
238
+ @staticmethod
239
+ def _telemetry_record(chunk: Any) -> dict[str, Any]:
240
+ """Flatten a StreamChunk into a compact record for the telemetry store."""
241
+ md = getattr(chunk, "metadata", None) or {}
242
+ node = md.get("node") or md.get("current_node") or getattr(chunk, "node_name", "") or ""
243
+
244
+ usages = None
245
+ message = getattr(chunk, "message", None)
246
+ if message is not None:
247
+ u = getattr(message, "usages", None)
248
+ if u is not None:
249
+ try:
250
+ usages = u.model_dump() if hasattr(u, "model_dump") else dict(u)
251
+ except Exception:
252
+ usages = None
253
+
254
+ # Tool call / tool result hints for span reconstruction.
255
+ content_kinds: list[str] = []
256
+ tool_names: list[str] = []
257
+ if message is not None:
258
+ content = getattr(message, "content", None)
259
+ if isinstance(content, list):
260
+ for block in content:
261
+ btype = getattr(block, "type", None) or (
262
+ block.get("type") if isinstance(block, dict) else None
263
+ )
264
+ if btype:
265
+ content_kinds.append(btype)
266
+ if btype in ("tool_call", "tool_result"):
267
+ bname = getattr(block, "name", None) or (
268
+ block.get("name") if isinstance(block, dict) else None
269
+ )
270
+ if bname:
271
+ tool_names.append(bname)
272
+
273
+ event = getattr(chunk, "event", None)
274
+ return {
275
+ "event": str(event.value) if hasattr(event, "value") else str(event or ""),
276
+ "node": node,
277
+ "timestamp": getattr(chunk, "timestamp", None),
278
+ "is_delta": bool(getattr(message, "delta", False)) if message else False,
279
+ "role": getattr(message, "role", None) if message else None,
280
+ "usages": usages,
281
+ "content_kinds": content_kinds,
282
+ "tool_names": tool_names,
283
+ "is_error": bool(getattr(chunk, "is_error", False)),
284
+ }
285
+
286
+ def _record_chunk(self, thread_id: str, run_id: str, chunk: Any) -> None:
287
+ store = self.telemetry
288
+ if not store:
289
+ return
290
+ try:
291
+ store.record(thread_id, run_id, self._telemetry_record(chunk))
292
+ except Exception as e: # - telemetry must never break a run
293
+ logger.debug("Telemetry record failed: %s", e)
294
+
295
+ async def stop_graph(
296
+ self,
297
+ thread_id: str,
298
+ user: dict[str, Any],
299
+ config: dict[str, Any] | None = None,
300
+ ) -> dict[str, Any]:
301
+ """
302
+ Stop the graph execution for a specific thread.
303
+
304
+ Args:
305
+ thread_id (str): The thread ID to stop
306
+ user (dict): User information for context
307
+ config (dict, optional): Additional configuration for the stop operation
308
+
309
+ Returns:
310
+ dict: Stop result with status information
311
+
312
+ Raises:
313
+ HTTPException: If stop operation fails or user doesn't have permission.
314
+ """
315
+ try:
316
+ logger.info(f"Stopping graph execution for thread: {thread_id}")
317
+ logger.debug(f"User info: {sanitize_for_logging(user)}")
318
+
319
+ # Start with client config if provided, then overlay trusted attributes
320
+ stop_config = client_config(config)
321
+ stop_config.update(
322
+ {
323
+ "thread_id": thread_id,
324
+ "user": user,
325
+ "user_id": user.get("user_id", "anonymous"),
326
+ }
327
+ )
328
+
329
+ # Call the graph's astop method
330
+ result = await self._graph.astop(stop_config)
331
+
332
+ logger.info(f"Graph stop completed for thread {thread_id}: {result}")
333
+ return result
334
+
335
+ except ValueError as e:
336
+ logger.warning(f"Graph stop input validation failed for thread {thread_id}: {e}")
337
+ raise HTTPException(status_code=422, detail=str(e))
338
+ except Exception as e:
339
+ _reraise_framework_errors(e)
340
+ logger.error(f"Graph stop failed for thread {thread_id}: {e}")
341
+ raise HTTPException(
342
+ status_code=500, detail=f"Graph stop failed for thread {thread_id}: {e!s}"
343
+ )
344
+
345
+ def _reject_tool_start_node(self, initial_state: dict[str, Any]) -> None:
346
+ """Allow a run to start on any node except a ``ToolNode``.
347
+
348
+ Clients may pick the start node, e.g. to jump straight to one agent. A ``ToolNode``
349
+ runs the tool calls in the last message of the saved context without asking the
350
+ model, so starting there would execute tools the model never requested.
351
+ """
352
+ from tenxgraph.core.graph import ToolNode
353
+
354
+ node_name = initial_state.get("current_node")
355
+ if not isinstance(node_name, str):
356
+ return
357
+ state_graph = getattr(self._graph, "_state_graph", None)
358
+ nodes = getattr(state_graph, "nodes", {}) if state_graph else {}
359
+ node = nodes.get(node_name)
360
+ if node is not None and isinstance(getattr(node, "func", None), ToolNode):
361
+ raise ValueError(f"current_node '{node_name}' is a tool node; runs cannot start there")
362
+
363
+ def _remote_tool_names(self) -> set[str]:
364
+ """Names of the tools the client runs (remote tools), across every ToolNode."""
365
+ from tenxgraph.core.graph import ToolNode
366
+
367
+ state_graph = getattr(self._graph, "_state_graph", None)
368
+ nodes = getattr(state_graph, "nodes", {}) if state_graph else {}
369
+ names: set[str] = set()
370
+ for node in nodes.values():
371
+ tool_node = getattr(node, "func", None)
372
+ if isinstance(tool_node, ToolNode):
373
+ names.update(n for n in getattr(tool_node, "remote_tool_names", None) or [] if n)
374
+ return names
375
+
376
+ def _server_tool_names(self) -> set[str]:
377
+ """Names of the tools the server runs itself (local and MCP), across every ToolNode."""
378
+ from tenxgraph.core.graph import ToolNode
379
+
380
+ state_graph = getattr(self._graph, "_state_graph", None)
381
+ nodes = getattr(state_graph, "nodes", {}) if state_graph else {}
382
+ names: set[str] = set()
383
+ for node in nodes.values():
384
+ tool_node = getattr(node, "func", None)
385
+ if isinstance(tool_node, ToolNode):
386
+ names.update(getattr(tool_node, "_funcs", None) or {})
387
+ names.update(getattr(tool_node, "mcp_tools", None) or [])
388
+ return names
389
+
390
+ async def _check_tool_results(self, messages: list[Any], config: dict[str, Any]) -> None:
391
+ """Accept a tool result only as the answer to a remote tool call awaiting one.
392
+
393
+ Remote tools run on the client, which sends their results back to resume the run.
394
+ Anything else is a forged result: an answer to a call the model never made, a second
395
+ answer to the same call, or an answer to a server tool the client never ran.
396
+ """
397
+ result_ids = tool_result_ids(messages)
398
+ if not result_ids:
399
+ return
400
+ if len(set(result_ids)) != len(result_ids):
401
+ raise ValueError("Each tool call can be answered only once")
402
+
403
+ state = await self.checkpointer.aget_state(config) if self.checkpointer else None
404
+ pending = unanswered_tool_calls(getattr(state, "context", None))
405
+ # A client tool can never carry a server tool's name: otherwise a client could declare
406
+ # it and post a "result" for a server tool call (one paused for approval, say).
407
+ remote = self._remote_tool_names() | (_run_tool_names(config) - self._server_tool_names())
408
+ for call_id in result_ids:
409
+ if call_id not in pending or pending[call_id] not in remote:
410
+ raise ValueError(f"No remote tool call '{call_id}' is waiting for a result")
411
+
412
+ async def _new_thread_id(self) -> str:
413
+ """Generate a thread id that nobody owns yet.
414
+
415
+ The permission check approved "a new thread", so the id handed out here must really
416
+ be new. Generated ids (Snowflake by default) are predictable, and a client may create
417
+ a thread under any id it likes, so another user could have claimed the next id
418
+ first; running on it would put this user's messages in their thread.
419
+ """
420
+ for _ in range(MAX_THREAD_ID_ATTEMPTS):
421
+ thread_id = str(await InjectQ.get_instance().atry_get("generated_id") or uuid4())
422
+ try:
423
+ owner = await self.checkpointer.aget_thread_owner(thread_id)
424
+ except NotImplementedError:
425
+ return thread_id # the checkpointer cannot tell; keep the old behaviour
426
+ if owner is None:
427
+ return thread_id
428
+ logger.warning("Generated thread id %s is already taken; generating another", thread_id)
429
+ raise RuntimeError("Could not generate an unused thread id")
430
+
431
+ async def _prepare_input(
432
+ self,
433
+ graph_input: GraphInputSchema,
434
+ user_id: str | None = None,
435
+ server_config: dict[str, Any] | None = None,
436
+ ):
437
+ is_new_thread = False
438
+ config = client_config(graph_input.config)
439
+ # Server-owned keys set by a trusted caller (a protocol adapter), after the client's
440
+ # own config has been stripped of them.
441
+ config.update(server_config or {})
442
+ # Same normalisation as the permission check, so the checked thread is the one used.
443
+ thread_id = normalize_thread_id(config.get("thread_id"))
444
+ if thread_id is None:
445
+ thread_id = await self._new_thread_id()
446
+ is_new_thread = True
447
+
448
+ # update thread id
449
+ config["thread_id"] = str(thread_id)
450
+
451
+ # check recursion limit set or not
452
+ config["recursion_limit"] = graph_input.recursion_limit or 25
453
+
454
+ # Prepare the input for the graph
455
+ # Preprocess multimodal messages (resolve file_id → cached text, etc.)
456
+ preprocessed = await preprocess_multimodal_messages(
457
+ graph_input.messages,
458
+ self.media_service,
459
+ user_id,
460
+ )
461
+ input_data: dict = {
462
+ "messages": preprocessed,
463
+ }
464
+ if graph_input.is_resume:
465
+ input_data["resume"] = graph_input.resume
466
+ if graph_input.initial_state:
467
+ self._reject_tool_start_node(graph_input.initial_state)
468
+ input_data["state"] = graph_input.initial_state
469
+
470
+ return (
471
+ input_data,
472
+ config,
473
+ {
474
+ "is_new_thread": is_new_thread,
475
+ "thread_id": str(thread_id),
476
+ },
477
+ )
478
+
479
+ async def invoke_graph(
480
+ self,
481
+ graph_input: GraphInputSchema,
482
+ user: dict[str, Any],
483
+ ) -> GraphInvokeOutputSchema:
484
+ """
485
+ Invokes the graph with the provided input and returns the final result.
486
+
487
+ Args:
488
+ graph_input (GraphInputSchema): The input data for graph execution.
489
+
490
+ Returns:
491
+ GraphInvokeOutputSchema: The final result from graph execution.
492
+
493
+ Raises:
494
+ HTTPException: If graph execution fails.
495
+ """
496
+ try:
497
+ logger.debug(f"Invoking graph with input: {graph_input.messages}")
498
+
499
+ # Prepare the input
500
+ input_data, config, meta = await self._prepare_input(graph_input, user.get("user_id"))
501
+ # add user inside config
502
+ config["user"] = user
503
+ config["user_id"] = user.get("user_id", "anonymous")
504
+ await self._check_tool_results(input_data["messages"], config)
505
+
506
+ # Try to save thread info in the db even for existing threads
507
+ # this will help in updating last accessed time
508
+ # and get is thread newly created or not, this way it's consistent
509
+ is_new_thread = await self._save_thread(config, config["thread_id"])
510
+ if is_new_thread and type(is_new_thread) is bool:
511
+ meta["is_new_thread"] = True
512
+
513
+ # Telemetry run (invoke has no chunk stream; we record from the final
514
+ # messages so cost/usage still shows on the observability page).
515
+ inv_thread_id = str(config["thread_id"])
516
+ inv_run_id = str(config.get("run_id") or uuid4())
517
+ config.setdefault("run_id", inv_run_id)
518
+ inv_started = datetime.now().timestamp()
519
+ if self.telemetry:
520
+ self.telemetry.start_run(inv_thread_id, inv_run_id, inv_started)
521
+
522
+ # Execute the graph
523
+ result = await self._graph.ainvoke(
524
+ input_data,
525
+ config=config,
526
+ response_granularity=graph_input.response_granularity,
527
+ )
528
+
529
+ logger.info("Graph execution completed successfully")
530
+
531
+ # Extract messages and state from result
532
+ messages: list[Message] = result.get("messages", [])
533
+
534
+ # Record final messages into telemetry, then close the run.
535
+ if self.telemetry:
536
+ for msg in messages:
537
+ self._record_chunk(
538
+ inv_thread_id,
539
+ inv_run_id,
540
+ StreamChunk(
541
+ event=StreamEvent.MESSAGE,
542
+ message=msg,
543
+ metadata={"node": getattr(msg, "node", "") or ""},
544
+ timestamp=datetime.now().timestamp(),
545
+ ),
546
+ )
547
+ self.telemetry.finish_run(
548
+ inv_thread_id, inv_run_id, datetime.now().timestamp(), "done"
549
+ )
550
+ raw_state: AgentState | None = result.get("state", None)
551
+
552
+ # Extract context information using helper method
553
+ context, context_summary = self._extract_context_info(raw_state, result)
554
+
555
+ if meta["is_new_thread"] and self.config.thread_name_generator_path:
556
+ messages_str = [msg.text() for msg in messages]
557
+ thread_name = await self._save_thread_name(
558
+ config, config["thread_id"], messages_str
559
+ )
560
+ meta["thread_name"] = thread_name
561
+
562
+ return GraphInvokeOutputSchema(
563
+ messages=messages,
564
+ state=raw_state.model_dump(serialize_as_any=True) if raw_state else None,
565
+ context=context,
566
+ summary=context_summary,
567
+ meta=meta,
568
+ )
569
+
570
+ except UnsupportedMediaInputError as e:
571
+ logger.warning("Unsupported media input: %s", e.message)
572
+ raise HTTPException(status_code=422, detail=e.message)
573
+ except ValueError as e:
574
+ logger.warning(f"Graph input validation failed: {e}")
575
+ raise HTTPException(status_code=422, detail=str(e))
576
+ except Exception as e:
577
+ # Let typed/HTTP errors reach their registered handlers instead of
578
+ # collapsing every failure into a generic 500.
579
+ _reraise_framework_errors(e)
580
+ logger.error(f"Graph execution failed: {e}")
581
+ raise HTTPException(status_code=500, detail=f"Graph execution failed: {e!s}")
582
+
583
+ async def stream_graph(
584
+ self,
585
+ graph_input: GraphInputSchema,
586
+ user: dict[str, Any],
587
+ ) -> AsyncIterable[str]:
588
+ """
589
+ Streams the graph execution with the provided input.
590
+
591
+ Args:
592
+ graph_input (GraphInputSchema): The input data for graph execution.
593
+ stream_mode (str): The stream mode ("values", "updates", "messages", etc.).
594
+
595
+ Yields:
596
+ str: Individual JSON chunks from graph execution with newline delimiters.
597
+ """
598
+ async for chunk in self.stream_chunks(graph_input, user):
599
+ yield chunk.model_dump_json(serialize_as_any=True) + "\n"
600
+
601
+ async def stream_chunks(
602
+ self,
603
+ graph_input: GraphInputSchema,
604
+ user: dict[str, Any],
605
+ server_config: dict[str, Any] | None = None,
606
+ ) -> AsyncIterable[StreamChunk]:
607
+ """
608
+ Streams the graph execution as ``StreamChunk`` objects.
609
+
610
+ Shared by the NDJSON stream and protocol adapters such as AG-UI. The graph reuses one
611
+ chunk object for successive messages, so consume each chunk before asking for the next.
612
+ ``server_config`` carries server-owned run config (for example per-run
613
+ ``remote_tools``) that a client could not set through ``graph_input.config``.
614
+
615
+ Yields:
616
+ StreamChunk: Graph output; failures arrive as an ``ERROR`` chunk, never raised.
617
+ """
618
+ # Initialize meta here so it is available in the except blocks even if
619
+ # _prepare_input raises before assigning it.
620
+ meta: dict[str, Any] = {}
621
+ thread_id: str | None = None
622
+ run_id: str | None = None
623
+ try:
624
+ logger.debug(f"Streaming graph with input: {graph_input.messages}")
625
+
626
+ # Prepare the config
627
+ input_data, config, meta = await self._prepare_input(
628
+ graph_input, user.get("user_id"), server_config
629
+ )
630
+ # add user inside config
631
+ config["user"] = user
632
+ config["user_id"] = user.get("user_id", "anonymous")
633
+ await self._check_tool_results(input_data["messages"], config)
634
+
635
+ # Try to save thread info in the db even for existing threads
636
+ # this will help in updating last accessed time
637
+ # and get is thread newly created or not, this way it's consistent
638
+ is_new_thread = await self._save_thread(config, config["thread_id"])
639
+ if is_new_thread and type(is_new_thread) is bool:
640
+ meta["is_new_thread"] = True
641
+
642
+ messages_str = []
643
+
644
+ # Begin a telemetry run so the observability endpoint can rebuild the trace.
645
+ thread_id = str(config["thread_id"])
646
+ run_id = str(config.get("run_id") or uuid4())
647
+ config.setdefault("run_id", run_id)
648
+ started_at = datetime.now().timestamp()
649
+ if self.telemetry:
650
+ self.telemetry.start_run(thread_id, run_id, started_at)
651
+ run_status = "done"
652
+
653
+ # Stream the graph execution
654
+ async for chunk in self._graph.astream(
655
+ input_data,
656
+ config=config,
657
+ response_granularity=graph_input.response_granularity,
658
+ ):
659
+ mt = chunk.metadata or {}
660
+ mt.update(meta)
661
+ chunk.metadata = mt
662
+ self._record_chunk(thread_id, run_id, chunk)
663
+ if getattr(chunk, "is_error", False) or getattr(chunk, "event", None) in (
664
+ "error",
665
+ StreamEvent.ERROR,
666
+ ):
667
+ run_status = "error"
668
+ yield chunk
669
+ if (
670
+ self.config.thread_name_generator_path
671
+ and meta["is_new_thread"]
672
+ and chunk.event == StreamEvent.MESSAGE
673
+ and chunk.message
674
+ and not chunk.message.delta
675
+ ):
676
+ messages_str.append(chunk.message.text())
677
+
678
+ logger.info("Graph streaming completed successfully")
679
+
680
+ if self.telemetry:
681
+ self.telemetry.finish_run(thread_id, run_id, datetime.now().timestamp(), run_status)
682
+
683
+ if meta["is_new_thread"] and self.config.thread_name_generator_path:
684
+ thread_name = await self._save_thread_name(
685
+ config, config["thread_id"], messages_str
686
+ )
687
+ meta["thread_name"] = thread_name
688
+
689
+ yield StreamChunk(
690
+ event=StreamEvent.UPDATES,
691
+ data={"status": "completed"},
692
+ metadata=meta,
693
+ )
694
+
695
+ except Exception as e:
696
+ # Never raise HTTPException from inside an async generator that is
697
+ # consumed by StreamingResponse. By the time any exception is raised
698
+ # from within the generator body, Starlette has already committed to a
699
+ # 200 OK response and cannot replace it with a 4xx/5xx. Raising here
700
+ # would cause: RuntimeError("Caught handled exception, but response
701
+ # already started.") Instead, we yield a structured error chunk so
702
+ # the client can detect and display the failure.
703
+ logger.error(f"Graph streaming failed: {e}")
704
+ try:
705
+ if self.telemetry and thread_id and run_id:
706
+ self.telemetry.finish_run(
707
+ thread_id, run_id, datetime.now().timestamp(), "error"
708
+ )
709
+ except Exception as telemetry_exc:
710
+ logger.debug("Telemetry finish_run failed on stream error: %s", telemetry_exc)
711
+ # The global error handlers never see this exception (it is caught inside
712
+ # the generator), so sanitize here too. Otherwise a driver/DB exception
713
+ # embedding a connection string or internal path would stream verbatim in
714
+ # production, bypassing MODE=production redaction.
715
+ # Imported lazily: a top-level import of handle_errors creates a circular
716
+ # import through the shared utils/exceptions modules.
717
+ from tenxgraph_api.src.app.core.config.settings import get_settings
718
+ from tenxgraph_api.src.app.core.exceptions.handle_errors import (
719
+ _sanitize_error_message,
720
+ )
721
+
722
+ is_production = get_settings().MODE == "production"
723
+ reason = _sanitize_error_message(str(e), "GRAPH_STREAM_ERROR", is_production)
724
+ yield StreamChunk(
725
+ event=StreamEvent.ERROR,
726
+ data={"reason": reason},
727
+ metadata=meta,
728
+ )
729
+
730
+ async def realtime_graph(
731
+ self,
732
+ input_queue: Any,
733
+ init: dict[str, Any],
734
+ user: dict[str, Any],
735
+ ):
736
+ """Bridge a realtime (audio) session over the compiled graph.
737
+
738
+ Thin wrapper over ``CompiledGraph.arealtime``: builds the per-session config from
739
+ the init control frame + authenticated user, persists thread info, and yields the
740
+ normalized RealtimeEvents. The compiled graph must be rooted at a LiveAgent (e.g.
741
+ an ``AudioAgent``); otherwise ``arealtime`` raises.
742
+ """
743
+ thread_id = normalize_thread_id(init.get("thread_id")) or await self._new_thread_id()
744
+ config: dict[str, Any] = {
745
+ "thread_id": thread_id,
746
+ "user": user,
747
+ "user_id": user.get("user_id", "anonymous"),
748
+ }
749
+ # Map the client init frame onto RealtimeConfig field names so the live agent can
750
+ # apply per-session overrides (model/voice/modalities/vad/...). Only present keys
751
+ # are forwarded; absent ones fall back to the agent's build-time config.
752
+ realtime = self._restrict_realtime_overrides(self._realtime_overrides(init))
753
+ if realtime:
754
+ config["realtime"] = realtime
755
+ await self._save_thread(config, thread_id)
756
+ logger.info("Realtime graph session starting: thread_id=%s", thread_id)
757
+
758
+ async for event in self._graph.arealtime(input_queue, config):
759
+ yield event
760
+
761
+ logger.info("Realtime graph session completed: thread_id=%s", thread_id)
762
+
763
+ @staticmethod
764
+ def _realtime_overrides(init: dict[str, Any]) -> dict[str, Any]:
765
+ """Translate the client init frame into RealtimeConfig field overrides."""
766
+ # init key -> RealtimeConfig field name
767
+ mapping = {
768
+ "model": "model",
769
+ "voice": "voice",
770
+ "modalities": "response_modalities",
771
+ "vad": "vad",
772
+ "system_prompt": "system_instruction",
773
+ "tools_tags": "tools_tags",
774
+ }
775
+ overrides: dict[str, Any] = {}
776
+ for init_key, field in mapping.items():
777
+ value = init.get(init_key)
778
+ if value is not None:
779
+ overrides[field] = value
780
+ # Clients commonly send a single modality as a bare string ("AUDIO"); RealtimeConfig
781
+ # expects a list. Coerce so the shorthand doesn't trip the one-modality validator.
782
+ if isinstance(overrides.get("response_modalities"), str):
783
+ overrides["response_modalities"] = [overrides["response_modalities"]]
784
+ return overrides
785
+
786
+ def _live_tools_tags(self) -> set[str] | None:
787
+ """The tag filter the live agent was built with, or ``None`` when it has none."""
788
+ find_live_nodes = getattr(self._graph, "_find_live_nodes", None)
789
+ for _name, node in find_live_nodes() if callable(find_live_nodes) else []:
790
+ realtime_config = getattr(getattr(node, "func", None), "realtime_config", None)
791
+ tags = getattr(realtime_config, "tools_tags", None)
792
+ if tags:
793
+ return {str(tag) for tag in tags}
794
+ return None
795
+
796
+ def _restrict_realtime_overrides(self, overrides: dict[str, Any]) -> dict[str, Any]:
797
+ """Drop the init-frame overrides a client is not allowed to make.
798
+
799
+ ``model`` is honoured only when listed in ``websocket.realtime_models``. ``tools_tags``
800
+ may only narrow the agent's own filter: widening it, or clearing it (an empty filter
801
+ advertises every tool), would expose tools the agent was built to hide.
802
+ """
803
+ restricted = dict(overrides)
804
+ model = restricted.get("model")
805
+ if model is not None and model not in self.config.websocket.realtime_models:
806
+ logger.info(
807
+ "Realtime init model %r is not in websocket.realtime_models; ignored", model
808
+ )
809
+ restricted.pop("model")
810
+
811
+ if "tools_tags" in restricted:
812
+ requested = restricted.pop("tools_tags")
813
+ if isinstance(requested, str):
814
+ requested = [requested]
815
+ if not isinstance(requested, list):
816
+ requested = []
817
+ base = self._live_tools_tags()
818
+ tags = [str(tag) for tag in requested if base is None or str(tag) in base]
819
+ if tags:
820
+ restricted["tools_tags"] = tags
821
+ return restricted
822
+
823
+ async def graph_details(self) -> GraphSchema:
824
+ try:
825
+ logger.info("Getting graph details")
826
+ res = self._graph.generate_graph()
827
+ # Surface live/realtime capability so clients can route to /v1/graph/live
828
+ # and gate their UI. Not part of core's generate_graph() output.
829
+ info = res.get("info")
830
+ if isinstance(info, dict):
831
+ info["is_realtime"] = self.is_live_agent
832
+ return GraphSchema(**res)
833
+ except ValueError as e:
834
+ logger.warning(f"Graph details validation failed: {e}")
835
+ raise HTTPException(status_code=422, detail=str(e))
836
+ except Exception as e:
837
+ logger.error(f"Failed to get graph details: {e}")
838
+ raise HTTPException(status_code=500, detail=f"Failed to get graph details: {e!s}")
839
+
840
+ async def get_tools(self) -> GraphToolsSchema:
841
+ """Collect the tools exposed by every ToolNode in the graph.
842
+
843
+ Walks the compiled graph's nodes, and for each ``ToolNode`` calls
844
+ ``all_tools()`` (which resolves local functions, MCP tools from any
845
+ connected server, and remote/client-registered tools). Each tool is
846
+ tagged with its ``source`` so the UI can group them.
847
+
848
+ A failing MCP server on one node does not break the endpoint: that node's
849
+ tools are collected best-effort and the error is logged.
850
+ """
851
+ # Local import to avoid a hard dependency at module import time.
852
+ from tenxgraph.core.graph import ToolNode
853
+
854
+ state_graph = getattr(self._graph, "_state_graph", None)
855
+ nodes = getattr(state_graph, "nodes", {}) if state_graph else {}
856
+
857
+ node_schemas: list[ToolNodeSchema] = []
858
+ total_tools = 0
859
+
860
+ for node_name, node in nodes.items():
861
+ tool_node = getattr(node, "func", None)
862
+ if not isinstance(tool_node, ToolNode):
863
+ continue
864
+
865
+ try:
866
+ raw_tools = await tool_node.all_tools()
867
+ except Exception as e: # - one bad MCP server shouldn't 500 the page
868
+ logger.warning("Failed to list tools for node '%s': %s", node_name, e)
869
+ raw_tools = []
870
+
871
+ # After all_tools(), these hold the names by origin.
872
+ local_names = set(getattr(tool_node, "_funcs", {}).keys())
873
+ mcp_names = set(getattr(tool_node, "mcp_tools", []) or [])
874
+ remote_names = set(getattr(tool_node, "remote_tool_names", []) or [])
875
+
876
+ tools: list[ToolSchema] = []
877
+ for entry in raw_tools:
878
+ fn = entry.get("function", {}) if isinstance(entry, dict) else {}
879
+ name = fn.get("name", "")
880
+ if name in mcp_names:
881
+ source = "mcp"
882
+ elif name in remote_names:
883
+ source = "remote"
884
+ elif name in local_names:
885
+ source = "local"
886
+ else:
887
+ # Unknown origin — default to local (a plain function schema).
888
+ source = "local"
889
+
890
+ tools.append(
891
+ ToolSchema(
892
+ name=name,
893
+ description=fn.get("description", "") or "",
894
+ source=source,
895
+ parameters=fn.get("parameters", {}) or {},
896
+ )
897
+ )
898
+
899
+ total_tools += len(tools)
900
+ node_schemas.append(
901
+ ToolNodeSchema(
902
+ node_name=node_name,
903
+ tool_count=len(tools),
904
+ tools=tools,
905
+ )
906
+ )
907
+
908
+ return GraphToolsSchema(
909
+ node_count=len(node_schemas),
910
+ tool_count=total_tools,
911
+ nodes=node_schemas,
912
+ )
913
+
914
+ async def get_observability(
915
+ self,
916
+ thread_id: str,
917
+ run_id: str | None = None,
918
+ ) -> "ObservabilitySchema":
919
+ """Reconstruct an observability trace for a thread from captured run events.
920
+
921
+ Rebuilds spans (root → node → llm/tool), an event list, and aggregated
922
+ token usage from the telemetry records a run emitted. Returns the latest
923
+ run by default, or the requested ``run_id``.
924
+ """
925
+ store = self.telemetry
926
+ if not store:
927
+ return ObservabilitySchema(thread_id=str(thread_id), run_count=0)
928
+
929
+ runs = store.get_runs(thread_id)
930
+ run_ids = [r.run_id for r in runs]
931
+
932
+ trace = None
933
+ if run_id:
934
+ trace = store.get_run(thread_id, run_id)
935
+ elif runs:
936
+ trace = runs[-1]
937
+
938
+ if trace is None:
939
+ return ObservabilitySchema(
940
+ thread_id=str(thread_id),
941
+ run_count=len(runs),
942
+ run_ids=run_ids,
943
+ run=None,
944
+ )
945
+
946
+ run_schema = self._reconstruct_run(trace)
947
+ return ObservabilitySchema(
948
+ thread_id=str(thread_id),
949
+ run_count=len(runs),
950
+ run_ids=run_ids,
951
+ run=run_schema,
952
+ )
953
+
954
+ def _reconstruct_run(self, trace: Any) -> "ObsRunSchema":
955
+ """Turn a captured RunTrace into spans + events + usage."""
956
+ records = list(trace.records)
957
+ started = trace.started_at
958
+ # Fall back to the first record's timestamp if start wasn't stamped.
959
+ first_ts = next((r.get("timestamp") for r in records if r.get("timestamp")), started)
960
+ base = started or first_ts or 0.0
961
+ last_ts = max([r.get("timestamp") or base for r in records] + [trace.finished_at or base])
962
+ total_ms = max(0.0, (last_ts - base) * 1000.0)
963
+
964
+ def off_ms(ts: float | None) -> float:
965
+ return max(0.0, ((ts or base) - base) * 1000.0)
966
+
967
+ usage = ObsTokenUsageSchema()
968
+ events: list[ObsEventSchema] = []
969
+ spans: list[ObsSpanSchema] = []
970
+
971
+ # Root span spans the whole run.
972
+ spans.append(
973
+ ObsSpanSchema(
974
+ id="root",
975
+ name="graph",
976
+ kind="root",
977
+ parent=None,
978
+ start_ms=0.0,
979
+ duration_ms=total_ms,
980
+ )
981
+ )
982
+
983
+ # Walk records to build node spans (by node transitions) + llm/tool spans.
984
+ node_spans: dict[str, ObsSpanSchema] = {}
985
+ node_order: list[str] = []
986
+ llm_calls = 0
987
+ tool_calls = 0
988
+ span_seq = 0
989
+
990
+ def new_span_id() -> str:
991
+ nonlocal span_seq
992
+ span_seq += 1
993
+ return f"s{span_seq}"
994
+
995
+ for idx, rec in enumerate(records):
996
+ node = rec.get("node") or "—"
997
+ ts = rec.get("timestamp")
998
+ ev_type = rec.get("event") or "message"
999
+
1000
+ # Node span: open on first sight of a node, extend its end as records arrive.
1001
+ if node and node != "—":
1002
+ span = node_spans.get(node)
1003
+ if span is None:
1004
+ span = ObsSpanSchema(
1005
+ id=new_span_id(),
1006
+ name=f"node: {node}",
1007
+ kind="node",
1008
+ parent="root",
1009
+ start_ms=off_ms(ts),
1010
+ duration_ms=0.0,
1011
+ )
1012
+ node_spans[node] = span
1013
+ node_order.append(node)
1014
+ spans.append(span)
1015
+ else:
1016
+ span.duration_ms = max(span.duration_ms, off_ms(ts) - span.start_ms)
1017
+
1018
+ # LLM span: a non-delta assistant message with usages under this node.
1019
+ u = rec.get("usages")
1020
+ if u:
1021
+ usage.prompt_tokens += int(u.get("prompt_tokens", 0) or 0)
1022
+ usage.completion_tokens += int(u.get("completion_tokens", 0) or 0)
1023
+ usage.reasoning_tokens += int(u.get("reasoning_tokens", 0) or 0)
1024
+ usage.total_tokens += int(
1025
+ u.get("total_tokens", 0)
1026
+ or (u.get("prompt_tokens", 0) or 0) + (u.get("completion_tokens", 0) or 0)
1027
+ )
1028
+ llm_calls += 1
1029
+ parent = node_spans.get(node)
1030
+ spans.append(
1031
+ ObsSpanSchema(
1032
+ id=new_span_id(),
1033
+ name="llm.generate",
1034
+ kind="llm",
1035
+ parent=parent.id if parent else "root",
1036
+ start_ms=off_ms(ts),
1037
+ duration_ms=0.0,
1038
+ model=(rec.get("model") or None),
1039
+ input_tokens=int(u.get("prompt_tokens", 0) or 0),
1040
+ output_tokens=int(u.get("completion_tokens", 0) or 0),
1041
+ )
1042
+ )
1043
+
1044
+ # Tool spans: from tool_call / tool_result content blocks.
1045
+ for tname in rec.get("tool_names", []) or []:
1046
+ if "tool_call" in (rec.get("content_kinds") or []):
1047
+ tool_calls += 1
1048
+ parent = node_spans.get(node)
1049
+ spans.append(
1050
+ ObsSpanSchema(
1051
+ id=new_span_id(),
1052
+ name=f"tool: {tname}",
1053
+ kind="tool",
1054
+ parent=parent.id if parent else "root",
1055
+ start_ms=off_ms(ts),
1056
+ duration_ms=0.0,
1057
+ )
1058
+ )
1059
+
1060
+ # Event row.
1061
+ events.append(
1062
+ ObsEventSchema(
1063
+ id=f"e{idx}",
1064
+ type=ev_type,
1065
+ node=node if node != "—" else "",
1066
+ offset_ms=off_ms(ts),
1067
+ summary=self._event_summary(rec),
1068
+ )
1069
+ )
1070
+
1071
+ # Newest-first events for the pane.
1072
+ events.reverse()
1073
+
1074
+ return ObsRunSchema(
1075
+ run_id=trace.run_id,
1076
+ thread_id=trace.thread_id,
1077
+ status=trace.status,
1078
+ started_at=trace.started_at,
1079
+ finished_at=trace.finished_at,
1080
+ duration_ms=total_ms,
1081
+ spans=spans,
1082
+ events=events,
1083
+ usage=usage,
1084
+ llm_calls=llm_calls,
1085
+ tool_calls=tool_calls,
1086
+ iterations=len(node_order),
1087
+ )
1088
+
1089
+ @staticmethod
1090
+ def _event_summary(rec: dict[str, Any]) -> str:
1091
+ kinds = rec.get("content_kinds") or []
1092
+ tools = rec.get("tool_names") or []
1093
+ if rec.get("is_error"):
1094
+ return "error"
1095
+ if tools and ("tool_call" in kinds or "tool_result" in kinds):
1096
+ label = "tool_call" if "tool_call" in kinds else "tool_result"
1097
+ return f"{label} {', '.join(tools)}"
1098
+ if rec.get("usages"):
1099
+ u = rec["usages"]
1100
+ return f"llm.generate · {u.get('total_tokens', 0)} tokens"
1101
+ if rec.get("is_delta"):
1102
+ return "delta"
1103
+ if kinds:
1104
+ return f"{rec.get('role') or 'message'}: {', '.join(kinds)}"
1105
+ return rec.get("event") or "chunk"
1106
+
1107
+ async def get_state_schema(self) -> dict:
1108
+ try:
1109
+ logger.info("Getting state schema")
1110
+ res: BaseModel = self._graph._state
1111
+ return res.model_json_schema()
1112
+ except ValueError as e:
1113
+ logger.warning(f"State schema validation failed: {e}")
1114
+ raise HTTPException(status_code=422, detail=str(e))
1115
+ except Exception as e:
1116
+ logger.error(f"Failed to get state schema: {e}")
1117
+ raise HTTPException(status_code=500, detail=f"Failed to get state schema: {e!s}")
1118
+
1119
+ def _has_empty_tool_call(self, msg: Message) -> bool:
1120
+ """Return True if any tool call on the message has empty content.
1121
+
1122
+ A tool call is considered empty if its ``content`` attribute/key is ``None`` or
1123
+ an empty string. Tool calls may be dict-like or objects with a ``content`` attribute.
1124
+ """
1125
+ tool_calls = getattr(msg, "tools_calls", None)
1126
+ if not tool_calls:
1127
+ return False
1128
+ for tool_call in tool_calls:
1129
+ content = (
1130
+ tool_call.get("content")
1131
+ if isinstance(tool_call, dict)
1132
+ else getattr(tool_call, "content", None)
1133
+ )
1134
+ if content in (None, ""):
1135
+ return True
1136
+ return False
1137
+
1138
+ async def fix_graph(
1139
+ self,
1140
+ thread_id: str,
1141
+ user: dict[str, Any],
1142
+ config: dict[str, Any] | None = None,
1143
+ ) -> dict[str, Any]:
1144
+ """
1145
+ Fix graph state by removing messages with empty tool call content.
1146
+
1147
+ This method retrieves the current state from the checkpointer, identifies messages
1148
+ with tool calls that have empty content, removes those messages, and updates the
1149
+ state.
1150
+
1151
+ Args:
1152
+ thread_id (str): The thread ID to fix the graph state for
1153
+ user (dict): User information for context
1154
+ config (dict, optional): Additional configuration for the operation
1155
+
1156
+ Returns:
1157
+ dict: Result dictionary containing:
1158
+ - success (bool): Whether the operation was successful
1159
+ - message (str): Status message
1160
+ - removed_count (int): Number of messages removed
1161
+ - state (dict): Updated state after fixing
1162
+
1163
+ Raises:
1164
+ HTTPException: If the operation fails
1165
+ """
1166
+ try:
1167
+ logger.info(f"Starting fix graph operation for thread: {thread_id}")
1168
+ logger.debug(f"User info: {sanitize_for_logging(user)}")
1169
+
1170
+ # Start with client config if provided, then overlay trusted attributes
1171
+ fix_config = client_config(config)
1172
+ fix_config.update(
1173
+ {"thread_id": thread_id, "user": user, "user_id": user.get("user_id", "anonymous")}
1174
+ )
1175
+
1176
+ logger.debug("Fetching current state from checkpointer")
1177
+ state: AgentState | None = await self.checkpointer.aget_state(fix_config)
1178
+ if not state:
1179
+ logger.warning(f"No state found for thread: {thread_id}")
1180
+ return {
1181
+ "success": False,
1182
+ "message": f"No state found for thread: {thread_id}",
1183
+ "removed_count": 0,
1184
+ "state": None,
1185
+ }
1186
+
1187
+ messages: list[Message] = list(state.context or [])
1188
+ logger.debug(f"Found {len(messages)} messages in state")
1189
+ if not messages:
1190
+ return {
1191
+ "success": True,
1192
+ "message": "No messages found in state",
1193
+ "removed_count": 0,
1194
+ "state": state.model_dump_json(serialize_as_any=True),
1195
+ }
1196
+
1197
+ filtered = [m for m in messages if not self._has_empty_tool_call(m)]
1198
+ removed_count = len(messages) - len(filtered)
1199
+
1200
+ if removed_count:
1201
+ state.context = filtered
1202
+ await self.checkpointer.aput_state(fix_config, state)
1203
+ message = f"Successfully removed {removed_count} message(s)"
1204
+ else:
1205
+ message = "No messages with empty tool calls found"
1206
+
1207
+ return {
1208
+ "success": True,
1209
+ "message": message,
1210
+ "removed_count": removed_count,
1211
+ "state": state.model_dump_json(serialize_as_any=True),
1212
+ }
1213
+ except ValueError as e:
1214
+ logger.warning(f"Fix graph input validation failed: {e}")
1215
+ raise HTTPException(status_code=422, detail=str(e))
1216
+ except Exception as e:
1217
+ _reraise_framework_errors(e)
1218
+ logger.error(f"Fix graph operation failed: {e}")
1219
+ raise HTTPException(status_code=500, detail=f"Fix graph operation failed: {e!s}")