flock-core 0.5.6__py3-none-any.whl → 0.5.8__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 CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import os
7
- from collections.abc import Sequence
7
+ from collections.abc import Callable, Sequence
8
8
  from dataclasses import dataclass
9
9
  from typing import TYPE_CHECKING, Any, TypedDict
10
10
 
@@ -64,6 +64,21 @@ class MCPServerConfig(TypedDict, total=False):
64
64
  class AgentOutput:
65
65
  spec: ArtifactSpec
66
66
  default_visibility: Visibility
67
+ count: int = 1 # Number of artifacts to generate (fan-out)
68
+ filter_predicate: Callable[[BaseModel], bool] | None = None # Where clause
69
+ validate_predicate: Callable[[BaseModel], bool] | list[tuple[Callable, str]] | None = (
70
+ None # Validation logic
71
+ )
72
+ group_description: str | None = None # Group description override
73
+
74
+ def __post_init__(self):
75
+ """Validate field constraints."""
76
+ if self.count < 1:
77
+ raise ValueError(f"count must be >= 1, got {self.count}")
78
+
79
+ def is_many(self) -> bool:
80
+ """Return True if this output generates multiple artifacts (count > 1)."""
81
+ return self.count > 1
67
82
 
68
83
  def apply(
69
84
  self,
@@ -85,6 +100,27 @@ class AgentOutput:
85
100
  )
86
101
 
87
102
 
103
+ @dataclass
104
+ class OutputGroup:
105
+ """Represents one .publishes() call.
106
+
107
+ Each OutputGroup triggers one engine execution that generates
108
+ all artifacts in the group together.
109
+ """
110
+
111
+ outputs: list[AgentOutput]
112
+ shared_visibility: Visibility | None = None
113
+ group_description: str | None = None # Group-level description override
114
+
115
+ def is_single_call(self) -> bool:
116
+ """True if this is one engine call generating multiple artifacts.
117
+
118
+ Currently always returns True as each group = one engine call.
119
+ Future: Could return False for parallel sub-groups.
120
+ """
121
+ return True
122
+
123
+
88
124
  class Agent(metaclass=AutoTracedMeta):
