qtype 0.0.16__py3-none-any.whl → 0.1.1__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 (128) hide show
  1. qtype/application/commons/tools.py +1 -1
  2. qtype/application/converters/tools_from_api.py +5 -5
  3. qtype/application/converters/tools_from_module.py +2 -2
  4. qtype/application/converters/types.py +14 -43
  5. qtype/application/documentation.py +1 -1
  6. qtype/application/facade.py +94 -73
  7. qtype/base/types.py +227 -7
  8. qtype/cli.py +4 -0
  9. qtype/commands/convert.py +20 -8
  10. qtype/commands/generate.py +19 -27
  11. qtype/commands/run.py +73 -36
  12. qtype/commands/serve.py +74 -54
  13. qtype/commands/validate.py +34 -8
  14. qtype/commands/visualize.py +46 -22
  15. qtype/dsl/__init__.py +6 -5
  16. qtype/dsl/custom_types.py +1 -1
  17. qtype/dsl/domain_types.py +65 -5
  18. qtype/dsl/linker.py +384 -0
  19. qtype/dsl/loader.py +315 -0
  20. qtype/dsl/model.py +612 -363
  21. qtype/dsl/parser.py +200 -0
  22. qtype/dsl/types.py +50 -0
  23. qtype/interpreter/api.py +57 -136
  24. qtype/interpreter/auth/aws.py +19 -9
  25. qtype/interpreter/auth/generic.py +93 -16
  26. qtype/interpreter/base/base_step_executor.py +436 -0
  27. qtype/interpreter/base/batch_step_executor.py +171 -0
  28. qtype/interpreter/base/exceptions.py +50 -0
  29. qtype/interpreter/base/executor_context.py +74 -0
  30. qtype/interpreter/base/factory.py +117 -0
  31. qtype/interpreter/base/progress_tracker.py +110 -0
  32. qtype/interpreter/base/secrets.py +339 -0
  33. qtype/interpreter/base/step_cache.py +74 -0
  34. qtype/interpreter/base/stream_emitter.py +469 -0
  35. qtype/interpreter/conversions.py +462 -22
  36. qtype/interpreter/converters.py +77 -0
  37. qtype/interpreter/endpoints.py +355 -0
  38. qtype/interpreter/executors/agent_executor.py +242 -0
  39. qtype/interpreter/executors/aggregate_executor.py +93 -0
  40. qtype/interpreter/executors/decoder_executor.py +163 -0
  41. qtype/interpreter/executors/doc_to_text_executor.py +112 -0
  42. qtype/interpreter/executors/document_embedder_executor.py +107 -0
  43. qtype/interpreter/executors/document_search_executor.py +122 -0
  44. qtype/interpreter/executors/document_source_executor.py +118 -0
  45. qtype/interpreter/executors/document_splitter_executor.py +105 -0
  46. qtype/interpreter/executors/echo_executor.py +63 -0
  47. qtype/interpreter/executors/field_extractor_executor.py +160 -0
  48. qtype/interpreter/executors/file_source_executor.py +101 -0
  49. qtype/interpreter/executors/file_writer_executor.py +110 -0
  50. qtype/interpreter/executors/index_upsert_executor.py +228 -0
  51. qtype/interpreter/executors/invoke_embedding_executor.py +92 -0
  52. qtype/interpreter/executors/invoke_flow_executor.py +51 -0
  53. qtype/interpreter/executors/invoke_tool_executor.py +358 -0
  54. qtype/interpreter/executors/llm_inference_executor.py +272 -0
  55. qtype/interpreter/executors/prompt_template_executor.py +78 -0
  56. qtype/interpreter/executors/sql_source_executor.py +106 -0
  57. qtype/interpreter/executors/vector_search_executor.py +91 -0
  58. qtype/interpreter/flow.py +159 -22
  59. qtype/interpreter/metadata_api.py +115 -0
  60. qtype/interpreter/resource_cache.py +5 -4
  61. qtype/interpreter/rich_progress.py +225 -0
  62. qtype/interpreter/stream/chat/__init__.py +15 -0
  63. qtype/interpreter/stream/chat/converter.py +391 -0
  64. qtype/interpreter/{chat → stream/chat}/file_conversions.py +2 -2
  65. qtype/interpreter/stream/chat/ui_request_to_domain_type.py +140 -0
  66. qtype/interpreter/stream/chat/vercel.py +609 -0
  67. qtype/interpreter/stream/utils/__init__.py +15 -0
  68. qtype/interpreter/stream/utils/build_vercel_ai_formatter.py +74 -0
  69. qtype/interpreter/stream/utils/callback_to_stream.py +66 -0
  70. qtype/interpreter/stream/utils/create_streaming_response.py +18 -0
  71. qtype/interpreter/stream/utils/default_chat_extract_text.py +20 -0
  72. qtype/interpreter/stream/utils/error_streaming_response.py +20 -0
  73. qtype/interpreter/telemetry.py +135 -8
  74. qtype/interpreter/tools/__init__.py +5 -0
  75. qtype/interpreter/tools/function_tool_helper.py +265 -0
  76. qtype/interpreter/types.py +330 -0
  77. qtype/interpreter/typing.py +83 -89
  78. qtype/interpreter/ui/404/index.html +1 -1
  79. qtype/interpreter/ui/404.html +1 -1
  80. qtype/interpreter/ui/_next/static/{nUaw6_IwRwPqkzwe5s725 → 20HoJN6otZ_LyHLHpCPE6}/_buildManifest.js +1 -1
  81. qtype/interpreter/ui/_next/static/chunks/{393-8fd474427f8e19ce.js → 434-b2112d19f25c44ff.js} +3 -3
  82. qtype/interpreter/ui/_next/static/chunks/app/page-8c67d16ac90d23cb.js +1 -0
  83. qtype/interpreter/ui/_next/static/chunks/ba12c10f-546f2714ff8abc66.js +1 -0
  84. qtype/interpreter/ui/_next/static/css/8a8d1269e362fef7.css +3 -0
  85. qtype/interpreter/ui/icon.png +0 -0
  86. qtype/interpreter/ui/index.html +1 -1
  87. qtype/interpreter/ui/index.txt +4 -4
  88. qtype/semantic/checker.py +583 -0
  89. qtype/semantic/generate.py +262 -83
  90. qtype/semantic/loader.py +95 -0
  91. qtype/semantic/model.py +436 -159
  92. qtype/semantic/resolver.py +63 -19
  93. qtype/semantic/visualize.py +28 -31
  94. {qtype-0.0.16.dist-info → qtype-0.1.1.dist-info}/METADATA +16 -3
  95. qtype-0.1.1.dist-info/RECORD +135 -0
  96. qtype/dsl/base_types.py +0 -38
  97. qtype/dsl/validator.py +0 -465
  98. qtype/interpreter/batch/__init__.py +0 -0
  99. qtype/interpreter/batch/file_sink_source.py +0 -162
  100. qtype/interpreter/batch/flow.py +0 -95
  101. qtype/interpreter/batch/sql_source.py +0 -92
  102. qtype/interpreter/batch/step.py +0 -74
  103. qtype/interpreter/batch/types.py +0 -41
  104. qtype/interpreter/batch/utils.py +0 -178
  105. qtype/interpreter/chat/chat_api.py +0 -237
  106. qtype/interpreter/chat/vercel.py +0 -314
  107. qtype/interpreter/exceptions.py +0 -10
  108. qtype/interpreter/step.py +0 -67
  109. qtype/interpreter/steps/__init__.py +0 -0
  110. qtype/interpreter/steps/agent.py +0 -114
  111. qtype/interpreter/steps/condition.py +0 -36
  112. qtype/interpreter/steps/decoder.py +0 -88
  113. qtype/interpreter/steps/llm_inference.py +0 -171
  114. qtype/interpreter/steps/prompt_template.py +0 -54
  115. qtype/interpreter/steps/search.py +0 -24
  116. qtype/interpreter/steps/tool.py +0 -219
  117. qtype/interpreter/streaming_helpers.py +0 -123
  118. qtype/interpreter/ui/_next/static/chunks/app/page-7e26b6156cfb55d3.js +0 -1
  119. qtype/interpreter/ui/_next/static/chunks/ba12c10f-22556063851a6df2.js +0 -1
  120. qtype/interpreter/ui/_next/static/css/b40532b0db09cce3.css +0 -3
  121. qtype/interpreter/ui/favicon.ico +0 -0
  122. qtype/loader.py +0 -390
  123. qtype-0.0.16.dist-info/RECORD +0 -106
  124. /qtype/interpreter/ui/_next/static/{nUaw6_IwRwPqkzwe5s725 → 20HoJN6otZ_LyHLHpCPE6}/_ssgManifest.js +0 -0
  125. {qtype-0.0.16.dist-info → qtype-0.1.1.dist-info}/WHEEL +0 -0
  126. {qtype-0.0.16.dist-info → qtype-0.1.1.dist-info}/entry_points.txt +0 -0
  127. {qtype-0.0.16.dist-info → qtype-0.1.1.dist-info}/licenses/LICENSE +0 -0
  128. {qtype-0.0.16.dist-info → qtype-0.1.1.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,42 @@
1
1
  """
2
2
  Generic authorization context manager for QType interpreter.
3
3
 
4
- This module provides a unified context manager that can handle any AuthorizationProvider
5
- type and return the appropriate session or provider instance.
4
+ This module provides a unified `auth()` context manager that handles any
5
+ AuthorizationProvider type and returns the appropriate session or provider
6
+ instance with secrets resolved.
7
+
8
+ Key Features:
9
+ - Automatic secret resolution for auth credentials using SecretManager
10
+ - Unified interface for all auth provider types (AWS, API Key, OAuth2)
11
+ - Returns authenticated sessions ready for use with external services
12
+
13
+ The context manager automatically:
14
+ 1. Detects the auth provider type
15
+ 2. Resolves any SecretReferences in credentials
16
+ 3. Creates appropriate authentication sessions/objects
17
+ 4. Handles cleanup when exiting the context
18
+
19
+ Supported Auth Types:
20
+ - AWSAuthProvider: Returns boto3.Session for AWS services
21
+ - APIKeyAuthProvider: Returns provider with resolved API key
22
+ - OAuth2AuthProvider: Returns provider with resolved client secret
23
+
24
+ Example:
25
+ ```python
26
+ from qtype.semantic.model import APIKeyAuthProvider, SecretReference
27
+ from qtype.interpreter.auth.generic import auth
28
+
29
+ # Auth with secret reference
30
+ api_auth = APIKeyAuthProvider(
31
+ id="openai",
32
+ type="api_key",
33
+ api_key=SecretReference(secret_name="my-app/openai-key")
34
+ )
35
+
36
+ with auth(api_auth, secret_manager) as provider:
37
+ # provider.api_key contains the resolved secret
38
+ headers = {"Authorization": f"Bearer {provider.api_key}"}
39
+ ```
6
40
  """
7
41
 
8
42
  from __future__ import annotations
@@ -13,6 +47,7 @@ from typing import Generator
13
47
  import boto3 # type: ignore[import-untyped]
14
48
 
15
49
  from qtype.interpreter.auth.aws import aws
50
+ from qtype.interpreter.base.secrets import SecretManagerBase
16
51
  from qtype.semantic.model import (
17
52
  APIKeyAuthProvider,
18
53
  AuthorizationProvider,
@@ -27,10 +62,56 @@ class UnsupportedAuthProviderError(Exception):
27
62
  pass
28
63
 
29
64
 
65
+ def resolve_provider_secrets(
66
+ provider: AuthorizationProvider,
67
+ secret_manager: SecretManagerBase,
68
+ ) -> AuthorizationProvider:
69
+ """
70
+ Resolve all SecretReference fields in an auth provider.
71
+
72
+ This helper automatically detects and resolves any fields that contain
73
+ SecretReference objects, returning a copy of the provider with resolved
74
+ secret values. This eliminates duplication when handling different auth
75
+ provider types.
76
+
77
+ Note: Always returns a copy to ensure consistency, even when there are
78
+ no secrets to resolve. This prevents issues with object identity checks
79
+ in tests and ensures a clean separation between DSL and runtime objects.
80
+
81
+ Args:
82
+ provider: Auth provider instance with potential SecretReferences
83
+ secret_manager: Secret manager to use for resolution
84
+
85
+ Returns:
86
+ Copy of the provider with all SecretReferences resolved to strings
87
+
88
+ Example:
89
+ >>> provider = APIKeyAuthProvider(
90
+ ... id="my_auth",
91
+ ... api_key=SecretReference(secret_name="my-key")
92
+ ... )
93
+ >>> resolved = resolve_provider_secrets(provider, secret_manager)
94
+ >>> assert isinstance(resolved.api_key, str)
95
+ """
96
+ context = f"auth provider '{provider.id}'"
97
+ updates = {}
98
+
99
+ # Iterate over all fields and resolve any SecretReferences
100
+ for field_name, field_info in provider.model_fields.items():
101
+ value = getattr(provider, field_name)
102
+ # Check if value is a SecretReference by looking for secret_name attr
103
+ if hasattr(value, "secret_name"):
104
+ updates[field_name] = secret_manager(value, context)
105
+
106
+ # Always create a copy to ensure clean separation between DSL and runtime
107
+ return provider.model_copy(update=updates)
108
+
109
+
30
110
  @contextmanager
31
111
  def auth(
32
112
  auth_provider: AuthorizationProvider,
33
- ) -> Generator[boto3.Session | APIKeyAuthProvider, None, None]:
113
+ secret_manager: SecretManagerBase,
114
+ ) -> Generator[boto3.Session | AuthorizationProvider, None, None]:
34
115
  """
35
116
  Create an appropriate session or provider instance based on the auth provider type.
36
117
 
@@ -38,17 +119,16 @@ def auth(
38
119
  on the type of AuthorizationProvider:
39
120
  - AWSAuthProvider: Returns a configured boto3.Session
40
121
  - APIKeyAuthProvider: Returns the provider instance (contains the API key)
41
- - OAuth2AuthProvider: Raises NotImplementedError (not yet supported)
42
122
 
43
123
  Args:
44
124
  auth_provider: AuthorizationProvider instance of any supported type
125
+ secret_manager: Secret manager for resolving SecretReferences
45
126
 
46
127
  Yields:
47
128
  boto3.Session | APIKeyAuthProvider: The appropriate session or provider instance
48
129
 
49
130
  Raises:
50
131
  UnsupportedAuthProviderError: When an unsupported provider type is used
51
- NotImplementedError: When OAuth2AuthProvider is used (not yet implemented)
52
132
 
53
133
  Example:
54
134
  ```python
@@ -81,23 +161,20 @@ def auth(
81
161
  """
82
162
  if isinstance(auth_provider, AWSAuthProvider):
83
163
  # Use AWS-specific context manager
84
- with aws(auth_provider) as session:
164
+ with aws(auth_provider, secret_manager) as session:
85
165
  yield session
86
166
 
87
- elif isinstance(auth_provider, APIKeyAuthProvider):
88
- # For API key providers, just return the provider itself
89
- # The caller can access provider.api_key and provider.host
90
- yield auth_provider
91
-
92
- elif isinstance(auth_provider, OAuth2AuthProvider):
93
- # OAuth2 not yet implemented
94
- raise NotImplementedError(
95
- f"OAuth2 authentication is not yet implemented for provider '{auth_provider.id}'"
167
+ elif isinstance(auth_provider, (APIKeyAuthProvider, OAuth2AuthProvider)):
168
+ # For non-AWS providers, resolve secrets and yield modified copy
169
+ resolved_provider = resolve_provider_secrets(
170
+ auth_provider, secret_manager
96
171
  )
172
+ yield resolved_provider
97
173
 
98
174
  else:
99
175
  # Unknown provider type
100
176
  raise UnsupportedAuthProviderError(
101
- f"Unsupported authorization provider type: {type(auth_provider).__name__} "
177
+ f"Unsupported authorization provider type: "
178
+ f"{type(auth_provider).__name__} "
102
179
  f"for provider '{auth_provider.id}'"
103
180
  )
@@ -0,0 +1,436 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from abc import ABC, abstractmethod
5
+ from typing import Any, AsyncIterator
6
+
7
+ from aiostream import stream
8
+ from openinference.semconv.trace import (
9
+ OpenInferenceSpanKindValues,
10
+ SpanAttributes,
11
+ )
12
+ from opentelemetry import context, trace
13
+ from opentelemetry.trace import Status, StatusCode
14
+
15
+ from qtype.interpreter.base.executor_context import ExecutorContext
16
+ from qtype.interpreter.base.progress_tracker import ProgressTracker
17
+ from qtype.interpreter.base.step_cache import (
18
+ cache_key,
19
+ create_cache,
20
+ from_cache_value,
21
+ to_cache_value,
22
+ )
23
+ from qtype.interpreter.base.stream_emitter import StreamEmitter
24
+ from qtype.interpreter.types import FlowMessage
25
+ from qtype.semantic.model import SecretReference, Step
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class StepExecutor(ABC):
31
+ """
32
+ Base class for step executors that process individual messages.
33
+
34
+ This executor processes messages one at a time, supporting both sequential
35
+ and concurrent execution based on the step's concurrency_config.
36
+
37
+ **Execution Flow:**
38
+ 1. Failed messages are filtered out and collected for re-emission
39
+ 2. Valid messages are processed individually via `process_message()`
40
+ 3. Messages can be processed concurrently based on num_workers configuration
41
+ 4. Results are streamed back with progress updates
42
+ 5. Failed messages are emitted first (ordering not guaranteed with successes)
43
+ 6. Optional finalization step runs after all processing completes
44
+
45
+ **Subclass Requirements:**
46
+ - Must implement `process_message()` to handle individual message processing
47
+ - Can optionally implement `finalize()` for cleanup/terminal operations
48
+ - Can optionally override `span_kind` to set appropriate OpenInference span type
49
+
50
+ Args:
51
+ step: The semantic step model defining behavior and configuration
52
+ context: ExecutorContext with cross-cutting concerns
53
+ **dependencies: Executor-specific dependencies (e.g., LLM clients,
54
+ database connections). These are injected by the executor factory
55
+ and stored for use during execution.
56
+ """
57
+
58
+ # Subclasses can override this to set the appropriate span kind
59
+ span_kind: OpenInferenceSpanKindValues = (
60
+ OpenInferenceSpanKindValues.UNKNOWN
61
+ )
62
+
63
+ def __init__(
64
+ self,
65
+ step: Step,
66
+ context: ExecutorContext,
67
+ **dependencies: Any,
68
+ ):
69
+ self.step = step
70
+ self.context = context
71
+ self.dependencies = dependencies
72
+ self.progress = ProgressTracker(step.id)
73
+ self.stream_emitter = StreamEmitter(step, context.on_stream_event)
74
+ # Hook for subclasses to customize the processing function
75
+ # Base uses process_message with telemetry wrapping,
76
+ # BatchedStepExecutor uses process_batch
77
+ self._processor = self._process_message_with_cache
78
+ # Convenience properties from context
79
+ self._secret_manager = context.secret_manager
80
+ self._tracer = context.tracer or trace.get_tracer(__name__)
81
+
82
+ def _resolve_secret(self, value: str | SecretReference) -> str:
83
+ """
84
+ Resolve a value that may be a string or a SecretReference.
85
+
86
+ This is a convenience wrapper that adds step context to error messages.
87
+
88
+ Args:
89
+ value: Either a plain string or a SecretReference
90
+
91
+ Returns:
92
+ The resolved string value
93
+
94
+ Raises:
95
+ SecretResolutionError: If secret resolution fails
96
+ """
97
+ context = f"step '{self.step.id}'"
98
+ return self._secret_manager(value, context)
99
+
100
+ async def _filter_and_collect_errors(
101
+ self,
102
+ message_stream: AsyncIterator[FlowMessage],
103
+ failed_messages: list[FlowMessage],
104
+ ) -> AsyncIterator[FlowMessage]:
105
+ """
106
+ Filter out failed messages from the stream and collect them separately.
107
+
108
+ This allows failed messages to be re-emitted at the end of processing
109
+ while valid messages proceed through the execution pipeline.
110
+
111
+ Note: Progress tracking for errors is NOT done here - it's handled
112
+ in the main execute() loop to consolidate all progress updates.
113
+
114
+ Args:
115
+ message_stream: The input stream of messages
116
+ failed_messages: List to collect failed messages into
117
+
118
+ Yields:
119
+ Only messages that have not failed
120
+ """
121
+ async for msg in message_stream:
122
+ if msg.is_failed():
123
+ logger.debug(
124
+ "Skipping failed message for step %s: %s",
125
+ self.step.id,
126
+ msg.error,
127
+ )
128
+ failed_messages.append(msg)
129
+ else:
130
+ yield msg
131
+
132
+ def _prepare_message_stream(
133
+ self, valid_messages: AsyncIterator[FlowMessage]
134
+ ) -> AsyncIterator[Any]:
135
+ """
136
+ Prepare the valid message stream for processing.
137
+
138
+ This is a hook for subclasses to transform the message stream before
139
+ processing. The base implementation returns messages unchanged.
140
+ BatchedStepExecutor overrides this to chunk messages into batches.
141
+
142
+ Args:
143
+ valid_messages: Stream of valid (non-failed) messages
144
+
145
+ Returns:
146
+ Transformed stream ready for processing (same type for base,
147
+ AsyncIterator[list[FlowMessage]] for batched)
148
+ """
149
+ return valid_messages
150
+
151
+ async def execute(
152
+ self,
153
+ message_stream: AsyncIterator[FlowMessage],
154
+ ) -> AsyncIterator[FlowMessage]:
155
+ """
156
+ Execute the step with the given message stream.
157
+
158
+ This is the main execution pipeline that orchestrates message processing.
159
+ The specific behavior (individual vs batched) is controlled by
160
+ _prepare_message_stream() and self._processor.
161
+
162
+ The execution flow:
163
+ 1. Start step boundary for visual grouping
164
+ 2. Filter out failed messages and collect them
165
+ 3. Prepare valid messages for processing (hook for batching)
166
+ 4. Process messages with optional concurrency
167
+ 5. Emit failed messages first (no ordering guarantee)
168
+ 6. Emit processed results
169
+ 7. Run finalization hook
170
+ 8. End step boundary
171
+ 9. Track progress for all emitted messages
172
+
173
+ Args:
174
+ message_stream: Input stream of messages to process
175
+ Yields:
176
+ Processed messages, with failed messages emitted first
177
+ """
178
+ # Start a span for tracking
179
+ # Note: We manually manage the span lifecycle to allow yielding
180
+ span = self._tracer.start_span(
181
+ f"step.{self.step.id}",
182
+ attributes={
183
+ "step.id": self.step.id,
184
+ "step.type": self.step.__class__.__name__,
185
+ SpanAttributes.OPENINFERENCE_SPAN_KIND: self.span_kind.value,
186
+ },
187
+ )
188
+
189
+ # Make this span the active context so child spans will nest under it
190
+ # Only attach if span is recording (i.e., real tracer is configured)
191
+ ctx = trace.set_span_in_context(span)
192
+ token = context.attach(ctx) if span.is_recording() else None
193
+
194
+ # Initialize the cache
195
+ # this is done once per execution so re-runs are fast
196
+ self.cache = create_cache(self.step.cache_config, self.step.id)
197
+
198
+ # Start step boundary for visual grouping in UI
199
+ async with self.stream_emitter.step_boundary():
200
+ try:
201
+ # Collect failed messages to re-emit at the end
202
+ failed_messages: list[FlowMessage] = []
203
+ valid_messages = self._filter_and_collect_errors(
204
+ message_stream, failed_messages
205
+ )
206
+
207
+ # Determine concurrency from step configuration
208
+ num_workers = 1
209
+ if hasattr(self.step, "concurrency_config") and (
210
+ self.step.concurrency_config is not None # type: ignore[attr-defined]
211
+ ):
212
+ num_workers = (
213
+ self.step.concurrency_config.num_workers # type: ignore[attr-defined]
214
+ )
215
+ span.set_attribute("step.concurrency", num_workers)
216
+
217
+ # Prepare messages for processing (batching hook)
218
+ prepared_messages = self._prepare_message_stream(
219
+ valid_messages
220
+ )
221
+
222
+ # Apply processor with concurrency control
223
+ async def process_item(
224
+ item: Any, *args: Any
225
+ ) -> AsyncIterator[FlowMessage]:
226
+ async for msg in self._processor(item):
227
+ yield msg
228
+
229
+ result_stream = stream.flatmap(
230
+ prepared_messages, process_item, task_limit=num_workers
231
+ )
232
+
233
+ # Combine all streams
234
+ async def emit_failed_messages() -> AsyncIterator[FlowMessage]:
235
+ for msg in failed_messages:
236
+ yield msg
237
+
238
+ all_results = stream.concat(
239
+ stream.iterate([result_stream, emit_failed_messages()])
240
+ )
241
+
242
+ # Track message counts for telemetry
243
+ message_count = 0
244
+ error_count = len(failed_messages)
245
+
246
+ # Stream results and track progress
247
+ async with all_results.stream() as streamer:
248
+ result: FlowMessage
249
+ async for result in streamer:
250
+ message_count += 1
251
+ if result.is_failed():
252
+ error_count += 1
253
+ self.progress.update_for_message(
254
+ result, self.context.on_progress
255
+ )
256
+ yield result
257
+
258
+ # Finalize and track those messages too
259
+ async for msg in self.finalize():
260
+ message_count += 1
261
+ if msg.is_failed():
262
+ error_count += 1
263
+ yield msg
264
+
265
+ # Close the cache
266
+ if self.cache:
267
+ self.cache.close()
268
+
269
+ # Record metrics in span
270
+ span.set_attribute("step.messages.total", message_count)
271
+ span.set_attribute("step.messages.errors", error_count)
272
+
273
+ # Set span status based on errors
274
+ if error_count > 0:
275
+ span.set_status(
276
+ Status(
277
+ StatusCode.ERROR,
278
+ f"{error_count} of {message_count} messages failed",
279
+ )
280
+ )
281
+ else:
282
+ span.set_status(Status(StatusCode.OK))
283
+
284
+ except Exception as e:
285
+ # Record the exception and set error status
286
+ span.record_exception(e)
287
+ span.set_status(Status(StatusCode.ERROR, f"Step failed: {e}"))
288
+ raise
289
+ finally:
290
+ # Detach the context and end the span
291
+ # Only detach if we successfully attached (span was recording)
292
+ if token is not None:
293
+ context.detach(token)
294
+ span.end()
295
+
296
+ @abstractmethod
297
+ async def process_message(
298
+ self, message: FlowMessage
299
+ ) -> AsyncIterator[FlowMessage]:
300
+ """
301
+ Process a single message.
302
+
303
+ Subclasses MUST implement this method to define how individual
304
+ messages are processed.
305
+
306
+ This is a one-to-many operation: a single input message may yield
307
+ zero, one, or multiple output messages. For example, a document
308
+ splitter might yield multiple chunks from one document.
309
+
310
+ This method is automatically wrapped with telemetry tracing when
311
+ called through the executor's execution pipeline.
312
+
313
+ Args:
314
+ message: The input message to process
315
+
316
+ Yields:
317
+ Zero or more processed messages
318
+ """
319
+ yield # type: ignore[misc]
320
+
321
+ async def _process_message_with_cache(
322
+ self, message: FlowMessage
323
+ ) -> AsyncIterator[FlowMessage]:
324
+ downstream = self._process_message_with_telemetry
325
+ if self.cache is None:
326
+ async for output_msg in downstream(message):
327
+ yield output_msg
328
+ else:
329
+ key = cache_key(message, self.step)
330
+ cached_result = self.cache.get(key)
331
+ if cached_result is not None:
332
+ result = [from_cache_value(d, message) for d in cached_result] # type: ignore
333
+ self.progress.increment_cache(
334
+ self.context.on_progress,
335
+ hit_delta=len(result),
336
+ miss_delta=0,
337
+ )
338
+ # cache hit
339
+ for msg in result:
340
+ yield msg
341
+ else:
342
+ # cache miss -- process downstream and store result
343
+ buf = []
344
+ async for output_msg in downstream(message):
345
+ buf.append(output_msg)
346
+ yield output_msg
347
+
348
+ self.progress.increment_cache(
349
+ self.context.on_progress, hit_delta=0, miss_delta=len(buf)
350
+ )
351
+ # store the results in the cache of there are no errors or if instructed to do so
352
+ if (
353
+ all(not msg.is_failed() for msg in buf)
354
+ or self.step.cache_config.on_error == "Cache" # type: ignore
355
+ ):
356
+ serialized = [to_cache_value(m, self.step) for m in buf]
357
+ self.cache.set(
358
+ key, serialized, expire=self.step.cache_config.ttl
359
+ ) # type: ignore
360
+
361
+ async def _process_message_with_telemetry(
362
+ self, message: FlowMessage
363
+ ) -> AsyncIterator[FlowMessage]:
364
+ """
365
+ Internal wrapper that adds telemetry tracing to process_message.
366
+
367
+ This method creates a child span for each message processing
368
+ operation, automatically recording errors and success metrics.
369
+ The child span will automatically be nested under the current
370
+ active span in the context.
371
+ """
372
+ # Get current context and create child span within it
373
+ span = self._tracer.start_span(
374
+ f"step.{self.step.id}.process_message",
375
+ attributes={
376
+ "session.id": message.session.session_id,
377
+ },
378
+ )
379
+
380
+ try:
381
+ output_count = 0
382
+ error_occurred = False
383
+
384
+ async for output_msg in self.process_message(message):
385
+ output_count += 1
386
+ if output_msg.is_failed():
387
+ error_occurred = True
388
+ span.add_event(
389
+ "message_failed",
390
+ {
391
+ "error": str(output_msg.error),
392
+ },
393
+ )
394
+ yield output_msg
395
+
396
+ # Record processing metrics
397
+ span.set_attribute("message.outputs", output_count)
398
+
399
+ if error_occurred:
400
+ span.set_status(
401
+ Status(StatusCode.ERROR, "Message processing had errors")
402
+ )
403
+ else:
404
+ span.set_status(Status(StatusCode.OK))
405
+
406
+ except Exception as e:
407
+ span.record_exception(e)
408
+ span.set_status(
409
+ Status(StatusCode.ERROR, f"Processing failed: {e}")
410
+ )
411
+ raise
412
+ finally:
413
+ span.end()
414
+
415
+ async def finalize(self) -> AsyncIterator[FlowMessage]:
416
+ """
417
+ Optional finalization hook called after all input processing completes.
418
+
419
+ This method is called once after the input stream is exhausted and all
420
+ messages have been processed. It can be used for:
421
+ - Cleanup operations
422
+ - Emitting summary/aggregate results
423
+ - Flushing buffers
424
+ - Terminal operations (e.g., writing final output)
425
+
426
+ The default implementation yields nothing. Subclasses can override
427
+ to provide custom finalization behavior.
428
+
429
+ Yields:
430
+ Zero or more final messages to emit
431
+ """
432
+ # Make this an async generator for type checking
433
+ # The return here makes this unreachable, but we need the yield
434
+ # to make this function an async generator
435
+ return
436
+ yield # type: ignore[unreachable]