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.
- flock/agent.py +153 -17
- flock/components.py +36 -0
- flock/dashboard/collector.py +2 -0
- flock/dashboard/static_v2/assets/index-DFRnI_mt.js +1 -1
- flock/dashboard/static_v2/index.html +3 -3
- flock/engines/dspy_engine.py +41 -3
- flock/engines/examples/__init__.py +6 -0
- flock/engines/examples/simple_batch_engine.py +61 -0
- flock/frontend/README.md +4 -4
- flock/frontend/docs/DESIGN_SYSTEM.md +1 -1
- flock/frontend/package-lock.json +2 -2
- flock/frontend/package.json +1 -1
- flock/frontend/src/components/settings/SettingsPanel.css +1 -1
- flock/frontend/src/components/settings/ThemeSelector.tsx +2 -2
- flock/frontend/src/services/indexeddb.ts +1 -1
- flock/frontend/src/styles/variables.css +1 -1
- flock/orchestrator.py +500 -140
- flock/orchestrator_component.py +686 -0
- flock/runtime.py +3 -0
- {flock_core-0.5.4.dist-info → flock_core-0.5.6.dist-info}/METADATA +69 -3
- {flock_core-0.5.4.dist-info → flock_core-0.5.6.dist-info}/RECORD +24 -21
- {flock_core-0.5.4.dist-info → flock_core-0.5.6.dist-info}/WHEEL +0 -0
- {flock_core-0.5.4.dist-info → flock_core-0.5.6.dist-info}/entry_points.txt +0 -0
- {flock_core-0.5.4.dist-info → flock_core-0.5.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -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)
|