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

Files changed (94) hide show
  1. flock/__init__.py +1 -1
  2. flock/agent/__init__.py +30 -0
  3. flock/agent/builder_helpers.py +192 -0
  4. flock/agent/builder_validator.py +169 -0
  5. flock/agent/component_lifecycle.py +325 -0
  6. flock/agent/context_resolver.py +141 -0
  7. flock/agent/mcp_integration.py +212 -0
  8. flock/agent/output_processor.py +304 -0
  9. flock/api/__init__.py +20 -0
  10. flock/{api_models.py → api/models.py} +0 -2
  11. flock/{service.py → api/service.py} +3 -3
  12. flock/cli.py +2 -2
  13. flock/components/__init__.py +41 -0
  14. flock/components/agent/__init__.py +22 -0
  15. flock/{components.py → components/agent/base.py} +4 -3
  16. flock/{utility/output_utility_component.py → components/agent/output_utility.py} +12 -7
  17. flock/components/orchestrator/__init__.py +22 -0
  18. flock/{orchestrator_component.py → components/orchestrator/base.py} +5 -293
  19. flock/components/orchestrator/circuit_breaker.py +95 -0
  20. flock/components/orchestrator/collection.py +143 -0
  21. flock/components/orchestrator/deduplication.py +78 -0
  22. flock/core/__init__.py +30 -0
  23. flock/core/agent.py +953 -0
  24. flock/{artifacts.py → core/artifacts.py} +1 -1
  25. flock/{context_provider.py → core/context_provider.py} +3 -3
  26. flock/core/orchestrator.py +1102 -0
  27. flock/{store.py → core/store.py} +99 -454
  28. flock/{subscription.py → core/subscription.py} +1 -1
  29. flock/dashboard/collector.py +5 -5
  30. flock/dashboard/events.py +1 -1
  31. flock/dashboard/graph_builder.py +7 -7
  32. flock/dashboard/routes/__init__.py +21 -0
  33. flock/dashboard/routes/control.py +327 -0
  34. flock/dashboard/routes/helpers.py +340 -0
  35. flock/dashboard/routes/themes.py +76 -0
  36. flock/dashboard/routes/traces.py +521 -0
  37. flock/dashboard/routes/websocket.py +108 -0
  38. flock/dashboard/service.py +43 -1316
  39. flock/engines/dspy/__init__.py +20 -0
  40. flock/engines/dspy/artifact_materializer.py +216 -0
  41. flock/engines/dspy/signature_builder.py +474 -0
  42. flock/engines/dspy/streaming_executor.py +812 -0
  43. flock/engines/dspy_engine.py +45 -1330
  44. flock/engines/examples/simple_batch_engine.py +2 -2
  45. flock/engines/streaming/__init__.py +3 -0
  46. flock/engines/streaming/sinks.py +489 -0
  47. flock/examples.py +7 -7
  48. flock/logging/logging.py +1 -16
  49. flock/models/__init__.py +10 -0
  50. flock/orchestrator/__init__.py +45 -0
  51. flock/{artifact_collector.py → orchestrator/artifact_collector.py} +3 -3
  52. flock/orchestrator/artifact_manager.py +168 -0
  53. flock/{batch_accumulator.py → orchestrator/batch_accumulator.py} +2 -2
  54. flock/orchestrator/component_runner.py +389 -0
  55. flock/orchestrator/context_builder.py +167 -0
  56. flock/{correlation_engine.py → orchestrator/correlation_engine.py} +2 -2
  57. flock/orchestrator/event_emitter.py +167 -0
  58. flock/orchestrator/initialization.py +184 -0
  59. flock/orchestrator/lifecycle_manager.py +226 -0
  60. flock/orchestrator/mcp_manager.py +202 -0
  61. flock/orchestrator/scheduler.py +189 -0
  62. flock/orchestrator/server_manager.py +234 -0
  63. flock/orchestrator/tracing.py +147 -0
  64. flock/storage/__init__.py +10 -0
  65. flock/storage/artifact_aggregator.py +158 -0
  66. flock/storage/in_memory/__init__.py +6 -0
  67. flock/storage/in_memory/artifact_filter.py +114 -0
  68. flock/storage/in_memory/history_aggregator.py +115 -0
  69. flock/storage/sqlite/__init__.py +10 -0
  70. flock/storage/sqlite/agent_history_queries.py +154 -0
  71. flock/storage/sqlite/consumption_loader.py +100 -0
  72. flock/storage/sqlite/query_builder.py +112 -0
  73. flock/storage/sqlite/query_params_builder.py +91 -0
  74. flock/storage/sqlite/schema_manager.py +168 -0
  75. flock/storage/sqlite/summary_queries.py +194 -0
  76. flock/utils/__init__.py +14 -0
  77. flock/utils/async_utils.py +67 -0
  78. flock/{runtime.py → utils/runtime.py} +3 -3
  79. flock/utils/time_utils.py +53 -0
  80. flock/utils/type_resolution.py +38 -0
  81. flock/{utilities.py → utils/utilities.py} +2 -2
  82. flock/utils/validation.py +57 -0
  83. flock/utils/visibility.py +79 -0
  84. flock/utils/visibility_utils.py +134 -0
  85. {flock_core-0.5.11.dist-info → flock_core-0.5.21.dist-info}/METADATA +19 -5
  86. {flock_core-0.5.11.dist-info → flock_core-0.5.21.dist-info}/RECORD +92 -34
  87. flock/agent.py +0 -1578
  88. flock/orchestrator.py +0 -1983
  89. /flock/{visibility.py → core/visibility.py} +0 -0
  90. /flock/{system_artifacts.py → models/system_artifacts.py} +0 -0
  91. /flock/{helper → utils}/cli_helper.py +0 -0
  92. {flock_core-0.5.11.dist-info → flock_core-0.5.21.dist-info}/WHEEL +0 -0
  93. {flock_core-0.5.11.dist-info → flock_core-0.5.21.dist-info}/entry_points.txt +0 -0
  94. {flock_core-0.5.11.dist-info → flock_core-0.5.21.dist-info}/licenses/LICENSE +0 -0
