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,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}")
|