89
125
  """Executable agent constructed via `AgentBuilder`.
90
126
 
@@ -96,7 +132,7 @@ class Agent(metaclass=AutoTracedMeta):
96
132
  self.description: str | None = None
97
133
  self._orchestrator = orchestrator
98
134
  self.subscriptions: list[Subscription] = []
99
- self.outputs: list[AgentOutput] = []
135
+ self.output_groups: list[OutputGroup] = []
100
136
  self.utilities: list[AgentComponent] = []
101
137
  self.engines: list[EngineComponent] = []
102
138
  self.best_of_n: int = 1
@@ -115,6 +151,11 @@ class Agent(metaclass=AutoTracedMeta):
115
151
  self.mcp_server_mounts: dict[str, list[str]] = {} # Server-specific mount points
116
152
  self.tool_whitelist: list[str] | None = None
117
153
 
154
+ @property
155
+ def outputs(self) -> list[AgentOutput]:
156
+ """Backwards compatibility: return flat list of all outputs from all groups."""
157
+ return [output for group in self.output_groups for output in group.outputs]
158
+
118
159
  @property
119
160
  def identity(self) -> AgentIdentity:
120
161
  return AgentIdentity(name=self.name, labels=self.labels, tenant_id=self.tenant_id)
@@ -160,9 +201,40 @@ class Agent(metaclass=AutoTracedMeta):
160
201
  processed_inputs = await self._run_pre_consume(ctx, artifacts)
161
202
  eval_inputs = EvalInputs(artifacts=processed_inputs, state=dict(ctx.state))
162
203
  eval_inputs = await self._run_pre_evaluate(ctx, eval_inputs)
163
- result = await self._run_engines(ctx, eval_inputs)
164
- result = await self._run_post_evaluate(ctx, eval_inputs, result)
165
- outputs = await self._make_outputs(ctx, result)
204
+
205
+ # Phase 3: Call engine ONCE PER OutputGroup
206
+ all_outputs: list[Artifact] = []
207
+
208
+ if not self.output_groups:
209
+ # No output groups: Utility agents that don't publish
210
+ # Create empty OutputGroup for engines that may have side effects
211
+ empty_group = OutputGroup(outputs=[], group_description=None)
212
+ result = await self._run_engines(ctx, eval_inputs, empty_group)
213
+ # Run post_evaluate hooks for utility components (e.g., metrics)
214
+ result = await self._run_post_evaluate(ctx, eval_inputs, result)
215
+ # Utility agents return empty list (no outputs declared)
216
+ outputs = []
217
+ else:
218
+ # Loop over each output group
219
+ for group_idx, output_group in enumerate(self.output_groups):
220
+ # Prepare group-specific context
221
+ group_ctx = self._prepare_group_context(ctx, group_idx, output_group)
222
+
223
+ # Phase 7: Single evaluation path with auto-detection
224
+ # Engine's evaluate() auto-detects batch/fan-out from ctx and output_group
225
+ result = await self._run_engines(group_ctx, eval_inputs, output_group)
226
+
227
+ result = await self._run_post_evaluate(group_ctx, eval_inputs, result)
228
+
229
+ # Extract outputs for THIS group only
230
+ group_outputs = await self._make_outputs_for_group(
231
+ group_ctx, result, output_group
232
+ )
233
+
234
+ all_outputs.extend(group_outputs)
235
+
236
+ outputs = all_outputs
237
+
166
238
  await self._run_post_publish(ctx, outputs)
167
239
  if self.calls_func:
168
240
  await self._invoke_call(ctx, outputs or processed_inputs)
@@ -302,7 +374,19 @@ class Agent(metaclass=AutoTracedMeta):
302
374
  raise
303
375
  return current
304
376
 
305
- async def _run_engines(self, ctx: Context, inputs: EvalInputs) -> EvalResult:
377
+ async def _run_engines(
378
+ self, ctx: Context, inputs: EvalInputs, output_group: OutputGroup
379
+ ) -> EvalResult:
380
+ """Execute engines for a specific OutputGroup.
381
+
382
+ Args:
383
+ ctx: Execution context
384
+ inputs: EvalInputs with input artifacts
385
+ output_group: The OutputGroup defining what artifacts to produce
386
+
387
+ Returns:
388
+ EvalResult with artifacts matching output_group specifications
389
+ """
306
390
  engines = self._resolve_engines()
307
391
  if not engines:
308
392
  return EvalResult(artifacts=inputs.artifacts, state=inputs.state)
@@ -313,26 +397,10 @@ class Agent(metaclass=AutoTracedMeta):
313
397
  accumulated_metrics: dict[str, float] = {}
314
398
  for engine in engines:
315
399
  current_inputs = await engine.on_pre_evaluate(self, ctx, current_inputs)
316
- use_batch_mode = bool(getattr(ctx, "is_batch", False))
317
- try:
318
- if use_batch_mode:
319
- logger.debug(
320
- "Agent %s: routing %d artifacts to %s.evaluate_batch",
321
- self.name,
322
- len(current_inputs.artifacts),
323
- engine.__class__.__name__,
324
- )
325
- result = await engine.evaluate_batch(self, ctx, current_inputs)
326
- else:
327
- result = await engine.evaluate(self, ctx, current_inputs)
328
- except NotImplementedError:
329
- if use_batch_mode:
330
- logger.exception(
331
- "Agent %s: engine %s does not implement evaluate_batch()",
332
- self.name,
333
- engine.__class__.__name__,
334
- )
335
- raise
400
+
401
+ # Phase 7: Single evaluation path with auto-detection
402
+ # Engine's evaluate() auto-detects batching via ctx.is_batch
403
+ result = await engine.evaluate(self, ctx, current_inputs, output_group)
336
404
 
337
405
  # AUTO-WRAP: If engine returns BaseModel instead of EvalResult, wrap it
338
406
  from flock.runtime import EvalResult as ER
@@ -392,29 +460,177 @@ class Agent(metaclass=AutoTracedMeta):
392
460
  return current
393
461
 
394
462
  async def _make_outputs(self, ctx: Context, result: EvalResult) -> list[Artifact]:
395
- if not self.outputs:
463
+ if not self.output_groups:
396
464
  # Utility agents may not publish anything
397
465
  return list(result.artifacts)
398
466
 
399
467
  produced: list[Artifact] = []
400
- for output_decl in self.outputs:
401
- # Phase 6: Find the matching artifact from engine result to preserve its ID
402
- matching_artifact = self._find_matching_artifact(output_decl, result)
403
-
404
- payload = self._select_payload(output_decl, result)
405
- if payload is None:
406
- continue
407
- metadata = {
408
- "correlation_id": ctx.correlation_id,
409
- }
410
-
411
- # Phase 6: Preserve artifact ID from engine (for streaming message preview)
412
- if matching_artifact:
413
- metadata["artifact_id"] = matching_artifact.id
414
-
415
- artifact = output_decl.apply(payload, produced_by=self.name, metadata=metadata)
416
- produced.append(artifact)
417
- await ctx.board.publish(artifact)
468
+
469
+ # For Phase 2: Iterate ALL output_groups (even though we only have 1 engine call)
470
+ # Phase 3 will modify this to call engine once PER group
471
+ for output_group in self.output_groups:
472
+ for output_decl in output_group.outputs:
473
+ # Phase 6: Find the matching artifact from engine result to preserve its ID
474
+ matching_artifact = self._find_matching_artifact(output_decl, result)
475
+
476
+ payload = self._select_payload(output_decl, result)
477
+ if payload is None:
478
+ continue
479
+ metadata = {
480
+ "correlation_id": ctx.correlation_id,
481
+ }
482
+
483
+ # Phase 6: Preserve artifact ID from engine (for streaming message preview)
484
+ if matching_artifact:
485
+ metadata["artifact_id"] = matching_artifact.id
486
+
487
+ artifact = output_decl.apply(payload, produced_by=self.name, metadata=metadata)
488
+ produced.append(artifact)
489
+ await ctx.board.publish(artifact)
490
+
491
+ return produced
492
+
493
+ def _prepare_group_context(
494
+ self, ctx: Context, group_idx: int, output_group: OutputGroup
495
+ ) -> Context:
496
+ """Phase 3: Prepare context specific to this OutputGroup.
497
+
498
+ Creates a modified context for this group's engine call, potentially
499
+ with group-specific instructions or metadata.
500
+
501
+ Args:
502
+ ctx: Base context
503
+ group_idx: Index of this group (0-based)
504
+ output_group: The OutputGroup being processed
505
+
506
+ Returns:
507
+ Context for this group (may be the same instance or modified)
508
+ """
509
+ # For now, return the same context
510
+ # Phase 4 will add group-specific system prompts here
511
+ # Future: ctx.clone() and add group_description to system prompt
512
+ return ctx
513
+
514
+ async def _make_outputs_for_group(
515
+ self, ctx: Context, result: EvalResult, output_group: OutputGroup
516
+ ) -> list[Artifact]:
517
+ """Phase 3/5: Validate, filter, and publish artifacts for specific OutputGroup.
518
+
519
+ This function:
520
+ 1. Validates that the engine fulfilled its contract (produced expected count)
521
+ 2. Applies WHERE filtering (reduces artifacts, no error)
522
+ 3. Applies VALIDATE checks (raises ValueError if validation fails)
523
+ 4. Applies visibility (static or dynamic)
524
+ 5. Publishes artifacts to the board
525
+
526
+ Args:
527
+ ctx: Context for this group
528
+ result: EvalResult from engine for THIS group
529
+ output_group: OutputGroup defining expected outputs
530
+
531
+ Returns:
532
+ List of artifacts matching this group's outputs
533
+
534
+ Raises:
535
+ ValueError: If engine violated contract or validation failed
536
+ """
537
+ produced: list[Artifact] = []
538
+
539
+ for output_decl in output_group.outputs:
540
+ # 1. Find ALL matching artifacts for this type
541
+ from flock.registry import type_registry
542
+
543
+ expected_canonical = type_registry.resolve_name(output_decl.spec.type_name)
544
+
545
+ matching_artifacts: list[Artifact] = []
546
+ for artifact in result.artifacts:
547
+ try:
548
+ artifact_canonical = type_registry.resolve_name(artifact.type)
549
+ if artifact_canonical == expected_canonical:
550
+ matching_artifacts.append(artifact)
551
+ except Exception:
552
+ if artifact.type == output_decl.spec.type_name:
553
+ matching_artifacts.append(artifact)
554
+
555
+ # 2. STRICT VALIDATION: Engine must produce exactly what was promised
556
+ # (This happens BEFORE filtering so engine contract is validated first)
557
+ expected_count = output_decl.count
558
+ actual_count = len(matching_artifacts)
559
+
560
+ if actual_count != expected_count:
561
+ raise ValueError(
562
+ f"Engine contract violation in agent '{self.name}': "
563
+ f"Expected {expected_count} artifact(s) of type '{output_decl.spec.type_name}', "
564
+ f"but engine produced {actual_count}. "
565
+ f"Check your engine implementation to ensure it generates the correct number of outputs."
566
+ )
567
+
568
+ # 3. Apply WHERE filtering (Phase 5)
569
+ # Filtering reduces the number of published artifacts (this is intentional)
570
+ # NOTE: Predicates expect Pydantic model instances, not dicts
571
+ model_cls = type_registry.resolve(output_decl.spec.type_name)
572
+
573
+ if output_decl.filter_predicate:
574
+ original_count = len(matching_artifacts)
575
+ filtered = []
576
+ for a in matching_artifacts:
577
+ # Reconstruct Pydantic model from payload dict
578
+ model_instance = model_cls(**a.payload)
579
+ if output_decl.filter_predicate(model_instance):
580
+ filtered.append(a)
581
+ matching_artifacts = filtered
582
+ logger.debug(
583
+ f"Agent {self.name}: WHERE filter reduced artifacts from "
584
+ f"{original_count} to {len(matching_artifacts)} for type {output_decl.spec.type_name}"
585
+ )
586
+
587
+ # 4. Apply VALIDATE checks (Phase 5)
588
+ # Validation failures raise errors (fail-fast)
589
+ if output_decl.validate_predicate:
590
+ if callable(output_decl.validate_predicate):
591
+ # Single predicate
592
+ for artifact in matching_artifacts:
593
+ # Reconstruct Pydantic model from payload dict
594
+ model_instance = model_cls(**artifact.payload)
595
+ if not output_decl.validate_predicate(model_instance):
596
+ raise ValueError(
597
+ f"Validation failed for {output_decl.spec.type_name} "
598
+ f"in agent '{self.name}'"
599
+ )
600
+ elif isinstance(output_decl.validate_predicate, list):
601
+ # List of (callable, error_msg) tuples
602
+ for artifact in matching_artifacts:
603
+ # Reconstruct Pydantic model from payload dict
604
+ model_instance = model_cls(**artifact.payload)
605
+ for check, error_msg in output_decl.validate_predicate:
606
+ if not check(model_instance):
607
+ raise ValueError(f"{error_msg}: {output_decl.spec.type_name}")
608
+
609
+ # 5. Apply visibility and publish artifacts (Phase 5)
610
+ for artifact_from_engine in matching_artifacts:
611
+ metadata = {
612
+ "correlation_id": ctx.correlation_id,
613
+ "artifact_id": artifact_from_engine.id, # Preserve engine's ID
614
+ }
615
+
616
+ # Determine visibility (static or dynamic)
617
+ visibility = output_decl.default_visibility
618
+ if callable(visibility):
619
+ # Dynamic visibility based on artifact content
620
+ # Reconstruct Pydantic model from payload dict
621
+ model_instance = model_cls(**artifact_from_engine.payload)
622
+ visibility = visibility(model_instance)
623
+
624
+ # Override metadata visibility
625
+ metadata["visibility"] = visibility
626
+
627
+ # Re-wrap the artifact with agent metadata
628
+ artifact = output_decl.apply(
629
+ artifact_from_engine.payload, produced_by=self.name, metadata=metadata
630
+ )
631
+ produced.append(artifact)
632
+ await ctx.board.publish(artifact)
633
+
418
634
  return produced
419
635
 
420
636
  async def _run_post_publish(self, ctx: Context, artifacts: Sequence[Artifact]) -> None:
@@ -453,9 +669,16 @@ class Agent(metaclass=AutoTracedMeta):
453
669
  for component in self._sorted_utilities():
454
670
  comp_name = self._component_display_name(component)
455
671
  priority = getattr(component, "priority", 0)
672
+
673
+ # Python 3.12+ TaskGroup raises BaseExceptionGroup - extract sub-exceptions
674
+ error_detail = str(error)
675
+ if isinstance(error, BaseExceptionGroup):
676
+ sub_exceptions = [f"{type(e).__name__}: {e}" for e in error.exceptions]
677
+ error_detail = f"{error!s} - Sub-exceptions: {sub_exceptions}"
678
+
456
679
  logger.debug(
457
680
  f"Agent error hook: agent={self.name}, component={comp_name}, "
458
- f"priority={priority}, error={error!s}"
681
+ f"priority={priority}, error={error_detail}"
459
682
  )
460
683
  try:
461
684
  await component.on_error(self, ctx, error)
@@ -699,43 +922,34 @@ class AgentBuilder:
699
922
  return self
700
923
 
701
924
  def publishes(
702
- self, *types: type[BaseModel], visibility: Visibility | None = None
925
+ self,
926
+ *types: type[BaseModel],
927
+ visibility: Visibility | Callable[[BaseModel], Visibility] | None = None,
928
+ fan_out: int | None = None,
929
+ where: Callable[[BaseModel], bool] | None = None,
930
+ validate: Callable[[BaseModel], bool] | list[tuple[Callable, str]] | None = None,
931
+ description: str | None = None,
703
932
  ) -> PublishBuilder:
704
933
  """Declare which artifact types this agent produces.
