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.

@@ -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
- return await self._evaluate_internal(agent, ctx, inputs, batched=False)
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
- async def evaluate_batch(self, agent, ctx, inputs: EvalInputs) -> EvalResult: # type: ignore[override]
159
- return await self._evaluate_internal(agent, ctx, inputs, batched=True)
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
- # Prepare signature with optional context field
199
- signature = self._prepare_signature_with_context(
231
+ # Generate signature with semantic field naming
232
+ signature = self._prepare_signature_for_output_group(
200
233
  dspy_mod,
201
- description=self.instructions or agent.description,
202
- input_schema=input_model,
203
- output_schema=output_model,
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 context
216
- if batched:
217
- execution_payload = {"input": validated_input}
218
- if has_context:
219
- execution_payload["context"] = context_history
220
- elif has_context:
221
- execution_payload = {
222
- "input": validated_input,
223
- "context": context_history,
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
- normalized_output = self._normalize_output_payload(getattr(raw_result, "output", None))
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
- agent.outputs,
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("TRELLIS_MODEL") or os.getenv("OPENAI_MODEL")
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 TRELLIS_MODEL, OPENAI_MODEL, or pass model=...)."
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
- try:
526
- instance = model_cls(**data)
527
- except Exception as exc: # noqa: BLE001 - collect validation errors for logs
528
- errors.append(str(exc))
529
- continue
530
-
531
- # Use the pre-generated ID if provided (for streaming), otherwise let Artifact auto-generate
532
- artifact_kwargs = {
533
- "type": output.spec.type_name,
534
- "payload": instance.model_dump(),
535
- "produced_by": produced_by,
536
- }
537
- if pre_generated_id is not None:
538
- artifact_kwargs["id"] = pre_generated_id
539
-
540
- artifacts.append(Artifact(**artifact_kwargs))
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 isinstance(candidate, Mapping):
556
- return dict(candidate)
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 new format: {"input": ..., "context": ...}
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
- if hasattr(agent, "outputs") and agent.outputs:
620
- artifact_type_name = agent.outputs[0].spec.type_name
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 "input" in payload:
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
- # Handle new format vs old format
836
- if isinstance(payload, dict) and "input" in payload:
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 - backwards compatible
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
- if hasattr(agent, "outputs") and agent.outputs:
862
- artifact_type_name = agent.outputs[0].spec.type_name
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
- # If the field is a BaseModel, unwrap it to dict
1117
- if isinstance(field_value, BaseModel):
1118
- payload_data.update(field_value.model_dump())
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, timezone
1623
+ from datetime import datetime
1128
1624
 
1129
- display_data["created_at"] = datetime.now(timezone.utc).isoformat()
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)