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.
- 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
- 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
- 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
- 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
- 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
- 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
- agentflow_cli/__init__.py +69 -0
- tenxgraph_api/__init__.py +23 -0
- tenxgraph_api/cli/__init__.py +6 -0
- tenxgraph_api/cli/capabilities.py +127 -0
- tenxgraph_api/cli/commands/__init__.py +49 -0
- tenxgraph_api/cli/commands/api.py +262 -0
- tenxgraph_api/cli/commands/audit.py +213 -0
- tenxgraph_api/cli/commands/build.py +305 -0
- tenxgraph_api/cli/commands/config.py +71 -0
- tenxgraph_api/cli/commands/demo.py +93 -0
- tenxgraph_api/cli/commands/eval.py +909 -0
- tenxgraph_api/cli/commands/init.py +531 -0
- tenxgraph_api/cli/commands/skills.py +532 -0
- tenxgraph_api/cli/commands/test.py +113 -0
- tenxgraph_api/cli/commands/version.py +47 -0
- tenxgraph_api/cli/config_editor/__init__.py +15 -0
- tenxgraph_api/cli/config_editor/schema.py +566 -0
- tenxgraph_api/cli/config_editor/server.py +240 -0
- tenxgraph_api/cli/config_editor/static/app.css +2 -0
- tenxgraph_api/cli/config_editor/static/app.js +3 -0
- tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
- tenxgraph_api/cli/config_editor/static/index.html +20 -0
- tenxgraph_api/cli/config_editor/store.py +120 -0
- tenxgraph_api/cli/config_editor/validation.py +377 -0
- tenxgraph_api/cli/constants.py +112 -0
- tenxgraph_api/cli/context.py +46 -0
- tenxgraph_api/cli/core/__init__.py +0 -0
- tenxgraph_api/cli/core/animations.py +550 -0
- tenxgraph_api/cli/core/config.py +279 -0
- tenxgraph_api/cli/core/output.py +625 -0
- tenxgraph_api/cli/core/prompts.py +220 -0
- tenxgraph_api/cli/core/screen.py +287 -0
- tenxgraph_api/cli/core/steps.py +621 -0
- tenxgraph_api/cli/core/theme.py +242 -0
- tenxgraph_api/cli/core/validation.py +251 -0
- tenxgraph_api/cli/exceptions.py +129 -0
- tenxgraph_api/cli/logger.py +84 -0
- tenxgraph_api/cli/main.py +936 -0
- tenxgraph_api/cli/templates/__init__.py +0 -0
- tenxgraph_api/cli/templates/defaults.py +844 -0
- tenxgraph_api/cli/templates/dev/.env.example +1 -0
- tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
- tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
- tenxgraph_api/cli/templates/prod/.env.example +75 -0
- tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
- tenxgraph_api/cli/templates/prod/.python-version +1 -0
- tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
- tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
- tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
- tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
- tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
- tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
- tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
- tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
- tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
- tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
- tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
- tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
- tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
- tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
- tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
- tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
- tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
- tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
- tenxgraph_api/py.typed +0 -0
- tenxgraph_api/src/__init__.py +0 -0
- tenxgraph_api/src/app/__init__.py +13 -0
- tenxgraph_api/src/app/core/__init__.py +15 -0
- tenxgraph_api/src/app/core/auth/__init__.py +0 -0
- tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
- tenxgraph_api/src/app/core/auth/authorization.py +379 -0
- tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
- tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
- tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
- tenxgraph_api/src/app/core/auth/permissions.py +354 -0
- tenxgraph_api/src/app/core/auth/request_config.py +130 -0
- tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
- tenxgraph_api/src/app/core/config/__init__.py +0 -0
- tenxgraph_api/src/app/core/config/graph_config.py +532 -0
- tenxgraph_api/src/app/core/config/media_settings.py +72 -0
- tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
- tenxgraph_api/src/app/core/config/settings.py +202 -0
- tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
- tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
- tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
- tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
- tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
- tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
- tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
- tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
- tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
- tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
- tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
- tenxgraph_api/src/app/core/utils/__init__.py +14 -0
- tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
- tenxgraph_api/src/app/loader.py +516 -0
- tenxgraph_api/src/app/main.py +180 -0
- tenxgraph_api/src/app/routers/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
- tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
- tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
- tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
- tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
- tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
- tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
- tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
- tenxgraph_api/src/app/routers/evals/router.py +38 -0
- tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
- tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
- tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
- tenxgraph_api/src/app/routers/graph/router.py +821 -0
- tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
- tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
- tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
- tenxgraph_api/src/app/routers/media/__init__.py +506 -0
- tenxgraph_api/src/app/routers/media/router.py +242 -0
- tenxgraph_api/src/app/routers/media/schemas.py +39 -0
- tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ping/router.py +28 -0
- tenxgraph_api/src/app/routers/setup_router.py +60 -0
- tenxgraph_api/src/app/routers/store/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/router.py +200 -0
- tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
- tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
- tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
- tenxgraph_api/src/app/tasks/__init__.py +0 -0
- tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
- tenxgraph_api/src/app/utils/__init__.py +12 -0
- tenxgraph_api/src/app/utils/callable_helper.py +26 -0
- tenxgraph_api/src/app/utils/media/__init__.py +15 -0
- tenxgraph_api/src/app/utils/media/extractor.py +172 -0
- tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
- tenxgraph_api/src/app/utils/parse_output.py +17 -0
- tenxgraph_api/src/app/utils/response_helper.py +129 -0
- tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
- tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
- tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
- tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
- tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
- tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
- tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
- tenxgraph_api/src/app/worker.py +91 -0
|
@@ -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
|
+
)
|