705
934
 
706
- Configures the output types and default visibility controls for artifacts
707
- published by this agent. Can chain with .where() for conditional publishing.
708
-
709
935
  Args:
710
936
  *types: Artifact types (Pydantic models) to publish
711
- visibility: Default visibility control for all outputs. Defaults to PublicVisibility.
712
- Can be overridden per-publish or with .where() chaining.
937
+ visibility: Default visibility control OR callable for dynamic visibility
938
+ fan_out: Number of artifacts to publish (applies to ALL types)
939
+ where: Filter predicate for output artifacts
940
+ validate: Validation predicate(s) - callable or list of (callable, error_msg) tuples
941
+ description: Group-level description override
713
942
 
714
943
  Returns:
715
944
  PublishBuilder for conditional publishing configuration
716
945
 
717
946
  Examples:
718
- >>> # Basic output declaration
719
- >>> agent.publishes(Report)
720
-
721
- >>> # Multiple output types
722
- >>> agent.publishes(Summary, DetailedReport, Alert)
723
-
724
- >>> # Private outputs (only specific agents can see)
725
- >>> agent.publishes(
726
- ... SecretData,
727
- ... visibility=PrivateVisibility(agents={"admin", "auditor"})
728
- ... )
729
-
730
- >>> # Tenant-isolated outputs
731
- >>> agent.publishes(
732
- ... Invoice,
733
- ... visibility=TenantVisibility()
734
- ... )
735
-
736
- >>> # Conditional publishing with chaining
737
- >>> (agent.publishes(Alert)
738
- ... .where(lambda result: result.severity == "critical"))
947
+ >>> agent.publishes(Report) # Publish 1 Report
948
+ >>> agent.publishes(Task, Task, Task) # Publish 3 Tasks (duplicate counting)
949
+ >>> agent.publishes(Task, fan_out=3) # Same as above (sugar syntax)
950
+ >>> agent.publishes(Task, where=lambda t: t.priority > 5) # With filtering
951
+ >>> agent.publishes(Report, validate=lambda r: r.score > 0) # With validation
952
+ >>> agent.publishes(Task, description="Special instructions") # With description
739
953
 
