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,4 @@
1
+ from .setup_router import init_routes
2
+
3
+
4
+ __all__ = ["init_routes"]
@@ -0,0 +1,10 @@
1
+ """AG-UI protocol endpoint (``POST /v1/ag-ui``).
2
+
3
+ Needs the optional ``ag-ui-protocol`` package, so it is only imported when ``ag_ui.enabled``
4
+ is set in 10xgraph.json.
5
+ """
6
+
7
+ from .router import router
8
+
9
+
10
+ __all__ = ["router"]
@@ -0,0 +1,195 @@
1
+ """Turn an AG-UI ``RunAgentInput`` into 10xGraph graph input.
2
+
3
+ AG-UI clients send the whole conversation on every run, while the thread's checkpoint already
4
+ holds everything the graph has seen. Only the trailing user turn (or the tool results that
5
+ answer a pending client-side tool call) is new, so that is all the graph receives. Messages the
6
+ client claims came from the assistant, system or developer are never forwarded: the server's
7
+ checkpoint is the record of what the model said and was told.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from typing import Any
15
+
16
+ from ag_ui.core import RunAgentInput
17
+ from pydantic import ValidationError
18
+ from tenxgraph.core.state import (
19
+ AudioBlock,
20
+ DocumentBlock,
21
+ ImageBlock,
22
+ Message,
23
+ TextBlock,
24
+ ToolResultBlock,
25
+ VideoBlock,
26
+ )
27
+ from tenxgraph.core.state.message_block import MediaRef
28
+
29
+
30
+ _MEDIA_BLOCKS = {
31
+ "image": ImageBlock,
32
+ "audio": AudioBlock,
33
+ "video": VideoBlock,
34
+ "document": DocumentBlock,
35
+ }
36
+
37
+ # Roles the graph accepts from a client. Assistant turns come from the checkpoint, and client
38
+ # system/developer prompts would let a caller rewrite the agent's instructions.
39
+ _CLIENT_ROLES = frozenset({"user", "tool"})
40
+
41
+
42
+ def parse_run_input(body: Any) -> RunAgentInput:
43
+ """Validate a request body as ``RunAgentInput``.
44
+
45
+ Older AG-UI clients (CopilotKit up to at least 1.75) still send the legacy ``binary``
46
+ content part, which the 1.0 SDK rejects; it is rewritten to the matching media part first.
47
+
48
+ Raises:
49
+ ValueError: The body is not a valid ``RunAgentInput``.
50
+ """
51
+ if isinstance(body, dict) and isinstance(body.get("messages"), list):
52
+ body = {**body, "messages": [_upgrade_message(m) for m in body["messages"]]}
53
+ try:
54
+ return RunAgentInput.model_validate(body)
55
+ except ValidationError as exc:
56
+ raise ValueError(f"Invalid AG-UI RunAgentInput: {exc}") from exc
57
+
58
+
59
+ # Limits on the tools an AG-UI client may bring to a run. The name rule matches what model
60
+ # providers accept for function names.
61
+ MAX_CLIENT_TOOLS = 64
62
+ MAX_TOOL_DESCRIPTION_CHARS = 4096
63
+ MAX_TOOL_SCHEMA_BYTES = 16 * 1024
64
+ _TOOL_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
65
+
66
+
67
+ def check_client_tools(tools: list[Any]) -> None:
68
+ """Reject client tool lists that are malformed or oversized.
69
+
70
+ Client tools put client-written text (names, descriptions, schemas) in front of the model and
71
+ in every request to the provider, so they are bounded before anything runs.
72
+
73
+ Raises:
74
+ ValueError: A tool breaks one of the limits above.
75
+ """
76
+ if len(tools) > MAX_CLIENT_TOOLS:
77
+ raise ValueError(f"At most {MAX_CLIENT_TOOLS} client tools are allowed per run")
78
+ seen: set[str] = set()
79
+ for tool in tools:
80
+ if not _TOOL_NAME.match(tool.name or ""):
81
+ raise ValueError(
82
+ f"Invalid client tool name {tool.name!r}: use 1-64 letters, digits, '_' or '-'"
83
+ )
84
+ if tool.name in seen:
85
+ raise ValueError(f"Duplicate client tool name {tool.name!r}")
86
+ seen.add(tool.name)
87
+ if len(tool.description or "") > MAX_TOOL_DESCRIPTION_CHARS:
88
+ raise ValueError(
89
+ f"Client tool {tool.name!r}: description is longer than "
90
+ f"{MAX_TOOL_DESCRIPTION_CHARS} characters"
91
+ )
92
+ schema_size = len(json.dumps(tool.parameters or {}, default=str).encode())
93
+ if schema_size > MAX_TOOL_SCHEMA_BYTES:
94
+ raise ValueError(
95
+ f"Client tool {tool.name!r}: parameters schema is larger than "
96
+ f"{MAX_TOOL_SCHEMA_BYTES} bytes"
97
+ )
98
+
99
+
100
+ def _upgrade_message(message: Any) -> Any:
101
+ if not isinstance(message, dict) or not isinstance(message.get("content"), list):
102
+ return message
103
+ return {**message, "content": [_upgrade_part(p) for p in message["content"]]}
104
+
105
+
106
+ def _upgrade_part(part: Any) -> Any:
107
+ if not isinstance(part, dict) or part.get("type") != "binary":
108
+ return part
109
+ mime_type = part.get("mimeType") or part.get("mime_type") or ""
110
+ kind = mime_type.split("/", 1)[0]
111
+ part_type = kind if kind in ("image", "audio", "video") else "document"
112
+ if part.get("data"):
113
+ source = {"type": "data", "value": part["data"], "mimeType": mime_type}
114
+ elif part.get("url"):
115
+ source = {"type": "url", "value": part["url"], "mimeType": mime_type}
116
+ else:
117
+ source = {"type": "file", "value": part.get("id") or "", "mimeType": mime_type}
118
+ return {"type": part_type, "source": source}
119
+
120
+
121
+ def select_new_messages(
122
+ messages: list[Any],
123
+ known_ids: set[str] | frozenset[str] = frozenset(),
124
+ answered_call_ids: set[str] | frozenset[str] = frozenset(),
125
+ ) -> list[Any]:
126
+ """The client messages the graph has not seen yet.
127
+
128
+ Args:
129
+ messages: ``RunAgentInput.messages``, the full client-side history.
130
+ known_ids: Message ids already in the thread's checkpoint.
131
+ answered_call_ids: Tool call ids the checkpoint already has a result for. Clients mint
132
+ a fresh id for each tool message, so results are matched by call id instead.
133
+ """
134
+ turn: list[Any] = []
135
+ for message in reversed(messages):
136
+ if message.role == "assistant":
137
+ break
138
+ turn.append(message)
139
+ turn.reverse()
140
+ return [
141
+ m
142
+ for m in turn
143
+ if m.role in _CLIENT_ROLES
144
+ and m.id not in known_ids
145
+ and not (m.role == "tool" and m.tool_call_id in answered_call_ids)
146
+ ]
147
+
148
+
149
+ def to_tenxgraph_messages(messages: list[Any]) -> list[Message]:
150
+ """Convert AG-UI user and tool messages to 10xGraph messages, keeping their ids."""
151
+ converted: list[Message] = []
152
+ for message in messages:
153
+ if message.role == "user":
154
+ blocks = _content_blocks(message.content)
155
+ if blocks:
156
+ converted.append(Message(message_id=message.id, role="user", content=blocks))
157
+ elif message.role == "tool":
158
+ error = getattr(message, "error", None)
159
+ output = error or _text(message.content)
160
+ result = ToolResultBlock(
161
+ call_id=message.tool_call_id,
162
+ output=output,
163
+ is_error=bool(error),
164
+ status="failed" if error else "completed",
165
+ )
166
+ converted.append(Message(message_id=message.id, role="tool", content=[result]))
167
+ return converted
168
+
169
+
170
+ def _content_blocks(content: Any) -> list[Any]:
171
+ if isinstance(content, str):
172
+ return [TextBlock(text=content)] if content else []
173
+ blocks: list[Any] = []
174
+ for part in content or []:
175
+ if part.type == "text":
176
+ if part.text:
177
+ blocks.append(TextBlock(text=part.text))
178
+ elif part.type in _MEDIA_BLOCKS:
179
+ blocks.append(_MEDIA_BLOCKS[part.type](media=_media_ref(part.source)))
180
+ return blocks
181
+
182
+
183
+ def _media_ref(source: Any) -> MediaRef:
184
+ mime_type = getattr(source, "mime_type", None)
185
+ if source.type == "data":
186
+ return MediaRef(kind="data", data_base64=source.value, mime_type=mime_type)
187
+ if source.type == "url":
188
+ return MediaRef(kind="url", url=source.value, mime_type=mime_type)
189
+ return MediaRef(kind="file_id", file_id=source.value, mime_type=mime_type)
190
+
191
+
192
+ def _text(content: Any) -> str:
193
+ if isinstance(content, str):
194
+ return content
195
+ return "".join(part.text for part in content or [] if part.type == "text")
@@ -0,0 +1,353 @@
1
+ """Translate 10xGraph ``StreamChunk`` output into AG-UI protocol events.
2
+
3
+ One mapper handles one run. It tracks which text, reasoning and step blocks are open so the
4
+ event stream always follows the AG-UI ordering rules: ``RUN_STARTED`` first, every ``START``
5
+ before its ``CONTENT`` and ``END``, everything closed before the single terminal
6
+ ``RUN_FINISHED`` or ``RUN_ERROR``.
7
+
8
+ 10xGraph emits a model turn either as a series of delta messages followed by a final message
9
+ with a new id (streaming providers), or as one final message (plain function nodes). Text that
10
+ was already streamed is not sent again from the final message. Tool calls are only complete in
11
+ the final message, so ``TOOL_CALL_START/ARGS/END`` are sent from there in one go.
12
+
13
+ A graph paused by ``interrupt()`` ends the run with ``RUN_FINISHED`` and an ``interrupt``
14
+ outcome, which clients answer with ``RunAgentInput.resume``. Otherwise ``RUN_FINISHED`` carries no
15
+ ``outcome``: CopilotKit's pinned ``@ag-ui/core`` (0.0.59 in CopilotKit 1.75) rejects the
16
+ ``success`` outcome's newer fields and the ``cancelled`` outcome.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from typing import Any
23
+
24
+ import ag_ui.core as agui
25
+ from ag_ui.core import (
26
+ BaseEvent,
27
+ Interrupt,
28
+ ReasoningEndEvent,
29
+ ReasoningMessageContentEvent,
30
+ ReasoningMessageEndEvent,
31
+ ReasoningMessageStartEvent,
32
+ ReasoningStartEvent,
33
+ RunErrorEvent,
34
+ RunFinishedEvent,
35
+ RunFinishedInterruptOutcome,
36
+ RunStartedEvent,
37
+ StateSnapshotEvent,
38
+ StepFinishedEvent,
39
+ StepStartedEvent,
40
+ TextMessageContentEvent,
41
+ TextMessageEndEvent,
42
+ TextMessageStartEvent,
43
+ ToolCallArgsEvent,
44
+ ToolCallEndEvent,
45
+ ToolCallResultEvent,
46
+ ToolCallStartEvent,
47
+ )
48
+ from tenxgraph.core.state import AgentState, Message, StreamChunk, StreamEvent, ToolCallBlock
49
+ from tenxgraph.core.state.message_block import RemoteToolCallBlock
50
+ from tenxgraph.utils.constants import END, START
51
+
52
+
53
+ # Graph bookkeeping nodes, not steps a user cares about.
54
+ _HIDDEN_NODES = frozenset({START, END})
55
+
56
+ # AgentState fields that are not application state: the conversation travels as messages,
57
+ # and execution metadata is internal.
58
+ _INTERNAL_STATE_FIELDS = frozenset({"context", "context_summary", "execution_meta"})
59
+
60
+
61
+ def app_state(state: AgentState) -> dict[str, Any]:
62
+ """The application fields of ``state``, as JSON, for a ``STATE_SNAPSHOT``."""
63
+ return state.model_dump(mode="json", exclude=set(_INTERNAL_STATE_FIELDS))
64
+
65
+
66
+ class AgUiEventMapper:
67
+ """Stateful translator for one AG-UI run.
68
+
69
+ Call :meth:`start` once, :meth:`map` for every chunk, then :meth:`finish`. After a
70
+ ``RUN_ERROR`` (from :meth:`fail` or an error chunk) every later call returns nothing.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ thread_id: str,
76
+ run_id: str,
77
+ initial_state: dict[str, Any] | None = None,
78
+ ) -> None:
79
+ self.thread_id = thread_id
80
+ self.run_id = run_id
81
+ self.finished = False
82
+ self._text_id: str | None = None
83
+ self._reasoning_id: str | None = None
84
+ # The text message the current model turn produced, as parent for its tool calls.
85
+ self._turn_text_id: str | None = None
86
+ self._turn_streamed = False
87
+ self._open_steps: list[str] = []
88
+ self._tool_calls: set[str] = set()
89
+ self._tool_results: set[str] = set()
90
+ self._last_state = initial_state or None
91
+ self._interrupt: dict[str, Any] | None = None
92
+
93
+ def start(self) -> list[BaseEvent]:
94
+ extra: dict[str, Any] = {}
95
+ protocol_version = getattr(agui, "PROTOCOL_VERSION", None)
96
+ if protocol_version and "protocol_version" in RunStartedEvent.model_fields:
97
+ extra["protocol_version"] = protocol_version
98
+ return [RunStartedEvent(thread_id=self.thread_id, run_id=self.run_id, **extra)]
99
+
100
+ def map(self, chunk: StreamChunk) -> list[BaseEvent]:
101
+ if self.finished:
102
+ return []
103
+ if chunk.event == StreamEvent.MESSAGE and chunk.message is not None:
104
+ return self._on_message(chunk.message)
105
+ if chunk.event == StreamEvent.UPDATES:
106
+ return self._on_update(chunk.data or {})
107
+ if chunk.event == StreamEvent.STATE and chunk.state is not None:
108
+ return self._on_state(chunk.state)
109
+ if chunk.event == StreamEvent.ERROR:
110
+ return self._on_error(chunk.data or {})
111
+ return []
112
+
113
+ def map_state(self, state: AgentState) -> list[BaseEvent]:
114
+ """A ``STATE_SNAPSHOT`` for ``state``, when it differs from what the client has."""
115
+ if self.finished:
116
+ return []
117
+ return self._on_state(state)
118
+
119
+ def report_interrupt(self, interrupt: dict[str, Any]) -> None:
120
+ """End the run as paused at ``interrupt`` (a ``tenxgraph`` ``Interrupt`` as JSON)."""
121
+ self._interrupt = interrupt
122
+
123
+ def finish(self) -> list[BaseEvent]:
124
+ if self.finished:
125
+ return []
126
+ events = self._close_all()
127
+ self.finished = True
128
+ extra: dict[str, Any] = {}
129
+ if self._interrupt is not None:
130
+ extra["outcome"] = RunFinishedInterruptOutcome(
131
+ interrupts=[_agui_interrupt(self._interrupt)]
132
+ )
133
+ events.append(RunFinishedEvent(thread_id=self.thread_id, run_id=self.run_id, **extra))
134
+ return events
135
+
136
+ def fail(self, message: str, code: str | None = None) -> list[BaseEvent]:
137
+ if self.finished:
138
+ return []
139
+ events = self._close_all()
140
+ self.finished = True
141
+ events.append(RunErrorEvent(message=message, code=code))
142
+ return events
143
+
144
+ # ------------------------------------------------------------------ messages
145
+
146
+ def _on_message(self, message: Message) -> list[BaseEvent]:
147
+ if message.role == "assistant":
148
+ return self._on_delta(message) if message.delta else self._on_final(message)
149
+ if message.role == "tool":
150
+ return self._on_tool_result(message)
151
+ return [] # the user's own message echoed back
152
+
153
+ def _on_delta(self, message: Message) -> list[BaseEvent]:
154
+ message_id = str(message.message_id)
155
+ events: list[BaseEvent] = []
156
+ for block in message.content:
157
+ if block.type == "reasoning" and block.summary:
158
+ events += self._reasoning(message_id, block.summary)
159
+ elif block.type == "text" and block.text:
160
+ events += self._text(message_id, block.text)
161
+ return events + self._on_tool_calls(message)
162
+
163
+ def _on_final(self, message: Message) -> list[BaseEvent]:
164
+ message_id = str(message.message_id)
165
+ events: list[BaseEvent] = []
166
+ if not self._turn_streamed:
167
+ reasoning = "\n\n".join(
168
+ b.summary for b in message.content if b.type == "reasoning" and b.summary
169
+ )
170
+ text = "".join(b.text for b in message.content if b.type == "text" and b.text)
171
+ if reasoning:
172
+ events += self._reasoning(message_id, reasoning)
173
+ if text:
174
+ events += self._text(message_id, text)
175
+ events += self._close_reasoning() + self._close_text()
176
+ events += self._on_tool_calls(message, fallback_parent=message_id)
177
+ self._turn_text_id = None
178
+ self._turn_streamed = False
179
+ return events
180
+
181
+ def _on_tool_calls(
182
+ self, message: Message, fallback_parent: str | None = None
183
+ ) -> list[BaseEvent]:
184
+ calls = [c for c in _tool_calls(message) if c[0] not in self._tool_calls]
185
+ if not calls:
186
+ return []
187
+ events = self._close_reasoning() + self._close_text()
188
+ parent = self._turn_text_id
189
+ if parent is None:
190
+ # Announce the parent message so the client has something to attach the call to.
191
+ parent = fallback_parent or str(message.message_id)
192
+ events += [
193
+ TextMessageStartEvent(message_id=parent, role="assistant"),
194
+ TextMessageEndEvent(message_id=parent),
195
+ ]
196
+ self._turn_text_id = parent
197
+ for call_id, name, arguments in calls:
198
+ self._tool_calls.add(call_id)
199
+ events += [
200
+ ToolCallStartEvent(
201
+ tool_call_id=call_id, tool_call_name=name, parent_message_id=parent
202
+ ),
203
+ ToolCallArgsEvent(tool_call_id=call_id, delta=arguments),
204
+ ToolCallEndEvent(tool_call_id=call_id),
205
+ ]
206
+ return events
207
+
208
+ def _on_tool_result(self, message: Message) -> list[BaseEvent]:
209
+ events: list[BaseEvent] = []
210
+ for block in message.content:
211
+ if block.type != "tool_result" or block.call_id in self._tool_results:
212
+ continue
213
+ message_id = str(message.message_id)
214
+ if events:
215
+ message_id = f"{message_id}-{block.call_id}"
216
+ events.append(self._tool_result(message_id, block.call_id, _stringify(block.output)))
217
+ return events
218
+
219
+ def _tool_result(self, message_id: str, call_id: str, content: str) -> ToolCallResultEvent:
220
+ self._tool_results.add(call_id)
221
+ return ToolCallResultEvent(
222
+ message_id=message_id, tool_call_id=call_id, content=content, role="tool"
223
+ )
224
+
225
+ # --------------------------------------------------------- text and reasoning
226
+
227
+ def _text(self, message_id: str, delta: str) -> list[BaseEvent]:
228
+ events = self._close_reasoning()
229
+ if self._text_id != message_id:
230
+ events += self._close_text()
231
+ events.append(TextMessageStartEvent(message_id=message_id, role="assistant"))
232
+ self._text_id = message_id
233
+ self._turn_text_id = message_id
234
+ self._turn_streamed = True
235
+ events.append(TextMessageContentEvent(message_id=message_id, delta=delta))
236
+ return events
237
+
238
+ def _reasoning(self, message_id: str, delta: str) -> list[BaseEvent]:
239
+ reasoning_id = f"{message_id}-reasoning"
240
+ events = self._close_text()
241
+ if self._reasoning_id != reasoning_id:
242
+ events += self._close_reasoning()
243
+ events += [
244
+ ReasoningStartEvent(message_id=reasoning_id),
245
+ ReasoningMessageStartEvent(message_id=reasoning_id),
246
+ ]
247
+ self._reasoning_id = reasoning_id
248
+ self._turn_streamed = True
249
+ events.append(ReasoningMessageContentEvent(message_id=reasoning_id, delta=delta))
250
+ return events
251
+
252
+ def _close_text(self) -> list[BaseEvent]:
253
+ if self._text_id is None:
254
+ return []
255
+ events: list[BaseEvent] = [TextMessageEndEvent(message_id=self._text_id)]
256
+ self._text_id = None
257
+ return events
258
+
259
+ def _close_reasoning(self) -> list[BaseEvent]:
260
+ if self._reasoning_id is None:
261
+ return []
262
+ reasoning_id = self._reasoning_id
263
+ self._reasoning_id = None
264
+ return [
265
+ ReasoningMessageEndEvent(message_id=reasoning_id),
266
+ ReasoningEndEvent(message_id=reasoning_id),
267
+ ]
268
+
269
+ def _close_all(self) -> list[BaseEvent]:
270
+ events = self._close_reasoning() + self._close_text()
271
+ while self._open_steps:
272
+ events.append(StepFinishedEvent(step_name=self._open_steps.pop()))
273
+ return events
274
+
275
+ # ------------------------------------------------------ updates, state, errors
276
+
277
+ def _on_update(self, data: dict[str, Any]) -> list[BaseEvent]:
278
+ if data.get("status") == "interrupted" and isinstance(data.get("interrupt"), dict):
279
+ self.report_interrupt(data["interrupt"])
280
+ return []
281
+ node = data.get("node")
282
+ if not isinstance(node, str) or node in _HIDDEN_NODES:
283
+ return []
284
+ status = data.get("status")
285
+ if status == "invoking_node" and node not in self._open_steps:
286
+ self._open_steps.append(node)
287
+ return [StepStartedEvent(step_name=node)]
288
+ if status == "node_invoked" and node in self._open_steps:
289
+ self._open_steps.remove(node)
290
+ return [StepFinishedEvent(step_name=node)]
291
+ return []
292
+
293
+ def _on_state(self, state: AgentState) -> list[BaseEvent]:
294
+ snapshot = app_state(state)
295
+ if not snapshot or snapshot == self._last_state:
296
+ return []
297
+ self._last_state = snapshot
298
+ return [StateSnapshotEvent(snapshot=snapshot)]
299
+
300
+ def _on_error(self, data: dict[str, Any]) -> list[BaseEvent]:
301
+ if data.get("status") == "tool_failed":
302
+ # A failed tool is a result the model reacts to, not the end of the run.
303
+ call_id = data.get("tool_call_id")
304
+ if not call_id or call_id in self._tool_results:
305
+ return []
306
+ # ``error`` holds the tool's own message; ``reason`` is a generic summary.
307
+ reason = data.get("error") or data.get("reason") or "Tool failed"
308
+ return [self._tool_result(f"{call_id}-error", str(call_id), f"Error: {reason}")]
309
+ return self.fail(str(data.get("reason") or "Graph execution failed"), "GRAPH_ERROR")
310
+
311
+
312
+ def _tool_calls(message: Message) -> list[tuple[str, str, str]]:
313
+ """``(id, name, JSON arguments)`` for each tool call in ``message``, without duplicates.
314
+
315
+ A call can appear both in ``tools_calls`` (OpenAI shape, drives execution) and as a
316
+ content block (drives rendering).
317
+ """
318
+ calls: dict[str, tuple[str, str, str]] = {}
319
+ for call in message.tools_calls or []:
320
+ if not isinstance(call, dict) or not call.get("id"):
321
+ continue
322
+ function = call.get("function") or {}
323
+ name = function.get("name") or call.get("name") or ""
324
+ arguments = function.get("arguments", call.get("args"))
325
+ if not isinstance(arguments, str):
326
+ arguments = json.dumps(arguments or {})
327
+ calls.setdefault(str(call["id"]), (str(call["id"]), str(name), arguments or "{}"))
328
+ for block in message.content:
329
+ if isinstance(block, ToolCallBlock | RemoteToolCallBlock) and block.id:
330
+ calls.setdefault(block.id, (block.id, block.name, json.dumps(block.args or {})))
331
+ return list(calls.values())
332
+
333
+
334
+ def _agui_interrupt(interrupt: dict[str, Any]) -> Interrupt:
335
+ """An AG-UI ``Interrupt`` for a ``tenxgraph`` interrupt; its payload goes in metadata."""
336
+ return Interrupt(
337
+ id=str(interrupt.get("id")),
338
+ reason=str(interrupt.get("reason") or "input_required"),
339
+ message=interrupt.get("message"),
340
+ tool_call_id=interrupt.get("tool_call_id"),
341
+ response_schema=interrupt.get("response_schema"),
342
+ metadata={"value": interrupt.get("value"), "node": interrupt.get("node")},
343
+ )
344
+
345
+
346
+ def _stringify(output: Any) -> str:
347
+ if output is None:
348
+ return ""
349
+ if isinstance(output, str):
350
+ return output
351
+ if hasattr(output, "model_dump"):
352
+ output = output.model_dump(mode="json")
353
+ return json.dumps(output, default=str)
@@ -0,0 +1,90 @@
1
+ from typing import Any
2
+
3
+ from ag_ui.encoder import EventEncoder
4
+ from fastapi import APIRouter, Depends, HTTPException, Request
5
+ from fastapi.responses import StreamingResponse
6
+ from injectq.integrations import InjectAPI
7
+
8
+ from tenxgraph_api.src.app.core import logger
9
+ from tenxgraph_api.src.app.core.auth.authorization import AuthorizationBackend
10
+ from tenxgraph_api.src.app.core.auth.permissions import RequirePermission
11
+ from tenxgraph_api.src.app.core.auth.request_config import normalize_thread_id
12
+ from tenxgraph_api.src.app.core.config.graph_config import GraphConfig
13
+ from tenxgraph_api.src.app.routers.graph.services.graph_service import GraphService
14
+
15
+ from .converter import check_client_tools, parse_run_input
16
+ from .service import stream_ag_ui
17
+
18
+
19
+ router = APIRouter(
20
+ tags=["AG-UI"],
21
+ )
22
+
23
+
24
+ @router.post(
25
+ "/v1/ag-ui",
26
+ summary="Run the graph over the AG-UI protocol",
27
+ description=(
28
+ "Accepts an AG-UI RunAgentInput and streams the graph run as AG-UI events over "
29
+ "server-sent events, for AG-UI clients such as CopilotKit. Enabled with "
30
+ '"ag_ui": {"enabled": true} in 10xgraph.json.'
31
+ ),
32
+ openapi_extra={
33
+ "requestBody": {
34
+ "required": True,
35
+ "content": {"application/json": {"schema": {"type": "object"}}},
36
+ }
37
+ },
38
+ )
39
+ async def run_ag_ui(
40
+ request: Request,
41
+ service: GraphService = InjectAPI(GraphService),
42
+ user: dict[str, Any] = Depends(RequirePermission("graph", "stream")),
43
+ # Abstract type as the DI key, as in graph/router.py (see the mypy backlog in pyproject).
44
+ authz: AuthorizationBackend = InjectAPI(AuthorizationBackend), # type: ignore[type-abstract]
45
+ config: GraphConfig = InjectAPI(GraphConfig),
46
+ ):
47
+ """
48
+ Stream one AG-UI run.
49
+
50
+ AG-UI names the thread ``threadId``, which the generic permission check does not read, so
51
+ thread ownership is checked here, the same way the WebSocket stream checks it: only when
52
+ auth is configured, since an unauthenticated server has no owners to tell apart.
53
+ """
54
+ try:
55
+ body = await request.json()
56
+ except ValueError as exc:
57
+ raise HTTPException(status_code=422, detail="Request body must be JSON") from exc
58
+ try:
59
+ run_input = parse_run_input(body)
60
+ check_client_tools(run_input.tools or [])
61
+ except ValueError as exc:
62
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
63
+
64
+ thread_id = normalize_thread_id(run_input.thread_id)
65
+ if thread_id is None:
66
+ raise HTTPException(status_code=422, detail="threadId is required")
67
+ run_input.thread_id = thread_id
68
+ if config.auth_config() and not await authz.authorize(
69
+ user, "graph", "stream", resource_id=thread_id
70
+ ):
71
+ raise HTTPException(status_code=403, detail="Not allowed to access this thread")
72
+
73
+ logger.info("AG-UI run %s received for thread %s", run_input.run_id, thread_id)
74
+ encoder = EventEncoder(accept=request.headers.get("accept") or "")
75
+ return StreamingResponse(
76
+ stream_ag_ui(
77
+ service,
78
+ run_input,
79
+ user,
80
+ encoder,
81
+ allow_client_tools=config.ag_ui.allow_client_tools,
82
+ ),
83
+ media_type=encoder.get_content_type(),
84
+ headers={
85
+ "Cache-Control": "no-cache, no-transform",
86
+ "Connection": "keep-alive",
87
+ "X-Accel-Buffering": "no", # Disable nginx buffering
88
+ "Content-Encoding": "identity", # Disable any content encoding (bypasses GZip)
89
+ },
90
+ )