flock-core 0.5.4__py3-none-any.whl → 0.5.6__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.

@@ -0,0 +1,686 @@
1
+ """OrchestratorComponent base class and supporting types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from flock.components import TracedModelMeta
13
+ from flock.logging.logging import get_logger
14
+
15
+
16
+ if TYPE_CHECKING:
17
+ from flock.agent import Agent
18
+ from flock.artifacts import Artifact
19
+ from flock.orchestrator import Flock
20
+ from flock.subscription import Subscription
21
+
22
+ # Initialize logger for components
23
+ logger = get_logger("flock.component")
24
+
25
+
26
+ class ScheduleDecision(str, Enum):
27
+ """Decision returned by on_before_schedule hook.
28
+
29
+ Determines whether to proceed with agent scheduling after subscription match.
30
+
31
+ Attributes:
32
+ CONTINUE: Proceed with artifact collection and scheduling
33
+ SKIP: Skip this agent/subscription (not an error, just filtered out)
34
+ DEFER: Defer scheduling for later (used by batching/correlation)
35
+
36
+ Examples:
37
+ >>> # Circuit breaker that skips after limit
38
+ >>> if iteration_count >= max_iterations:
39
+ ... return ScheduleDecision.SKIP
40
+
41
+ >>> # Normal case: proceed
42
+ >>> return ScheduleDecision.CONTINUE
43
+ """
44
+
45
+ CONTINUE = "CONTINUE" # Proceed with scheduling
46
+ SKIP = "SKIP" # Skip this subscription (not an error)
47
+ DEFER = "DEFER" # Defer until later (e.g., waiting for AND gate)
48
+
49
+
50
+ @dataclass
51
+ class CollectionResult:
52
+ """Result from on_collect_artifacts hook.
53
+
54
+ Indicates whether artifact collection is complete and which artifacts
55
+ should be passed to the agent for execution.
56
+
57
+ Attributes:
58
+ artifacts: List of artifacts collected for this subscription
59
+ complete: True if collection is complete and agent should be scheduled,
60
+ False if still waiting for more artifacts (AND gate, correlation, batch)
61
+
62
+ Examples:
63
+ >>> # Immediate scheduling (single artifact, no collection needed)
64
+ >>> result = CollectionResult.immediate([artifact])
65
+
66
+ >>> # Waiting for more artifacts (AND gate incomplete)
67
+ >>> result = CollectionResult.waiting()
68
+
69
+ >>> # Collection complete with multiple artifacts
70
+ >>> result = CollectionResult(artifacts=[art1, art2, art3], complete=True)
71
+ """
72
+
73
+ artifacts: list[Artifact]
74
+ complete: bool
75
+
76
+ @classmethod
77
+ def immediate(cls, artifacts: list[Artifact]) -> CollectionResult:
78
+ """Create result for immediate scheduling (no collection needed).
79
+
80
+ Args:
81
+ artifacts: Artifacts to schedule agent with
82
+
83
+ Returns:
84
+ CollectionResult with complete=True
85
+
86
+ Examples:
87
+ >>> result = CollectionResult.immediate([artifact])
88
+ >>> assert result.complete is True
89
+ """
90
+ return cls(artifacts=artifacts, complete=True)
91
+
92
+ @classmethod
93
+ def waiting(cls) -> CollectionResult:
94
+ """Create result indicating collection is incomplete (waiting for more).
95
+
96
+ Returns:
97
+ CollectionResult with complete=False and empty artifacts list
98
+
99
+ Examples:
100
+ >>> result = CollectionResult.waiting()
101
+ >>> assert result.complete is False
102
+ >>> assert result.artifacts == []
103
+ """
104
+ return cls(artifacts=[], complete=False)
105
+
106
+
107
+ class OrchestratorComponentConfig(BaseModel):
108
+ """Configuration for orchestrator components.
109
+
110
+ Base configuration class that can be extended by specific components.
111
+
112
+ Examples:
113
+ >>> # Simple usage (no extra config)
114
+ >>> config = OrchestratorComponentConfig()
115
+
116
+ >>> # Extended by specific components
117
+ >>> class CircuitBreakerConfig(OrchestratorComponentConfig):
118
+ ... max_iterations: int = 1000
119
+ """
120
+
121
+ # Can be extended by specific components
122
+
123
+
124
+ class OrchestratorComponent(BaseModel, metaclass=TracedModelMeta):
125
+ """Base class for orchestrator components with lifecycle hooks.
126
+
127
+ Components extend orchestrator functionality without modifying core code.
128
+ Execute in priority order (lower priority number = earlier execution).
129
+
130
+ All public methods are automatically traced via OpenTelemetry through
131
+ the TracedModelMeta metaclass (which combines Pydantic's ModelMetaclass
132
+ with AutoTracedMeta).
133
+
134
+ Lifecycle hooks are called at specific points during orchestrator execution:
135
+ 1. on_initialize: Once at orchestrator startup
136
+ 2. on_artifact_published: After artifact persisted, before scheduling
137
+ 3. on_before_schedule: Before scheduling an agent (filter/policy)
138
+ 4. on_collect_artifacts: Handle AND gates/correlation/batching
139
+ 5. on_before_agent_schedule: Final gate before task creation
140
+ 6. on_agent_scheduled: After task created (notification)
141
+ 7. on_orchestrator_idle: When orchestrator becomes idle
142
+ 8. on_shutdown: During orchestrator shutdown
143
+
144
+ Attributes:
145
+ name: Optional component name (for logging/debugging)
146
+ config: Component configuration
147
+ priority: Execution priority (lower = earlier, default=0)
148
+
149
+ Examples:
150
+ >>> # Simple component
151
+ >>> class LoggingComponent(OrchestratorComponent):
152
+ ... async def on_agent_scheduled(self, orch, agent, artifacts, task):
153
+ ... print(f"Agent {agent.name} scheduled with {len(artifacts)} artifacts")
154
+
155
+ >>> # Circuit breaker component
156
+ >>> class CircuitBreakerComponent(OrchestratorComponent):
157
+ ... max_iterations: int = 1000
158
+ ... _counts: dict = PrivateAttr(default_factory=dict)
159
+ ...
160
+ ... async def on_before_schedule(self, orch, artifact, agent, sub):
161
+ ... count = self._counts.get(agent.name, 0)
162
+ ... if count >= self.max_iterations:
163
+ ... return ScheduleDecision.SKIP
164
+ ... self._counts[agent.name] = count + 1
165
+ ... return ScheduleDecision.CONTINUE
166
+ """
167
+
168
+ name: str | None = None
169
+ config: OrchestratorComponentConfig = Field(default_factory=OrchestratorComponentConfig)
170
+ priority: int = 0 # Lower priority = earlier execution
171
+
172
+ # ──────────────────────────────────────────────────────────
173
+ # LIFECYCLE HOOKS (Override in subclasses)
174
+ # ──────────────────────────────────────────────────────────
175
+
176
+ async def on_initialize(self, orchestrator: Flock) -> None:
177
+ """Called once when orchestrator starts up.
178
+
179
+ Use for: Resource allocation, loading state, connecting to external systems.
180
+
181
+ Args:
182
+ orchestrator: Flock orchestrator instance
183
+
184
+ Examples:
185
+ >>> async def on_initialize(self, orchestrator):
186
+ ... self.metrics_client = await connect_to_prometheus()
187
+ ... self._state = await load_checkpoint()
188
+ """
189
+
190
+ async def on_artifact_published(
191
+ self, orchestrator: Flock, artifact: Artifact
192
+ ) -> Artifact | None:
193
+ """Called when artifact is published to blackboard, before scheduling.
194
+
195
+ Components execute in priority order, each receiving the artifact
196
+ from the previous component (chaining).
197
+
198
+ Use for: Filtering, transformation, validation, enrichment.
199
+
200
+ Args:
201
+ orchestrator: Flock orchestrator instance
202
+ artifact: Published artifact
203
+
204
+ Returns:
205
+ Modified artifact to continue with, or None to block scheduling entirely
206
+
207
+ Examples:
208
+ >>> # Enrich artifact with metadata
209
+ >>> async def on_artifact_published(self, orch, artifact):
210
+ ... artifact.tags.add("processed")
211
+ ... return artifact
212
+
213
+ >>> # Block sensitive artifacts
214
+ >>> async def on_artifact_published(self, orch, artifact):
215
+ ... if artifact.type == "SensitiveData":
216
+ ... return None # Block
217
+ ... return artifact
218
+ """
219
+ return artifact
220
+
221
+ async def on_before_schedule(
222
+ self,
223
+ orchestrator: Flock,
224
+ artifact: Artifact,
225
+ agent: Agent,
226
+ subscription: Subscription,
227
+ ) -> ScheduleDecision:
228
+ """Called before scheduling an agent for a matched subscription.
229
+
230
+ Use for: Circuit breaking, deduplication, rate limiting, policy checks.
231
+
232
+ Args:
233
+ orchestrator: Flock orchestrator instance
234
+ artifact: Artifact that matched subscription
235
+ agent: Agent to potentially schedule
236
+ subscription: Subscription that matched
237
+
238
+ Returns:
239
+ ScheduleDecision (CONTINUE, SKIP, or DEFER)
240
+
241
+ Examples:
242
+ >>> # Circuit breaker
243
+ >>> async def on_before_schedule(self, orch, artifact, agent, sub):
244
+ ... if self._counts.get(agent.name, 0) >= 1000:
245
+ ... return ScheduleDecision.SKIP
246
+ ... return ScheduleDecision.CONTINUE
247
+
248
+ >>> # Deduplication
249
+ >>> async def on_before_schedule(self, orch, artifact, agent, sub):
250
+ ... key = (artifact.id, agent.name)
251
+ ... if key in self._processed:
252
+ ... return ScheduleDecision.SKIP
253
+ ... self._processed.add(key)
254
+ ... return ScheduleDecision.CONTINUE
255
+ """
256
+ return ScheduleDecision.CONTINUE
257
+
258
+ async def on_collect_artifacts(
259
+ self,
260
+ orchestrator: Flock,
261
+ artifact: Artifact,
262
+ agent: Agent,
263
+ subscription: Subscription,
264
+ ) -> CollectionResult | None:
265
+ """Called to collect artifacts for agent execution.
266
+
267
+ First component to return non-None wins (short-circuit).
268
+
269
+ Use for: AND gates, JoinSpec correlation, BatchSpec batching.
270
+
271
+ Args:
272
+ orchestrator: Flock orchestrator instance
273
+ artifact: Current artifact
274
+ agent: Agent to schedule
275
+ subscription: Matched subscription
276
+
277
+ Returns:
278
+ CollectionResult if this component handles collection,
279
+ or None to let next component handle it
280
+
281
+ Examples:
282
+ >>> # JoinSpec correlation
283
+ >>> async def on_collect_artifacts(self, orch, artifact, agent, sub):
284
+ ... if sub.join is None:
285
+ ... return None # Not our concern
286
+ ...
287
+ ... group = self._engine.add_artifact(artifact, sub)
288
+ ... if group is None:
289
+ ... return CollectionResult.waiting()
290
+ ...
291
+ ... return CollectionResult.immediate(group.get_artifacts())
292
+ """
293
+ return None # Let other components handle
294
+
295
+ async def on_before_agent_schedule(
296
+ self, orchestrator: Flock, agent: Agent, artifacts: list[Artifact]
297
+ ) -> list[Artifact] | None:
298
+ """Called before final agent scheduling with collected artifacts.
299
+
300
+ Components execute in priority order, each receiving artifacts
301
+ from the previous component (chaining).
302
+
303
+ Use for: Final validation, artifact transformation, enrichment.
304
+
305
+ Args:
306
+ orchestrator: Flock orchestrator instance
307
+ agent: Agent to schedule
308
+ artifacts: Collected artifacts
309
+
310
+ Returns:
311
+ Modified artifacts to schedule with, or None to block scheduling
312
+
313
+ Examples:
314
+ >>> # Final validation
315
+ >>> async def on_before_agent_schedule(self, orch, agent, artifacts):
316
+ ... if len(artifacts) == 0:
317
+ ... return None # Block empty schedules
318
+ ... return artifacts
319
+
320
+ >>> # Artifact enrichment
321
+ >>> async def on_before_agent_schedule(self, orch, agent, artifacts):
322
+ ... for artifact in artifacts:
323
+ ... artifact.metadata["scheduled_at"] = datetime.now()
324
+ ... return artifacts
325
+ """
326
+ return artifacts
327
+
328
+ async def on_agent_scheduled(
329
+ self,
330
+ orchestrator: Flock,
331
+ agent: Agent,
332
+ artifacts: list[Artifact],
333
+ task: asyncio.Task,
334
+ ) -> None:
335
+ """Called after agent task is scheduled (notification only).
336
+
337
+ Exceptions in this hook are logged but don't block scheduling.
338
+
339
+ Use for: Metrics, logging, event emission, monitoring.
340
+
341
+ Args:
342
+ orchestrator: Flock orchestrator instance
343
+ agent: Scheduled agent
344
+ artifacts: Artifacts agent was scheduled with
345
+ task: Asyncio task that was created
346
+
347
+ Examples:
348
+ >>> # Metrics tracking
349
+ >>> async def on_agent_scheduled(self, orch, agent, artifacts, task):
350
+ ... self.metrics["agents_scheduled"] += 1
351
+ ... self.metrics["artifacts_processed"] += len(artifacts)
352
+
353
+ >>> # WebSocket notification
354
+ >>> async def on_agent_scheduled(self, orch, agent, artifacts, task):
355
+ ... await self.ws.broadcast({
356
+ ... "event": "agent_scheduled",
357
+ ... "agent": agent.name,
358
+ ... "count": len(artifacts)
359
+ ... })
360
+ """
361
+
362
+ async def on_orchestrator_idle(self, orchestrator: Flock) -> None:
363
+ """Called when orchestrator becomes idle (no pending tasks).
364
+
365
+ Use for: Cleanup, state reset, checkpointing, metrics flush.
366
+
367
+ Args:
368
+ orchestrator: Flock orchestrator instance
369
+
370
+ Examples:
371
+ >>> # Reset circuit breaker
372
+ >>> async def on_orchestrator_idle(self, orchestrator):
373
+ ... self._iteration_counts.clear()
374
+
375
+ >>> # Flush metrics
376
+ >>> async def on_orchestrator_idle(self, orchestrator):
377
+ ... await self.metrics_client.flush()
378
+ """
379
+
380
+ async def on_shutdown(self, orchestrator: Flock) -> None:
381
+ """Called when orchestrator shuts down.
382
+
383
+ Use for: Resource cleanup, connection closing, final persistence.
384
+
385
+ Args:
386
+ orchestrator: Flock orchestrator instance
387
+
388
+ Examples:
389
+ >>> # Close connections
390
+ >>> async def on_shutdown(self, orchestrator):
391
+ ... await self.database.close()
392
+ ... await self.mcp_manager.cleanup_all()
393
+
394
+ >>> # Save checkpoint
395
+ >>> async def on_shutdown(self, orchestrator):
396
+ ... await save_checkpoint(self._state)
397
+ """
398
+
399
+
400
+ class BuiltinCollectionComponent(OrchestratorComponent):
401
+ """Built-in component that handles AND gates, correlation, and batching.
402
+
403
+ This component wraps the existing ArtifactCollector, CorrelationEngine,
404
+ and BatchEngine to provide collection logic via the component system.
405
+
406
+ Priority: 100 (runs after user components for circuit breaking/dedup)
407
+ """
408
+
409
+ priority: int = 100 # Run late (after circuit breaker/dedup components)
410
+ name: str = "builtin_collection"
411
+
412
+ async def on_collect_artifacts(
413
+ self,
414
+ orchestrator: Flock,
415
+ artifact: Artifact,
416
+ agent: Agent,
417
+ subscription: Subscription,
418
+ ) -> CollectionResult | None:
419
+ """Handle AND gates, correlation (JoinSpec), and batching (BatchSpec).
420
+
421
+ This maintains backward compatibility with existing collection engines
422
+ while allowing user components to override collection logic.
423
+ """
424
+ from datetime import timedelta
425
+
426
+ # Check if subscription has required attributes (defensive for mocks/tests)
427
+ if (
428
+ not hasattr(subscription, "join")
429
+ or not hasattr(subscription, "type_models")
430
+ or not hasattr(subscription, "batch")
431
+ ):
432
+ # Fallback: immediate with single artifact
433
+ return CollectionResult.immediate([artifact])
434
+
435
+ # JoinSpec CORRELATION: Check if subscription has correlated AND gate
436
+ if subscription.join is not None:
437
+ subscription_index = agent.subscriptions.index(subscription)
438
+ completed_group = orchestrator._correlation_engine.add_artifact(
439
+ artifact=artifact,
440
+ subscription=subscription,
441
+ subscription_index=subscription_index,
442
+ )
443
+
444
+ # Start correlation cleanup task if time-based window
445
+ if (
446
+ isinstance(subscription.join.within, timedelta)
447
+ and orchestrator._correlation_cleanup_task is None
448
+ ):
449
+ import asyncio
450
+
451
+ orchestrator._correlation_cleanup_task = asyncio.create_task(
452
+ orchestrator._correlation_cleanup_loop()
453
+ )
454
+
455
+ if completed_group is None:
456
+ # Still waiting for correlation to complete
457
+ await orchestrator._emit_correlation_updated_event(
458
+ agent_name=agent.name,
459
+ subscription_index=subscription_index,
460
+ artifact=artifact,
461
+ )
462
+ return CollectionResult.waiting()
463
+
464
+ # Correlation complete!
465
+ artifacts = completed_group.get_artifacts()
466
+ else:
467
+ # AND GATE: Use artifact collector for simple AND gates (no correlation)
468
+ is_complete, artifacts = orchestrator._artifact_collector.add_artifact(
469
+ agent, subscription, artifact
470
+ )
471
+
472
+ if not is_complete:
473
+ return CollectionResult.waiting()
474
+
475
+ # BatchSpec BATCHING: Check if subscription has batch accumulator
476
+ if subscription.batch is not None:
477
+ subscription_index = agent.subscriptions.index(subscription)
478
+
479
+ # Treat artifact groups as single batch items
480
+ if subscription.join is not None or len(subscription.type_models) > 1:
481
+ should_flush = orchestrator._batch_engine.add_artifact_group(
482
+ artifacts=artifacts,
483
+ subscription=subscription,
484
+ subscription_index=subscription_index,
485
+ )
486
+
487
+ if subscription.batch.timeout and orchestrator._batch_timeout_task is None:
488
+ import asyncio
489
+
490
+ orchestrator._batch_timeout_task = asyncio.create_task(
491
+ orchestrator._batch_timeout_checker_loop()
492
+ )
493
+ else:
494
+ # Single type: Add each artifact individually
495
+ should_flush = False
496
+ for single_artifact in artifacts:
497
+ should_flush = orchestrator._batch_engine.add_artifact(
498
+ artifact=single_artifact,
499
+ subscription=subscription,
500
+ subscription_index=subscription_index,
501
+ )
502
+
503
+ if subscription.batch.timeout and orchestrator._batch_timeout_task is None:
504
+ import asyncio
505
+
506
+ orchestrator._batch_timeout_task = asyncio.create_task(
507
+ orchestrator._batch_timeout_checker_loop()
508
+ )
509
+
510
+ if should_flush:
511
+ break
512
+
513
+ if not should_flush:
514
+ # Batch not full yet
515
+ await orchestrator._emit_batch_item_added_event(
516
+ agent_name=agent.name,
517
+ subscription_index=subscription_index,
518
+ subscription=subscription,
519
+ artifact=artifact,
520
+ )
521
+ return CollectionResult.waiting()
522
+
523
+ # Flush batch
524
+ batched_artifacts = orchestrator._batch_engine.flush_batch(
525
+ agent.name, subscription_index
526
+ )
527
+
528
+ if batched_artifacts is None:
529
+ return CollectionResult.waiting()
530
+
531
+ artifacts = batched_artifacts
532
+
533
+ # Collection complete!
534
+ return CollectionResult.immediate(artifacts)
535
+
536
+
537
+ class CircuitBreakerComponent(OrchestratorComponent):
538
+ """Circuit breaker to prevent runaway agent loops.
539
+
540
+ Tracks iteration count per agent and blocks scheduling when limit is reached.
541
+ Automatically resets counters when orchestrator becomes idle.
542
+
543
+ Priority: 10 (runs early, before deduplication)
544
+
545
+ Configuration:
546
+ max_iterations: Maximum iterations per agent before circuit breaker trips
547
+
548
+ Examples:
549
+ >>> # Use default (1000 iterations)
550
+ >>> flock = Flock("openai/gpt-4.1")
551
+
552
+ >>> # Custom limit
553
+ >>> flock = Flock("openai/gpt-4.1")
554
+ >>> for component in flock._components:
555
+ ... if component.name == "circuit_breaker":
556
+ ... component.max_iterations = 500
557
+ """
558
+
559
+ priority: int = 10 # Run early (before dedup at 20)
560
+ name: str = "circuit_breaker"
561
+ max_iterations: int = 1000 # Default from orchestrator
562
+
563
+ def __init__(self, max_iterations: int = 1000, **kwargs):
564
+ """Initialize circuit breaker with iteration limit.
565
+
566
+ Args:
567
+ max_iterations: Maximum iterations per agent (default 1000)
568
+ """
569
+ super().__init__(**kwargs)
570
+ self.max_iterations = max_iterations
571
+ self._iteration_counts: dict[str, int] = {}
572
+
573
+ async def on_before_schedule(
574
+ self,
575
+ orchestrator: Flock,
576
+ artifact: Artifact,
577
+ agent: Agent,
578
+ subscription: Subscription,
579
+ ) -> ScheduleDecision:
580
+ """Check if agent has exceeded iteration limit.
581
+
582
+ Returns SKIP if agent has reached max_iterations, preventing
583
+ potential infinite loops.
584
+
585
+ Uses orchestrator.max_agent_iterations if available, otherwise
586
+ falls back to self.max_iterations.
587
+ """
588
+ current_count = self._iteration_counts.get(agent.name, 0)
589
+
590
+ # Check orchestrator property first (allows runtime modification)
591
+ # Fall back to component's max_iterations if orchestrator property doesn't exist
592
+ max_limit = self.max_iterations
593
+ if hasattr(orchestrator, "max_agent_iterations"):
594
+ orch_limit = orchestrator.max_agent_iterations
595
+ # Only use orchestrator limit if it's a valid int
596
+ if isinstance(orch_limit, int):
597
+ max_limit = orch_limit
598
+
599
+ if current_count >= max_limit:
600
+ # Circuit breaker tripped
601
+ return ScheduleDecision.SKIP
602
+
603
+ # Increment counter
604
+ self._iteration_counts[agent.name] = current_count + 1
605
+ return ScheduleDecision.CONTINUE
606
+
607
+ async def on_orchestrator_idle(self, orchestrator: Flock) -> None:
608
+ """Reset iteration counters when orchestrator becomes idle.
609
+
610
+ This prevents the circuit breaker from permanently blocking agents
611
+ across different workflow runs.
612
+ """
613
+ self._iteration_counts.clear()
614
+
615
+
616
+ class DeduplicationComponent(OrchestratorComponent):
617
+ """Deduplication to prevent agents from processing same artifact multiple times.
618
+
619
+ Tracks which artifacts have been processed by which agents and skips
620
+ duplicate scheduling attempts.
621
+
622
+ Priority: 20 (runs after circuit breaker at 10)
623
+
624
+ Examples:
625
+ >>> # Auto-added to all orchestrators
626
+ >>> flock = Flock("openai/gpt-4.1")
627
+
628
+ >>> # Deduplication prevents:
629
+ >>> # - Agents re-processing artifacts they already handled
630
+ >>> # - Feedback loops from agent self-triggers
631
+ >>> # - Duplicate work from retry logic
632
+ """
633
+
634
+ priority: int = 20 # Run after circuit breaker (10)
635
+ name: str = "deduplication"
636
+
637
+ def __init__(self, **kwargs):
638
+ """Initialize deduplication with empty processed set."""
639
+ super().__init__(**kwargs)
640
+ self._processed: set[tuple[str, str]] = set() # (artifact_id, agent_name)
641
+
642
+ async def on_before_schedule(
643
+ self,
644
+ orchestrator: Flock,
645
+ artifact: Artifact,
646
+ agent: Agent,
647
+ subscription: Subscription,
648
+ ) -> ScheduleDecision:
649
+ """Check if artifact has already been processed by this agent.
650
+
651
+ Returns SKIP if agent has already processed this artifact,
652
+ preventing duplicate work.
653
+ """
654
+ key = (str(artifact.id), agent.name)
655
+
656
+ if key in self._processed:
657
+ # Already processed - skip
658
+ return ScheduleDecision.SKIP
659
+
660
+ # Not yet processed - allow
661
+ return ScheduleDecision.CONTINUE
662
+
663
+ async def on_before_agent_schedule(
664
+ self, orchestrator: Flock, agent: Agent, artifacts: list[Artifact]
665
+ ) -> list[Artifact]:
666
+ """Mark artifacts as processed before agent execution.
667
+
668
+ This ensures artifacts are marked as seen even if agent execution fails,
669
+ preventing infinite retry loops.
670
+ """
671
+ for artifact in artifacts:
672
+ key = (str(artifact.id), agent.name)
673
+ self._processed.add(key)
674
+
675
+ return artifacts
676
+
677
+
678
+ __all__ = [
679
+ "BuiltinCollectionComponent",
680
+ "CircuitBreakerComponent",
681
+ "CollectionResult",
682
+ "DeduplicationComponent",
683
+ "OrchestratorComponent",
684
+ "OrchestratorComponentConfig",
685
+ "ScheduleDecision",
686
+ ]
flock/runtime.py CHANGED
@@ -250,6 +250,9 @@ class Context(BaseModel):
250
250
  correlation_id: UUID | None = None # NEW!
251
251
  task_id: str
252
252
  state: dict[str, Any] = Field(default_factory=dict)
253
+ is_batch: bool = Field(
254
+ default=False, description="True if this execution is processing a BatchSpec accumulation"
255
+ )
253
256
 
254
257
  def get_variable(self, key: str, default: Any = None) -> Any:
255
258
  return self.state.get(key, default)