flock/core/agent.py ADDED
@@ -0,0 +1,953 @@
1
+ """Agent definitions and fluent builder APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING, Any, TypedDict
10
+
11
+ from pydantic import BaseModel
12
+
13
+ # Phase 5B: Import builder modules
14
+ from flock.agent.builder_helpers import Pipeline, PublishBuilder, RunHandle
15
+ from flock.agent.builder_validator import BuilderValidator
16
+ from flock.agent.component_lifecycle import ComponentLifecycle
17
+ from flock.agent.mcp_integration import MCPIntegration
18
+
19
+ # Phase 4: Import extracted modules
20
+ from flock.agent.output_processor import OutputProcessor
21
+ from flock.core.artifacts import Artifact, ArtifactSpec
22
+ from flock.core.subscription import BatchSpec, JoinSpec, Subscription, TextPredicate
23
+ from flock.core.visibility import AgentIdentity, Visibility, ensure_visibility
24
+ from flock.logging.auto_trace import AutoTracedMeta
25
+ from flock.logging.logging import get_logger
26
+ from flock.registry import function_registry, type_registry
27
+ from flock.utils.runtime import Context, EvalInputs, EvalResult
28
+
29
+
30
+ logger = get_logger(__name__)
31
+
32
+ if TYPE_CHECKING: # pragma: no cover - type hints only
33
+ from collections.abc import Callable, Iterable, Sequence
34
+
35
+ from flock.components.agent import AgentComponent, EngineComponent
36
+ from flock.core import Flock
37
+
38
+
39
+ class MCPServerConfig(TypedDict, total=False):
40
+ """Configuration for MCP server assignment to an agent.
41
+
42
+ All fields are optional. If omitted, no restrictions apply.
43
+
44
+ Attributes:
45
+ roots: Filesystem paths this server can access.
46
+ Empty list or omitted = no mount restrictions.
47
+ tool_whitelist: Tool names the agent can use from this server.
48
+ Empty list or omitted = all tools available.
49
+
50
+ Examples:
51
+ >>> # No restrictions
52
+ >>> config: MCPServerConfig = {}
53
+
54
+ >>> # Mount restrictions only
55
+ >>> config: MCPServerConfig = {"roots": ["/workspace/data"]}
56
+
57
+ >>> # Tool whitelist only
58
+ >>> config: MCPServerConfig = {
59
+ ... "tool_whitelist": ["read_file", "write_file"]
60
+ ... }
61
+
62
+ >>> # Both restrictions
63
+ >>> config: MCPServerConfig = {
64
+ ... "roots": ["/workspace/data"],
65
+ ... "tool_whitelist": ["read_file"],
66
+ ... }
67
+ """
68
+
69
+ roots: list[str]
70
+ tool_whitelist: list[str]
71
+
72
+
73
+ @dataclass
74
+ class AgentOutput:
75
+ spec: ArtifactSpec
76
+ default_visibility: Visibility
77
+ count: int = 1 # Number of artifacts to generate (fan-out)
78
+ filter_predicate: Callable[[BaseModel], bool] | None = None # Where clause
79
+ validate_predicate: (
80
+ Callable[[BaseModel], bool] | list[tuple[Callable, str]] | None
81
+ ) = None # Validation logic
82
+ group_description: str | None = None # Group description override
83
+
84
+ def __post_init__(self):
85
+ """Validate field constraints."""
86
+ if self.count < 1:
87
+ raise ValueError(f"count must be >= 1, got {self.count}")
88
+
89
+ def is_many(self) -> bool:
90
+ """Return True if this output generates multiple artifacts (count > 1)."""
91
+ return self.count > 1
92
+
93
+ def apply(
94
+ self,
95
+ data: dict[str, Any],
96
+ *,
97
+ produced_by: str,
98
+ metadata: dict[str, Any] | None = None,
99
+ ) -> Artifact:
100
+ metadata = metadata or {}
101
+ return self.spec.build(
102
+ produced_by=produced_by,
103
+ data=data,
104
+ visibility=metadata.get("visibility", self.default_visibility),
105
+ correlation_id=metadata.get("correlation_id"),
106
+ partition_key=metadata.get("partition_key"),
107
+ tags=metadata.get("tags"),
108
+ version=metadata.get("version", 1),
109
+ artifact_id=metadata.get("artifact_id"), # Phase 6: Preserve engine's ID
110
+ )
111
+
112
+
113
+ @dataclass
114
+ class OutputGroup:
115
+ """Represents one .publishes() call.
116
+
117
+ Each OutputGroup triggers one engine execution that generates
118
+ all artifacts in the group together.
119
+ """
120
+
121
+ outputs: list[AgentOutput]
122
+ shared_visibility: Visibility | None = None
123
+ group_description: str | None = None # Group-level description override
124
+
125
+ def is_single_call(self) -> bool:
126
+ """True if this is one engine call generating multiple artifacts.
127
+
128
+ Currently always returns True as each group = one engine call.
129
+ Future: Could return False for parallel sub-groups.
130
+ """
131
+ return True
132
+
133
+
134
+ class Agent(metaclass=AutoTracedMeta):
135
+ """Executable agent constructed via `AgentBuilder`.
136
+
137
+ All public methods are automatically traced via OpenTelemetry.
138
+ """
139
+
140
+ # Phase 6+7: Class-level streaming coordination (SHARED across ALL agent instances)
141
+ # These class variables enable all agents to coordinate CLI streaming behavior
142
+ _streaming_counter: int = 0 # Global count of agents currently streaming to CLI
143
+ _websocket_broadcast_global: Any = (
144
+ None # WebSocket broadcast wrapper (dashboard mode)
145
+ )
146
+
147
+ def __init__(self, name: str, *, orchestrator: Flock) -> None:
148
+ self.name = name
149
+ self.description: str | None = None
150
+ self._orchestrator = orchestrator
151
+ self.subscriptions: list[Subscription] = []
152
+ self.output_groups: list[OutputGroup] = []
153
+ self.utilities: list[AgentComponent] = []
154
+ self.engines: list[EngineComponent] = []
155
+ self.best_of_n: int = 1
156
+ self.best_of_score: Callable[[EvalResult], float] | None = None
157
+ self.max_concurrency: int = 2
158
+ self._semaphore = asyncio.Semaphore(self.max_concurrency)
159
+ self.calls_func: Callable[..., Any] | None = None
160
+ self.tools: set[Callable[..., Any]] = set()
161
+ self.labels: set[str] = set()
162
+ self.tenant_id: str | None = None
163
+ self.model: str | None = None
164
+ self.prevent_self_trigger: bool = True # T065: Prevent infinite feedback loops
165
+ # Phase 3: Per-agent context provider (security fix)
166
+ self.context_provider: Any = None
167
+
168
+ # Phase 4: Initialize extracted modules
169
+ self._output_processor = OutputProcessor(name)
170
+ self._mcp_integration = MCPIntegration(name, orchestrator)
171
+ self._component_lifecycle = ComponentLifecycle(name)
172
+
173
+ @property
174
+ def outputs(self) -> list[AgentOutput]:
175
+ """Return flat list of all outputs from all groups."""
176
+ return [output for group in self.output_groups for output in group.outputs]
177
+
178
+ # Phase 4: MCP properties - delegate to MCPIntegration
179
+ @property
180
+ def mcp_server_names(self) -> set[str]:
181
+ """MCP server names assigned to this agent."""
182
+ return self._mcp_integration.mcp_server_names
183
+
184
+ @mcp_server_names.setter
185
+ def mcp_server_names(self, value: set[str]) -> None:
186
+ self._mcp_integration.mcp_server_names = value
187
+
188
+ @property
189
+ def mcp_server_mounts(self) -> dict[str, list[str]]:
190
+ """Server-specific mount points."""
191
+ return self._mcp_integration.mcp_server_mounts
192
+
193
+ @mcp_server_mounts.setter
194
+ def mcp_server_mounts(self, value: dict[str, list[str]]) -> None:
195
+ self._mcp_integration.mcp_server_mounts = value
196
+
197
+ @property
198
+ def tool_whitelist(self) -> list[str] | None:
199
+ """Tool whitelist for MCP servers."""
200
+ return self._mcp_integration.tool_whitelist
201
+
202
+ @tool_whitelist.setter
203
+ def tool_whitelist(self, value: list[str] | None) -> None:
204
+ self._mcp_integration.tool_whitelist = value
205
+
206
+ @property
207
+ def identity(self) -> AgentIdentity:
208
+ return AgentIdentity(
209
+ name=self.name, labels=self.labels, tenant_id=self.tenant_id
210
+ )
211
+
212
+ @staticmethod
213
+ def _component_display_name(component: AgentComponent) -> str:
214
+ return component.name or component.__class__.__name__
215
+
216
+ def _sorted_utilities(self) -> list[AgentComponent]:
217
+ if not self.utilities:
218
+ return []
219
+ return sorted(self.utilities, key=lambda comp: getattr(comp, "priority", 0))
220
+
221
+ def _add_utilities(self, components: Sequence[AgentComponent]) -> None:
222
+ if not components:
223
+ return
224
+ for component in components:
225
+ self.utilities.append(component)
226
+ comp_name = self._component_display_name(component)
227
+ priority = getattr(component, "priority", 0)
228
+ logger.info(
229
+ "Agent %s: utility added: component=%s, priority=%s, total_utilities=%s",
230
+ self.name,
231
+ comp_name,
232
+ priority,
233
+ len(self.utilities),
234
+ )
235
+ self.utilities.sort(key=lambda comp: getattr(comp, "priority", 0))
236
+
237
+ def set_max_concurrency(self, value: int) -> None:
238
+ self.max_concurrency = max(1, value)
239
+ self._semaphore = asyncio.Semaphore(self.max_concurrency)
240
+
241
+ async def run_direct(self, *inputs: BaseModel) -> list[Artifact]:
242
+ return await self._orchestrator.direct_invoke(self, list(inputs))
243
+
244
+ async def execute(self, ctx: Context, artifacts: list[Artifact]) -> list[Artifact]:
245
+ async with self._semaphore:
246
+ try:
247
+ self._resolve_engines()
248
+ self._resolve_utilities()
249
+ await self._run_initialize(ctx)
250
+ processed_inputs = await self._run_pre_consume(ctx, artifacts)
251
+ eval_inputs = EvalInputs(
252
+ artifacts=processed_inputs, state=dict(ctx.state)
253
+ )
254
+ eval_inputs = await self._run_pre_evaluate(ctx, eval_inputs)
255
+
256
+ # Phase 3: Call engine ONCE PER OutputGroup
257
+ all_outputs: list[Artifact] = []
258
+
259
+ if not self.output_groups:
260
+ # No output groups: Utility agents that don't publish
261
+ # Create empty OutputGroup for engines that may have side effects
262
+ empty_group = OutputGroup(outputs=[], group_description=None)
263
+ result = await self._run_engines(ctx, eval_inputs, empty_group)
264
+ # Run post_evaluate hooks for utility components (e.g., metrics)
265
+ result = await self._run_post_evaluate(ctx, eval_inputs, result)
266
+ # Utility agents return empty list (no outputs declared)
267
+ outputs = []
268
+ else:
269
+ # Loop over each output group
270
+ for group_idx, output_group in enumerate(self.output_groups):
271
+ # Prepare group-specific context
272
+ group_ctx = self._prepare_group_context(
273
+ ctx, group_idx, output_group
274
+ )
275
+
276
+ # Phase 7: Single evaluation path with auto-detection
277
+ # Engine's evaluate() auto-detects batch/fan-out from ctx and output_group
278
+ result = await self._run_engines(
279
+ group_ctx, eval_inputs, output_group
280
+ )
281
+
282
+ result = await self._run_post_evaluate(
283
+ group_ctx, eval_inputs, result
284
+ )
285
+
286
+ # Extract outputs for THIS group only
287
+ group_outputs = await self._make_outputs_for_group(
288
+ group_ctx, result, output_group
289
+ )
290
+
291
+ all_outputs.extend(group_outputs)
292
+
293
+ outputs = all_outputs
294
+
295
+ await self._run_post_publish(ctx, outputs)
296
+ if self.calls_func:
297
+ await self._invoke_call(ctx, outputs or processed_inputs)
298
+ return outputs
299
+ except Exception as exc:
300
+ await self._run_error(ctx, exc)
301
+ raise
302
+ finally:
303
+ await self._run_terminate(ctx)
304
+
305
+ async def _get_mcp_tools(self, ctx: Context) -> list[Callable]:
306
+ """Delegate to MCPIntegration module."""
307
+ return await self._mcp_integration.get_mcp_tools(ctx)
308
+
309
+ async def _run_initialize(self, ctx: Context) -> None:
310
+ """Delegate to ComponentLifecycle module."""
311
+ await self._component_lifecycle.run_initialize(
312
+ self, ctx, self._sorted_utilities(), self.engines
313
+ )
314
+
315
+ async def _run_pre_consume(
316
+ self, ctx: Context, inputs: list[Artifact]
317
+ ) -> list[Artifact]:
318
+ """Delegate to ComponentLifecycle module."""
319
+ return await self._component_lifecycle.run_pre_consume(
320
+ self, ctx, inputs, self._sorted_utilities()
321
+ )
322
+
323
+ async def _run_pre_evaluate(self, ctx: Context, inputs: EvalInputs) -> EvalInputs:
324
+ """Delegate to ComponentLifecycle module."""
325
+ return await self._component_lifecycle.run_pre_evaluate(
326
+ self, ctx, inputs, self._sorted_utilities()
327
+ )
328
+
329
+ async def _run_engines(
330
+ self, ctx: Context, inputs: EvalInputs, output_group: OutputGroup
331
+ ) -> EvalResult:
332
+ """Execute engines for a specific OutputGroup.
333
+
334
+ Args:
335
+ ctx: Execution context
336
+ inputs: EvalInputs with input artifacts
337
+ output_group: The OutputGroup defining what artifacts to produce
338
+
339
+ Returns:
340
+ EvalResult with artifacts matching output_group specifications
341
+ """
342
+ engines = self._resolve_engines()
343
+ if not engines:
344
+ return EvalResult(artifacts=inputs.artifacts, state=inputs.state)
345
+
346
+ async def run_chain() -> EvalResult:
347
+ current_inputs = inputs
348
+ accumulated_logs: list[str] = []
349
+ accumulated_metrics: dict[str, float] = {}
350
+ for engine in engines:
351
+ current_inputs = await engine.on_pre_evaluate(self, ctx, current_inputs)
352
+
353
+ # Phase 7: Single evaluation path with auto-detection
354
+ # Engine's evaluate() auto-detects batching via ctx.is_batch
355
+ result = await engine.evaluate(self, ctx, current_inputs, output_group)
356
+
357
+ # AUTO-WRAP: If engine returns BaseModel instead of EvalResult, wrap it
358
+ from flock.utils.runtime import EvalResult as ER
359
+
360
+ if isinstance(result, BaseModel) and not isinstance(result, ER):
361
+ result = ER.from_object(result, agent=self)
362
+
363
+ artifacts = result.artifacts
364
+ for artifact in artifacts:
365
+ artifact.correlation_id = ctx.correlation_id
366
+
367
+ result = await engine.on_post_evaluate(
368
+ self, ctx, current_inputs, result
369
+ )
370
+ accumulated_logs.extend(result.logs)
371
+ accumulated_metrics.update(result.metrics)
372
+ merged_state = dict(current_inputs.state)
373
+ merged_state.update(result.state)
374
+ current_inputs = EvalInputs(
375
+ artifacts=result.artifacts or current_inputs.artifacts,
376
+ state=merged_state,
377
+ )
378
+ return EvalResult(
379
+ artifacts=current_inputs.artifacts,
380
+ state=current_inputs.state,
381
+ metrics=accumulated_metrics,
382
+ logs=accumulated_logs,
383
+ )
384
+
385
+ if self.best_of_n <= 1:
386
+ return await run_chain()
387
+
388
+ async with asyncio.TaskGroup() as tg: # Python 3.12
389
+ tasks: list[asyncio.Task[EvalResult]] = []
390
+ for _ in range(self.best_of_n):
391
+ tasks.append(tg.create_task(run_chain()))
392
+ results = [task.result() for task in tasks]
393
+ if not results:
394
+ return EvalResult(artifacts=[], state={})
395
+ if self.best_of_score is None:
396
+ return results[0]
397
+ return max(results, key=self.best_of_score)
398
+
399
+ async def _run_post_evaluate(
400
+ self, ctx: Context, inputs: EvalInputs, result: EvalResult
401
+ ) -> EvalResult:
402
+ """Delegate to ComponentLifecycle module."""
403
+ return await self._component_lifecycle.run_post_evaluate(
404
+ self, ctx, inputs, result, self._sorted_utilities()
405
+ )
406
+
407
+ async def _make_outputs(self, ctx: Context, result: EvalResult) -> list[Artifact]:
408
+ """Delegate to OutputProcessor module."""
409
+ return await self._output_processor.make_outputs(
410
+ ctx, result, self.output_groups
411
+ )
412
+
413
+ def _prepare_group_context(
414
+ self, ctx: Context, group_idx: int, output_group: OutputGroup
415
+ ) -> Context:
416
+ """Delegate to OutputProcessor module."""
417
+ return self._output_processor.prepare_group_context(
418
+ ctx, group_idx, output_group
419
+ )
420
+
421
+ async def _make_outputs_for_group(
422
+ self, ctx: Context, result: EvalResult, output_group: OutputGroup
423
+ ) -> list[Artifact]:
424
+ """Delegate to OutputProcessor module."""
425
+ return await self._output_processor.make_outputs_for_group(
426
+ ctx, result, output_group
427
+ )
428
+
429
+ async def _run_post_publish(
430
+ self, ctx: Context, artifacts: Sequence[Artifact]
431
+ ) -> None:
432
+ """Delegate to ComponentLifecycle module."""
433
+ await self._component_lifecycle.run_post_publish(
434
+ self, ctx, artifacts, self._sorted_utilities()
435
+ )
436
+
437
+ async def _invoke_call(self, ctx: Context, artifacts: Sequence[Artifact]) -> None:
438
+ func = self.calls_func
439
+ if func is None:
440
+ return
441
+ if not artifacts:
442
+ return
443
+ first = artifacts[0]
444
+ model_cls = type_registry.resolve(first.type)
445
+ payload = model_cls(**first.payload)
446
+ maybe_coro = func(payload)
447
+ if asyncio.iscoroutine(maybe_coro): # pragma: no cover - optional async support
448
+ await maybe_coro
449
+
450
+ async def _run_error(self, ctx: Context, error: Exception) -> None:
451
+ """Delegate to ComponentLifecycle module."""
452
+ await self._component_lifecycle.run_error(
453
+ self, ctx, error, self._sorted_utilities(), self.engines
454
+ )
455
+
456
+ async def _run_terminate(self, ctx: Context) -> None:
457
+ """Delegate to ComponentLifecycle module."""
458
+ await self._component_lifecycle.run_terminate(
459
+ self, ctx, self._sorted_utilities(), self.engines
460
+ )
461
+
462
+ def _resolve_engines(self) -> list[EngineComponent]:
463
+ if self.engines:
464
+ return self.engines
465
+ try:
466
+ from flock.engines import DSPyEngine
467
+ except Exception: # pragma: no cover - optional dependency issues
468
+ return []
469
+
470
+ default_engine = DSPyEngine(
471
+ model=self._orchestrator.model
472
+ or os.getenv("DEFAULT_MODEL", "openai/gpt-4.1"),
473
+ instructions=self.description,
474
+ )
475
+ self.engines = [default_engine]
476
+ return self.engines
477
+
478
+ def _resolve_utilities(self) -> list[AgentComponent]:
479
+ if self.utilities:
480
+ return self.utilities
481
+ try:
482
+ from flock.components.agent import (
483
+ OutputUtilityComponent,
484
+ )
485
+ except Exception: # pragma: no cover - optional dependency issues
486
+ return []
487
+
488
+ default_component = OutputUtilityComponent()
489
+ self._add_utilities([default_component])
490
+ return self.utilities
491
+
492
+ def _find_matching_artifact(
493
+ self, output_decl: AgentOutput, result: EvalResult
494
+ ) -> Artifact | None:
495
+ """Delegate to OutputProcessor module."""
496
+ return self._output_processor.find_matching_artifact(output_decl, result)
497
+
498
+ def _select_payload(
499
+ self, output_decl: AgentOutput, result: EvalResult
500
+ ) -> dict[str, Any] | None:
501
+ """Delegate to OutputProcessor module."""
502
+ return self._output_processor.select_payload(output_decl, result)
503
+
504
+
505
+ class AgentBuilder:
506
+ """Fluent builder that also acts as the runtime agent handle."""
507
+
508
+ def __init__(self, orchestrator: Flock, name: str) -> None:
509
+ self._orchestrator = orchestrator
510
+ self._agent = Agent(name, orchestrator=orchestrator)
511
+ self._agent.model = orchestrator.model
512
+ orchestrator.register_agent(self._agent)
513
+
514
+ # Fluent configuration -------------------------------------------------
515
+
516
+ def description(self, text: str) -> AgentBuilder:
517
+ """Set the agent's description for documentation and tracing.
518
+
519
+ Args:
520
+ text: Human-readable description of what the agent does
521
+
522
+ Returns:
523
+ self for method chaining
524
+
525
+ Example:
526
+ >>> agent = (
527
+ ... flock.agent("pizza_chef")
528
+ ... .description("Creates authentic Italian pizza recipes")
529
+ ... .consumes(Idea)
530
+ ... .publishes(Recipe)
531
+ ... )
532
+ """
533
+ self._agent.description = text
534
+ return self
535
+
536
+ def consumes(
537
+ self,
538
+ *types: type[BaseModel],
539
+ where: Callable[[BaseModel], bool]
540
+ | Sequence[Callable[[BaseModel], bool]]
541
+ | None = None,
542
+ text: str | None = None,
543
+ min_p: float = 0.0,
544
+ from_agents: Iterable[str] | None = None,
545
+ tags: Iterable[str] | None = None,
546
+ join: dict | JoinSpec | None = None,
547
+ batch: dict | BatchSpec | None = None,
548
+ delivery: str = "exclusive",
549
+ mode: str = "both",
550
+ priority: int = 0,
551
+ ) -> AgentBuilder:
552
+ """Declare which artifact types this agent processes.
553
+
554
+ Sets up subscription rules that determine when the agent executes.
555
+ Supports type-based matching, conditional filters, batching, and joins.
556
+
557
+ Args:
558
+ *types: Artifact types (Pydantic models) to consume
559
+ where: Optional filter predicate(s). Agent only executes if predicate returns True.
560
+ Can be a single callable or sequence of callables (all must pass).
561
+ text: Optional semantic text filter using embedding similarity
562
+ min_p: Minimum probability threshold for text similarity (0.0-1.0)
563
+ from_agents: Only consume artifacts from specific agents
564
+ tags: Only consume artifacts with matching tags
565
+ join: Join specification for coordinating multiple artifact types
566
+ batch: Batch specification for processing multiple artifacts together
567
+ delivery: Delivery mode - "exclusive" (one agent) or "broadcast" (all matching)
568
+ mode: Processing mode - "both", "streaming", or "batch"
569
+ priority: Execution priority (higher = executes first)
570
+
571
+ Returns:
572
+ self for method chaining
573
+
574
+ Examples:
575
+ >>> # Basic type subscription
576
+ >>> agent.consumes(Task)
577
+
578
+ >>> # Multiple types
579
+ >>> agent.consumes(Task, Event, Command)
580
+
581
+ >>> # Conditional consumption (filtering)
582
+ >>> agent.consumes(Review, where=lambda r: r.score >= 8)
583
+
584
+ >>> # Multiple predicates (all must pass)
585
+ >>> agent.consumes(
586
+ ... Order,
587
+ ... where=[lambda o: o.total > 100, lambda o: o.status == "pending"],
588
+ ... )
589
+
590
+ >>> # Consume from specific agents
591
+ >>> agent.consumes(Report, from_agents=["analyzer", "validator"])
592
+
593
+ >>> # Channel-based routing
594
+ >>> agent.consumes(Alert, tags={"critical", "security"})
595
+
596
+ >>> # Batch processing
597
+ >>> agent.consumes(Email, batch={"size": 10, "timeout": 5.0})
598
+ """
599
+ predicates: Sequence[Callable[[BaseModel], bool]] | None
600
+ if where is None:
601
+ predicates = None
602
+ elif callable(where):
603
+ predicates = [where]
604
+ else:
605
+ predicates = list(where)
606
+
607
+ # Phase 5B: Use BuilderValidator for normalization
608
+ join_spec = BuilderValidator.normalize_join(join)
609
+ batch_spec = BuilderValidator.normalize_batch(batch)
610
+ text_predicates = [TextPredicate(text=text, min_p=min_p)] if text else []
611
+ subscription = Subscription(
612
+ agent_name=self._agent.name,
613
+ types=types,
614
+ where=predicates,
615
+ text_predicates=text_predicates,
616
+ from_agents=from_agents,
617
+ tags=tags,
618
+ join=join_spec,
619
+ batch=batch_spec,
620
+ delivery=delivery,
621
+ mode=mode,
622
+ priority=priority,
623
+ )
624
+ self._agent.subscriptions.append(subscription)
625
+ return self
626
+
627
+ def publishes(
628
+ self,
629
+ *types: type[BaseModel],
630
+ visibility: Visibility | Callable[[BaseModel], Visibility] | None = None,
631
+ fan_out: int | None = None,
632
+ where: Callable[[BaseModel], bool] | None = None,
633
+ validate: Callable[[BaseModel], bool]
634
+ | list[tuple[Callable, str]]
635
+ | None = None,
636
+ description: str | None = None,
637
+ ) -> PublishBuilder:
638
+ """Declare which artifact types this agent produces.
639
+
640
+ Args:
641
+ *types: Artifact types (Pydantic models) to publish
642
+ visibility: Default visibility control OR callable for dynamic visibility
643
+ fan_out: Number of artifacts to publish (applies to ALL types)
644
+ where: Filter predicate for output artifacts
645
+ validate: Validation predicate(s) - callable or list of (callable, error_msg) tuples
646
+ description: Group-level description override
647
+
648
+ Returns:
649
+ PublishBuilder for conditional publishing configuration
650
+
651
+ Examples:
652
+ >>> agent.publishes(Report) # Publish 1 Report
653
+ >>> agent.publishes(
654
+ ... Task, Task, Task
655
+ ... ) # Publish 3 Tasks (duplicate counting)
656
+ >>> agent.publishes(Task, fan_out=3) # Same as above (sugar syntax)
657
+ >>> agent.publishes(Task, where=lambda t: t.priority > 5) # With filtering
658
+ >>> agent.publishes(
659
+ ... Report, validate=lambda r: r.score > 0
660
+ ... ) # With validation
661
+ >>> agent.publishes(
662
+ ... Task, description="Special instructions"
663
+ ... ) # With description
664
+
665
+ See Also:
666
+ - PublicVisibility: Default, visible to all agents
667
+ - PrivateVisibility: Allowlist-based access control
668
+ - TenantVisibility: Multi-tenant isolation
669
+ - LabelledVisibility: Role-based access control
670
+ """
671
+ # Validate fan_out if provided
672
+ if fan_out is not None and fan_out < 1:
673
+ raise ValueError(f"fan_out must be >= 1, got {fan_out}")
674
+
675
+ # Resolve visibility
676
+ resolved_visibility = (
677
+ ensure_visibility(visibility) if not callable(visibility) else visibility
678
+ )
679
+
680
+ # Create AgentOutput objects for this group
681
+ outputs: list[AgentOutput] = []
682
+
683
+ if fan_out is not None:
684
+ # Apply fan_out to ALL types
685
+ for model in types:
686
+ spec = ArtifactSpec.from_model(model)
687
+ output = AgentOutput(
688
+ spec=spec,
689
+ default_visibility=resolved_visibility,
690
+ count=fan_out,
691
+ filter_predicate=where,
692
+ validate_predicate=validate,
693
+ group_description=description,
694
+ )
695
+ outputs.append(output)
696
+ else:
697
+ # Create separate AgentOutput for each type (including duplicates)
698
+ # This preserves order: .publishes(A, B, A) → [A, B, A] (3 outputs)
699
+ for model in types:
700
+ spec = ArtifactSpec.from_model(model)
701
+ output = AgentOutput(
702
+ spec=spec,
703
+ default_visibility=resolved_visibility,
704
+ count=1,
705
+ filter_predicate=where,
706
+ validate_predicate=validate,
707
+ group_description=description,
708
+ )
709
+ outputs.append(output)
710
+
711
+ # Create OutputGroup from outputs
712
+ group = OutputGroup(
713
+ outputs=outputs,
714
+ shared_visibility=resolved_visibility
715
+ if not callable(resolved_visibility)
716
+ else None,
717
+ group_description=description,
718
+ )
719
+
720
+ # Append to agent's output_groups
721
+ self._agent.output_groups.append(group)
722
+
723
+ # Phase 5B: Use BuilderValidator for validation
724
+ BuilderValidator.validate_self_trigger_risk(self._agent)
725
+
726
+ return PublishBuilder(self, outputs)
727
+
728
+ def with_utilities(self, *components: AgentComponent) -> AgentBuilder:
729
+ """Add utility components to customize agent lifecycle and behavior.
730
+
731
+ Components are hooks that run at specific points in the agent execution
732
+ lifecycle. Common uses include rate limiting, budgets, metrics, caching,
733
+ and custom preprocessing/postprocessing.
734
+
735
+ Args:
736
+ *components: AgentComponent instances with lifecycle hooks
737
+
738
+ Returns:
739
+ self for method chaining
740
+
741
+ Examples:
742
+ >>> # Rate limiting
743
+ >>> agent.with_utilities(RateLimiter(max_calls=10, window=60))
744
+
745
+ >>> # Budget control
746
+ >>> agent.with_utilities(TokenBudget(max_tokens=10000))
747
+
748
+ >>> # Multiple components (executed in order)
749
+ >>> agent.with_utilities(
750
+ ... RateLimiter(max_calls=5), MetricsCollector(), CacheLayer(ttl=3600)
751
+ ... )
752
+
753
+ See Also:
754
+ - AgentComponent: Base class for custom components
755
+ - Lifecycle hooks: on_initialize, on_pre_consume, on_post_publish, etc.
756
+ """
757
+ if components:
758
+ self._agent._add_utilities(list(components))
759
+ return self
760
+
761
+ def with_engines(self, *engines: EngineComponent) -> AgentBuilder:
762
+ """Configure LLM engines for agent evaluation.
763
+
764
+ Engines determine how agents process inputs. Default is DSPy with the
765
+ orchestrator's model. Custom engines enable different LLM backends,
766
+ non-LLM logic, or hybrid approaches.
767
+
768
+ Args:
769
+ *engines: EngineComponent instances for evaluation
770
+
771
+ Returns:
772
+ self for method chaining
773
+
774
+ Examples:
775
+ >>> # DSPy engine with specific model
776
+ >>> agent.with_engines(DSPyEngine(model="openai/gpt-4o"))
777
+
778
+ >>> # Custom non-LLM engine
779
+ >>> agent.with_engines(RuleBasedEngine(rules=my_rules))
780
+
781
+ >>> # Hybrid approach (multiple engines)
782
+ >>> agent.with_engines(
783
+ ... DSPyEngine(model="openai/gpt-4o-mini"), FallbackEngine()
784
+ ... )
785
+
786
+ Note:
787
+ If no engines specified, agent uses DSPy with the orchestrator's default model.
788
+
789
+ See Also:
790
+ - DSPyEngine: Default LLM-based evaluation
791
+ - EngineComponent: Base class for custom engines
792
+ """
793
+ self._agent.engines.extend(engines)
794
+ return self
795
+
796
+ def best_of(self, n: int, score: Callable[[EvalResult], float]) -> AgentBuilder:
797
+ self._agent.best_of_n = max(1, n)
798
+ self._agent.best_of_score = score
799
+ # Phase 5B: Use BuilderValidator for validation
800
+ BuilderValidator.validate_best_of(self._agent.name, n)
801
+ return self
802
+
803
+ def max_concurrency(self, n: int) -> AgentBuilder:
804
+ self._agent.set_max_concurrency(n)
805
+ # Phase 5B: Use BuilderValidator for validation
806
+ BuilderValidator.validate_concurrency(self._agent.name, n)
807
+ return self
808
+
809
+ def calls(self, func: Callable[..., Any]) -> AgentBuilder:
810
+ function_registry.register(func)
811
+ self._agent.calls_func = func
812
+ return self
813
+
814
+ def with_tools(self, funcs: Iterable[Callable[..., Any]]) -> AgentBuilder:
815
+ self._agent.tools.update(funcs)
816
+ return self
817
+
818
+ def with_context(self, provider: Any) -> AgentBuilder:
819
+ """Configure a custom context provider for this agent (Phase 3 security fix).
820
+
821
+ Context providers control what artifacts an agent can see, enforcing
822
+ visibility filtering at the security boundary layer.
823
+
824
+ Args:
825
+ provider: ContextProvider instance for this agent
826
+
827
+ Returns:
828
+ self for method chaining
829
+
830
+ Examples:
831
+ >>> # Use custom provider for this agent
832
+ >>> agent.with_context(MyCustomProvider())
833
+
834
+ >>> # Use FilteredContextProvider for declarative filtering
835
+ >>> agent.with_context(
836
+ ... FilteredContextProvider(FilterConfig(tags={"important"}))
837
+ ... )
838
+
839
+ Note:
840
+ Per-agent provider takes precedence over global provider configured
841
+ on Flock(context_provider=...). If neither is set, DefaultContextProvider
842
+ is used automatically.
843
+
844
+ See Also:
845
+ - DefaultContextProvider: Default security boundary with visibility enforcement
846
+ - FilteredContextProvider: Declarative filtering with FilterConfig
847
+ """
848
+ self._agent.context_provider = provider
849
+ return self
850
+
851
+ def with_mcps(
852
+ self,
853
+ servers: (Iterable[str] | dict[str, MCPServerConfig]),
854
+ ) -> AgentBuilder:
855
+ """Assign MCP servers to this agent with optional server-specific mount points.
856
+
857
+ Architecture Decision: AD001 - Two-Level Architecture
858
+ Agents reference servers registered at orchestrator level.
859
+
860
+ Args:
861
+ servers: One of:
862
+ - List of server names (strings) - no specific mounts
863
+ - Dict mapping server names to MCPServerConfig
864
+
865
+ Returns:
866
+ self for method chaining
867
+
868
+ Raises:
869
+ ValueError: If any server name is not registered with orchestrator
870
+
871
+ Examples:
872
+ >>> # Simple: no mount restrictions
873
+ >>> agent.with_mcps(["filesystem", "github"])
874
+
875
+ >>> # Server-specific config with roots and tool whitelist
876
+ >>> agent.with_mcps({
877
+ ... "filesystem": {
878
+ ... "roots": ["/workspace/dir/data"],
879
+ ... "tool_whitelist": ["read_file"],
880
+ ... },
881
+ ... "github": {}, # No restrictions for github
882
+ ... })
883
+ """
884
+ # Delegate to MCPIntegration module
885
+ registered_servers = set(self._orchestrator._mcp_configs.keys())
886
+ self._agent._mcp_integration.configure_servers(servers, registered_servers)
887
+ return self
888
+
889
+ def labels(self, *labels: str) -> AgentBuilder:
890
+ self._agent.labels.update(labels)
891
+ return self
892
+
893
+ def tenant(self, tenant_id: str) -> AgentBuilder:
894
+ self._agent.tenant_id = tenant_id
895
+ return self
896
+
897
+ def prevent_self_trigger(self, enabled: bool = True) -> AgentBuilder:
898
+ """Prevent agent from being triggered by its own outputs.
899
+
900
+ When enabled (default), the orchestrator will skip scheduling this agent
901
+ for artifacts it produced itself. This prevents infinite feedback loops
902
+ when an agent consumes and publishes the same type.
903
+
904
+ Args:
905
+ enabled: True to prevent self-triggering (safe default),
906
+ False to allow feedback loops (advanced use case)
907
+
908
+ Returns:
909
+ AgentBuilder for method chaining
910
+
911
+ Example:
912
+ # Safe by default (recommended)
913
+ agent.consumes(Document).publishes(Document)
914
+ # Won't trigger on own outputs ✅
915
+
916
+ # Explicit feedback loop (use with caution!)
917
+ agent.consumes(Data, where=lambda d: d.depth < 10)
918
+ .publishes(Data)
919
+ .prevent_self_trigger(False) # Acknowledge risk
920
+ """
921
+ self._agent.prevent_self_trigger = enabled
922
+ return self
923
+
924
+ # Runtime helpers ------------------------------------------------------
925
+
926
+ def run(self, *inputs: BaseModel) -> RunHandle:
927
+ return RunHandle(self._agent, list(inputs))
928
+
929
+ def then(self, other: AgentBuilder) -> Pipeline:
930
+ return Pipeline([self, other])
931
+
932
+ # Phase 5B: Validation and normalization moved to BuilderValidator module
933
+
934
+ # Properties -----------------------------------------------------------
935
+
936
+ @property
937
+ def name(self) -> str:
938
+ return self._agent.name
939
+
940
+ @property
941
+ def agent(self) -> Agent:
942
+ return self._agent
943
+
944
+
945
+ # Phase 5B: Helper classes moved to builder_helpers module
946
+
947
+
948
+ __all__ = [
949
+ "Agent",
950
+ "AgentBuilder",
951
+ "AgentOutput",
952
+ "OutputGroup",
953
+ ]