microsoft-agents-a365-observability-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. microsoft_agents_a365/observability/core/__init__.py +61 -0
  2. microsoft_agents_a365/observability/core/agent_details.py +42 -0
  3. microsoft_agents_a365/observability/core/config.py +246 -0
  4. microsoft_agents_a365/observability/core/constants.py +107 -0
  5. microsoft_agents_a365/observability/core/execute_tool_scope.py +88 -0
  6. microsoft_agents_a365/observability/core/execution_type.py +13 -0
  7. microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +310 -0
  8. microsoft_agents_a365/observability/core/exporters/utils.py +72 -0
  9. microsoft_agents_a365/observability/core/inference_call_details.py +18 -0
  10. microsoft_agents_a365/observability/core/inference_operation_type.py +11 -0
  11. microsoft_agents_a365/observability/core/inference_scope.py +140 -0
  12. microsoft_agents_a365/observability/core/invoke_agent_details.py +17 -0
  13. microsoft_agents_a365/observability/core/invoke_agent_scope.py +166 -0
  14. microsoft_agents_a365/observability/core/middleware/__init__.py +7 -0
  15. microsoft_agents_a365/observability/core/middleware/baggage_builder.py +319 -0
  16. microsoft_agents_a365/observability/core/middleware/turn_context_baggage.py +193 -0
  17. microsoft_agents_a365/observability/core/models/__init__.py +2 -0
  18. microsoft_agents_a365/observability/core/models/agent_type.py +25 -0
  19. microsoft_agents_a365/observability/core/models/caller_details.py +25 -0
  20. microsoft_agents_a365/observability/core/opentelemetry_scope.py +250 -0
  21. microsoft_agents_a365/observability/core/request.py +19 -0
  22. microsoft_agents_a365/observability/core/source_metadata.py +15 -0
  23. microsoft_agents_a365/observability/core/tenant_details.py +11 -0
  24. microsoft_agents_a365/observability/core/tool_call_details.py +18 -0
  25. microsoft_agents_a365/observability/core/tool_type.py +13 -0
  26. microsoft_agents_a365/observability/core/trace_processor/__init__.py +13 -0
  27. microsoft_agents_a365/observability/core/trace_processor/span_processor.py +75 -0
  28. microsoft_agents_a365/observability/core/trace_processor/util.py +44 -0
  29. microsoft_agents_a365/observability/core/utils.py +151 -0
  30. microsoft_agents_a365_observability_core-0.1.0.dist-info/METADATA +78 -0
  31. microsoft_agents_a365_observability_core-0.1.0.dist-info/RECORD +33 -0
  32. microsoft_agents_a365_observability_core-0.1.0.dist-info/WHEEL +5 -0
  33. microsoft_agents_a365_observability_core-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,310 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ # pip install opentelemetry-sdk opentelemetry-api requests
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import threading
10
+ import time
11
+ from collections.abc import Callable, Sequence
12
+ from typing import Any
13
+
14
+ import requests
15
+ from microsoft_agents_a365.runtime.power_platform_api_discovery import PowerPlatformApiDiscovery
16
+ from opentelemetry.sdk.trace import ReadableSpan
17
+ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
18
+ from opentelemetry.trace import StatusCode
19
+
20
+ from .utils import (
21
+ hex_span_id,
22
+ hex_trace_id,
23
+ kind_name,
24
+ partition_by_identity,
25
+ status_name,
26
+ )
27
+
28
+ # ---- Exporter ---------------------------------------------------------------
29
+
30
+ # Hardcoded constants - not configurable
31
+ DEFAULT_HTTP_TIMEOUT_SECONDS = 30.0
32
+ DEFAULT_MAX_RETRIES = 3
33
+
34
+ # Create logger for this module - inherits from 'microsoft_agents_a365.observability.core'
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class Agent365Exporter(SpanExporter):
39
+ """
40
+ Agent 365 span exporter for Agent 365:
41
+ * Partitions spans by (tenantId, agentId)
42
+ * Builds OTLP-like JSON: resourceSpans -> scopeSpans -> spans
43
+ * POSTs per group to https://{endpoint}/maven/agent365/agents/{agentId}/traces?api-version=1
44
+ * Adds Bearer token via token_resolver(agentId, tenantId)
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ token_resolver: Callable[[str, str], str | None],
50
+ cluster_category: str = "prod",
51
+ use_s2s_endpoint: bool = False,
52
+ ):
53
+ if token_resolver is None:
54
+ raise ValueError("token_resolver must be provided.")
55
+ self._session = requests.Session()
56
+ self._closed = False
57
+ self._lock = threading.Lock()
58
+ self._token_resolver = token_resolver
59
+ self._cluster_category = cluster_category
60
+ self._use_s2s_endpoint = use_s2s_endpoint
61
+
62
+ # ------------- SpanExporter API -----------------
63
+
64
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
65
+ if self._closed:
66
+ return SpanExportResult.FAILURE
67
+
68
+ try:
69
+ groups = partition_by_identity(spans)
70
+ if not groups:
71
+ # No spans with identity; treat as success
72
+ logger.info("No spans with tenant/agent identity found; nothing exported.")
73
+ return SpanExportResult.SUCCESS
74
+
75
+ # Debug: Log number of groups and total span count
76
+ total_spans = sum(len(activities) for activities in groups.values())
77
+ logger.info(
78
+ f"Found {len(groups)} identity groups with {total_spans} total spans to export"
79
+ )
80
+
81
+ any_failure = False
82
+ for (tenant_id, agent_id), activities in groups.items():
83
+ payload = self._build_export_request(activities)
84
+ body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
85
+
86
+ # Resolve endpoint + token
87
+ discovery = PowerPlatformApiDiscovery(self._cluster_category)
88
+ endpoint = discovery.get_tenant_island_cluster_endpoint(tenant_id)
89
+ endpoint_path = (
90
+ f"/maven/agent365/service/agents/{agent_id}/traces"
91
+ if self._use_s2s_endpoint
92
+ else f"/maven/agent365/agents/{agent_id}/traces"
93
+ )
94
+ url = f"https://{endpoint}{endpoint_path}?api-version=1"
95
+
96
+ # Debug: Log endpoint being used
97
+ logger.info(
98
+ f"Exporting {len(activities)} spans to endpoint: {url} "
99
+ f"(tenant: {tenant_id}, agent: {agent_id})"
100
+ )
101
+
102
+ headers = {"content-type": "application/json"}
103
+ try:
104
+ token = self._token_resolver(agent_id, tenant_id)
105
+ if token:
106
+ headers["authorization"] = f"Bearer {token}"
107
+ logger.info(f"Token resolved successfully for agent {agent_id}")
108
+ else:
109
+ logger.info(f"No token returned for agent {agent_id}")
110
+ except Exception as e:
111
+ # If token resolution fails, treat as failure for this group
112
+ logger.error(
113
+ f"Token resolution failed for agent {agent_id}, tenant {tenant_id}: {e}"
114
+ )
115
+ any_failure = True
116
+ continue
117
+
118
+ # Basic retry loop
119
+ ok = self._post_with_retries(url, body, headers)
120
+ if not ok:
121
+ any_failure = True
122
+
123
+ return SpanExportResult.FAILURE if any_failure else SpanExportResult.SUCCESS
124
+
125
+ except Exception as e:
126
+ # Exporters should not raise; signal failure.
127
+ logger.error(f"Export failed with exception: {e}")
128
+ return SpanExportResult.FAILURE
129
+
130
+ def shutdown(self) -> None:
131
+ with self._lock:
132
+ if self._closed:
133
+ return
134
+ self._closed = True
135
+ try:
136
+ self._session.close()
137
+ except Exception:
138
+ pass
139
+
140
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
141
+ return True
142
+
143
+ # ------------- HTTP helper ----------------------
144
+
145
+ @staticmethod
146
+ def _truncate_text(text: str, max_length: int) -> str:
147
+ """Truncate text to a maximum length, adding '...' if truncated."""
148
+ if len(text) > max_length:
149
+ return text[:max_length] + "..."
150
+ return text
151
+
152
+ def _post_with_retries(self, url: str, body: str, headers: dict[str, str]) -> bool:
153
+ for attempt in range(DEFAULT_MAX_RETRIES + 1):
154
+ try:
155
+ resp = self._session.post(
156
+ url,
157
+ data=body.encode("utf-8"),
158
+ headers=headers,
159
+ timeout=DEFAULT_HTTP_TIMEOUT_SECONDS,
160
+ )
161
+
162
+ # Extract correlation ID from response headers for logging
163
+ correlation_id = (
164
+ resp.headers.get("x-ms-correlation-id")
165
+ or resp.headers.get("request-id")
166
+ or "N/A"
167
+ )
168
+
169
+ # 2xx => success
170
+ if 200 <= resp.status_code < 300:
171
+ logger.info(
172
+ f"HTTP {resp.status_code} success on attempt {attempt + 1}. "
173
+ f"Correlation ID: {correlation_id}. "
174
+ f"Response: {self._truncate_text(resp.text, 200)}"
175
+ )
176
+ return True
177
+
178
+ # Log non-success responses
179
+ response_text = self._truncate_text(resp.text, 500)
180
+
181
+ # Retry transient
182
+ if resp.status_code in (408, 429) or 500 <= resp.status_code < 600:
183
+ if attempt < DEFAULT_MAX_RETRIES:
184
+ time.sleep(0.2 * (attempt + 1))
185
+ continue
186
+ # Final attempt failed
187
+ logger.error(
188
+ f"HTTP {resp.status_code} final failure after {DEFAULT_MAX_RETRIES + 1} attempts. "
189
+ f"Correlation ID: {correlation_id}. "
190
+ f"Response: {response_text}"
191
+ )
192
+ else:
193
+ # Non-retryable error
194
+ logger.error(
195
+ f"HTTP {resp.status_code} non-retryable error. "
196
+ f"Correlation ID: {correlation_id}. "
197
+ f"Response: {response_text}"
198
+ )
199
+ return False
200
+
201
+ except requests.RequestException as e:
202
+ if attempt < DEFAULT_MAX_RETRIES:
203
+ time.sleep(0.2 * (attempt + 1))
204
+ continue
205
+ # Final attempt failed
206
+ logger.error(
207
+ f"Request failed after {DEFAULT_MAX_RETRIES + 1} attempts with exception: {e}"
208
+ )
209
+ return False
210
+ return False
211
+
212
+ # ------------- Payload mapping ------------------
213
+
214
+ def _build_export_request(self, spans: Sequence[ReadableSpan]) -> dict[str, Any]:
215
+ # Group by instrumentation scope (name, version)
216
+ scope_map: dict[tuple[str, str | None], list[dict[str, Any]]] = {}
217
+
218
+ for sp in spans:
219
+ scope = sp.instrumentation_scope
220
+ scope_key = (scope.name, scope.version)
221
+ scope_map.setdefault(scope_key, []).append(self._map_span(sp))
222
+
223
+ scope_spans: list[dict[str, Any]] = []
224
+ for (name, version), mapped_spans in scope_map.items():
225
+ scope_spans.append(
226
+ {
227
+ "scope": {
228
+ "name": name,
229
+ "version": version,
230
+ },
231
+ "spans": mapped_spans,
232
+ }
233
+ )
234
+
235
+ # Resource attributes (from the first span – all spans in a batch usually share resource)
236
+ # If you need to merge across spans, adapt accordingly.
237
+ resource_attrs = {}
238
+ if spans:
239
+ resource_attrs = dict(getattr(spans[0].resource, "attributes", {}) or {})
240
+
241
+ return {
242
+ "resourceSpans": [
243
+ {
244
+ "resource": {"attributes": resource_attrs or None},
245
+ "scopeSpans": scope_spans,
246
+ }
247
+ ]
248
+ }
249
+
250
+ def _map_span(self, sp: ReadableSpan) -> dict[str, Any]:
251
+ ctx = sp.context
252
+
253
+ parent_span_id = None
254
+ if sp.parent is not None and sp.parent.span_id != 0:
255
+ parent_span_id = hex_span_id(sp.parent.span_id)
256
+
257
+ # attributes
258
+ attrs = dict(sp.attributes or {})
259
+ # events
260
+ events = []
261
+ for ev in sp.events:
262
+ ev_attrs = dict(ev.attributes or {}) if ev.attributes else None
263
+ events.append(
264
+ {
265
+ "timeUnixNano": ev.timestamp, # already ns
266
+ "name": ev.name,
267
+ "attributes": ev_attrs,
268
+ }
269
+ )
270
+ if not events:
271
+ events = None
272
+
273
+ # links
274
+ links = []
275
+ for ln in sp.links or []:
276
+ ln_attrs = dict(ln.attributes or {}) if ln.attributes else None
277
+ links.append(
278
+ {
279
+ "traceId": hex_trace_id(ln.context.trace_id),
280
+ "spanId": hex_span_id(ln.context.span_id),
281
+ "attributes": ln_attrs,
282
+ }
283
+ )
284
+ if not links:
285
+ links = None
286
+
287
+ # status
288
+ status_code = sp.status.status_code if sp.status else StatusCode.UNSET
289
+ status = {
290
+ "code": status_name(status_code),
291
+ "message": getattr(sp.status, "description", "") or "",
292
+ }
293
+
294
+ # times are ns in ReadableSpan
295
+ start_ns = sp.start_time
296
+ end_ns = sp.end_time
297
+
298
+ return {
299
+ "traceId": hex_trace_id(ctx.trace_id),
300
+ "spanId": hex_span_id(ctx.span_id),
301
+ "parentSpanId": parent_span_id,
302
+ "name": sp.name,
303
+ "kind": kind_name(sp.kind),
304
+ "startTimeUnixNano": start_ns,
305
+ "endTimeUnixNano": end_ns,
306
+ "attributes": attrs or None,
307
+ "events": events,
308
+ "links": links,
309
+ "status": status,
310
+ }
@@ -0,0 +1,72 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ import os
4
+ from collections.abc import Sequence
5
+ from typing import Any
6
+
7
+ from opentelemetry.sdk.trace import ReadableSpan
8
+ from opentelemetry.trace import SpanKind, StatusCode
9
+
10
+ from ..constants import (
11
+ ENABLE_A365_OBSERVABILITY_EXPORTER,
12
+ GEN_AI_AGENT_ID_KEY,
13
+ TENANT_ID_KEY,
14
+ )
15
+
16
+
17
+ def hex_trace_id(value: int) -> str:
18
+ # 128-bit -> 32 hex chars
19
+ return f"{value:032x}"
20
+
21
+
22
+ def hex_span_id(value: int) -> str:
23
+ # 64-bit -> 16 hex chars
24
+ return f"{value:016x}"
25
+
26
+
27
+ def as_str(v: Any) -> str | None:
28
+ if v is None:
29
+ return None
30
+ s = str(v)
31
+ return s if s.strip() else None
32
+
33
+
34
+ def kind_name(kind: SpanKind) -> str:
35
+ # Return span kind name (enum name or numeric)
36
+ try:
37
+ return kind.name # Enum in otel 1.27+
38
+ except Exception:
39
+ return str(kind)
40
+
41
+
42
+ def status_name(code: StatusCode) -> str:
43
+ try:
44
+ return code.name
45
+ except Exception:
46
+ return str(code)
47
+
48
+
49
+ def partition_by_identity(
50
+ spans: Sequence[ReadableSpan],
51
+ ) -> dict[tuple[str, str], list[ReadableSpan]]:
52
+ """
53
+ Extract (tenantId, agentId). Prefer attributes; if you also stamp baggage
54
+ into attributes via a processor, they'll be here already.
55
+ """
56
+ groups: dict[tuple[str, str], list[ReadableSpan]] = {}
57
+ for sp in spans:
58
+ attrs = sp.attributes or {}
59
+ tenant = as_str(attrs.get(TENANT_ID_KEY))
60
+ agent = as_str(attrs.get(GEN_AI_AGENT_ID_KEY))
61
+ if not tenant or not agent:
62
+ continue
63
+ key = (tenant, agent)
64
+ groups.setdefault(key, []).append(sp)
65
+ return groups
66
+
67
+
68
+ def is_agent365_exporter_enabled() -> bool:
69
+ """Check if Agent 365 exporter is enabled."""
70
+ # Check environment variable
71
+ enable_exporter = os.getenv(ENABLE_A365_OBSERVABILITY_EXPORTER, "").lower()
72
+ return (enable_exporter) in ("true", "1", "yes", "on")
@@ -0,0 +1,18 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .inference_operation_type import InferenceOperationType
6
+
7
+
8
+ @dataclass
9
+ class InferenceCallDetails:
10
+ """Details of an inference call for generative AI operations."""
11
+
12
+ operationName: InferenceOperationType
13
+ model: str
14
+ providerName: str
15
+ inputTokens: int | None = None
16
+ outputTokens: int | None = None
17
+ finishReasons: list[str] | None = None
18
+ responseId: str | None = None
@@ -0,0 +1,11 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class InferenceOperationType(Enum):
7
+ """Supported inference operation types for generative AI."""
8
+
9
+ CHAT = "Chat"
10
+ TEXT_COMPLETION = "TextCompletion"
11
+ GENERATE_CONTENT = "GenerateContent"
@@ -0,0 +1,140 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ from typing import List
5
+
6
+ from .agent_details import AgentDetails
7
+ from .constants import (
8
+ GEN_AI_INPUT_MESSAGES_KEY,
9
+ GEN_AI_OPERATION_NAME_KEY,
10
+ GEN_AI_OUTPUT_MESSAGES_KEY,
11
+ GEN_AI_PROVIDER_NAME_KEY,
12
+ GEN_AI_REQUEST_MODEL_KEY,
13
+ GEN_AI_RESPONSE_FINISH_REASONS_KEY,
14
+ GEN_AI_RESPONSE_ID_KEY,
15
+ GEN_AI_THOUGHT_PROCESS_KEY,
16
+ GEN_AI_USAGE_INPUT_TOKENS_KEY,
17
+ GEN_AI_USAGE_OUTPUT_TOKENS_KEY,
18
+ )
19
+ from .inference_call_details import InferenceCallDetails
20
+ from .opentelemetry_scope import OpenTelemetryScope
21
+ from .request import Request
22
+ from .tenant_details import TenantDetails
23
+ from .utils import safe_json_dumps
24
+
25
+
26
+ class InferenceScope(OpenTelemetryScope):
27
+ """Provides OpenTelemetry tracing scope for generative AI inference operations."""
28
+
29
+ @staticmethod
30
+ def start(
31
+ details: InferenceCallDetails,
32
+ agent_details: AgentDetails,
33
+ tenant_details: TenantDetails,
34
+ request: Request | None = None,
35
+ ) -> "InferenceScope":
36
+ """Creates and starts a new scope for inference tracing.
37
+
38
+ Args:
39
+ details: The details of the inference call
40
+ agent_details: The details of the agent making the call
41
+ tenant_details: The details of the tenant
42
+ request: Optional request details for additional context
43
+
44
+ Returns:
45
+ A new InferenceScope instance
46
+ """
47
+ return InferenceScope(details, agent_details, tenant_details, request)
48
+
49
+ def __init__(
50
+ self,
51
+ details: InferenceCallDetails,
52
+ agent_details: AgentDetails,
53
+ tenant_details: TenantDetails,
54
+ request: Request | None = None,
55
+ ):
56
+ """Initialize the inference scope.
57
+
58
+ Args:
59
+ details: The details of the inference call
60
+ agent_details: The details of the agent making the call
61
+ tenant_details: The details of the tenant
62
+ request: Optional request details for additional context
63
+ """
64
+
65
+ super().__init__(
66
+ kind="Client",
67
+ operation_name=details.operationName.value,
68
+ activity_name=f"{details.operationName.value} {details.model}",
69
+ agent_details=agent_details,
70
+ tenant_details=tenant_details,
71
+ )
72
+
73
+ if request:
74
+ self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, request.content)
75
+
76
+ self.set_tag_maybe(GEN_AI_OPERATION_NAME_KEY, details.operationName.value)
77
+ self.set_tag_maybe(GEN_AI_REQUEST_MODEL_KEY, details.model)
78
+ self.set_tag_maybe(GEN_AI_PROVIDER_NAME_KEY, details.providerName)
79
+ self.set_tag_maybe(
80
+ GEN_AI_USAGE_INPUT_TOKENS_KEY,
81
+ str(details.inputTokens) if details.inputTokens is not None else None,
82
+ )
83
+ self.set_tag_maybe(
84
+ GEN_AI_USAGE_OUTPUT_TOKENS_KEY,
85
+ str(details.outputTokens) if details.outputTokens is not None else None,
86
+ )
87
+ self.set_tag_maybe(
88
+ GEN_AI_RESPONSE_FINISH_REASONS_KEY,
89
+ safe_json_dumps(details.finishReasons) if details.finishReasons else None,
90
+ )
91
+ self.set_tag_maybe(GEN_AI_RESPONSE_ID_KEY, details.responseId)
92
+
93
+ def record_input_messages(self, messages: List[str]) -> None:
94
+ """Records the input messages for telemetry tracking.
95
+
96
+ Args:
97
+ messages: List of input messages
98
+ """
99
+ self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps(messages))
100
+
101
+ def record_output_messages(self, messages: List[str]) -> None:
102
+ """Records the output messages for telemetry tracking.
103
+
104
+ Args:
105
+ messages: List of output messages
106
+ """
107
+ self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(messages))
108
+
109
+ def record_input_tokens(self, input_tokens: int) -> None:
110
+ """Records the number of input tokens for telemetry tracking.
111
+
112
+ Args:
113
+ input_tokens: Number of input tokens
114
+ """
115
+ self.set_tag_maybe(GEN_AI_USAGE_INPUT_TOKENS_KEY, str(input_tokens))
116
+
117
+ def record_output_tokens(self, output_tokens: int) -> None:
118
+ """Records the number of output tokens for telemetry tracking.
119
+
120
+ Args:
121
+ output_tokens: Number of output tokens
122
+ """
123
+ self.set_tag_maybe(GEN_AI_USAGE_OUTPUT_TOKENS_KEY, str(output_tokens))
124
+
125
+ def record_finish_reasons(self, finish_reasons: List[str]) -> None:
126
+ """Records the finish reasons for telemetry tracking.
127
+
128
+ Args:
129
+ finish_reasons: List of finish reasons
130
+ """
131
+ if finish_reasons:
132
+ self.set_tag_maybe(GEN_AI_RESPONSE_FINISH_REASONS_KEY, safe_json_dumps(finish_reasons))
133
+
134
+ def record_thought_process(self, thought_process: str) -> None:
135
+ """Records the thought process.
136
+
137
+ Args:
138
+ thought_process: The thought process to record
139
+ """
140
+ self.set_tag_maybe(GEN_AI_THOUGHT_PROCESS_KEY, thought_process)
@@ -0,0 +1,17 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ # Data class for invoke agent details.
4
+
5
+ from dataclasses import dataclass
6
+ from urllib.parse import ParseResult
7
+
8
+ from .agent_details import AgentDetails
9
+
10
+
11
+ @dataclass
12
+ class InvokeAgentDetails:
13
+ """Details for agent invocation tracing."""
14
+
15
+ details: AgentDetails
16
+ endpoint: ParseResult | None = None
17
+ session_id: str | None = None