flock-core 0.5.5__py3-none-any.whl → 0.5.7__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/orchestrator.py CHANGED
@@ -8,7 +8,7 @@ import os
8
8
  from asyncio import Task
9
9
  from collections.abc import AsyncGenerator, Iterable, Mapping, Sequence
10
10
  from contextlib import asynccontextmanager
11
- from datetime import datetime, timedelta, timezone
11
+ from datetime import datetime, timezone
12
12
  from pathlib import Path
13
13
  from typing import TYPE_CHECKING, Any
14
14
  from uuid import uuid4
@@ -31,9 +31,15 @@ from flock.mcp import (
31
31
  FlockMCPFeatureConfiguration,
32
32
  ServerParameters,
33
33
  )
34
+ from flock.orchestrator_component import (
35
+ CollectionResult,
36
+ OrchestratorComponent,
37
+ ScheduleDecision,
38
+ )
34
39
  from flock.registry import type_registry
35
40
  from flock.runtime import Context
36
41
  from flock.store import BlackboardStore, ConsumptionRecord, InMemoryBlackboardStore
42
+ from flock.subscription import Subscription
37
43
  from flock.visibility import AgentIdentity, PublicVisibility, Visibility
38
44
 
39
45
 
@@ -153,6 +159,25 @@ class Flock(metaclass=AutoTracedMeta):
153
159
  "yes",
154
160
  "on",
155
161
  }
162
+
163
+ # Phase 2: OrchestratorComponent system
164
+ self._components: list[OrchestratorComponent] = []
165
+ self._components_initialized: bool = False
166
+
167
+ # Auto-add built-in components
168
+ from flock.orchestrator_component import (
169
+ BuiltinCollectionComponent,
170
+ CircuitBreakerComponent,
171
+ DeduplicationComponent,
172
+ )
173
+
174
+ self.add_component(CircuitBreakerComponent(max_iterations=max_agent_iterations))
175
+ self.add_component(DeduplicationComponent())
176
+ self.add_component(BuiltinCollectionComponent())
177
+
178
+ # Log orchestrator initialization
179
+ self._logger.debug("Orchestrator initialized: components=[]")
180
+
156
181
  if not model:
157
182
  self.model = os.getenv("DEFAULT_MODEL")
158
183
 
@@ -203,6 +228,47 @@ class Flock(metaclass=AutoTracedMeta):
203
228
  def agents(self) -> list[Agent]:
204
229
  return list(self._agents.values())
205
230
 
231
+ # Component management -------------------------------------------------
232
+
233
+ def add_component(self, component: OrchestratorComponent) -> Flock:
234
+ """Add an OrchestratorComponent to this orchestrator.
235
+
236
+ Components execute in priority order (lower priority number = earlier).
237
+ Multiple components can have the same priority.
238
+
239
+ Args:
240
+ component: Component to add (must be an OrchestratorComponent instance)
241
+
242
+ Returns:
243
+ Self for method chaining
244
+
245
+ Examples:
246
+ >>> # Add single component
247
+ >>> flock = Flock("openai/gpt-4.1")
248
+ >>> flock.add_component(CircuitBreakerComponent(max_iterations=500))
249
+
250
+ >>> # Method chaining
251
+ >>> flock.add_component(CircuitBreakerComponent()) \\
252
+ ... .add_component(MetricsComponent()) \\
253
+ ... .add_component(DeduplicationComponent())
254
+
255
+ >>> # Custom priority (lower = earlier)
256
+ >>> flock.add_component(
257
+ ... CustomComponent(priority=5, name="early_component")
258
+ ... )
259
+ """
260
+ self._components.append(component)
261
+ self._components.sort(key=lambda c: c.priority)
262
+
263
+ # Log component addition
264
+ comp_name = component.name or component.__class__.__name__
265
+ self._logger.info(
266
+ f"Component added: name={comp_name}, "
267
+ f"priority={component.priority}, total_components={len(self._components)}"
268
+ )
269
+
270
+ return self
271
+
206
272
  # MCP management -------------------------------------------------------