740
954
  See Also:
741
955
  - PublicVisibility: Default, visible to all agents
@@ -743,14 +957,59 @@ class AgentBuilder:
743
957
  - TenantVisibility: Multi-tenant isolation
744
958
  - LabelledVisibility: Role-based access control
745
959
  """
746
- outputs = []
747
- for model in types:
748
- spec = ArtifactSpec.from_model(model)
749
- output = AgentOutput(spec=spec, default_visibility=ensure_visibility(visibility))
750
- self._agent.outputs.append(output)
751
- outputs.append(output)
752
- # T074: Validate configuration after adding outputs
960
+ # Validate fan_out if provided
961
+ if fan_out is not None and fan_out < 1:
962
+ raise ValueError(f"fan_out must be >= 1, got {fan_out}")
963
+
964
+ # Resolve visibility
965
+ resolved_visibility = (
966
+ ensure_visibility(visibility) if not callable(visibility) else visibility
967
+ )
968
+
969
+ # Create AgentOutput objects for this group
970
+ outputs: list[AgentOutput] = []
971
+
972
+ if fan_out is not None:
973
+ # Apply fan_out to ALL types
974
+ for model in types:
975
+ spec = ArtifactSpec.from_model(model)
976
+ output = AgentOutput(
977
+ spec=spec,
978
+ default_visibility=resolved_visibility,
979
+ count=fan_out,
980
+ filter_predicate=where,
981
+ validate_predicate=validate,
982
+ group_description=description,
983
+ )
984
+ outputs.append(output)
985
+ else:
986
+ # Create separate AgentOutput for each type (including duplicates)
987
+ # This preserves order: .publishes(A, B, A) → [A, B, A] (3 outputs)
988
+ for model in types:
989
+ spec = ArtifactSpec.from_model(model)
990
+ output = AgentOutput(
991
+ spec=spec,
992
+ default_visibility=resolved_visibility,
993
+ count=1,
994
+ filter_predicate=where,
995
+ validate_predicate=validate,
996
+ group_description=description,
997
+ )
998
+ outputs.append(output)
999
+
1000
+ # Create OutputGroup from outputs
1001
+ group = OutputGroup(
1002
+ outputs=outputs,
1003
+ shared_visibility=resolved_visibility if not callable(resolved_visibility) else None,
1004
+ group_description=description,
1005
+ )
1006
+
1007
+ # Append to agent's output_groups
1008
+ self._agent.output_groups.append(group)
1009
+
1010
+ # Validate configuration
753
1011
  self._validate_self_trigger_risk()
1012
+
754
1013
  return PublishBuilder(self, outputs)
755
1014
 
756
1015
  def with_utilities(self, *components: AgentComponent) -> AgentBuilder:
@@ -1081,7 +1340,9 @@ class AgentBuilder:
1081
1340
  consuming_types.update(sub.type_names)
1082
1341
 
1083
1342
  # Get types agent publishes
1084
- publishing_types = {output.spec.type_name for output in self._agent.outputs}
1343
+ publishing_types = {
1344
+ output.spec.type_name for group in self._agent.output_groups for output in group.outputs
1345
+ }
1085
1346
 
1086
1347
  # Check for overlap
1087
1348
  overlap = consuming_types.intersection(publishing_types)
@@ -1225,4 +1486,6 @@ class Pipeline:
1225
1486
  __all__ = [
1226
1487
  "Agent",
1227
1488
  "AgentBuilder",
1489
+ "AgentOutput",
1490
+ "OutputGroup",
1228
1491
  ]
flock/artifacts.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from datetime import datetime, timezone
5
+ from datetime import UTC, datetime
6
6
  from typing import Any
7
7
  from uuid import UUID, uuid4
8
8
 
@@ -23,7 +23,7 @@ class Artifact(BaseModel):
23
23
  partition_key: str | None = None
24
24
  tags: set[str] = Field(default_factory=set)
25
25
  visibility: Visibility = Field(default_factory=lambda: ensure_visibility(None))
26
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
26
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
27
27
  version: int = 1
28
28
 
29
29
  def model_dump_payload(self) -> dict[str, Any]: # pragma: no cover - convenience
flock/components.py CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any
5
+ from typing import TYPE_CHECKING, Any, Self
6
6
 
7
7
  from pydantic import BaseModel, Field, create_model
8
8
  from pydantic._internal._model_construction import ModelMetaclass
9
- from typing_extensions import Self, TypeVar
9
+ from typing_extensions import TypeVar
10
10
 
11
11
  from flock.logging.auto_trace import AutoTracedMeta
12
12
 
@@ -14,7 +14,7 @@ from flock.logging.auto_trace import AutoTracedMeta
14
14
  if TYPE_CHECKING: # pragma: no cover - type checking only
15
15
  from uuid import UUID
16
16
 
17
- from flock.agent import Agent
17
+ from flock.agent import Agent, OutputGroup
18
18
  from flock.artifacts import Artifact
19
19
  from flock.runtime import Context, EvalInputs, EvalResult
20
20
 
@@ -109,41 +109,49 @@ class EngineComponent(AgentComponent):
109
109
  default_factory=set, description="Artifact types to exclude from context"
110
110
  )
111
111
 
112
- async def evaluate(self, agent: Agent, ctx: Context, inputs: EvalInputs) -> EvalResult:
113
- """Override this method in your engine implementation."""
114
- raise NotImplementedError
112
+ async def evaluate(
113
+ self, agent: Agent, ctx: Context, inputs: EvalInputs, output_group: OutputGroup
114
+ ) -> EvalResult:
115
+ """Universal evaluation method with auto-detection of batch and fan-out modes.
115
116
 
