flock-core 0.5.11__py3-none-any.whl → 0.5.21__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.
Potentially problematic release.
This version of flock-core might be problematic. Click here for more details.
- flock/__init__.py +1 -1
- flock/agent/__init__.py +30 -0
- flock/agent/builder_helpers.py +192 -0
- flock/agent/builder_validator.py +169 -0
- flock/agent/component_lifecycle.py +325 -0
- flock/agent/context_resolver.py +141 -0
- flock/agent/mcp_integration.py +212 -0
- flock/agent/output_processor.py +304 -0
- flock/api/__init__.py +20 -0
- flock/{api_models.py → api/models.py} +0 -2
- flock/{service.py → api/service.py} +3 -3
- flock/cli.py +2 -2
- flock/components/__init__.py +41 -0
- flock/components/agent/__init__.py +22 -0
- flock/{components.py → components/agent/base.py} +4 -3
- flock/{utility/output_utility_component.py → components/agent/output_utility.py} +12 -7
- flock/components/orchestrator/__init__.py +22 -0
- flock/{orchestrator_component.py → components/orchestrator/base.py} +5 -293
- flock/components/orchestrator/circuit_breaker.py +95 -0
- flock/components/orchestrator/collection.py +143 -0
- flock/components/orchestrator/deduplication.py +78 -0
- flock/core/__init__.py +30 -0
- flock/core/agent.py +953 -0
- flock/{artifacts.py → core/artifacts.py} +1 -1
- flock/{context_provider.py → core/context_provider.py} +3 -3
- flock/core/orchestrator.py +1102 -0
- flock/{store.py → core/store.py} +99 -454
- flock/{subscription.py → core/subscription.py} +1 -1
- flock/dashboard/collector.py +5 -5
- flock/dashboard/events.py +1 -1
- flock/dashboard/graph_builder.py +7 -7
- flock/dashboard/routes/__init__.py +21 -0
- flock/dashboard/routes/control.py +327 -0
- flock/dashboard/routes/helpers.py +340 -0
- flock/dashboard/routes/themes.py +76 -0
- flock/dashboard/routes/traces.py +521 -0
- flock/dashboard/routes/websocket.py +108 -0
- flock/dashboard/service.py +43 -1316
- flock/engines/dspy/__init__.py +20 -0
- flock/engines/dspy/artifact_materializer.py +216 -0
- flock/engines/dspy/signature_builder.py +474 -0
- flock/engines/dspy/streaming_executor.py +812 -0
- flock/engines/dspy_engine.py +45 -1330
- flock/engines/examples/simple_batch_engine.py +2 -2
- flock/engines/streaming/__init__.py +3 -0
- flock/engines/streaming/sinks.py +489 -0
- flock/examples.py +7 -7
- flock/logging/logging.py +1 -16
- flock/models/__init__.py +10 -0
- flock/orchestrator/__init__.py +45 -0
- flock/{artifact_collector.py → orchestrator/artifact_collector.py} +3 -3
- flock/orchestrator/artifact_manager.py +168 -0
- flock/{batch_accumulator.py → orchestrator/batch_accumulator.py} +2 -2
- flock/orchestrator/component_runner.py +389 -0
- flock/orchestrator/context_builder.py +167 -0
- flock/{correlation_engine.py → orchestrator/correlation_engine.py} +2 -2
- flock/orchestrator/event_emitter.py +167 -0
- flock/orchestrator/initialization.py +184 -0
- flock/orchestrator/lifecycle_manager.py +226 -0
- flock/orchestrator/mcp_manager.py +202 -0
- flock/orchestrator/scheduler.py +189 -0
- flock/orchestrator/server_manager.py +234 -0
- flock/orchestrator/tracing.py +147 -0
- flock/storage/__init__.py +10 -0
- flock/storage/artifact_aggregator.py +158 -0
- flock/storage/in_memory/__init__.py +6 -0
- flock/storage/in_memory/artifact_filter.py +114 -0
- flock/storage/in_memory/history_aggregator.py +115 -0
- flock/storage/sqlite/__init__.py +10 -0
- flock/storage/sqlite/agent_history_queries.py +154 -0
- flock/storage/sqlite/consumption_loader.py +100 -0
- flock/storage/sqlite/query_builder.py +112 -0
- flock/storage/sqlite/query_params_builder.py +91 -0
- flock/storage/sqlite/schema_manager.py +168 -0
- flock/storage/sqlite/summary_queries.py +194 -0
- flock/utils/__init__.py +14 -0
- flock/utils/async_utils.py +67 -0
- flock/{runtime.py → utils/runtime.py} +3 -3
- flock/utils/time_utils.py +53 -0
- flock/utils/type_resolution.py +38 -0
- flock/{utilities.py → utils/utilities.py} +2 -2
- flock/utils/validation.py +57 -0
- flock/utils/visibility.py +79 -0
- flock/utils/visibility_utils.py +134 -0
- {flock_core-0.5.11.dist-info → flock_core-0.5.21.dist-info}/METADATA +19 -5
- {flock_core-0.5.11.dist-info → flock_core-0.5.21.dist-info}/RECORD +92 -34
- flock/agent.py +0 -1578
- flock/orchestrator.py +0 -1983
- /flock/{visibility.py → core/visibility.py} +0 -0
- /flock/{system_artifacts.py → models/system_artifacts.py} +0 -0
- /flock/{helper → utils}/cli_helper.py +0 -0
- {flock_core-0.5.11.dist-info → flock_core-0.5.21.dist-info}/WHEEL +0 -0
- {flock_core-0.5.11.dist-info → flock_core-0.5.21.dist-info}/entry_points.txt +0 -0
- {flock_core-0.5.11.dist-info → flock_core-0.5.21.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
"""DSPy streaming execution with Rich display and WebSocket support.
|
|
2
|
+
|
|
3
|
+
Phase 6: Extracted from dspy_engine.py to reduce file size and improve modularity.
|
|
4
|
+
|
|
5
|
+
This module handles all streaming-related logic for DSPy program execution,
|
|
6
|
+
including two modes:
|
|
7
|
+
- CLI mode: Rich Live display with terminal formatting (agents.run())
|
|
8
|
+
- Dashboard mode: WebSocket-only streaming for parallel execution (no Rich overhead)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from collections import OrderedDict, defaultdict
|
|
15
|
+
from contextlib import nullcontext
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from typing import Any, Awaitable, Callable, Sequence
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from flock.dashboard.events import StreamingOutputEvent
|
|
22
|
+
from flock.engines.streaming.sinks import RichSink, StreamSink, WebSocketSink
|
|
23
|
+
from flock.logging.logging import get_logger
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DSPyStreamingExecutor:
|
|
30
|
+
"""Executes DSPy programs in streaming mode with Rich or WebSocket output.
|
|
31
|
+
|
|
32
|
+
Responsibilities:
|
|
33
|
+
- Standard (non-streaming) execution
|
|
34
|
+
- WebSocket-only streaming (dashboard mode, no Rich overhead)
|
|
35
|
+
- Rich CLI streaming with formatted tables
|
|
36
|
+
- Stream formatter setup (themes, styles)
|
|
37
|
+
- Final display rendering with artifact metadata
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
status_output_field: str,
|
|
44
|
+
stream_vertical_overflow: str,
|
|
45
|
+
theme: str,
|
|
46
|
+
no_output: bool,
|
|
47
|
+
):
|
|
48
|
+
"""Initialize streaming executor with configuration.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
status_output_field: Field name for status output
|
|
52
|
+
stream_vertical_overflow: Rich Live vertical overflow strategy
|
|
53
|
+
theme: Theme name for Rich output formatting
|
|
54
|
+
no_output: Whether to disable output
|
|
55
|
+
"""
|
|
56
|
+
self.status_output_field = status_output_field
|
|
57
|
+
self.stream_vertical_overflow = stream_vertical_overflow
|
|
58
|
+
self.theme = theme
|
|
59
|
+
self.no_output = no_output
|
|
60
|
+
self._model_stream_cls: Any | None = None
|
|
61
|
+
|
|
62
|
+
def _make_listeners(self, dspy_mod, signature) -> list[Any]:
|
|
63
|
+
"""Create DSPy stream listeners for string output fields."""
|
|
64
|
+
streaming_mod = getattr(dspy_mod, "streaming", None)
|
|
65
|
+
if not streaming_mod or not hasattr(streaming_mod, "StreamListener"):
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
listeners: list[Any] = []
|
|
69
|
+
try:
|
|
70
|
+
for name, field in getattr(signature, "output_fields", {}).items():
|
|
71
|
+
if getattr(field, "annotation", None) is str:
|
|
72
|
+
listeners.append(
|
|
73
|
+
streaming_mod.StreamListener(signature_field_name=name)
|
|
74
|
+
)
|
|
75
|
+
except Exception:
|
|
76
|
+
return []
|
|
77
|
+
return listeners
|
|
78
|
+
|
|
79
|
+
def _payload_kwargs(self, *, payload: Any, description: str) -> dict[str, Any]:
|
|
80
|
+
"""Normalize payload variations into kwargs for streamify."""
|
|
81
|
+
if isinstance(payload, dict) and "description" in payload:
|
|
82
|
+
return payload
|
|
83
|
+
|
|
84
|
+
if isinstance(payload, dict) and "input" in payload:
|
|
85
|
+
return {
|
|
86
|
+
"description": description,
|
|
87
|
+
"input": payload["input"],
|
|
88
|
+
"context": payload.get("context", []),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Legacy fallback: treat payload as the primary input.
|
|
92
|
+
return {"description": description, "input": payload, "context": []}
|
|
93
|
+
|
|
94
|
+
def _artifact_type_label(self, agent: Any, output_group: Any) -> str:
|
|
95
|
+
"""Derive user-facing artifact label for streaming events."""
|
|
96
|
+
outputs_to_display = (
|
|
97
|
+
getattr(output_group, "outputs", None)
|
|
98
|
+
if output_group and hasattr(output_group, "outputs")
|
|
99
|
+
else getattr(agent, "outputs", [])
|
|
100
|
+
if hasattr(agent, "outputs")
|
|
101
|
+
else []
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if not outputs_to_display:
|
|
105
|
+
return "output"
|
|
106
|
+
|
|
107
|
+
# Preserve ordering while avoiding duplicates.
|
|
108
|
+
seen: set[str] = set()
|
|
109
|
+
segments: list[str] = []
|
|
110
|
+
for output in outputs_to_display:
|
|
111
|
+
type_name = getattr(getattr(output, "spec", None), "type_name", None)
|
|
112
|
+
if type_name and type_name not in seen:
|
|
113
|
+
seen.add(type_name)
|
|
114
|
+
segments.append(type_name)
|
|
115
|
+
|
|
116
|
+
return ", ".join(segments) if segments else "output"
|
|
117
|
+
|
|
118
|
+
def _streaming_classes_for(self, dspy_mod: Any) -> tuple[type | None, type | None]:
|
|
119
|
+
streaming_mod = getattr(dspy_mod, "streaming", None)
|
|
120
|
+
if not streaming_mod:
|
|
121
|
+
return None, None
|
|
122
|
+
status_cls = getattr(streaming_mod, "StatusMessage", None)
|
|
123
|
+
stream_cls = getattr(streaming_mod, "StreamResponse", None)
|
|
124
|
+
return status_cls, stream_cls
|
|
125
|
+
|
|
126
|
+
def _resolve_model_stream_cls(self) -> Any | None:
|
|
127
|
+
if self._model_stream_cls is None:
|
|
128
|
+
try:
|
|
129
|
+
from litellm import ModelResponseStream # type: ignore
|
|
130
|
+
except Exception: # pragma: no cover - litellm optional at runtime
|
|
131
|
+
self._model_stream_cls = False
|
|
132
|
+
else:
|
|
133
|
+
self._model_stream_cls = ModelResponseStream
|
|
134
|
+
return self._model_stream_cls or None
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _normalize_status_message(
|
|
138
|
+
value: Any,
|
|
139
|
+
) -> tuple[str, str | None, str | None, Any | None]:
|
|
140
|
+
message = getattr(value, "message", "")
|
|
141
|
+
return "status", str(message), None, None
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _normalize_stream_response(
|
|
145
|
+
value: Any,
|
|
146
|
+
) -> tuple[str, str | None, str | None, Any | None]:
|
|
147
|
+
chunk = getattr(value, "chunk", None)
|
|
148
|
+
signature_field = getattr(value, "signature_field_name", None)
|
|
149
|
+
return "token", ("" if chunk is None else str(chunk)), signature_field, None
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _normalize_model_stream(
|
|
153
|
+
value: Any,
|
|
154
|
+
) -> tuple[str, str | None, str | None, Any | None]:
|
|
155
|
+
token_text = ""
|
|
156
|
+
try:
|
|
157
|
+
token_text = value.choices[0].delta.content or ""
|
|
158
|
+
except Exception: # pragma: no cover - defensive parity with legacy path
|
|
159
|
+
token_text = ""
|
|
160
|
+
signature_field = getattr(value, "signature_field_name", None)
|
|
161
|
+
return "token", str(token_text), signature_field, None
|
|
162
|
+
|
|
163
|
+
def _initialize_display_data(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
signature_order: Sequence[str],
|
|
167
|
+
agent: Any,
|
|
168
|
+
ctx: Any,
|
|
169
|
+
pre_generated_artifact_id: Any,
|
|
170
|
+
output_group: Any,
|
|
171
|
+
status_field: str,
|
|
172
|
+
) -> tuple[OrderedDict[str, Any], str]:
|
|
173
|
+
"""Build the initial Rich display structure for CLI streaming."""
|
|
174
|
+
display_data: OrderedDict[str, Any] = OrderedDict()
|
|
175
|
+
display_data["id"] = str(pre_generated_artifact_id)
|
|
176
|
+
|
|
177
|
+
artifact_type_name = self._artifact_type_label(agent, output_group)
|
|
178
|
+
display_data["type"] = artifact_type_name
|
|
179
|
+
|
|
180
|
+
payload_section: OrderedDict[str, Any] = OrderedDict()
|
|
181
|
+
for field_name in signature_order:
|
|
182
|
+
if field_name != "description":
|
|
183
|
+
payload_section[field_name] = ""
|
|
184
|
+
display_data["payload"] = payload_section
|
|
185
|
+
|
|
186
|
+
display_data["produced_by"] = getattr(agent, "name", "")
|
|
187
|
+
correlation_id = None
|
|
188
|
+
if ctx and getattr(ctx, "correlation_id", None):
|
|
189
|
+
correlation_id = str(ctx.correlation_id)
|
|
190
|
+
display_data["correlation_id"] = correlation_id
|
|
191
|
+
display_data["partition_key"] = None
|
|
192
|
+
display_data["tags"] = "set()"
|
|
193
|
+
display_data["visibility"] = OrderedDict([("kind", "Public")])
|
|
194
|
+
display_data["created_at"] = "streaming..."
|
|
195
|
+
display_data["version"] = 1
|
|
196
|
+
display_data["status"] = status_field
|
|
197
|
+
|
|
198
|
+
return display_data, artifact_type_name
|
|
199
|
+
|
|
200
|
+
def _prepare_rich_env(
|
|
201
|
+
self,
|
|
202
|
+
*,
|
|
203
|
+
console,
|
|
204
|
+
display_data: OrderedDict[str, Any],
|
|
205
|
+
agent: Any,
|
|
206
|
+
overflow_mode: str,
|
|
207
|
+
) -> tuple[Any, dict[str, Any], dict[str, Any], str, Any]:
|
|
208
|
+
"""Create formatter metadata and Live context for Rich output."""
|
|
209
|
+
from rich.live import Live
|
|
210
|
+
|
|
211
|
+
from flock.engines.dspy_engine import _ensure_live_crop_above
|
|
212
|
+
|
|
213
|
+
_ensure_live_crop_above()
|
|
214
|
+
formatter, theme_dict, styles, agent_label = self.prepare_stream_formatter(
|
|
215
|
+
agent
|
|
216
|
+
)
|
|
217
|
+
initial_panel = formatter.format_result(
|
|
218
|
+
display_data, agent_label, theme_dict, styles
|
|
219
|
+
)
|
|
220
|
+
live_cm = Live(
|
|
221
|
+
initial_panel,
|
|
222
|
+
console=console,
|
|
223
|
+
refresh_per_second=4,
|
|
224
|
+
transient=False,
|
|
225
|
+
vertical_overflow=overflow_mode,
|
|
226
|
+
)
|
|
227
|
+
return formatter, theme_dict, styles, agent_label, live_cm
|
|
228
|
+
|
|
229
|
+
def _build_rich_sink(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
live: Any,
|
|
233
|
+
formatter: Any | None,
|
|
234
|
+
display_data: OrderedDict[str, Any],
|
|
235
|
+
agent_label: str | None,
|
|
236
|
+
theme_dict: dict[str, Any] | None,
|
|
237
|
+
styles: dict[str, Any] | None,
|
|
238
|
+
status_field: str,
|
|
239
|
+
signature_order: Sequence[str],
|
|
240
|
+
stream_buffers: defaultdict[str, list[str]],
|
|
241
|
+
timestamp_factory: Callable[[], str],
|
|
242
|
+
) -> RichSink | None:
|
|
243
|
+
if formatter is None or live is None:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def refresh_panel() -> None:
|
|
247
|
+
live.update(
|
|
248
|
+
formatter.format_result(display_data, agent_label, theme_dict, styles)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return RichSink(
|
|
252
|
+
display_data=display_data,
|
|
253
|
+
stream_buffers=stream_buffers,
|
|
254
|
+
status_field=status_field,
|
|
255
|
+
signature_order=signature_order,
|
|
256
|
+
formatter=formatter,
|
|
257
|
+
theme_dict=theme_dict,
|
|
258
|
+
styles=styles,
|
|
259
|
+
agent_label=agent_label,
|
|
260
|
+
refresh_panel=refresh_panel,
|
|
261
|
+
timestamp_factory=timestamp_factory,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _build_websocket_sink(
|
|
265
|
+
self,
|
|
266
|
+
*,
|
|
267
|
+
ws_broadcast: Callable[[StreamingOutputEvent], Awaitable[None]] | None,
|
|
268
|
+
ctx: Any,
|
|
269
|
+
agent: Any,
|
|
270
|
+
pre_generated_artifact_id: Any,
|
|
271
|
+
artifact_type_name: str,
|
|
272
|
+
) -> WebSocketSink | None:
|
|
273
|
+
if not ws_broadcast:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
def event_factory(
|
|
277
|
+
output_type: str, content: str, sequence: int, is_final: bool
|
|
278
|
+
) -> StreamingOutputEvent:
|
|
279
|
+
return self._build_event(
|
|
280
|
+
ctx=ctx,
|
|
281
|
+
agent=agent,
|
|
282
|
+
artifact_id=pre_generated_artifact_id,
|
|
283
|
+
artifact_type=artifact_type_name,
|
|
284
|
+
output_type=output_type,
|
|
285
|
+
content=content,
|
|
286
|
+
sequence=sequence,
|
|
287
|
+
is_final=is_final,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return WebSocketSink(ws_broadcast=ws_broadcast, event_factory=event_factory)
|
|
291
|
+
|
|
292
|
+
def _collect_sinks(
|
|
293
|
+
self,
|
|
294
|
+
*,
|
|
295
|
+
rich_sink: RichSink | None,
|
|
296
|
+
ws_sink: WebSocketSink | None,
|
|
297
|
+
) -> list[StreamSink]:
|
|
298
|
+
sinks: list[StreamSink] = []
|
|
299
|
+
if rich_sink:
|
|
300
|
+
sinks.append(rich_sink)
|
|
301
|
+
if ws_sink:
|
|
302
|
+
sinks.append(ws_sink)
|
|
303
|
+
return sinks
|
|
304
|
+
|
|
305
|
+
async def _dispatch_to_sinks(
|
|
306
|
+
self, sinks: Sequence[StreamSink], method: str, *args: Any
|
|
307
|
+
) -> None:
|
|
308
|
+
for sink in sinks:
|
|
309
|
+
await getattr(sink, method)(*args)
|
|
310
|
+
|
|
311
|
+
async def _consume_stream(
|
|
312
|
+
self,
|
|
313
|
+
stream_generator: Any,
|
|
314
|
+
sinks: Sequence[StreamSink],
|
|
315
|
+
dspy_mod: Any,
|
|
316
|
+
) -> tuple[Any | None, int]:
|
|
317
|
+
tokens_emitted = 0
|
|
318
|
+
final_result: Any | None = None
|
|
319
|
+
|
|
320
|
+
async for value in stream_generator:
|
|
321
|
+
kind, text, signature_field, prediction = self._normalize_value(
|
|
322
|
+
value, dspy_mod
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if kind == "status" and text:
|
|
326
|
+
await self._dispatch_to_sinks(sinks, "on_status", text)
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
if kind == "token" and text:
|
|
330
|
+
tokens_emitted += 1
|
|
331
|
+
await self._dispatch_to_sinks(sinks, "on_token", text, signature_field)
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
if kind == "prediction":
|
|
335
|
+
final_result = prediction
|
|
336
|
+
await self._dispatch_to_sinks(
|
|
337
|
+
sinks, "on_final", prediction, tokens_emitted
|
|
338
|
+
)
|
|
339
|
+
await self._close_stream_generator(stream_generator)
|
|
340
|
+
return final_result, tokens_emitted
|
|
341
|
+
|
|
342
|
+
return final_result, tokens_emitted
|
|
343
|
+
|
|
344
|
+
async def _flush_sinks(self, sinks: Sequence[StreamSink]) -> None:
|
|
345
|
+
for sink in sinks:
|
|
346
|
+
await sink.flush()
|
|
347
|
+
|
|
348
|
+
def _finalize_stream_display(
|
|
349
|
+
self,
|
|
350
|
+
*,
|
|
351
|
+
rich_sink: RichSink | None,
|
|
352
|
+
formatter: Any | None,
|
|
353
|
+
display_data: OrderedDict[str, Any],
|
|
354
|
+
theme_dict: dict[str, Any] | None,
|
|
355
|
+
styles: dict[str, Any] | None,
|
|
356
|
+
agent_label: str | None,
|
|
357
|
+
) -> tuple[Any, OrderedDict, dict | None, dict | None, str | None]:
|
|
358
|
+
if rich_sink:
|
|
359
|
+
return rich_sink.final_display_data
|
|
360
|
+
return formatter, display_data, theme_dict, styles, agent_label
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
async def _close_stream_generator(stream_generator: Any) -> None:
|
|
364
|
+
aclose = getattr(stream_generator, "aclose", None)
|
|
365
|
+
if callable(aclose):
|
|
366
|
+
try:
|
|
367
|
+
await aclose()
|
|
368
|
+
except GeneratorExit:
|
|
369
|
+
pass
|
|
370
|
+
except BaseExceptionGroup as exc: # pragma: no cover - defensive logging
|
|
371
|
+
remaining = [
|
|
372
|
+
err
|
|
373
|
+
for err in getattr(exc, "exceptions", [])
|
|
374
|
+
if not isinstance(err, GeneratorExit)
|
|
375
|
+
]
|
|
376
|
+
if remaining:
|
|
377
|
+
logger.debug("Error closing stream generator", exc_info=True)
|
|
378
|
+
except Exception:
|
|
379
|
+
logger.debug("Error closing stream generator", exc_info=True)
|
|
380
|
+
|
|
381
|
+
def _build_event(
|
|
382
|
+
self,
|
|
383
|
+
*,
|
|
384
|
+
ctx: Any,
|
|
385
|
+
agent: Any,
|
|
386
|
+
artifact_id: Any,
|
|
387
|
+
artifact_type: str,
|
|
388
|
+
output_type: str,
|
|
389
|
+
content: str,
|
|
390
|
+
sequence: int,
|
|
391
|
+
is_final: bool,
|
|
392
|
+
) -> StreamingOutputEvent:
|
|
393
|
+
"""Construct a StreamingOutputEvent with consistent metadata."""
|
|
394
|
+
correlation_id = ""
|
|
395
|
+
run_id = ""
|
|
396
|
+
if ctx:
|
|
397
|
+
correlation_id = str(getattr(ctx, "correlation_id", "") or "")
|
|
398
|
+
run_id = str(getattr(ctx, "task_id", "") or "")
|
|
399
|
+
|
|
400
|
+
return StreamingOutputEvent(
|
|
401
|
+
correlation_id=correlation_id,
|
|
402
|
+
agent_name=getattr(agent, "name", ""),
|
|
403
|
+
run_id=run_id,
|
|
404
|
+
output_type=output_type,
|
|
405
|
+
content=content,
|
|
406
|
+
sequence=sequence,
|
|
407
|
+
is_final=is_final,
|
|
408
|
+
artifact_id=str(artifact_id) if artifact_id is not None else "",
|
|
409
|
+
artifact_type=artifact_type,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def _normalize_value(
|
|
413
|
+
self, value: Any, dspy_mod: Any
|
|
414
|
+
) -> tuple[str, str | None, str | None, Any | None]:
|
|
415
|
+
"""Normalize raw DSPy streaming values into (kind, text, field, final)."""
|
|
416
|
+
status_cls, stream_cls = self._streaming_classes_for(dspy_mod)
|
|
417
|
+
model_stream_cls = self._resolve_model_stream_cls()
|
|
418
|
+
prediction_cls = getattr(dspy_mod, "Prediction", None)
|
|
419
|
+
|
|
420
|
+
if status_cls and isinstance(value, status_cls):
|
|
421
|
+
return self._normalize_status_message(value)
|
|
422
|
+
|
|
423
|
+
if stream_cls and isinstance(value, stream_cls):
|
|
424
|
+
return self._normalize_stream_response(value)
|
|
425
|
+
|
|
426
|
+
if model_stream_cls and isinstance(value, model_stream_cls):
|
|
427
|
+
return self._normalize_model_stream(value)
|
|
428
|
+
|
|
429
|
+
if prediction_cls and isinstance(value, prediction_cls):
|
|
430
|
+
return "prediction", None, None, value
|
|
431
|
+
|
|
432
|
+
return "unknown", None, None, None
|
|
433
|
+
|
|
434
|
+
async def execute_standard(
|
|
435
|
+
self, dspy_mod, program, *, description: str, payload: dict[str, Any]
|
|
436
|
+
) -> Any:
|
|
437
|
+
"""Execute DSPy program in standard mode (no streaming).
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
dspy_mod: DSPy module
|
|
441
|
+
program: DSPy program (Predict or ReAct)
|
|
442
|
+
description: System description
|
|
443
|
+
payload: Execution payload with semantic field names
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
DSPy Prediction result
|
|
447
|
+
"""
|
|
448
|
+
# Handle semantic fields format: {"description": ..., "task": ..., "report": ...}
|
|
449
|
+
if isinstance(payload, dict) and "description" in payload:
|
|
450
|
+
# Semantic fields: pass all fields as kwargs
|
|
451
|
+
return program(**payload)
|
|
452
|
+
|
|
453
|
+
# Fallback for unexpected payload format
|
|
454
|
+
raise ValueError(
|
|
455
|
+
f"Invalid payload format: expected dict with 'description' key, got {type(payload).__name__}"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
async def execute_streaming_websocket_only(
|
|
459
|
+
self,
|
|
460
|
+
dspy_mod,
|
|
461
|
+
program,
|
|
462
|
+
signature,
|
|
463
|
+
*,
|
|
464
|
+
description: str,
|
|
465
|
+
payload: dict[str, Any],
|
|
466
|
+
agent: Any,
|
|
467
|
+
ctx: Any = None,
|
|
468
|
+
pre_generated_artifact_id: Any = None,
|
|
469
|
+
output_group=None,
|
|
470
|
+
) -> tuple[Any, None]:
|
|
471
|
+
"""Execute streaming for WebSocket only (no Rich display).
|
|
472
|
+
|
|
473
|
+
Optimized path for dashboard mode that skips all Rich formatting overhead.
|
|
474
|
+
Used when multiple agents stream in parallel to avoid terminal conflicts
|
|
475
|
+
and deadlocks with MCP tools.
|
|
476
|
+
|
|
477
|
+
This method eliminates the Rich Live context that can cause deadlocks when
|
|
478
|
+
combined with MCP tool execution and parallel agent streaming.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
dspy_mod: DSPy module
|
|
482
|
+
program: DSPy program (Predict or ReAct)
|
|
483
|
+
signature: DSPy Signature
|
|
484
|
+
description: System description
|
|
485
|
+
payload: Execution payload with semantic field names
|
|
486
|
+
agent: Agent instance
|
|
487
|
+
ctx: Execution context
|
|
488
|
+
pre_generated_artifact_id: Pre-generated artifact ID for streaming
|
|
489
|
+
output_group: OutputGroup defining expected outputs
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Tuple of (DSPy Prediction result, None)
|
|
493
|
+
"""
|
|
494
|
+
logger.info(
|
|
495
|
+
f"Agent {agent.name}: Starting WebSocket-only streaming (dashboard mode)"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Get WebSocket broadcast function (security: wrapper prevents object traversal)
|
|
499
|
+
# Phase 6+7 Security Fix: Use broadcast wrapper from Agent class variable (prevents GOD MODE restoration)
|
|
500
|
+
from flock.core import Agent
|
|
501
|
+
|
|
502
|
+
ws_broadcast = Agent._websocket_broadcast_global
|
|
503
|
+
|
|
504
|
+
if not ws_broadcast:
|
|
505
|
+
logger.warning(
|
|
506
|
+
f"Agent {agent.name}: No WebSocket manager, falling back to standard execution"
|
|
507
|
+
)
|
|
508
|
+
result = await self.execute_standard(
|
|
509
|
+
dspy_mod, program, description=description, payload=payload
|
|
510
|
+
)
|
|
511
|
+
return result, None
|
|
512
|
+
|
|
513
|
+
artifact_type_name = self._artifact_type_label(agent, output_group)
|
|
514
|
+
listeners = self._make_listeners(dspy_mod, signature)
|
|
515
|
+
|
|
516
|
+
# Create streaming task
|
|
517
|
+
streaming_task = dspy_mod.streamify(
|
|
518
|
+
program,
|
|
519
|
+
is_async_program=True,
|
|
520
|
+
stream_listeners=listeners if listeners else None,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
stream_kwargs = self._payload_kwargs(payload=payload, description=description)
|
|
524
|
+
stream_generator = streaming_task(**stream_kwargs)
|
|
525
|
+
|
|
526
|
+
def event_factory(
|
|
527
|
+
output_type: str, content: str, sequence: int, is_final: bool
|
|
528
|
+
) -> StreamingOutputEvent:
|
|
529
|
+
return self._build_event(
|
|
530
|
+
ctx=ctx,
|
|
531
|
+
agent=agent,
|
|
532
|
+
artifact_id=pre_generated_artifact_id,
|
|
533
|
+
artifact_type=artifact_type_name,
|
|
534
|
+
output_type=output_type,
|
|
535
|
+
content=content,
|
|
536
|
+
sequence=sequence,
|
|
537
|
+
is_final=is_final,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
sink: StreamSink = WebSocketSink(
|
|
541
|
+
ws_broadcast=ws_broadcast,
|
|
542
|
+
event_factory=event_factory,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
final_result = None
|
|
546
|
+
tokens_emitted = 0
|
|
547
|
+
|
|
548
|
+
async for value in stream_generator:
|
|
549
|
+
kind, text, signature_field, prediction = self._normalize_value(
|
|
550
|
+
value, dspy_mod
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
if kind == "status" and text:
|
|
554
|
+
await sink.on_status(text)
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
if kind == "token" and text:
|
|
558
|
+
tokens_emitted += 1
|
|
559
|
+
await sink.on_token(text, signature_field)
|
|
560
|
+
continue
|
|
561
|
+
|
|
562
|
+
if kind == "prediction":
|
|
563
|
+
final_result = prediction
|
|
564
|
+
await sink.on_final(prediction, tokens_emitted)
|
|
565
|
+
await self._close_stream_generator(stream_generator)
|
|
566
|
+
break
|
|
567
|
+
|
|
568
|
+
await sink.flush()
|
|
569
|
+
|
|
570
|
+
if final_result is None:
|
|
571
|
+
raise RuntimeError(
|
|
572
|
+
f"Agent {agent.name}: Streaming did not yield a final prediction"
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
logger.info(
|
|
576
|
+
f"Agent {agent.name}: WebSocket streaming completed ({tokens_emitted} tokens)"
|
|
577
|
+
)
|
|
578
|
+
return final_result, None
|
|
579
|
+
|
|
580
|
+
async def execute_streaming(
|
|
581
|
+
self,
|
|
582
|
+
dspy_mod,
|
|
583
|
+
program,
|
|
584
|
+
signature,
|
|
585
|
+
*,
|
|
586
|
+
description: str,
|
|
587
|
+
payload: dict[str, Any],
|
|
588
|
+
agent: Any,
|
|
589
|
+
ctx: Any = None,
|
|
590
|
+
pre_generated_artifact_id: Any = None,
|
|
591
|
+
output_group=None,
|
|
592
|
+
) -> Any:
|
|
593
|
+
"""Execute DSPy program in streaming mode with Rich table updates."""
|
|
594
|
+
|
|
595
|
+
from rich.console import Console
|
|
596
|
+
|
|
597
|
+
console = Console()
|
|
598
|
+
|
|
599
|
+
# Get WebSocket broadcast function (security: wrapper prevents object traversal)
|
|
600
|
+
from flock.core import Agent
|
|
601
|
+
|
|
602
|
+
ws_broadcast = Agent._websocket_broadcast_global
|
|
603
|
+
|
|
604
|
+
listeners = self._make_listeners(dspy_mod, signature)
|
|
605
|
+
streaming_task = dspy_mod.streamify(
|
|
606
|
+
program,
|
|
607
|
+
is_async_program=True,
|
|
608
|
+
stream_listeners=listeners if listeners else None,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
stream_kwargs = self._payload_kwargs(payload=payload, description=description)
|
|
612
|
+
stream_generator = streaming_task(**stream_kwargs)
|
|
613
|
+
|
|
614
|
+
status_field = self.status_output_field
|
|
615
|
+
try:
|
|
616
|
+
signature_order = list(signature.output_fields.keys())
|
|
617
|
+
except Exception:
|
|
618
|
+
signature_order = []
|
|
619
|
+
|
|
620
|
+
display_data, artifact_type_name = self._initialize_display_data(
|
|
621
|
+
signature_order=signature_order,
|
|
622
|
+
agent=agent,
|
|
623
|
+
ctx=ctx,
|
|
624
|
+
pre_generated_artifact_id=pre_generated_artifact_id,
|
|
625
|
+
output_group=output_group,
|
|
626
|
+
status_field=status_field,
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
stream_buffers: defaultdict[str, list[str]] = defaultdict(list)
|
|
630
|
+
overflow_mode = self.stream_vertical_overflow
|
|
631
|
+
|
|
632
|
+
if not self.no_output:
|
|
633
|
+
(
|
|
634
|
+
formatter,
|
|
635
|
+
theme_dict,
|
|
636
|
+
styles,
|
|
637
|
+
agent_label,
|
|
638
|
+
live_cm,
|
|
639
|
+
) = self._prepare_rich_env(
|
|
640
|
+
console=console,
|
|
641
|
+
display_data=display_data,
|
|
642
|
+
agent=agent,
|
|
643
|
+
overflow_mode=overflow_mode,
|
|
644
|
+
)
|
|
645
|
+
else:
|
|
646
|
+
formatter = theme_dict = styles = agent_label = None
|
|
647
|
+
live_cm = nullcontext()
|
|
648
|
+
|
|
649
|
+
timestamp_factory = lambda: datetime.now(UTC).isoformat()
|
|
650
|
+
|
|
651
|
+
final_result: Any = None
|
|
652
|
+
tokens_emitted = 0
|
|
653
|
+
sinks: list[StreamSink] = []
|
|
654
|
+
rich_sink: RichSink | None = None
|
|
655
|
+
|
|
656
|
+
with live_cm as live:
|
|
657
|
+
rich_sink = self._build_rich_sink(
|
|
658
|
+
live=live,
|
|
659
|
+
formatter=formatter,
|
|
660
|
+
display_data=display_data,
|
|
661
|
+
agent_label=agent_label,
|
|
662
|
+
theme_dict=theme_dict,
|
|
663
|
+
styles=styles,
|
|
664
|
+
status_field=status_field,
|
|
665
|
+
signature_order=signature_order,
|
|
666
|
+
stream_buffers=stream_buffers,
|
|
667
|
+
timestamp_factory=timestamp_factory,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
ws_sink = self._build_websocket_sink(
|
|
671
|
+
ws_broadcast=ws_broadcast,
|
|
672
|
+
ctx=ctx,
|
|
673
|
+
agent=agent,
|
|
674
|
+
pre_generated_artifact_id=pre_generated_artifact_id,
|
|
675
|
+
artifact_type_name=artifact_type_name,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
sinks = self._collect_sinks(rich_sink=rich_sink, ws_sink=ws_sink)
|
|
679
|
+
final_result, tokens_emitted = await self._consume_stream(
|
|
680
|
+
stream_generator, sinks, dspy_mod
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
await self._flush_sinks(sinks)
|
|
684
|
+
|
|
685
|
+
if final_result is None:
|
|
686
|
+
raise RuntimeError("Streaming did not yield a final prediction.")
|
|
687
|
+
|
|
688
|
+
stream_display = self._finalize_stream_display(
|
|
689
|
+
rich_sink=rich_sink,
|
|
690
|
+
formatter=formatter,
|
|
691
|
+
display_data=display_data,
|
|
692
|
+
theme_dict=theme_dict,
|
|
693
|
+
styles=styles,
|
|
694
|
+
agent_label=agent_label,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
logger.info(
|
|
698
|
+
f"Agent {agent.name}: Rich streaming completed ({tokens_emitted} tokens)"
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
return final_result, stream_display
|
|
702
|
+
|
|
703
|
+
def prepare_stream_formatter(
|
|
704
|
+
self, agent: Any
|
|
705
|
+
) -> tuple[Any, dict[str, Any], dict[str, Any], str]:
|
|
706
|
+
"""Build formatter + theme metadata for streaming tables.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
agent: Agent instance
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
Tuple of (formatter, theme_dict, styles, agent_label)
|
|
713
|
+
"""
|
|
714
|
+
import pathlib
|
|
715
|
+
|
|
716
|
+
# Import model from local context since we're in a separate module
|
|
717
|
+
from flock.engines.dspy_engine import DSPyEngine
|
|
718
|
+
from flock.logging.formatters.themed_formatter import (
|
|
719
|
+
ThemedAgentResultFormatter,
|
|
720
|
+
create_pygments_syntax_theme,
|
|
721
|
+
get_default_styles,
|
|
722
|
+
load_syntax_theme_from_file,
|
|
723
|
+
load_theme_from_file,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
# Get themes directory relative to engine module
|
|
727
|
+
themes_dir = (
|
|
728
|
+
pathlib.Path(DSPyEngine.__module__.replace(".", "/")).parent.parent
|
|
729
|
+
/ "themes"
|
|
730
|
+
)
|
|
731
|
+
# Fallback: use __file__ if module path doesn't work
|
|
732
|
+
if not themes_dir.exists():
|
|
733
|
+
import flock.engines.dspy_engine as engine_mod
|
|
734
|
+
|
|
735
|
+
themes_dir = (
|
|
736
|
+
pathlib.Path(engine_mod.__file__).resolve().parents[1] / "themes"
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
theme_filename = self.theme
|
|
740
|
+
if not theme_filename.endswith(".toml"):
|
|
741
|
+
theme_filename = f"{theme_filename}.toml"
|
|
742
|
+
theme_path = themes_dir / theme_filename
|
|
743
|
+
|
|
744
|
+
try:
|
|
745
|
+
theme_dict = load_theme_from_file(theme_path)
|
|
746
|
+
except Exception:
|
|
747
|
+
fallback_path = themes_dir / "afterglow.toml"
|
|
748
|
+
theme_dict = load_theme_from_file(fallback_path)
|
|
749
|
+
theme_path = fallback_path
|
|
750
|
+
|
|
751
|
+
from flock.logging.formatters.themes import OutputTheme
|
|
752
|
+
|
|
753
|
+
formatter = ThemedAgentResultFormatter(theme=OutputTheme.afterglow)
|
|
754
|
+
styles = get_default_styles(theme_dict)
|
|
755
|
+
formatter.styles = styles
|
|
756
|
+
|
|
757
|
+
try:
|
|
758
|
+
syntax_theme = load_syntax_theme_from_file(theme_path)
|
|
759
|
+
formatter.syntax_style = create_pygments_syntax_theme(syntax_theme)
|
|
760
|
+
except Exception:
|
|
761
|
+
formatter.syntax_style = None
|
|
762
|
+
|
|
763
|
+
# Get model label from agent if available
|
|
764
|
+
model_label = getattr(agent, "engine", None)
|
|
765
|
+
if model_label and hasattr(model_label, "model"):
|
|
766
|
+
model_label = model_label.model or ""
|
|
767
|
+
else:
|
|
768
|
+
model_label = ""
|
|
769
|
+
|
|
770
|
+
agent_label = agent.name if not model_label else f"{agent.name} - {model_label}"
|
|
771
|
+
|
|
772
|
+
return formatter, theme_dict, styles, agent_label
|
|
773
|
+
|
|
774
|
+
def print_final_stream_display(
|
|
775
|
+
self,
|
|
776
|
+
stream_display_data: tuple[Any, OrderedDict, dict, dict, str],
|
|
777
|
+
artifact_id: str,
|
|
778
|
+
artifact,
|
|
779
|
+
) -> None:
|
|
780
|
+
"""Print the final streaming display with the real artifact ID.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
stream_display_data: Tuple of (formatter, display_data, theme_dict, styles, agent_label)
|
|
784
|
+
artifact_id: Final artifact ID
|
|
785
|
+
artifact: Artifact instance with metadata
|
|
786
|
+
"""
|
|
787
|
+
from rich.console import Console
|
|
788
|
+
|
|
789
|
+
formatter, display_data, theme_dict, styles, agent_label = stream_display_data
|
|
790
|
+
|
|
791
|
+
# Update display_data with the real artifact information
|
|
792
|
+
display_data["id"] = artifact_id
|
|
793
|
+
display_data["created_at"] = artifact.created_at.isoformat()
|
|
794
|
+
|
|
795
|
+
# Update all artifact metadata
|
|
796
|
+
display_data["correlation_id"] = (
|
|
797
|
+
str(artifact.correlation_id) if artifact.correlation_id else None
|
|
798
|
+
)
|
|
799
|
+
display_data["partition_key"] = artifact.partition_key
|
|
800
|
+
display_data["tags"] = (
|
|
801
|
+
"set()" if not artifact.tags else f"set({list(artifact.tags)})"
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
# Print the final panel
|
|
805
|
+
console = Console()
|
|
806
|
+
final_panel = formatter.format_result(
|
|
807
|
+
display_data, agent_label, theme_dict, styles
|
|
808
|
+
)
|
|
809
|
+
console.print(final_panel)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
__all__ = ["DSPyStreamingExecutor"]
|