207
273
 
208
274
  def add_mcp(
@@ -506,11 +572,15 @@ class Flock(metaclass=AutoTracedMeta):
506
572
  self._agent_iteration_count.clear()
507
573
  return
508
574
 
575
+ # Notify components that orchestrator reached idle state
576
+ if self._components_initialized:
577
+ await self._run_idle()
578
+
509
579
  # T068: Reset circuit breaker counters when idle
510
580
  self._agent_iteration_count.clear()
511
581
 
512
582
  # Automatically shutdown MCP connections when idle
513
- await self.shutdown()
583
+ await self.shutdown(include_components=False)
514
584
 
515
585
  async def direct_invoke(
516
586
  self, agent: Agent, inputs: Sequence[BaseModel | Mapping[str, Any] | Artifact]
@@ -579,8 +649,17 @@ class Flock(metaclass=AutoTracedMeta):
579
649
  """
580
650
  return asyncio.run(self.arun(agent_builder, *inputs))
581
651
 
582
- async def shutdown(self) -> None:
583
- """Shutdown orchestrator and clean up resources."""
652
+ async def shutdown(self, *, include_components: bool = True) -> None:
653
+ """Shutdown orchestrator and clean up resources.
654
+
655
+ Args:
656
+ include_components: Whether to invoke component shutdown hooks.
657
+ Internal callers (e.g., run_until_idle) disable this to avoid
658
+ tearing down component state between cascades.
659
+ """
660
+ if include_components and self._components_initialized:
661
+ await self._run_shutdown()
662
+
584
663
  # Cancel correlation cleanup task if running
585
664
  if self._correlation_cleanup_task and not self._correlation_cleanup_task.done():
586
665
  self._correlation_cleanup_task.cancel()
@@ -658,8 +737,8 @@ class Flock(metaclass=AutoTracedMeta):
658
737
 
659
738
  # Inject event collector into all existing agents
660
739
  for agent in self._agents.values():
661
- # Insert at beginning of utilities list (highest priority)
662
- agent.utilities.insert(0, event_collector)
740
+ # Add dashboard collector with priority ordering handled by agent
741
+ agent._add_utilities([event_collector])
663
742
 
664
743
  # Start dashboard launcher (npm process + browser)
665
744
  launcher_kwargs: dict[str, Any] = {"port": port}
@@ -892,188 +971,340 @@ class Flock(metaclass=AutoTracedMeta):
892
971
 
893
972
  return outputs
894
973
 
895
- # Keep publish_external as deprecated alias
896
- async def publish_external(
897
- self,
898
- type_name: str,
899
- payload: dict[str, Any],
900
- *,
901
- visibility: Visibility | None = None,
902
- correlation_id: str | None = None,
903
- partition_key: str | None = None,
904
- tags: set[str] | None = None,
905
- ) -> Artifact:
906
- """Deprecated: Use publish() instead.
974
+ async def _persist_and_schedule(self, artifact: Artifact) -> None:
975
+ await self.store.publish(artifact)
976
+ self.metrics["artifacts_published"] += 1
977
+ await self._schedule_artifact(artifact)
907
978
 
908
- This method will be removed in v2.0.
979
+ # Component Hook Runners ───────────────────────────────────────
980
+
981
+ async def _run_initialize(self) -> None:
982
+ """Initialize all components in priority order (called once).
983
+
984
+ Executes on_initialize hook for each component. Sets _components_initialized
985
+ flag to prevent multiple initializations.
986
+ """
987
+ if self._components_initialized:
988
+ return
989
+
990
+ self._logger.info(f"Initializing {len(self._components)} orchestrator components")
991
+
992
+ for component in self._components:
993
+ comp_name = component.name or component.__class__.__name__
994
+ self._logger.debug(
995
+ f"Initializing component: name={comp_name}, priority={component.priority}"
996
+ )
997
+
998
+ try:
999
+ await component.on_initialize(self)
1000
+ except Exception as e:
1001
+ self._logger.exception(
1002
+ f"Component initialization failed: name={comp_name}, error={e!s}"
1003
+ )
1004
+ raise
1005
+
1006
+ self._components_initialized = True
1007
+ self._logger.info(f"All components initialized: count={len(self._components)}")
1008
+
1009
+ async def _run_artifact_published(self, artifact: Artifact) -> Artifact | None:
1010
+ """Run on_artifact_published hooks (returns modified artifact or None to block).
1011
+
1012
+ Components execute in priority order, each receiving the artifact from the
1013
+ previous component (chaining). If any component returns None, the artifact
1014
+ is blocked and scheduling stops.
1015
+ """
1016
+ current_artifact = artifact
1017
+
1018
+ for component in self._components:
1019
+ comp_name = component.name or component.__class__.__name__
1020
+ self._logger.debug(
1021
+ f"Running on_artifact_published: component={comp_name}, "
1022
+ f"artifact_type={current_artifact.type}, artifact_id={current_artifact.id}"
1023
+ )
1024
+
1025
+ try:
1026
+ result = await component.on_artifact_published(self, current_artifact)
1027
+
1028
+ if result is None:
1029
+ self._logger.info(
1030
+ f"Artifact blocked by component: component={comp_name}, "
1031
+ f"artifact_type={current_artifact.type}, artifact_id={current_artifact.id}"
1032
+ )
1033
+ return None
1034
+
1035
+ current_artifact = result
1036
+ except Exception as e:
1037
+ self._logger.exception(
1038
+ f"Component hook failed: component={comp_name}, "
1039
+ f"hook=on_artifact_published, error={e!s}"
1040
+ )
1041
+ raise
1042
+
1043
+ return current_artifact
1044
+
1045
+ async def _run_before_schedule(
1046
+ self, artifact: Artifact, agent: Agent, subscription: Subscription
1047
+ ) -> ScheduleDecision:
1048
+ """Run on_before_schedule hooks (returns CONTINUE, SKIP, or DEFER).
1049
+
1050
+ Components execute in priority order. First component to return SKIP or
1051
+ DEFER stops execution and returns that decision.
909
1052
  """
910
- import warnings
1053
+ from flock.orchestrator_component import ScheduleDecision
1054
+
1055
+ for component in self._components:
1056
+ comp_name = component.name or component.__class__.__name__
911
1057
 
912
- warnings.warn(
913
- "publish_external() is deprecated. Use publish(obj) instead.",
914
- DeprecationWarning,
915
- stacklevel=2,
1058
+ self._logger.debug(
1059
+ f"Running on_before_schedule: component={comp_name}, "
1060
+ f"agent={agent.name}, artifact_type={artifact.type}"
1061
+ )
1062
+
1063
+ try:
1064
+ decision = await component.on_before_schedule(self, artifact, agent, subscription)
1065
+
1066
+ if decision == ScheduleDecision.SKIP:
1067
+ self._logger.info(
1068
+ f"Scheduling skipped by component: component={comp_name}, "
1069
+ f"agent={agent.name}, artifact_type={artifact.type}, decision=SKIP"
1070
+ )
1071
+ return ScheduleDecision.SKIP
1072
+
1073
+ if decision == ScheduleDecision.DEFER:
1074
+ self._logger.debug(
1075
+ f"Scheduling deferred by component: component={comp_name}, "
1076
+ f"agent={agent.name}, decision=DEFER"
1077
+ )
1078
+ return ScheduleDecision.DEFER
1079
+
1080
+ except Exception as e:
1081
+ self._logger.exception(
1082
+ f"Component hook failed: component={comp_name}, "
1083
+ f"hook=on_before_schedule, error={e!s}"
1084
+ )
1085
+ raise
1086
+
1087
+ return ScheduleDecision.CONTINUE
1088
+
1089
+ async def _run_collect_artifacts(
1090
+ self, artifact: Artifact, agent: Agent, subscription: Subscription
1091
+ ) -> CollectionResult:
1092
+ """Run on_collect_artifacts hooks (returns first non-None result).
1093
+
1094
+ Components execute in priority order. First component to return non-None
1095
+ wins (short-circuit). If all return None, default is immediate scheduling.
1096
+ """
1097
+ from flock.orchestrator_component import CollectionResult
1098
+
1099
+ for component in self._components:
1100
+ comp_name = component.name or component.__class__.__name__
1101
+
1102
+ self._logger.debug(
1103
+ f"Running on_collect_artifacts: component={comp_name}, "
1104
+ f"agent={agent.name}, artifact_type={artifact.type}"
1105
+ )
1106
+
1107
+ try:
1108
+ result = await component.on_collect_artifacts(self, artifact, agent, subscription)
1109
+
1110
+ if result is not None:
1111
+ self._logger.debug(
1112
+ f"Collection handled by component: component={comp_name}, "
1113
+ f"complete={result.complete}, artifact_count={len(result.artifacts)}"
1114
+ )
1115
+ return result
1116
+ except Exception as e:
1117
+ self._logger.exception(
1118
+ f"Component hook failed: component={comp_name}, "
1119
+ f"hook=on_collect_artifacts, error={e!s}"
1120
+ )
1121
+ raise
1122
+
1123
+ # Default: immediate scheduling with single artifact
1124
+ self._logger.debug(
1125
+ f"No component handled collection, using default: "
1126
+ f"agent={agent.name}, artifact_type={artifact.type}"
916
1127
  )
917
- return await self.publish(
918
- {"type": type_name, "payload": payload},
919
- visibility=visibility,
920
- correlation_id=correlation_id,
921
- partition_key=partition_key,
922
- tags=tags,
1128
+ return CollectionResult.immediate([artifact])
1129
+
1130
+ async def _run_before_agent_schedule(
1131
+ self, agent: Agent, artifacts: list[Artifact]
1132
+ ) -> list[Artifact] | None:
1133
+ """Run on_before_agent_schedule hooks (returns modified artifacts or None to block).
1134
+
1135
+ Components execute in priority order, each receiving artifacts from the
1136
+ previous component (chaining). If any component returns None, scheduling
1137
+ is blocked.
1138
+ """
1139
+ current_artifacts = artifacts
1140
+
1141
+ for component in self._components:
1142
+ comp_name = component.name or component.__class__.__name__
1143
+
1144
+ self._logger.debug(
1145
+ f"Running on_before_agent_schedule: component={comp_name}, "
1146
+ f"agent={agent.name}, artifact_count={len(current_artifacts)}"
1147
+ )
1148
+
1149
+ try:
1150
+ result = await component.on_before_agent_schedule(self, agent, current_artifacts)
1151
+
1152
+ if result is None:
1153
+ self._logger.info(
1154
+ f"Agent scheduling blocked by component: component={comp_name}, "
1155
+ f"agent={agent.name}"
1156
+ )
1157
+ return None
1158
+
1159
+ current_artifacts = result
1160
+ except Exception as e:
1161
+ self._logger.exception(
1162
+ f"Component hook failed: component={comp_name}, "
1163
+ f"hook=on_before_agent_schedule, error={e!s}"
1164
+ )
1165
+ raise
1166
+
1167
+ return current_artifacts
1168
+
1169
+ async def _run_agent_scheduled(
1170
+ self, agent: Agent, artifacts: list[Artifact], task: Task[Any]
1171
+ ) -> None:
1172
+ """Run on_agent_scheduled hooks (notification only, non-blocking).
1173
+
1174
+ Components execute in priority order. Exceptions are logged but don't
1175
+ prevent other components from executing or block scheduling.
1176
+ """
1177
+ for component in self._components:
1178
+ comp_name = component.name or component.__class__.__name__
1179
+
1180
+ self._logger.debug(
1181
+ f"Running on_agent_scheduled: component={comp_name}, "
1182
+ f"agent={agent.name}, artifact_count={len(artifacts)}"
1183
+ )
1184
+
1185
+ try:
1186
+ await component.on_agent_scheduled(self, agent, artifacts, task)
1187
+ except Exception as e:
1188
+ self._logger.warning(
1189
+ f"Component notification hook failed (non-critical): "
1190
+ f"component={comp_name}, hook=on_agent_scheduled, error={e!s}"
1191
+ )
1192
+ # Don't propagate - this is a notification hook
1193
+
1194
+ async def _run_idle(self) -> None:
1195
+ """Run on_orchestrator_idle hooks when orchestrator becomes idle.
1196
+
1197
+ Components execute in priority order. Exceptions are logged but don't
1198
+ prevent other components from executing.
1199
+ """
1200
+ self._logger.debug(
1201
+ f"Running on_orchestrator_idle hooks: component_count={len(self._components)}"
923
1202
  )
924
1203
 
925
- async def _persist_and_schedule(self, artifact: Artifact) -> None:
926
- await self.store.publish(artifact)
927
- self.metrics["artifacts_published"] += 1
928
- await self._schedule_artifact(artifact)
1204
+ for component in self._components:
1205
+ comp_name = component.name or component.__class__.__name__
1206
+
1207
+ try:
1208
+ await component.on_orchestrator_idle(self)
1209
+ except Exception as e:
1210
+ self._logger.warning(
1211
+ f"Component idle hook failed (non-critical): "
1212
+ f"component={comp_name}, hook=on_orchestrator_idle, error={e!s}"
1213
+ )
1214
+
1215
+ async def _run_shutdown(self) -> None:
1216
+ """Run on_shutdown hooks when orchestrator shuts down.
1217
+
1218
+ Components execute in priority order. Exceptions are logged but don't
1219
+ prevent shutdown of other components (best-effort cleanup).
1220
+ """
1221
+ self._logger.info(f"Shutting down {len(self._components)} orchestrator components")
1222
+
1223
+ for component in self._components:
1224
+ comp_name = component.name or component.__class__.__name__
1225
+ self._logger.debug(f"Shutting down component: name={comp_name}")
1226
+
1227
+ try:
1228
+ await component.on_shutdown(self)
1229
+ except Exception as e:
1230
+ self._logger.exception(
1231
+ f"Component shutdown failed: component={comp_name}, "
1232
+ f"hook=on_shutdown, error={e!s}"
1233
+ )
1234
+ # Continue shutting down other components
1235
+
1236
+ # Scheduling ───────────────────────────────────────────────────
929
1237
 
930
1238
  async def _schedule_artifact(self, artifact: Artifact) -> None:
1239
+ """Schedule agents for an artifact using component hooks.
1240
+
1241
+ Refactored to use OrchestratorComponent hook system for extensibility.
1242
+ Components can modify artifact, control scheduling, and handle collection.
1243
+ """
1244
+ # Phase 3: Initialize components on first artifact
1245
+ if not self._components_initialized:
1246
+ await self._run_initialize()
1247
+
1248
+ # Phase 3: Component hook - artifact published (can transform or block)
1249
+ artifact = await self._run_artifact_published(artifact)
1250
+ if artifact is None:
1251
+ return # Artifact blocked by component
1252
+
931
1253
  for agent in self.agents:
932
1254
  identity = agent.identity
933
1255
  for subscription in agent.subscriptions:
934
1256
  if not subscription.accepts_events():
935
1257
  continue
1258
+
936
1259
  # T066: Check prevent_self_trigger
937
1260
  if agent.prevent_self_trigger and artifact.produced_by == agent.name:
938
1261
  continue # Skip - agent produced this artifact (prevents feedback loops)
939
- # T068: Circuit breaker - check iteration limit
940
- iteration_count = self._agent_iteration_count.get(agent.name, 0)
941
- if iteration_count >= self.max_agent_iterations:
942
- # Agent hit iteration limit - possible infinite loop
943
- continue
1262
+
1263
+ # Visibility check
944
1264
  if not self._check_visibility(artifact, identity):
945
1265
  continue
1266
+
1267
+ # Subscription match check
946
1268
  if not subscription.matches(artifact):
947
1269
  continue
948
- if self._seen_before(artifact, agent):
949
- continue
950
1270
 
951
- # JoinSpec CORRELATION: Check if subscription has correlated AND gate
952
- if subscription.join is not None:
953
- # Use CorrelationEngine for JoinSpec (correlated AND gates)
954
- subscription_index = agent.subscriptions.index(subscription)
955
- completed_group = self._correlation_engine.add_artifact(
956
- artifact=artifact,
957
- subscription=subscription,
958
- subscription_index=subscription_index,
959
- )
1271
+ # Phase 3: Component hook - before schedule (circuit breaker, deduplication, etc.)
1272
+ from flock.orchestrator_component import ScheduleDecision
960
1273
 
961
- # Start correlation cleanup task if time-based window and not running
962
- if (
963
- isinstance(subscription.join.within, timedelta)
964
- and self._correlation_cleanup_task is None
965
- ):
966
- self._correlation_cleanup_task = asyncio.create_task(
967
- self._correlation_cleanup_loop()
968
- )
969
-
970
- if completed_group is None:
971
- # Still waiting for correlation to complete
972
- # Phase 1.2: Emit real-time correlation update event
973
- await self._emit_correlation_updated_event(
974
- agent_name=agent.name,
975
- subscription_index=subscription_index,
976
- artifact=artifact,
977
- )
978
- continue
979
-
980
- # Correlation complete! Get all correlated artifacts
981
- artifacts = completed_group.get_artifacts()
982
- else:
983
- # AND GATE LOGIC: Use artifact collector for simple AND gates (no correlation)
984
- is_complete, artifacts = self._artifact_collector.add_artifact(
985
- agent, subscription, artifact
986
- )
1274
+ decision = await self._run_before_schedule(artifact, agent, subscription)
1275
+ if decision == ScheduleDecision.SKIP:
1276
+ continue # Skip this subscription
1277
+ if decision == ScheduleDecision.DEFER:
1278
+ continue # Defer for later (batching/correlation)
987
1279
 
988
- if not is_complete:
989
- # Still waiting for more types (AND gate incomplete)
990
- continue
991
-
992
- # BatchSpec BATCHING: Check if subscription has batch accumulator
993
- if subscription.batch is not None:
994
- # Add to batch accumulator
995
- subscription_index = agent.subscriptions.index(subscription)
996
-
997
- # COMBINED FEATURES: JoinSpec + BatchSpec
998
- # If we have JoinSpec, artifacts is a correlated GROUP - treat as single batch item
999
- # If we have AND gate, artifacts is a complete set - treat as single batch item
1000
- # Otherwise (single type), add each artifact individually
1001
-
1002
- if subscription.join is not None or len(subscription.type_models) > 1:
1003
- # JoinSpec or AND gate: Treat artifact group as ONE batch item
1004
- should_flush = self._batch_engine.add_artifact_group(
1005
- artifacts=artifacts,
1006
- subscription=subscription,
1007
- subscription_index=subscription_index,
1008
- )
1009
-
1010
- # Start timeout checker if this batch has a timeout and checker not running
1011
- if subscription.batch.timeout and self._batch_timeout_task is None:
1012
- self._batch_timeout_task = asyncio.create_task(
1013
- self._batch_timeout_checker_loop()
1014
- )
1015
- else:
1016
- # Single type subscription: Add each artifact individually
1017
- should_flush = False
1018
- for single_artifact in artifacts:
1019
- should_flush = self._batch_engine.add_artifact(
1020
- artifact=single_artifact,
1021
- subscription=subscription,
1022
- subscription_index=subscription_index,
1023
- )
1024
-
1025
- # Start timeout checker if this batch has a timeout and checker not running
1026
- if subscription.batch.timeout and self._batch_timeout_task is None:
1027
- self._batch_timeout_task = asyncio.create_task(
1028
- self._batch_timeout_checker_loop()
1029
- )
1030
-
1031
- if should_flush:
1032
- # Size threshold reached! Flush batch now
1033
- break
1034
-
1035
- if not should_flush:
1036
- # Batch not full yet - wait for more artifacts
1037
- # Phase 1.2: Emit real-time batch update event
1038
- await self._emit_batch_item_added_event(
1039
- agent_name=agent.name,
1040
- subscription_index=subscription_index,
1041
- subscription=subscription,
1042
- artifact=artifact,
1043
- )
1044
- continue
1045
-
1046
- # Flush the batch and get all accumulated artifacts
1047
- batched_artifacts = self._batch_engine.flush_batch(
1048
- agent.name, subscription_index
1049
- )
1280
+ # Phase 3: Component hook - collect artifacts (handles AND gates, correlation, batching)
1281
+ collection = await self._run_collect_artifacts(artifact, agent, subscription)
1282
+ if not collection.complete:
1283
+ continue # Still collecting (AND gate, correlation, or batch incomplete)
1050
1284
 
1051
- if batched_artifacts is None:
1052
- # No batch to flush (shouldn't happen, but defensive)
1053
- continue
1285
+ artifacts = collection.artifacts
1054
1286
 
1055
- # Replace artifacts with batched artifacts
1056
- artifacts = batched_artifacts
1287
+ # Phase 3: Component hook - before agent schedule (final validation/transformation)
1288
+ artifacts = await self._run_before_agent_schedule(agent, artifacts)
1289
+ if artifacts is None:
1290
+ continue # Scheduling blocked by component
1057
1291
 
1058
- # Complete! Schedule agent with all collected artifacts
1059
- # T068: Increment iteration counter
1060
- self._agent_iteration_count[agent.name] = iteration_count + 1
1061
-
1062
- # Mark all artifacts as processed (prevent duplicate triggers)
1063
- for collected_artifact in artifacts:
1064
- self._mark_processed(collected_artifact, agent)
1065
-
1066
- # Schedule agent with ALL artifacts (batched, correlated, or AND gate complete)
1067
- # NEW: Mark as batch execution if flushed from BatchSpec
1292
+ # Complete! Schedule agent with collected artifacts
1293
+ # Schedule agent task
1068
1294
  is_batch_execution = subscription.batch is not None
1069
- self._schedule_task(agent, artifacts, is_batch=is_batch_execution)
1295
+ task = self._schedule_task(agent, artifacts, is_batch=is_batch_execution)
1296
+
1297
+ # Phase 3: Component hook - agent scheduled (notification)
1298
+ await self._run_agent_scheduled(agent, artifacts, task)
1070
1299
 
1071
1300
  def _schedule_task(
1072
1301
  self, agent: Agent, artifacts: list[Artifact], is_batch: bool = False
1073
- ) -> None:
1302
+ ) -> Task[Any]:
1303
+ """Schedule agent task and return the task handle."""
1074
1304
  task = asyncio.create_task(self._run_agent_task(agent, artifacts, is_batch=is_batch))
1075
1305
  self._tasks.add(task)
1076
1306
  task.add_done_callback(self._tasks.discard)
1307
+ return task
1077
1308
 
1078
1309
  def _record_agent_run(self, agent: Agent) -> None:
1079
1310
  self.metrics["agent_runs"] += 1