flock-core 0.5.7__py3-none-any.whl → 0.5.9__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/agent.py +336 -80
- flock/artifacts.py +2 -2
- flock/components.py +38 -30
- flock/correlation_engine.py +3 -6
- flock/dashboard/collector.py +9 -9
- flock/dashboard/events.py +8 -8
- flock/dashboard/service.py +7 -7
- flock/engines/dspy_engine.py +560 -64
- flock/engines/examples/simple_batch_engine.py +36 -20
- flock/examples.py +2 -2
- flock/helper/cli_helper.py +2 -2
- flock/logging/formatters/themed_formatter.py +3 -1
- flock/mcp/config.py +1 -2
- flock/mcp/tool.py +1 -2
- flock/orchestrator.py +2 -2
- flock/store.py +2 -2
- flock/utilities.py +1 -1
- flock/visibility.py +3 -3
- {flock_core-0.5.7.dist-info → flock_core-0.5.9.dist-info}/METADATA +99 -4
- {flock_core-0.5.7.dist-info → flock_core-0.5.9.dist-info}/RECORD +23 -23
- {flock_core-0.5.7.dist-info → flock_core-0.5.9.dist-info}/WHEEL +0 -0
- {flock_core-0.5.7.dist-info → flock_core-0.5.9.dist-info}/entry_points.txt +0 -0
- {flock_core-0.5.7.dist-info → flock_core-0.5.9.dist-info}/licenses/LICENSE +0 -0
flock/engines/dspy_engine.py
CHANGED
|
@@ -8,6 +8,7 @@ import os
|
|
|
8
8
|
from collections import OrderedDict, defaultdict
|
|
9
9
|
from collections.abc import Iterable, Mapping, Sequence
|
|
10
10
|
from contextlib import nullcontext
|
|
11
|
+
from datetime import UTC
|
|
11
12
|
from typing import Any, Literal
|
|
12
13
|
|
|
13
14
|
from pydantic import BaseModel, Field
|
|
@@ -152,11 +153,42 @@ class DSPyEngine(EngineComponent):
|
|
|
152
153
|
description="Enable caching of DSPy program results",
|
|
153
154
|
)
|
|
154
155
|
|
|
155
|
-
async def evaluate(self, agent, ctx, inputs: EvalInputs) -> EvalResult: # type: ignore[override]
|
|
156
|
-
|
|
156
|
+
async def evaluate(self, agent, ctx, inputs: EvalInputs, output_group) -> EvalResult: # type: ignore[override]
|
|
157
|
+
"""Universal evaluation with auto-detection of batch and fan-out modes.
|
|
158
|
+
|
|
159
|
+
This single method handles ALL evaluation scenarios by auto-detecting:
|
|
160
|
+
- Batching: Via ctx.is_batch flag (set by orchestrator for BatchSpec)
|
|
161
|
+
- Fan-out: Via output_group.outputs[*].count (signature building adapts)
|
|
162
|
+
- Multi-output: Via len(output_group.outputs) (multiple types in one call)
|
|
163
|
+
|
|
164
|
+
The signature building in _prepare_signature_for_output_group() automatically:
|
|
165
|
+
- Pluralizes field names for batching ("tasks" vs "task")
|
|
166
|
+
- Uses list[Type] for batching and fan-out
|
|
167
|
+
- Generates semantic field names for all modes
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
agent: Agent instance
|
|
171
|
+
ctx: Execution context (ctx.is_batch indicates batch mode)
|
|
172
|
+
inputs: EvalInputs with input artifacts
|
|
173
|
+
output_group: OutputGroup defining what artifacts to produce
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
EvalResult with artifacts matching output_group specifications
|
|
177
|
+
|
|
178
|
+
Examples:
|
|
179
|
+
Single: .publishes(Report) → {"report": Report}
|
|
180
|
+
Batch: BatchSpec(size=3) + ctx.is_batch=True → {"reports": list[Report]}
|
|
181
|
+
Fan-out: .publishes(Idea, fan_out=5) → {"ideas": list[Idea]}
|
|
182
|
+
Multi: .publishes(Summary, Analysis) → {"summary": Summary, "analysis": Analysis}
|
|
183
|
+
"""
|
|
184
|
+
# Auto-detect batching from context flag
|
|
185
|
+
batched = bool(getattr(ctx, "is_batch", False))
|
|
157
186
|
|
|
158
|
-
|
|
159
|
-
|
|
187
|
+
# Fan-out and multi-output detection happens automatically in signature building
|
|
188
|
+
# via output_group.outputs[*].count and len(output_group.outputs)
|
|
189
|
+
return await self._evaluate_internal(
|
|
190
|
+
agent, ctx, inputs, batched=batched, output_group=output_group
|
|
191
|
+
)
|
|
160
192
|
|
|
161
193
|
async def _evaluate_internal(
|
|
162
194
|
self,
|
|
@@ -165,6 +197,7 @@ class DSPyEngine(EngineComponent):
|
|
|
165
197
|
inputs: EvalInputs,
|
|
166
198
|
*,
|
|
167
199
|
batched: bool,
|
|
200
|
+
output_group=None,
|
|
168
201
|
) -> EvalResult:
|
|
169
202
|
if not inputs.artifacts:
|
|
170
203
|
return EvalResult(artifacts=[], state=dict(inputs.state))
|
|
@@ -195,12 +228,12 @@ class DSPyEngine(EngineComponent):
|
|
|
195
228
|
context_history = await self.fetch_conversation_context(ctx)
|
|
196
229
|
has_context = bool(context_history) and self.should_use_context(inputs)
|
|
197
230
|
|
|
198
|
-
#
|
|
199
|
-
signature = self.
|
|
231
|
+
# Generate signature with semantic field naming
|
|
232
|
+
signature = self._prepare_signature_for_output_group(
|
|
200
233
|
dspy_mod,
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
234
|
+
agent=agent,
|
|
235
|
+
inputs=inputs,
|
|
236
|
+
output_group=output_group,
|
|
204
237
|
has_context=has_context,
|
|
205
238
|
batched=batched,
|
|
206
239
|
)
|
|
@@ -212,19 +245,15 @@ class DSPyEngine(EngineComponent):
|
|
|
212
245
|
|
|
213
246
|
pre_generated_artifact_id = uuid4()
|
|
214
247
|
|
|
215
|
-
# Build execution payload with
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
else:
|
|
226
|
-
# Backwards compatible - direct input
|
|
227
|
-
execution_payload = validated_input
|
|
248
|
+
# Build execution payload with semantic field names matching signature
|
|
249
|
+
execution_payload = self._prepare_execution_payload_for_output_group(
|
|
250
|
+
inputs,
|
|
251
|
+
output_group,
|
|
252
|
+
batched=batched,
|
|
253
|
+
has_context=has_context,
|
|
254
|
+
context_history=context_history,
|
|
255
|
+
sys_desc=sys_desc,
|
|
256
|
+
)
|
|
228
257
|
|
|
229
258
|
# Merge native tools with MCP tools
|
|
230
259
|
native_tools = list(agent.tools or [])
|
|
@@ -292,6 +321,7 @@ class DSPyEngine(EngineComponent):
|
|
|
292
321
|
agent=agent,
|
|
293
322
|
ctx=ctx,
|
|
294
323
|
pre_generated_artifact_id=pre_generated_artifact_id,
|
|
324
|
+
output_group=output_group,
|
|
295
325
|
)
|
|
296
326
|
else:
|
|
297
327
|
# CLI mode: Rich streaming with terminal display
|
|
@@ -310,6 +340,7 @@ class DSPyEngine(EngineComponent):
|
|
|
310
340
|
agent=agent,
|
|
311
341
|
ctx=ctx,
|
|
312
342
|
pre_generated_artifact_id=pre_generated_artifact_id,
|
|
343
|
+
output_group=output_group,
|
|
313
344
|
)
|
|
314
345
|
if not self.no_output and ctx:
|
|
315
346
|
ctx.state["_flock_stream_live_active"] = True
|
|
@@ -331,13 +362,18 @@ class DSPyEngine(EngineComponent):
|
|
|
331
362
|
if orchestrator and hasattr(orchestrator, "_active_streams"):
|
|
332
363
|
orchestrator._active_streams = max(0, orchestrator._active_streams - 1)
|
|
333
364
|
|
|
334
|
-
|
|
365
|
+
# Extract semantic fields from Prediction
|
|
366
|
+
normalized_output = self._extract_multi_output_payload(raw_result, output_group)
|
|
367
|
+
|
|
335
368
|
artifacts, errors = self._materialize_artifacts(
|
|
336
369
|
normalized_output,
|
|
337
|
-
|
|
370
|
+
output_group.outputs,
|
|
338
371
|
agent.name,
|
|
339
372
|
pre_generated_id=pre_generated_artifact_id,
|
|
340
373
|
)
|
|
374
|
+
logger.info(f"[_materialize_artifacts] normalized_output {normalized_output}")
|
|
375
|
+
logger.info(f"[_materialize_artifacts] artifacts {artifacts}")
|
|
376
|
+
logger.info(f"[_materialize_artifacts] errors {errors}")
|
|
341
377
|
|
|
342
378
|
state = dict(inputs.state)
|
|
343
379
|
state.setdefault("dspy", {})
|
|
@@ -358,10 +394,10 @@ class DSPyEngine(EngineComponent):
|
|
|
358
394
|
# Helpers mirroring the design engine
|
|
359
395
|
|
|
360
396
|
def _resolve_model_name(self) -> str:
|
|
361
|
-
model = self.model or os.getenv("
|
|
397
|
+
model = self.model or os.getenv("DEFAULT_MODEL")
|
|
362
398
|
if not model:
|
|
363
399
|
raise NotImplementedError(
|
|
364
|
-
"DSPyEngine requires a configured model (set
|
|
400
|
+
"DSPyEngine requires a configured model (set DEFAULT_MODEL, or pass model=...)."
|
|
365
401
|
)
|
|
366
402
|
return model
|
|
367
403
|
|
|
@@ -399,6 +435,76 @@ class DSPyEngine(EngineComponent):
|
|
|
399
435
|
except Exception:
|
|
400
436
|
return data
|
|
401
437
|
|
|
438
|
+
def _type_to_field_name(self, type_class: type) -> str:
|
|
439
|
+
"""Convert Pydantic model class name to snake_case field name.
|
|
440
|
+
|
|
441
|
+
Examples:
|
|
442
|
+
Movie → "movie"
|
|
443
|
+
ResearchQuestion → "research_question"
|
|
444
|
+
APIResponse → "api_response"
|
|
445
|
+
UserAuthToken → "user_auth_token"
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
type_class: The Pydantic model class
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
snake_case field name
|
|
452
|
+
"""
|
|
453
|
+
import re
|
|
454
|
+
|
|
455
|
+
name = type_class.__name__
|
|
456
|
+
# Convert CamelCase to snake_case
|
|
457
|
+
snake_case = re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
458
|
+
return snake_case
|
|
459
|
+
|
|
460
|
+
def _pluralize(self, field_name: str) -> str:
|
|
461
|
+
"""Convert singular field name to plural for lists.
|
|
462
|
+
|
|
463
|
+
Examples:
|
|
464
|
+
"idea" → "ideas"
|
|
465
|
+
"movie" → "movies"
|
|
466
|
+
"story" → "stories" (y → ies)
|
|
467
|
+
"analysis" → "analyses" (is → es)
|
|
468
|
+
"research_question" → "research_questions"
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
field_name: Singular field name in snake_case
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
Pluralized field name
|
|
475
|
+
"""
|
|
476
|
+
# Simple English pluralization rules
|
|
477
|
+
if field_name.endswith("y") and len(field_name) > 1 and field_name[-2] not in "aeiou":
|
|
478
|
+
# story → stories (consonant + y)
|
|
479
|
+
return field_name[:-1] + "ies"
|
|
480
|
+
if field_name.endswith(("s", "x", "z", "ch", "sh")):
|
|
481
|
+
# analysis → analyses, box → boxes
|
|
482
|
+
return field_name + "es"
|
|
483
|
+
# idea → ideas, movie → movies
|
|
484
|
+
return field_name + "s"
|
|
485
|
+
|
|
486
|
+
def _needs_multioutput_signature(self, output_group) -> bool:
|
|
487
|
+
"""Determine if OutputGroup requires multi-output signature generation.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
output_group: OutputGroup to analyze
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
True if multi-output signature needed, False for single output (backward compat)
|
|
494
|
+
"""
|
|
495
|
+
if not output_group or not hasattr(output_group, "outputs") or not output_group.outputs:
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
# Multiple different types → multi-output
|
|
499
|
+
if len(output_group.outputs) > 1:
|
|
500
|
+
return True
|
|
501
|
+
|
|
502
|
+
# Fan-out (single type, count > 1) → multi-output
|
|
503
|
+
if output_group.outputs[0].count > 1:
|
|
504
|
+
return True
|
|
505
|
+
|
|
506
|
+
return False
|
|
507
|
+
|
|
402
508
|
def _prepare_signature_with_context(
|
|
403
509
|
self,
|
|
404
510
|
dspy_mod,
|
|
@@ -444,10 +550,292 @@ class DSPyEngine(EngineComponent):
|
|
|
444
550
|
" The 'input' field will contain a list of items representing the batch; "
|
|
445
551
|
"process the entire collection coherently."
|
|
446
552
|
)
|
|
447
|
-
instruction += " Return only JSON."
|
|
553
|
+
# instruction += " Return only JSON."
|
|
554
|
+
|
|
555
|
+
return signature.with_instructions(instruction)
|
|
556
|
+
|
|
557
|
+
def _prepare_signature_for_output_group(
|
|
558
|
+
self,
|
|
559
|
+
dspy_mod,
|
|
560
|
+
*,
|
|
561
|
+
agent,
|
|
562
|
+
inputs: EvalInputs,
|
|
563
|
+
output_group,
|
|
564
|
+
has_context: bool = False,
|
|
565
|
+
batched: bool = False,
|
|
566
|
+
) -> Any:
|
|
567
|
+
"""Prepare DSPy signature dynamically based on OutputGroup with semantic field names.
|
|
568
|
+
|
|
569
|
+
This method generates signatures using semantic field naming:
|
|
570
|
+
- Type names → snake_case field names (Task → "task", ResearchQuestion → "research_question")
|
|
571
|
+
- Pluralization for fan-out (Idea → "ideas" for lists)
|
|
572
|
+
- Pluralization for batching (Task → "tasks" for list[Task])
|
|
573
|
+
- Multi-input support for joins (multiple input artifacts with semantic names)
|
|
574
|
+
- Collision handling (same input/output type → prefix with "input_" or "output_")
|
|
575
|
+
|
|
576
|
+
Examples:
|
|
577
|
+
Single output: .consumes(Task).publishes(Report)
|
|
578
|
+
→ {"task": (Task, InputField()), "report": (Report, OutputField())}
|
|
579
|
+
|
|
580
|
+
Multiple inputs (joins): .consumes(Document, Guidelines).publishes(Report)
|
|
581
|
+
→ {"document": (Document, InputField()), "guidelines": (Guidelines, InputField()),
|
|
582
|
+
"report": (Report, OutputField())}
|
|
583
|
+
|
|
584
|
+
Multiple outputs: .consumes(Task).publishes(Summary, Analysis)
|
|
585
|
+
→ {"task": (Task, InputField()), "summary": (Summary, OutputField()),
|
|
586
|
+
"analysis": (Analysis, OutputField())}
|
|
587
|
+
|
|
588
|
+
Fan-out: .publishes(Idea, fan_out=5)
|
|
589
|
+
→ {"topic": (Topic, InputField()), "ideas": (list[Idea], OutputField(...))}
|
|
590
|
+
|
|
591
|
+
Batching: evaluate_batch([task1, task2, task3])
|
|
592
|
+
→ {"tasks": (list[Task], InputField()), "reports": (list[Report], OutputField())}
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
dspy_mod: DSPy module
|
|
596
|
+
agent: Agent instance
|
|
597
|
+
inputs: EvalInputs with input artifacts
|
|
598
|
+
output_group: OutputGroup defining what to generate
|
|
599
|
+
has_context: Whether conversation context should be included
|
|
600
|
+
batched: Whether this is a batch evaluation (pluralizes input fields)
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
DSPy Signature with semantic field names
|
|
604
|
+
"""
|
|
605
|
+
fields = {
|
|
606
|
+
"description": (str, dspy_mod.InputField()),
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
# Add context field if we have conversation history
|
|
610
|
+
if has_context:
|
|
611
|
+
fields["context"] = (
|
|
612
|
+
list,
|
|
613
|
+
dspy_mod.InputField(
|
|
614
|
+
desc="Previous conversation artifacts providing context for this request"
|
|
615
|
+
),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# Track used field names for collision detection
|
|
619
|
+
used_field_names: set[str] = {"description", "context"}
|
|
620
|
+
|
|
621
|
+
# 1. Generate INPUT fields with semantic names
|
|
622
|
+
# Multi-input support: handle all input artifacts for joins
|
|
623
|
+
# Batching support: pluralize field names and use list[Type] when batched=True
|
|
624
|
+
if inputs.artifacts:
|
|
625
|
+
# Collect unique input types (avoid duplicates if multiple artifacts of same type)
|
|
626
|
+
input_types_seen: dict[type, list[Artifact]] = {}
|
|
627
|
+
for artifact in inputs.artifacts:
|
|
628
|
+
input_model = self._resolve_input_model(artifact)
|
|
629
|
+
if input_model is not None:
|
|
630
|
+
if input_model not in input_types_seen:
|
|
631
|
+
input_types_seen[input_model] = []
|
|
632
|
+
input_types_seen[input_model].append(artifact)
|
|
633
|
+
|
|
634
|
+
# Generate fields for each unique input type
|
|
635
|
+
for input_model, artifacts_of_type in input_types_seen.items():
|
|
636
|
+
field_name = self._type_to_field_name(input_model)
|
|
637
|
+
|
|
638
|
+
# Handle batching: pluralize field name and use list[Type]
|
|
639
|
+
if batched:
|
|
640
|
+
field_name = self._pluralize(field_name)
|
|
641
|
+
input_type = list[input_model]
|
|
642
|
+
desc = f"Batch of {input_model.__name__} instances to process"
|
|
643
|
+
fields[field_name] = (input_type, dspy_mod.InputField(desc=desc))
|
|
644
|
+
else:
|
|
645
|
+
# Single input: use singular field name
|
|
646
|
+
input_type = input_model
|
|
647
|
+
fields[field_name] = (input_type, dspy_mod.InputField())
|
|
648
|
+
|
|
649
|
+
used_field_names.add(field_name)
|
|
650
|
+
|
|
651
|
+
# Fallback: if we couldn't resolve any types, use generic "input"
|
|
652
|
+
if not input_types_seen:
|
|
653
|
+
fields["input"] = (dict, dspy_mod.InputField())
|
|
654
|
+
used_field_names.add("input")
|
|
655
|
+
|
|
656
|
+
# 2. Generate OUTPUT fields with semantic names
|
|
657
|
+
for output_decl in output_group.outputs:
|
|
658
|
+
output_schema = output_decl.spec.model
|
|
659
|
+
type_name = output_decl.spec.type_name
|
|
660
|
+
|
|
661
|
+
# Generate semantic field name
|
|
662
|
+
field_name = self._type_to_field_name(output_schema)
|
|
663
|
+
|
|
664
|
+
# Handle fan-out: pluralize field name and use list[Type]
|
|
665
|
+
if output_decl.count > 1:
|
|
666
|
+
field_name = self._pluralize(field_name)
|
|
667
|
+
output_type = list[output_schema]
|
|
668
|
+
|
|
669
|
+
# Create description with count hint
|
|
670
|
+
desc = f"Generate exactly {output_decl.count} {type_name} instances"
|
|
671
|
+
if output_decl.group_description:
|
|
672
|
+
desc = f"{desc}. {output_decl.group_description}"
|
|
673
|
+
|
|
674
|
+
fields[field_name] = (output_type, dspy_mod.OutputField(desc=desc))
|
|
675
|
+
else:
|
|
676
|
+
# Single output
|
|
677
|
+
output_type = output_schema
|
|
678
|
+
|
|
679
|
+
# Handle collision: if field name already used, prefix with "output_"
|
|
680
|
+
if field_name in used_field_names:
|
|
681
|
+
field_name = f"output_{field_name}"
|
|
682
|
+
|
|
683
|
+
desc = f"{type_name} output"
|
|
684
|
+
if output_decl.group_description:
|
|
685
|
+
desc = output_decl.group_description
|
|
686
|
+
|
|
687
|
+
fields[field_name] = (output_type, dspy_mod.OutputField(desc=desc))
|
|
688
|
+
|
|
689
|
+
used_field_names.add(field_name)
|
|
690
|
+
|
|
691
|
+
# 3. Create signature
|
|
692
|
+
signature = dspy_mod.Signature(fields)
|
|
693
|
+
|
|
694
|
+
# 4. Build instruction
|
|
695
|
+
description = self.instructions or agent.description
|
|
696
|
+
instruction = (
|
|
697
|
+
description or f"Process input and generate {len(output_group.outputs)} outputs."
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
if has_context:
|
|
701
|
+
instruction += " Consider the conversation context provided to inform your response."
|
|
702
|
+
|
|
703
|
+
# Add batching hint
|
|
704
|
+
if batched:
|
|
705
|
+
instruction += (
|
|
706
|
+
" Process the batch of inputs coherently, generating outputs for each item."
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# Add semantic field names to instruction for clarity
|
|
710
|
+
output_field_names = [
|
|
711
|
+
name for name in fields.keys() if name not in {"description", "context"}
|
|
712
|
+
]
|
|
713
|
+
if len(output_field_names) > 2: # Multiple outputs
|
|
714
|
+
instruction += (
|
|
715
|
+
f" Generate ALL output fields as specified: {', '.join(output_field_names[1:])}."
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# instruction += " Return only valid JSON."
|
|
448
719
|
|
|
449
720
|
return signature.with_instructions(instruction)
|
|
450
721
|
|
|
722
|
+
def _prepare_execution_payload_for_output_group(
|
|
723
|
+
self,
|
|
724
|
+
inputs: EvalInputs,
|
|
725
|
+
output_group,
|
|
726
|
+
*,
|
|
727
|
+
batched: bool,
|
|
728
|
+
has_context: bool,
|
|
729
|
+
context_history: list | None,
|
|
730
|
+
sys_desc: str,
|
|
731
|
+
) -> dict[str, Any]:
|
|
732
|
+
"""Prepare execution payload with semantic field names matching signature.
|
|
733
|
+
|
|
734
|
+
This method builds a payload dict with semantic field names that match the signature
|
|
735
|
+
generated by `_prepare_signature_for_output_group()`.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
inputs: EvalInputs with input artifacts
|
|
739
|
+
output_group: OutputGroup (not used here but kept for symmetry)
|
|
740
|
+
batched: Whether this is a batch evaluation
|
|
741
|
+
has_context: Whether conversation context should be included
|
|
742
|
+
context_history: Optional conversation history
|
|
743
|
+
sys_desc: System description for the "description" field
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
Dict with semantic field names ready for DSPy program execution
|
|
747
|
+
|
|
748
|
+
Examples:
|
|
749
|
+
Single input: {"description": desc, "task": {...}}
|
|
750
|
+
Multi-input: {"description": desc, "task": {...}, "topic": {...}}
|
|
751
|
+
Batched: {"description": desc, "tasks": [{...}, {...}, {...}]}
|
|
752
|
+
"""
|
|
753
|
+
payload = {"description": sys_desc}
|
|
754
|
+
|
|
755
|
+
# Add context if present
|
|
756
|
+
if has_context and context_history:
|
|
757
|
+
payload["context"] = context_history
|
|
758
|
+
|
|
759
|
+
# Build semantic input fields
|
|
760
|
+
if inputs.artifacts:
|
|
761
|
+
# Collect unique input types (same logic as signature generation)
|
|
762
|
+
input_types_seen: dict[type, list[Artifact]] = {}
|
|
763
|
+
for artifact in inputs.artifacts:
|
|
764
|
+
input_model = self._resolve_input_model(artifact)
|
|
765
|
+
if input_model is not None:
|
|
766
|
+
if input_model not in input_types_seen:
|
|
767
|
+
input_types_seen[input_model] = []
|
|
768
|
+
input_types_seen[input_model].append(artifact)
|
|
769
|
+
|
|
770
|
+
# Generate payload fields for each unique input type
|
|
771
|
+
for input_model, artifacts_of_type in input_types_seen.items():
|
|
772
|
+
field_name = self._type_to_field_name(input_model)
|
|
773
|
+
|
|
774
|
+
# Validate and prepare payloads
|
|
775
|
+
validated_payloads = [
|
|
776
|
+
self._validate_input_payload(input_model, art.payload)
|
|
777
|
+
for art in artifacts_of_type
|
|
778
|
+
]
|
|
779
|
+
|
|
780
|
+
if batched:
|
|
781
|
+
# Batch mode: pluralize field name and use list
|
|
782
|
+
field_name = self._pluralize(field_name)
|
|
783
|
+
payload[field_name] = validated_payloads
|
|
784
|
+
else:
|
|
785
|
+
# Single mode: use first (or only) artifact
|
|
786
|
+
# For multi-input joins, we have one artifact per type
|
|
787
|
+
payload[field_name] = validated_payloads[0] if validated_payloads else {}
|
|
788
|
+
|
|
789
|
+
return payload
|
|
790
|
+
|
|
791
|
+
def _extract_multi_output_payload(self, prediction, output_group) -> dict[str, Any]:
|
|
792
|
+
"""Extract semantic fields from DSPy Prediction for multi-output scenarios.
|
|
793
|
+
|
|
794
|
+
Maps semantic field names (e.g., "movie", "ideas") back to type names (e.g., "Movie", "Idea")
|
|
795
|
+
for artifact materialization compatibility.
|
|
796
|
+
|
|
797
|
+
Args:
|
|
798
|
+
prediction: DSPy Prediction object with semantic field names
|
|
799
|
+
output_group: OutputGroup defining expected outputs
|
|
800
|
+
|
|
801
|
+
Returns:
|
|
802
|
+
Dict mapping type names to extracted values
|
|
803
|
+
|
|
804
|
+
Examples:
|
|
805
|
+
Prediction(movie={...}, summary={...})
|
|
806
|
+
→ {"Movie": {...}, "Summary": {...}}
|
|
807
|
+
|
|
808
|
+
Prediction(ideas=[{...}, {...}, {...}])
|
|
809
|
+
→ {"Idea": [{...}, {...}, {...}]}
|
|
810
|
+
"""
|
|
811
|
+
payload = {}
|
|
812
|
+
|
|
813
|
+
for output_decl in output_group.outputs:
|
|
814
|
+
output_schema = output_decl.spec.model
|
|
815
|
+
type_name = output_decl.spec.type_name
|
|
816
|
+
|
|
817
|
+
# Generate the same semantic field name used in signature
|
|
818
|
+
field_name = self._type_to_field_name(output_schema)
|
|
819
|
+
|
|
820
|
+
# Handle fan-out: field name is pluralized
|
|
821
|
+
if output_decl.count > 1:
|
|
822
|
+
field_name = self._pluralize(field_name)
|
|
823
|
+
|
|
824
|
+
# Extract value from Prediction
|
|
825
|
+
if hasattr(prediction, field_name):
|
|
826
|
+
value = getattr(prediction, field_name)
|
|
827
|
+
|
|
828
|
+
# Store using type_name as key (for _select_output_payload compatibility)
|
|
829
|
+
payload[type_name] = value
|
|
830
|
+
else:
|
|
831
|
+
# Fallback: try with "output_" prefix (collision handling)
|
|
832
|
+
prefixed_name = f"output_{field_name}"
|
|
833
|
+
if hasattr(prediction, prefixed_name):
|
|
834
|
+
value = getattr(prediction, prefixed_name)
|
|
835
|
+
payload[type_name] = value
|
|
836
|
+
|
|
837
|
+
return payload
|
|
838
|
+
|
|
451
839
|
def _choose_program(self, dspy_mod, signature, tools: Iterable[Any]):
|
|
452
840
|
tools_list = list(tools or [])
|
|
453
841
|
try:
|
|
@@ -460,7 +848,7 @@ class DSPyEngine(EngineComponent):
|
|
|
460
848
|
def _system_description(self, description: str | None) -> str:
|
|
461
849
|
if description:
|
|
462
850
|
return description
|
|
463
|
-
return "Produce a valid output that matches the 'output' schema. Return only JSON.
|
|
851
|
+
return "Produce a valid output that matches the 'output' schema." # Return only JSON.
|
|
464
852
|
|
|
465
853
|
def _normalize_output_payload(self, raw: Any) -> dict[str, Any]:
|
|
466
854
|
if isinstance(raw, BaseModel):
|
|
@@ -517,27 +905,68 @@ class DSPyEngine(EngineComponent):
|
|
|
517
905
|
produced_by: str,
|
|
518
906
|
pre_generated_id: Any = None,
|
|
519
907
|
):
|
|
908
|
+
"""Materialize artifacts from payload, handling fan-out (count > 1).
|
|
909
|
+
|
|
910
|
+
For fan-out outputs (count > 1), splits the list into individual artifacts.
|
|
911
|
+
For single outputs (count = 1), creates one artifact from dict.
|
|
912
|
+
|
|
913
|
+
Args:
|
|
914
|
+
payload: Normalized output dict from DSPy
|
|
915
|
+
outputs: AgentOutput declarations defining what to create
|
|
916
|
+
produced_by: Agent name
|
|
917
|
+
pre_generated_id: Pre-generated ID for streaming (only used for single outputs)
|
|
918
|
+
|
|
919
|
+
Returns:
|
|
920
|
+
Tuple of (artifacts list, errors list)
|
|
921
|
+
"""
|
|
520
922
|
artifacts: list[Artifact] = []
|
|
521
923
|
errors: list[str] = []
|
|
522
924
|
for output in outputs or []:
|
|
523
925
|
model_cls = output.spec.model
|
|
524
926
|
data = self._select_output_payload(payload, model_cls, output.spec.type_name)
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
927
|
+
|
|
928
|
+
# FAN-OUT: If count > 1, data should be a list and we create multiple artifacts
|
|
929
|
+
if output.count > 1:
|
|
930
|
+
if not isinstance(data, list):
|
|
931
|
+
errors.append(
|
|
932
|
+
f"Fan-out expected list for {output.spec.type_name} (count={output.count}), "
|
|
933
|
+
f"got {type(data).__name__}"
|
|
934
|
+
)
|
|
935
|
+
continue
|
|
936
|
+
|
|
937
|
+
# Create one artifact for each item in the list
|
|
938
|
+
for item_data in data:
|
|
939
|
+
try:
|
|
940
|
+
instance = model_cls(**item_data)
|
|
941
|
+
except Exception as exc: # noqa: BLE001 - collect validation errors for logs
|
|
942
|
+
errors.append(f"{output.spec.type_name}: {exc!s}")
|
|
943
|
+
continue
|
|
944
|
+
|
|
945
|
+
# Fan-out artifacts auto-generate their IDs (can't reuse pre_generated_id)
|
|
946
|
+
artifact_kwargs = {
|
|
947
|
+
"type": output.spec.type_name,
|
|
948
|
+
"payload": instance.model_dump(),
|
|
949
|
+
"produced_by": produced_by,
|
|
950
|
+
}
|
|
951
|
+
artifacts.append(Artifact(**artifact_kwargs))
|
|
952
|
+
else:
|
|
953
|
+
# SINGLE OUTPUT: Create one artifact from dict
|
|
954
|
+
try:
|
|
955
|
+
instance = model_cls(**data)
|
|
956
|
+
except Exception as exc: # noqa: BLE001 - collect validation errors for logs
|
|
957
|
+
errors.append(str(exc))
|
|
958
|
+
continue
|
|
959
|
+
|
|
960
|
+
# Use the pre-generated ID if provided (for streaming), otherwise let Artifact auto-generate
|
|
961
|
+
artifact_kwargs = {
|
|
962
|
+
"type": output.spec.type_name,
|
|
963
|
+
"payload": instance.model_dump(),
|
|
964
|
+
"produced_by": produced_by,
|
|
965
|
+
}
|
|
966
|
+
if pre_generated_id is not None:
|
|
967
|
+
artifact_kwargs["id"] = pre_generated_id
|
|
968
|
+
|
|
969
|
+
artifacts.append(Artifact(**artifact_kwargs))
|
|
541
970
|
return artifacts, errors
|
|
542
971
|
|
|
543
972
|
def _select_output_payload(
|
|
@@ -545,15 +974,36 @@ class DSPyEngine(EngineComponent):
|
|
|
545
974
|
payload: Mapping[str, Any],
|
|
546
975
|
model_cls: type[BaseModel],
|
|
547
976
|
type_name: str,
|
|
548
|
-
) -> dict[str, Any]:
|
|
977
|
+
) -> dict[str, Any] | list[dict[str, Any]]:
|
|
978
|
+
"""Select the correct output payload from the normalized output dict.
|
|
979
|
+
|
|
980
|
+
Handles both simple type names and fully qualified names (with module prefix).
|
|
981
|
+
Returns either a dict (single output) or list[dict] (fan-out/batch).
|
|
982
|
+
"""
|
|
549
983
|
candidates = [
|
|
550
|
-
payload.get(type_name),
|
|
551
|
-
payload.get(model_cls.__name__),
|
|
552
|
-
payload.get(model_cls.__name__.lower()),
|
|
984
|
+
payload.get(type_name), # Try exact type_name (may be "__main__.Movie")
|
|
985
|
+
payload.get(model_cls.__name__), # Try simple class name ("Movie")
|
|
986
|
+
payload.get(model_cls.__name__.lower()), # Try lowercase ("movie")
|
|
553
987
|
]
|
|
988
|
+
|
|
989
|
+
# Extract value based on type
|
|
554
990
|
for candidate in candidates:
|
|
555
|
-
if
|
|
556
|
-
|
|
991
|
+
if candidate is not None:
|
|
992
|
+
# Handle lists (fan-out and batching)
|
|
993
|
+
if isinstance(candidate, list):
|
|
994
|
+
# Convert Pydantic instances to dicts
|
|
995
|
+
return [
|
|
996
|
+
item.model_dump() if isinstance(item, BaseModel) else item
|
|
997
|
+
for item in candidate
|
|
998
|
+
]
|
|
999
|
+
# Handle single Pydantic instance
|
|
1000
|
+
if isinstance(candidate, BaseModel):
|
|
1001
|
+
return candidate.model_dump()
|
|
1002
|
+
# Handle dict
|
|
1003
|
+
if isinstance(candidate, Mapping):
|
|
1004
|
+
return dict(candidate)
|
|
1005
|
+
|
|
1006
|
+
# Fallback: return entire payload (will likely fail validation)
|
|
557
1007
|
if isinstance(payload, Mapping):
|
|
558
1008
|
return dict(payload)
|
|
559
1009
|
return {}
|
|
@@ -562,7 +1012,12 @@ class DSPyEngine(EngineComponent):
|
|
|
562
1012
|
self, dspy_mod, program, *, description: str, payload: dict[str, Any]
|
|
563
1013
|
) -> Any:
|
|
564
1014
|
"""Execute DSPy program in standard mode (no streaming)."""
|
|
565
|
-
# Handle
|
|
1015
|
+
# Handle semantic fields format: {"description": ..., "task": ..., "report": ...}
|
|
1016
|
+
if isinstance(payload, dict) and "description" in payload:
|
|
1017
|
+
# Semantic fields: pass all fields as kwargs
|
|
1018
|
+
return program(**payload)
|
|
1019
|
+
|
|
1020
|
+
# Handle legacy format: {"input": ..., "context": ...}
|
|
566
1021
|
if isinstance(payload, dict) and "input" in payload:
|
|
567
1022
|
return program(
|
|
568
1023
|
description=description,
|
|
@@ -584,6 +1039,7 @@ class DSPyEngine(EngineComponent):
|
|
|
584
1039
|
agent: Any,
|
|
585
1040
|
ctx: Any = None,
|
|
586
1041
|
pre_generated_artifact_id: Any = None,
|
|
1042
|
+
output_group=None,
|
|
587
1043
|
) -> tuple[Any, None]:
|
|
588
1044
|
"""Execute streaming for WebSocket only (no Rich display).
|
|
589
1045
|
|
|
@@ -616,8 +1072,17 @@ class DSPyEngine(EngineComponent):
|
|
|
616
1072
|
|
|
617
1073
|
# Get artifact type name for WebSocket events
|
|
618
1074
|
artifact_type_name = "output"
|
|
619
|
-
|
|
620
|
-
|
|
1075
|
+
# Use output_group.outputs (current group) if available, otherwise fallback to agent.outputs (all groups)
|
|
1076
|
+
outputs_to_display = (
|
|
1077
|
+
output_group.outputs
|
|
1078
|
+
if output_group and hasattr(output_group, "outputs")
|
|
1079
|
+
else agent.outputs
|
|
1080
|
+
if hasattr(agent, "outputs")
|
|
1081
|
+
else []
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
if outputs_to_display:
|
|
1085
|
+
artifact_type_name = outputs_to_display[0].spec.type_name
|
|
621
1086
|
|
|
622
1087
|
# Prepare stream listeners
|
|
623
1088
|
listeners = []
|
|
@@ -638,13 +1103,18 @@ class DSPyEngine(EngineComponent):
|
|
|
638
1103
|
)
|
|
639
1104
|
|
|
640
1105
|
# Execute with appropriate payload format
|
|
641
|
-
if isinstance(payload, dict) and "
|
|
1106
|
+
if isinstance(payload, dict) and "description" in payload:
|
|
1107
|
+
# Semantic fields: pass all fields as kwargs
|
|
1108
|
+
stream_generator = streaming_task(**payload)
|
|
1109
|
+
elif isinstance(payload, dict) and "input" in payload:
|
|
1110
|
+
# Legacy format: {"input": ..., "context": ...}
|
|
642
1111
|
stream_generator = streaming_task(
|
|
643
1112
|
description=description,
|
|
644
1113
|
input=payload["input"],
|
|
645
1114
|
context=payload.get("context", []),
|
|
646
1115
|
)
|
|
647
1116
|
else:
|
|
1117
|
+
# Old format: direct payload
|
|
648
1118
|
stream_generator = streaming_task(description=description, input=payload, context=[])
|
|
649
1119
|
|
|
650
1120
|
# Process stream (WebSocket only, no Rich display)
|
|
@@ -799,6 +1269,7 @@ class DSPyEngine(EngineComponent):
|
|
|
799
1269
|
agent: Any,
|
|
800
1270
|
ctx: Any = None,
|
|
801
1271
|
pre_generated_artifact_id: Any = None,
|
|
1272
|
+
output_group=None,
|
|
802
1273
|
) -> Any:
|
|
803
1274
|
"""Execute DSPy program in streaming mode with Rich table updates."""
|
|
804
1275
|
from rich.console import Console
|
|
@@ -832,15 +1303,19 @@ class DSPyEngine(EngineComponent):
|
|
|
832
1303
|
stream_listeners=listeners if listeners else None,
|
|
833
1304
|
)
|
|
834
1305
|
|
|
835
|
-
#
|
|
836
|
-
if isinstance(payload, dict) and "
|
|
1306
|
+
# Execute with appropriate payload format
|
|
1307
|
+
if isinstance(payload, dict) and "description" in payload:
|
|
1308
|
+
# Semantic fields: pass all fields as kwargs
|
|
1309
|
+
stream_generator = streaming_task(**payload)
|
|
1310
|
+
elif isinstance(payload, dict) and "input" in payload:
|
|
1311
|
+
# Legacy format: {"input": ..., "context": ...}
|
|
837
1312
|
stream_generator = streaming_task(
|
|
838
1313
|
description=description,
|
|
839
1314
|
input=payload["input"],
|
|
840
1315
|
context=payload.get("context", []),
|
|
841
1316
|
)
|
|
842
1317
|
else:
|
|
843
|
-
# Old format
|
|
1318
|
+
# Old format: direct payload
|
|
844
1319
|
stream_generator = streaming_task(description=description, input=payload, context=[])
|
|
845
1320
|
|
|
846
1321
|
signature_order = []
|
|
@@ -858,8 +1333,20 @@ class DSPyEngine(EngineComponent):
|
|
|
858
1333
|
|
|
859
1334
|
# Get the artifact type name from agent configuration
|
|
860
1335
|
artifact_type_name = "output"
|
|
861
|
-
|
|
862
|
-
|
|
1336
|
+
# Use output_group.outputs (current group) if available, otherwise fallback to agent.outputs (all groups)
|
|
1337
|
+
outputs_to_display = (
|
|
1338
|
+
output_group.outputs
|
|
1339
|
+
if output_group and hasattr(output_group, "outputs")
|
|
1340
|
+
else agent.outputs
|
|
1341
|
+
if hasattr(agent, "outputs")
|
|
1342
|
+
else []
|
|
1343
|
+
)
|
|
1344
|
+
|
|
1345
|
+
if outputs_to_display:
|
|
1346
|
+
artifact_type_name = outputs_to_display[0].spec.type_name
|
|
1347
|
+
for output in outputs_to_display:
|
|
1348
|
+
if output.spec.type_name not in artifact_type_name:
|
|
1349
|
+
artifact_type_name += ", " + output.spec.type_name
|
|
863
1350
|
|
|
864
1351
|
display_data["type"] = artifact_type_name
|
|
865
1352
|
display_data["payload"] = OrderedDict()
|
|
@@ -1113,10 +1600,19 @@ class DSPyEngine(EngineComponent):
|
|
|
1113
1600
|
for field_name in signature_order:
|
|
1114
1601
|
if field_name != "description" and hasattr(final_result, field_name):
|
|
1115
1602
|
field_value = getattr(final_result, field_name)
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1603
|
+
|
|
1604
|
+
# Convert BaseModel instances to dicts for proper table rendering
|
|
1605
|
+
if isinstance(field_value, list):
|
|
1606
|
+
# Handle lists of BaseModel instances (fan-out/batch)
|
|
1607
|
+
payload_data[field_name] = [
|
|
1608
|
+
item.model_dump() if isinstance(item, BaseModel) else item
|
|
1609
|
+
for item in field_value
|
|
1610
|
+
]
|
|
1611
|
+
elif isinstance(field_value, BaseModel):
|
|
1612
|
+
# Handle single BaseModel instance
|
|
1613
|
+
payload_data[field_name] = field_value.model_dump()
|
|
1119
1614
|
else:
|
|
1615
|
+
# Handle primitive types
|
|
1120
1616
|
payload_data[field_name] = field_value
|
|
1121
1617
|
|
|
1122
1618
|
# Update all fields with actual values
|
|
@@ -1124,9 +1620,9 @@ class DSPyEngine(EngineComponent):
|
|
|
1124
1620
|
display_data["payload"].update(payload_data)
|
|
1125
1621
|
|
|
1126
1622
|
# Update timestamp
|
|
1127
|
-
from datetime import datetime
|
|
1623
|
+
from datetime import datetime
|
|
1128
1624
|
|
|
1129
|
-
display_data["created_at"] = datetime.now(
|
|
1625
|
+
display_data["created_at"] = datetime.now(UTC).isoformat()
|
|
1130
1626
|
|
|
1131
1627
|
# Remove status field from display
|
|
1132
1628
|
display_data.pop("status", None)
|