116
- async def evaluate_batch(self, agent: Agent, ctx: Context, inputs: EvalInputs) -> EvalResult:
117
- """Process batch of accumulated artifacts (BatchSpec).
117
+ This single method handles ALL evaluation scenarios:
118
+ - Single artifact single output
119
+ - Batch processing (ctx.is_batch=True) → list[Type] signatures
120
+ - Fan-out (output_group.outputs[*].count > 1) → multiple artifacts
121
+ - Multi-output (len(output_group.outputs) > 1) → multiple types
118
122
 
119
- Override this method if your engine supports batch processing.
123
+ Auto-detection happens automatically:
124
+ - Batching: Detected via ctx.is_batch flag
125
+ - Fan-out: Detected via output_group.outputs[*].count
126
+ - Multi-input: Detected via len(inputs.artifacts)
127
+ - Multi-output: Detected via len(output_group.outputs)
120
128
 
121
129
  Args:
122
130
  agent: Agent instance executing this engine
123
- ctx: Execution context (ctx.is_batch will be True)
124
- inputs: EvalInputs with inputs.artifacts containing batch items
131
+ ctx: Execution context (check ctx.is_batch for batch mode)
132
+ inputs: EvalInputs with input artifacts
133
+ output_group: OutputGroup defining what artifacts to produce
134
+ (inspect outputs[*].count for fan-out detection)
125
135
 
126
136
  Returns:
127
- EvalResult with processed artifacts
128
-
129
- Raises:
130
- NotImplementedError: If engine doesn't support batching
131
-
132
- Example:
133
- >>> async def evaluate_batch(self, agent, ctx, inputs):
134
- ... events = inputs.all_as(Event) # Get ALL items
135
- ... results = await bulk_process(events)
136
- ... return EvalResult.from_objects(*results, agent=agent)
137
+ EvalResult with artifacts matching output_group specifications
138
+
139
+ Implementation Guide:
140
+ >>> async def evaluate(self, agent, ctx, inputs, output_group):
141
+ ... # Auto-detect batching from context
142
+ ... batched = bool(getattr(ctx, "is_batch", False))
143
+ ...
144
+ ... # Fan-out is auto-detected from output_group
145
+ ... # Your signature building should check:
146
+ ... # - output_group.outputs[i].count > 1 for fan-out
147
+ ... # - len(output_group.outputs) > 1 for multi-output
148
+ ...
149
+ ... # Build signature adapting to all modes
150
+ ... signature = self._build_signature(inputs, output_group, batched)
151
+ ... result = await self._execute(signature, inputs)
152
+ ... return EvalResult.from_objects(*result, agent=agent)
137
153
  """
138
- raise NotImplementedError(
139
- f"{self.__class__.__name__} does not support batch processing.\n\n"
140
- f"To fix this:\n"
141
- f"1. Remove BatchSpec from agent subscription, OR\n"
142
- f"2. Implement evaluate_batch() in {self.__class__.__name__}, OR\n"
143
- f"3. Use a batch-aware engine (e.g., CustomBatchEngine)\n\n"
144
- f"Agent: {agent.name}\n"
145
- f"Engine: {self.__class__.__name__}"
146
- )
154
+ raise NotImplementedError
147
155
 
148
156
  async def fetch_conversation_context(
149
157
  self,