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/agent.py DELETED
@@ -1,1578 +0,0 @@
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
- from flock.artifacts import Artifact, ArtifactSpec
14
- from flock.logging.auto_trace import AutoTracedMeta
15
- from flock.logging.logging import get_logger
16
- from flock.registry import function_registry, type_registry
17
- from flock.runtime import Context, EvalInputs, EvalResult
18
- from flock.subscription import BatchSpec, JoinSpec, Subscription, TextPredicate
19
- from flock.visibility import AgentIdentity, Visibility, ensure_visibility, only_for
20
-
21
-
22
- logger = get_logger(__name__)
23
-
24
- if TYPE_CHECKING: # pragma: no cover - type hints only
25
- from collections.abc import Callable, Iterable, Sequence
26
-
27
- from flock.components import AgentComponent, EngineComponent
28
- from flock.orchestrator import Flock
29
-
30
-
31
- class MCPServerConfig(TypedDict, total=False):
32
- """Configuration for MCP server assignment to an agent.
33
-
34
- All fields are optional. If omitted, no restrictions apply.
35
-
36
- Attributes:
37
- roots: Filesystem paths this server can access.
38
- Empty list or omitted = no mount restrictions.
39
- tool_whitelist: Tool names the agent can use from this server.
40
- Empty list or omitted = all tools available.
41
-
42
- Examples:
43
- >>> # No restrictions
44
- >>> config: MCPServerConfig = {}
45
-
46
- >>> # Mount restrictions only
47
- >>> config: MCPServerConfig = {"roots": ["/workspace/data"]}
48
-
49
- >>> # Tool whitelist only
50
- >>> config: MCPServerConfig = {
51
- ... "tool_whitelist": ["read_file", "write_file"]
52
- ... }
53
-
54
- >>> # Both restrictions
55
- >>> config: MCPServerConfig = {
56
- ... "roots": ["/workspace/data"],
57
- ... "tool_whitelist": ["read_file"],
58
- ... }
59
- """
60
-
61
- roots: list[str]
62
- tool_whitelist: list[str]
63
-
64
-
65
- @dataclass
66
- class AgentOutput:
67
- spec: ArtifactSpec
68
- default_visibility: Visibility
69
- count: int = 1 # Number of artifacts to generate (fan-out)
70
- filter_predicate: Callable[[BaseModel], bool] | None = None # Where clause
71
- validate_predicate: (
72
- Callable[[BaseModel], bool] | list[tuple[Callable, str]] | None
73
- ) = None # Validation logic
74
- group_description: str | None = None # Group description override
75
-
76
- def __post_init__(self):
77
- """Validate field constraints."""
78
- if self.count < 1:
79
- raise ValueError(f"count must be >= 1, got {self.count}")
80
-
81
- def is_many(self) -> bool:
82
- """Return True if this output generates multiple artifacts (count > 1)."""
83
- return self.count > 1
84
-
85
- def apply(
86
- self,
87
- data: dict[str, Any],
88
- *,
89
- produced_by: str,
90
- metadata: dict[str, Any] | None = None,
91
- ) -> Artifact:
92
- metadata = metadata or {}
93
- return self.spec.build(
94
- produced_by=produced_by,
95
- data=data,
96
- visibility=metadata.get("visibility", self.default_visibility),
97
- correlation_id=metadata.get("correlation_id"),
98
- partition_key=metadata.get("partition_key"),
99
- tags=metadata.get("tags"),
100
- version=metadata.get("version", 1),
101
- artifact_id=metadata.get("artifact_id"), # Phase 6: Preserve engine's ID
102
- )
103
-
104
-
105
- @dataclass
106
- class OutputGroup:
107
- """Represents one .publishes() call.
108
-
109
- Each OutputGroup triggers one engine execution that generates
110
- all artifacts in the group together.
111
- """
112
-
113
- outputs: list[AgentOutput]
114
- shared_visibility: Visibility | None = None
115
- group_description: str | None = None # Group-level description override
116
-
117
- def is_single_call(self) -> bool:
118
- """True if this is one engine call generating multiple artifacts.
119
-
120
- Currently always returns True as each group = one engine call.
121
- Future: Could return False for parallel sub-groups.
122
- """
123
- return True
124
-
125
-
126
- class Agent(metaclass=AutoTracedMeta):
127
- """Executable agent constructed via `AgentBuilder`.
128
-
129
- All public methods are automatically traced via OpenTelemetry.
130
- """
131
-
132
- # Phase 6+7: Class-level streaming coordination (SHARED across ALL agent instances)
133
- # These class variables enable all agents to coordinate CLI streaming behavior
134
- _streaming_counter: int = 0 # Global count of agents currently streaming to CLI
135
- _websocket_broadcast_global: Any = (
136
- None # WebSocket broadcast wrapper (dashboard mode)
137
- )
138
-
139
- def __init__(self, name: str, *, orchestrator: Flock) -> None:
140
- self.name = name
141
- self.description: str | None = None
142
- self._orchestrator = orchestrator
143
- self.subscriptions: list[Subscription] = []
144
- self.output_groups: list[OutputGroup] = []
145
- self.utilities: list[AgentComponent] = []
146
- self.engines: list[EngineComponent] = []
147
- self.best_of_n: int = 1
148
- self.best_of_score: Callable[[EvalResult], float] | None = None
149
- self.max_concurrency: int = 2
150
- self._semaphore = asyncio.Semaphore(self.max_concurrency)
151
- self.calls_func: Callable[..., Any] | None = None
152
- self.tools: set[Callable[..., Any]] = set()
153
- self.labels: set[str] = set()
154
- self.tenant_id: str | None = None
155
- self.model: str | None = None
156
- self.prevent_self_trigger: bool = True # T065: Prevent infinite feedback loops
157
- # Phase 3: Per-agent context provider (security fix)
158
- self.context_provider: Any = None
159
- # MCP integration
160
- self.mcp_server_names: set[str] = set()
161
- self.mcp_mount_points: list[
162
- str
163
- ] = [] # Deprecated: Use mcp_server_mounts instead
164
- self.mcp_server_mounts: dict[
165
- str, list[str]
166
- ] = {} # Server-specific mount points
167
- self.tool_whitelist: list[str] | None = None
168
-
169
- @property
170
- def outputs(self) -> list[AgentOutput]:
171
- """Backwards compatibility: return flat list of all outputs from all groups."""
172
- return [output for group in self.output_groups for output in group.outputs]
173
-
174
- @property
175
- def identity(self) -> AgentIdentity:
176
- return AgentIdentity(
177
- name=self.name, labels=self.labels, tenant_id=self.tenant_id
178
- )
179
-
180
- @staticmethod
181
- def _component_display_name(component: AgentComponent) -> str:
182
- return component.name or component.__class__.__name__
183
-
184
- def _sorted_utilities(self) -> list[AgentComponent]:
185
- if not self.utilities:
186
- return []
187
- return sorted(self.utilities, key=lambda comp: getattr(comp, "priority", 0))
188
-
189
- def _add_utilities(self, components: Sequence[AgentComponent]) -> None:
190
- if not components:
191
- return
192
- for component in components:
193
- self.utilities.append(component)
194
- comp_name = self._component_display_name(component)
195
- priority = getattr(component, "priority", 0)
196
- logger.info(
197
- "Agent %s: utility added: component=%s, priority=%s, total_utilities=%s",
198
- self.name,
199
- comp_name,
200
- priority,
201
- len(self.utilities),
202
- )
203
- self.utilities.sort(key=lambda comp: getattr(comp, "priority", 0))
204
-
205
- def set_max_concurrency(self, value: int) -> None:
206
- self.max_concurrency = max(1, value)
207
- self._semaphore = asyncio.Semaphore(self.max_concurrency)
208
-
209
- async def run_direct(self, *inputs: BaseModel) -> list[Artifact]:
210
- return await self._orchestrator.direct_invoke(self, list(inputs))
211
-
212
- async def execute(self, ctx: Context, artifacts: list[Artifact]) -> list[Artifact]:
213
- async with self._semaphore:
214
- try:
215
- self._resolve_engines()
216
- self._resolve_utilities()
217
- await self._run_initialize(ctx)
218
- processed_inputs = await self._run_pre_consume(ctx, artifacts)
219
- eval_inputs = EvalInputs(
220
- artifacts=processed_inputs, state=dict(ctx.state)
221
- )
222
- eval_inputs = await self._run_pre_evaluate(ctx, eval_inputs)
223
-
224
- # Phase 3: Call engine ONCE PER OutputGroup
225
- all_outputs: list[Artifact] = []
226
-
227
- if not self.output_groups:
228
- # No output groups: Utility agents that don't publish
229
- # Create empty OutputGroup for engines that may have side effects
230
- empty_group = OutputGroup(outputs=[], group_description=None)
231
- result = await self._run_engines(ctx, eval_inputs, empty_group)
232
- # Run post_evaluate hooks for utility components (e.g., metrics)
233
- result = await self._run_post_evaluate(ctx, eval_inputs, result)
234
- # Utility agents return empty list (no outputs declared)
235
- outputs = []
236
- else:
237
- # Loop over each output group
238
- for group_idx, output_group in enumerate(self.output_groups):
239
- # Prepare group-specific context
240
- group_ctx = self._prepare_group_context(
241
- ctx, group_idx, output_group
242
- )
243
-
244
- # Phase 7: Single evaluation path with auto-detection
245
- # Engine's evaluate() auto-detects batch/fan-out from ctx and output_group
246
- result = await self._run_engines(
247
- group_ctx, eval_inputs, output_group
248
- )
249
-
250
- result = await self._run_post_evaluate(
251
- group_ctx, eval_inputs, result
252
- )
253
-
254
- # Extract outputs for THIS group only
255
- group_outputs = await self._make_outputs_for_group(
256
- group_ctx, result, output_group
257
- )
258
-
259
- all_outputs.extend(group_outputs)
260
-
261
- outputs = all_outputs
262
-
263
- await self._run_post_publish(ctx, outputs)
264
- if self.calls_func:
265
- await self._invoke_call(ctx, outputs or processed_inputs)
266
- return outputs
267
- except Exception as exc:
268
- await self._run_error(ctx, exc)
269
- raise
270
- finally:
271
- await self._run_terminate(ctx)
272
-
273
- async def _get_mcp_tools(self, ctx: Context) -> list[Callable]:
274
- """Lazy-load MCP tools from assigned servers.
275
-
276
- Architecture Decision: AD001 - Two-Level Architecture
277
- Agents fetch tools from servers registered at orchestrator level.
278
-
279
- Architecture Decision: AD003 - Tool Namespacing
280
- All tools are namespaced as {server}__{tool}.
281
-
282
- Architecture Decision: AD007 - Graceful Degradation
283
- If MCP loading fails, returns empty list so agent continues with native tools.
284
-
285
- Args:
286
- ctx: Current execution context with agent_id and run_id
287
-
288
- Returns:
289
- List of DSPy-compatible tool callables
290
- """
291
- if not self.mcp_server_names:
292
- # No MCP servers assigned to this agent
293
- return []
294
-
295
- try:
296
- # Get the MCP manager from orchestrator
297
- manager = self._orchestrator.get_mcp_manager()
298
-
299
- # Fetch tools from all assigned servers
300
- tools_dict = await manager.get_tools_for_agent(
301
- agent_id=self.name,
302
- run_id=ctx.task_id,
303
- server_names=self.mcp_server_names,
304
- server_mounts=self.mcp_server_mounts, # Pass server-specific mounts
305
- )
306
-
307
- # Whitelisting logic
308
- tool_whitelist = self.tool_whitelist
309
- if (
310
- tool_whitelist is not None
311
- and isinstance(tool_whitelist, list)
312
- and len(tool_whitelist) > 0
313
- ):
314
- filtered_tools: dict[str, Any] = {}
315
- for tool_key, tool_entry in tools_dict.items():
316
- if isinstance(tool_entry, dict):
317
- original_name = tool_entry.get("original_name", None)
318
- if (
319
- original_name is not None
320
- and original_name in tool_whitelist
321
- ):
322
- filtered_tools[tool_key] = tool_entry
323
-
324
- tools_dict = filtered_tools
325
-
326
- # Convert to DSPy tool callables
327
- dspy_tools = []
328
- for namespaced_name, tool_info in tools_dict.items():
329
- tool_info["server_name"]
330
- flock_tool = tool_info["tool"] # Already a FlockMCPTool
331
- client = tool_info["client"]
332
-
333
- # Convert to DSPy tool
334
- dspy_tool = flock_tool.as_dspy_tool(server=client)
335
-
336
- # Update name to include namespace
337
- dspy_tool.name = namespaced_name
338
-
339
- dspy_tools.append(dspy_tool)
340
-
341
- return dspy_tools
342
-
343
- except Exception as e:
344
- # Architecture Decision: AD007 - Graceful Degradation
345
- # Agent continues with native tools only
346
- logger.error(
347
- f"Failed to load MCP tools for agent {self.name}: {e}", exc_info=True
348
- )
349
- return []
350
-
351
- async def _run_initialize(self, ctx: Context) -> None:
352
- for component in self._sorted_utilities():
353
- comp_name = self._component_display_name(component)
354
- priority = getattr(component, "priority", 0)
355
- logger.debug(
356
- f"Agent initialize: agent={self.name}, component={comp_name}, priority={priority}"
357
- )
358
- try:
359
- await component.on_initialize(self, ctx)
360
- except Exception as exc:
361
- logger.exception(
362
- f"Agent initialize failed: agent={self.name}, component={comp_name}, "
363
- f"priority={priority}, error={exc!s}"
364
- )
365
- raise
366
- for engine in self.engines:
367
- await engine.on_initialize(self, ctx)
368
-
369
- async def _run_pre_consume(
370
- self, ctx: Context, inputs: list[Artifact]
371
- ) -> list[Artifact]:
372
- current = inputs
373
- for component in self._sorted_utilities():
374
- comp_name = self._component_display_name(component)
375
- priority = getattr(component, "priority", 0)
376
- logger.debug(
377
- f"Agent pre_consume: agent={self.name}, component={comp_name}, "
378
- f"priority={priority}, input_count={len(current)}"
379
- )
380
- try:
381
- current = await component.on_pre_consume(self, ctx, current)
382
- except Exception as exc:
383
- logger.exception(
384
- f"Agent pre_consume failed: agent={self.name}, component={comp_name}, "
385
- f"priority={priority}, error={exc!s}"
386
- )
387
- raise
388
- return current
389
-
390
- async def _run_pre_evaluate(self, ctx: Context, inputs: EvalInputs) -> EvalInputs:
391
- current = inputs
392
- for component in self._sorted_utilities():
393
- comp_name = self._component_display_name(component)
394
- priority = getattr(component, "priority", 0)
395
- logger.debug(
396
- f"Agent pre_evaluate: agent={self.name}, component={comp_name}, "
397
- f"priority={priority}, artifact_count={len(current.artifacts)}"
398
- )
399
- try:
400
- current = await component.on_pre_evaluate(self, ctx, current)
401
- except Exception as exc:
402
- logger.exception(
403
- f"Agent pre_evaluate failed: agent={self.name}, component={comp_name}, "
404
- f"priority={priority}, error={exc!s}"
405
- )
406
- raise
407
- return current
408
-
409
- async def _run_engines(
410
- self, ctx: Context, inputs: EvalInputs, output_group: OutputGroup
411
- ) -> EvalResult:
412
- """Execute engines for a specific OutputGroup.
413
-
414
- Args:
415
- ctx: Execution context
416
- inputs: EvalInputs with input artifacts
417
- output_group: The OutputGroup defining what artifacts to produce
418
-
419
- Returns:
420
- EvalResult with artifacts matching output_group specifications
421
- """
422
- engines = self._resolve_engines()
423
- if not engines:
424
- return EvalResult(artifacts=inputs.artifacts, state=inputs.state)
425
-
426
- async def run_chain() -> EvalResult:
427
- current_inputs = inputs
428
- accumulated_logs: list[str] = []
429
- accumulated_metrics: dict[str, float] = {}
430
- for engine in engines:
431
- current_inputs = await engine.on_pre_evaluate(self, ctx, current_inputs)
432
-
433
- # Phase 7: Single evaluation path with auto-detection
434
- # Engine's evaluate() auto-detects batching via ctx.is_batch
435
- result = await engine.evaluate(self, ctx, current_inputs, output_group)
436
-
437
- # AUTO-WRAP: If engine returns BaseModel instead of EvalResult, wrap it
438
- from flock.runtime import EvalResult as ER
439
-
440
- if isinstance(result, BaseModel) and not isinstance(result, ER):
441
- result = ER.from_object(result, agent=self)
442
-
443
- artifacts = result.artifacts
444
- for artifact in artifacts:
445
- artifact.correlation_id = ctx.correlation_id
446
-
447
- result = await engine.on_post_evaluate(
448
- self, ctx, current_inputs, result
449
- )
450
- accumulated_logs.extend(result.logs)
451
- accumulated_metrics.update(result.metrics)
452
- merged_state = dict(current_inputs.state)
453
- merged_state.update(result.state)
454
- current_inputs = EvalInputs(
455
- artifacts=result.artifacts or current_inputs.artifacts,
456
- state=merged_state,
457
- )
458
- return EvalResult(
459
- artifacts=current_inputs.artifacts,
460
- state=current_inputs.state,
461
- metrics=accumulated_metrics,
462
- logs=accumulated_logs,
463
- )
464
-
465
- if self.best_of_n <= 1:
466
- return await run_chain()
467
-
468
- async with asyncio.TaskGroup() as tg: # Python 3.12
469
- tasks: list[asyncio.Task[EvalResult]] = []
470
- for _ in range(self.best_of_n):
471
- tasks.append(tg.create_task(run_chain()))
472
- results = [task.result() for task in tasks]
473
- if not results:
474
- return EvalResult(artifacts=[], state={})
475
- if self.best_of_score is None:
476
- return results[0]
477
- return max(results, key=self.best_of_score)
478
-
479
- async def _run_post_evaluate(
480
- self, ctx: Context, inputs: EvalInputs, result: EvalResult
481
- ) -> EvalResult:
482
- current = result
483
- for component in self._sorted_utilities():
484
- comp_name = self._component_display_name(component)
485
- priority = getattr(component, "priority", 0)
486
- logger.debug(
487
- f"Agent post_evaluate: agent={self.name}, component={comp_name}, "
488
- f"priority={priority}, artifact_count={len(current.artifacts)}"
489
- )
490
- try:
491
- current = await component.on_post_evaluate(self, ctx, inputs, current)
492
- except Exception as exc:
493
- logger.exception(
494
- f"Agent post_evaluate failed: agent={self.name}, component={comp_name}, "
495
- f"priority={priority}, error={exc!s}"
496
- )
497
- raise
498
- return current
499
-
500
- async def _make_outputs(self, ctx: Context, result: EvalResult) -> list[Artifact]:
501
- if not self.output_groups:
502
- # Utility agents may not publish anything
503
- return list(result.artifacts)
504
-
505
- produced: list[Artifact] = []
506
-
507
- # For Phase 2: Iterate ALL output_groups (even though we only have 1 engine call)
508
- # Phase 3 will modify this to call engine once PER group
509
- for output_group in self.output_groups:
510
- for output_decl in output_group.outputs:
511
- # Phase 6: Find the matching artifact from engine result to preserve its ID
512
- matching_artifact = self._find_matching_artifact(output_decl, result)
513
-
514
- payload = self._select_payload(output_decl, result)
515
- if payload is None:
516
- continue
517
- metadata = {
518
- "correlation_id": ctx.correlation_id,
519
- }
520
-
521
- # Phase 6: Preserve artifact ID from engine (for streaming message preview)
522
- if matching_artifact:
523
- metadata["artifact_id"] = matching_artifact.id
524
-
525
- artifact = output_decl.apply(
526
- payload, produced_by=self.name, metadata=metadata
527
- )
528
- produced.append(artifact)
529
- # Phase 6: REMOVED publishing - orchestrator now handles it
530
- # await ctx.board.publish(artifact)
531
-
532
- return produced
533
-
534
- def _prepare_group_context(
535
- self, ctx: Context, group_idx: int, output_group: OutputGroup
536
- ) -> Context:
537
- """Phase 3: Prepare context specific to this OutputGroup.
538
-
539
- Creates a modified context for this group's engine call, potentially
540
- with group-specific instructions or metadata.
541
-
542
- Args:
543
- ctx: Base context
544
- group_idx: Index of this group (0-based)
545
- output_group: The OutputGroup being processed
546
-
547
- Returns:
548
- Context for this group (may be the same instance or modified)
549
- """
550
- # For now, return the same context
551
- # Phase 4 will add group-specific system prompts here
552
- # Future: ctx.clone() and add group_description to system prompt
553
- return ctx
554
-
555
- async def _make_outputs_for_group(
556
- self, ctx: Context, result: EvalResult, output_group: OutputGroup
557
- ) -> list[Artifact]:
558
- """Phase 3/5: Validate, filter, and publish artifacts for specific OutputGroup.
559
-
560
- This function:
561
- 1. Validates that the engine fulfilled its contract (produced expected count)
562
- 2. Applies WHERE filtering (reduces artifacts, no error)
563
- 3. Applies VALIDATE checks (raises ValueError if validation fails)
564
- 4. Applies visibility (static or dynamic)
565
- 5. Publishes artifacts to the board
566
-
567
- Args:
568
- ctx: Context for this group
569
- result: EvalResult from engine for THIS group
570
- output_group: OutputGroup defining expected outputs
571
-
572
- Returns:
573
- List of artifacts matching this group's outputs
574
-
575
- Raises:
576
- ValueError: If engine violated contract or validation failed
577
- """
578
- produced: list[Artifact] = []
579
-
580
- for output_decl in output_group.outputs:
581
- # 1. Find ALL matching artifacts for this type
582
- from flock.registry import type_registry
583
-
584
- expected_canonical = type_registry.resolve_name(output_decl.spec.type_name)
585
-
586
- matching_artifacts: list[Artifact] = []
587
- for artifact in result.artifacts:
588
- try:
589
- artifact_canonical = type_registry.resolve_name(artifact.type)
590
- if artifact_canonical == expected_canonical:
591
- matching_artifacts.append(artifact)
592
- except Exception:
593
- if artifact.type == output_decl.spec.type_name:
594
- matching_artifacts.append(artifact)
595
-
596
- # 2. STRICT VALIDATION: Engine must produce exactly what was promised
597
- # (This happens BEFORE filtering so engine contract is validated first)
598
- expected_count = output_decl.count
599
- actual_count = len(matching_artifacts)
600
-
601
- if actual_count != expected_count:
602
- raise ValueError(
603
- f"Engine contract violation in agent '{self.name}': "
604
- f"Expected {expected_count} artifact(s) of type '{output_decl.spec.type_name}', "
605
- f"but engine produced {actual_count}. "
606
- f"Check your engine implementation to ensure it generates the correct number of outputs."
607
- )
608
-
609
- # 3. Apply WHERE filtering (Phase 5)
610
- # Filtering reduces the number of published artifacts (this is intentional)
611
- # NOTE: Predicates expect Pydantic model instances, not dicts
612
- model_cls = type_registry.resolve(output_decl.spec.type_name)
613
-
614
- if output_decl.filter_predicate:
615
- original_count = len(matching_artifacts)
616
- filtered = []
617
- for a in matching_artifacts:
618
- # Reconstruct Pydantic model from payload dict
619
- model_instance = model_cls(**a.payload)
620
- if output_decl.filter_predicate(model_instance):
621
- filtered.append(a)
622
- matching_artifacts = filtered
623
- logger.debug(
624
- f"Agent {self.name}: WHERE filter reduced artifacts from "
625
- f"{original_count} to {len(matching_artifacts)} for type {output_decl.spec.type_name}"
626
- )
627
-
628
- # 4. Apply VALIDATE checks (Phase 5)
629
- # Validation failures raise errors (fail-fast)
630
- if output_decl.validate_predicate:
631
- if callable(output_decl.validate_predicate):
632
- # Single predicate
633
- for artifact in matching_artifacts:
634
- # Reconstruct Pydantic model from payload dict
635
- model_instance = model_cls(**artifact.payload)
636
- if not output_decl.validate_predicate(model_instance):
637
- raise ValueError(
638
- f"Validation failed for {output_decl.spec.type_name} "
639
- f"in agent '{self.name}'"
640
- )
641
- elif isinstance(output_decl.validate_predicate, list):
642
- # List of (callable, error_msg) tuples
643
- for artifact in matching_artifacts:
644
- # Reconstruct Pydantic model from payload dict
645
- model_instance = model_cls(**artifact.payload)
646
- for check, error_msg in output_decl.validate_predicate:
647
- if not check(model_instance):
648
- raise ValueError(
649
- f"{error_msg}: {output_decl.spec.type_name}"
650
- )
651
-
652
- # 5. Apply visibility and publish artifacts (Phase 5)
653
- for artifact_from_engine in matching_artifacts:
654
- metadata = {
655
- "correlation_id": ctx.correlation_id,
656
- "artifact_id": artifact_from_engine.id, # Preserve engine's ID
657
- }
658
-
659
- # Determine visibility (static or dynamic)
660
- visibility = output_decl.default_visibility
661
- if callable(visibility):
662
- # Dynamic visibility based on artifact content
663
- # Reconstruct Pydantic model from payload dict
664
- model_instance = model_cls(**artifact_from_engine.payload)
665
- visibility = visibility(model_instance)
666
-
667
- # Override metadata visibility
668
- metadata["visibility"] = visibility
669
-
670
- # Re-wrap the artifact with agent metadata
671
- artifact = output_decl.apply(
672
- artifact_from_engine.payload,
673
- produced_by=self.name,
674
- metadata=metadata,
675
- )
676
- produced.append(artifact)
677
- # Phase 6 SECURITY FIX: REMOVED publishing - orchestrator now handles it
678
- # This fixes Vulnerability #2 (WRITE Bypass) - agents can no longer publish directly
679
- # await ctx.board.publish(artifact)
680
-
681
- return produced
682
-
683
- async def _run_post_publish(
684
- self, ctx: Context, artifacts: Sequence[Artifact]
685
- ) -> None:
686
- components = self._sorted_utilities()
687
- for artifact in artifacts:
688
- for component in components:
689
- comp_name = self._component_display_name(component)
690
- priority = getattr(component, "priority", 0)
691
- logger.debug(
692
- f"Agent post_publish: agent={self.name}, component={comp_name}, "
693
- f"priority={priority}, artifact_id={artifact.id}"
694
- )
695
- try:
696
- await component.on_post_publish(self, ctx, artifact)
697
- except Exception as exc:
698
- logger.exception(
699
- f"Agent post_publish failed: agent={self.name}, component={comp_name}, "
700
- f"priority={priority}, artifact_id={artifact.id}, error={exc!s}"
701
- )
702
- raise
703
-
704
- async def _invoke_call(self, ctx: Context, artifacts: Sequence[Artifact]) -> None:
705
- func = self.calls_func
706
- if func is None:
707
- return
708
- if not artifacts:
709
- return
710
- first = artifacts[0]
711
- model_cls = type_registry.resolve(first.type)
712
- payload = model_cls(**first.payload)
713
- maybe_coro = func(payload)
714
- if asyncio.iscoroutine(maybe_coro): # pragma: no cover - optional async support
715
- await maybe_coro
716
-
717
- async def _run_error(self, ctx: Context, error: Exception) -> None:
718
- for component in self._sorted_utilities():
719
- comp_name = self._component_display_name(component)
720
- priority = getattr(component, "priority", 0)
721
-
722
- # Python 3.12+ TaskGroup raises BaseExceptionGroup - extract sub-exceptions
723
- error_detail = str(error)
724
- if isinstance(error, BaseExceptionGroup):
725
- sub_exceptions = [f"{type(e).__name__}: {e}" for e in error.exceptions]
726
- error_detail = f"{error!s} - Sub-exceptions: {sub_exceptions}"
727
-
728
- logger.debug(
729
- f"Agent error hook: agent={self.name}, component={comp_name}, "
730
- f"priority={priority}, error={error_detail}"
731
- )
732
- try:
733
- await component.on_error(self, ctx, error)
734
- except Exception as exc:
735
- logger.exception(
736
- f"Agent error hook failed: agent={self.name}, component={comp_name}, "
737
- f"priority={priority}, original_error={error!s}, hook_error={exc!s}"
738
- )
739
- raise
740
- for engine in self.engines:
741
- await engine.on_error(self, ctx, error)
742
-
743
- async def _run_terminate(self, ctx: Context) -> None:
744
- for component in self._sorted_utilities():
745
- comp_name = self._component_display_name(component)
746
- priority = getattr(component, "priority", 0)
747
- logger.debug(
748
- f"Agent terminate: agent={self.name}, component={comp_name}, priority={priority}"
749
- )
750
- try:
751
- await component.on_terminate(self, ctx)
752
- except Exception as exc:
753
- logger.exception(
754
- f"Agent terminate failed: agent={self.name}, component={comp_name}, "
755
- f"priority={priority}, error={exc!s}"
756
- )
757
- raise
758
- for engine in self.engines:
759
- await engine.on_terminate(self, ctx)
760
-
761
- def _resolve_engines(self) -> list[EngineComponent]:
762
- if self.engines:
763
- return self.engines
764
- try:
765
- from flock.engines import DSPyEngine
766
- except Exception: # pragma: no cover - optional dependency issues
767
- return []
768
-
769
- default_engine = DSPyEngine(
770
- model=self._orchestrator.model
771
- or os.getenv("DEFAULT_MODEL", "openai/gpt-4.1"),
772
- instructions=self.description,
773
- )
774
- self.engines = [default_engine]
775
- return self.engines
776
-
777
- def _resolve_utilities(self) -> list[AgentComponent]:
778
- if self.utilities:
779
- return self.utilities
780
- try:
781
- from flock.utility.output_utility_component import (
782
- OutputUtilityComponent,
783
- )
784
- except Exception: # pragma: no cover - optional dependency issues
785
- return []
786
-
787
- default_component = OutputUtilityComponent()
788
- self._add_utilities([default_component])
789
- return self.utilities
790
-
791
- def _find_matching_artifact(
792
- self, output_decl: AgentOutput, result: EvalResult
793
- ) -> Artifact | None:
794
- """Phase 6: Find artifact from engine result that matches this output declaration.
795
-
796
- Returns the artifact object (with its ID) so we can preserve it when creating
797
- the final published artifact. This ensures streaming events use the same ID.
798
- """
799
- from flock.registry import type_registry
800
-
801
- if not result.artifacts:
802
- return None
803
-
804
- # Normalize the expected type name to canonical form
805
- expected_canonical = type_registry.resolve_name(output_decl.spec.type_name)
806
-
807
- for artifact in result.artifacts:
808
- # Normalize artifact type name to canonical form for comparison
809
- try:
810
- artifact_canonical = type_registry.resolve_name(artifact.type)
811
- if artifact_canonical == expected_canonical:
812
- return artifact
813
- except Exception:
814
- # If normalization fails, fall back to direct comparison
815
- if artifact.type == output_decl.spec.type_name:
816
- return artifact
817
-
818
- return None
819
-
820
- def _select_payload(
821
- self, output_decl: AgentOutput, result: EvalResult
822
- ) -> dict[str, Any] | None:
823
- from flock.registry import type_registry
824
-
825
- if not result.artifacts:
826
- return None
827
-
828
- # Normalize the expected type name to canonical form
829
- expected_canonical = type_registry.resolve_name(output_decl.spec.type_name)
830
-
831
- for artifact in result.artifacts:
832
- # Normalize artifact type name to canonical form for comparison
833
- try:
834
- artifact_canonical = type_registry.resolve_name(artifact.type)
835
- if artifact_canonical == expected_canonical:
836
- return artifact.payload
837
- except Exception:
838
- # If normalization fails, fall back to direct comparison
839
- if artifact.type == output_decl.spec.type_name:
840
- return artifact.payload
841
-
842
- # Fallback to state entries keyed by type name
843
- maybe_data = result.state.get(output_decl.spec.type_name)
844
- if isinstance(maybe_data, dict):
845
- return maybe_data
846
- return None
847
-
848
-
849
- class AgentBuilder:
850
- """Fluent builder that also acts as the runtime agent handle."""
851
-
852
- def __init__(self, orchestrator: Flock, name: str) -> None:
853
- self._orchestrator = orchestrator
854
- self._agent = Agent(name, orchestrator=orchestrator)
855
- self._agent.model = orchestrator.model
856
- orchestrator.register_agent(self._agent)
857
-
858
- # Fluent configuration -------------------------------------------------
859
-
860
- def description(self, text: str) -> AgentBuilder:
861
- """Set the agent's description for documentation and tracing.
862
-
863
- Args:
864
- text: Human-readable description of what the agent does
865
-
866
- Returns:
867
- self for method chaining
868
-
869
- Example:
870
- >>> agent = (
871
- ... flock.agent("pizza_chef")
872
- ... .description("Creates authentic Italian pizza recipes")
873
- ... .consumes(Idea)
874
- ... .publishes(Recipe)
875
- ... )
876
- """
877
- self._agent.description = text
878
- return self
879
-
880
- def consumes(
881
- self,
882
- *types: type[BaseModel],
883
- where: Callable[[BaseModel], bool]
884
- | Sequence[Callable[[BaseModel], bool]]
885
- | None = None,
886
- text: str | None = None,
887
- min_p: float = 0.0,
888
- from_agents: Iterable[str] | None = None,
889
- tags: Iterable[str] | None = None,
890
- join: dict | JoinSpec | None = None,
891
- batch: dict | BatchSpec | None = None,
892
- delivery: str = "exclusive",
893
- mode: str = "both",
894
- priority: int = 0,
895
- ) -> AgentBuilder:
896
- """Declare which artifact types this agent processes.
897
-
898
- Sets up subscription rules that determine when the agent executes.
899
- Supports type-based matching, conditional filters, batching, and joins.
900
-
901
- Args:
902
- *types: Artifact types (Pydantic models) to consume
903
- where: Optional filter predicate(s). Agent only executes if predicate returns True.
904
- Can be a single callable or sequence of callables (all must pass).
905
- text: Optional semantic text filter using embedding similarity
906
- min_p: Minimum probability threshold for text similarity (0.0-1.0)
907
- from_agents: Only consume artifacts from specific agents
908
- tags: Only consume artifacts with matching tags
909
- join: Join specification for coordinating multiple artifact types
910
- batch: Batch specification for processing multiple artifacts together
911
- delivery: Delivery mode - "exclusive" (one agent) or "broadcast" (all matching)
912
- mode: Processing mode - "both", "streaming", or "batch"
913
- priority: Execution priority (higher = executes first)
914
-
915
- Returns:
916
- self for method chaining
917
-
918
- Examples:
919
- >>> # Basic type subscription
920
- >>> agent.consumes(Task)
921
-
922
- >>> # Multiple types
923
- >>> agent.consumes(Task, Event, Command)
924
-
925
- >>> # Conditional consumption (filtering)
926
- >>> agent.consumes(Review, where=lambda r: r.score >= 8)
927
-
928
- >>> # Multiple predicates (all must pass)
929
- >>> agent.consumes(
930
- ... Order,
931
- ... where=[lambda o: o.total > 100, lambda o: o.status == "pending"],
932
- ... )
933
-
934
- >>> # Consume from specific agents
935
- >>> agent.consumes(Report, from_agents=["analyzer", "validator"])
936
-
937
- >>> # Channel-based routing
938
- >>> agent.consumes(Alert, tags={"critical", "security"})
939
-
940
- >>> # Batch processing
941
- >>> agent.consumes(Email, batch={"size": 10, "timeout": 5.0})
942
- """
943
- predicates: Sequence[Callable[[BaseModel], bool]] | None
944
- if where is None:
945
- predicates = None
946
- elif callable(where):
947
- predicates = [where]
948
- else:
949
- predicates = list(where)
950
-
951
- join_spec = self._normalize_join(join)
952
- batch_spec = self._normalize_batch(batch)
953
- text_predicates = [TextPredicate(text=text, min_p=min_p)] if text else []
954
- subscription = Subscription(
955
- agent_name=self._agent.name,
956
- types=types,
957
- where=predicates,
958
- text_predicates=text_predicates,
959
- from_agents=from_agents,
960
- tags=tags,
961
- join=join_spec,
962
- batch=batch_spec,
963
- delivery=delivery,
964
- mode=mode,
965
- priority=priority,
966
- )
967
- self._agent.subscriptions.append(subscription)
968
- return self
969
-
970
- def publishes(
971
- self,
972
- *types: type[BaseModel],
973
- visibility: Visibility | Callable[[BaseModel], Visibility] | None = None,
974
- fan_out: int | None = None,
975
- where: Callable[[BaseModel], bool] | None = None,
976
- validate: Callable[[BaseModel], bool]
977
- | list[tuple[Callable, str]]
978
- | None = None,
979
- description: str | None = None,
980
- ) -> PublishBuilder:
981
- """Declare which artifact types this agent produces.
982
-
983
- Args:
984
- *types: Artifact types (Pydantic models) to publish
985
- visibility: Default visibility control OR callable for dynamic visibility
986
- fan_out: Number of artifacts to publish (applies to ALL types)
987
- where: Filter predicate for output artifacts
988
- validate: Validation predicate(s) - callable or list of (callable, error_msg) tuples
989
- description: Group-level description override
990
-
991
- Returns:
992
- PublishBuilder for conditional publishing configuration
993
-
994
- Examples:
995
- >>> agent.publishes(Report) # Publish 1 Report
996
- >>> agent.publishes(
997
- ... Task, Task, Task
998
- ... ) # Publish 3 Tasks (duplicate counting)
999
- >>> agent.publishes(Task, fan_out=3) # Same as above (sugar syntax)
1000
- >>> agent.publishes(Task, where=lambda t: t.priority > 5) # With filtering
1001
- >>> agent.publishes(
1002
- ... Report, validate=lambda r: r.score > 0
1003
- ... ) # With validation
1004
- >>> agent.publishes(
1005
- ... Task, description="Special instructions"
1006
- ... ) # With description
1007
-
1008
- See Also:
1009
- - PublicVisibility: Default, visible to all agents
1010
- - PrivateVisibility: Allowlist-based access control
1011
- - TenantVisibility: Multi-tenant isolation
1012
- - LabelledVisibility: Role-based access control
1013
- """
1014
- # Validate fan_out if provided
1015
- if fan_out is not None and fan_out < 1:
1016
- raise ValueError(f"fan_out must be >= 1, got {fan_out}")
1017
-
1018
- # Resolve visibility
1019
- resolved_visibility = (
1020
- ensure_visibility(visibility) if not callable(visibility) else visibility
1021
- )
1022
-
1023
- # Create AgentOutput objects for this group
1024
- outputs: list[AgentOutput] = []
1025
-
1026
- if fan_out is not None:
1027
- # Apply fan_out to ALL types
1028
- for model in types:
1029
- spec = ArtifactSpec.from_model(model)
1030
- output = AgentOutput(
1031
- spec=spec,
1032
- default_visibility=resolved_visibility,
1033
- count=fan_out,
1034
- filter_predicate=where,
1035
- validate_predicate=validate,
1036
- group_description=description,
1037
- )
1038
- outputs.append(output)
1039
- else:
1040
- # Create separate AgentOutput for each type (including duplicates)
1041
- # This preserves order: .publishes(A, B, A) → [A, B, A] (3 outputs)
1042
- for model in types:
1043
- spec = ArtifactSpec.from_model(model)
1044
- output = AgentOutput(
1045
- spec=spec,
1046
- default_visibility=resolved_visibility,
1047
- count=1,
1048
- filter_predicate=where,
1049
- validate_predicate=validate,
1050
- group_description=description,
1051
- )
1052
- outputs.append(output)
1053
-
1054
- # Create OutputGroup from outputs
1055
- group = OutputGroup(
1056
- outputs=outputs,
1057
- shared_visibility=resolved_visibility
1058
- if not callable(resolved_visibility)
1059
- else None,
1060
- group_description=description,
1061
- )
1062
-
1063
- # Append to agent's output_groups
1064
- self._agent.output_groups.append(group)
1065
-
1066
- # Validate configuration
1067
- self._validate_self_trigger_risk()
1068
-
1069
- return PublishBuilder(self, outputs)
1070
-
1071
- def with_utilities(self, *components: AgentComponent) -> AgentBuilder:
1072
- """Add utility components to customize agent lifecycle and behavior.
1073
-
1074
- Components are hooks that run at specific points in the agent execution
1075
- lifecycle. Common uses include rate limiting, budgets, metrics, caching,
1076
- and custom preprocessing/postprocessing.
1077
-
1078
- Args:
1079
- *components: AgentComponent instances with lifecycle hooks
1080
-
1081
- Returns:
1082
- self for method chaining
1083
-
1084
- Examples:
1085
- >>> # Rate limiting
1086
- >>> agent.with_utilities(RateLimiter(max_calls=10, window=60))
1087
-
1088
- >>> # Budget control
1089
- >>> agent.with_utilities(TokenBudget(max_tokens=10000))
1090
-
1091
- >>> # Multiple components (executed in order)
1092
- >>> agent.with_utilities(
1093
- ... RateLimiter(max_calls=5), MetricsCollector(), CacheLayer(ttl=3600)
1094
- ... )
1095
-
1096
- See Also:
1097
- - AgentComponent: Base class for custom components
1098
- - Lifecycle hooks: on_initialize, on_pre_consume, on_post_publish, etc.
1099
- """
1100
- if components:
1101
- self._agent._add_utilities(list(components))
1102
- return self
1103
-
1104
- def with_engines(self, *engines: EngineComponent) -> AgentBuilder:
1105
- """Configure LLM engines for agent evaluation.
1106
-
1107
- Engines determine how agents process inputs. Default is DSPy with the
1108
- orchestrator's model. Custom engines enable different LLM backends,
1109
- non-LLM logic, or hybrid approaches.
1110
-
1111
- Args:
1112
- *engines: EngineComponent instances for evaluation
1113
-
1114
- Returns:
1115
- self for method chaining
1116
-
1117
- Examples:
1118
- >>> # DSPy engine with specific model
1119
- >>> agent.with_engines(DSPyEngine(model="openai/gpt-4o"))
1120
-
1121
- >>> # Custom non-LLM engine
1122
- >>> agent.with_engines(RuleBasedEngine(rules=my_rules))
1123
-
1124
- >>> # Hybrid approach (multiple engines)
1125
- >>> agent.with_engines(
1126
- ... DSPyEngine(model="openai/gpt-4o-mini"), FallbackEngine()
1127
- ... )
1128
-
1129
- Note:
1130
- If no engines specified, agent uses DSPy with the orchestrator's default model.
1131
-
1132
- See Also:
1133
- - DSPyEngine: Default LLM-based evaluation
1134
- - EngineComponent: Base class for custom engines
1135
- """
1136
- self._agent.engines.extend(engines)
1137
- return self
1138
-
1139
- def best_of(self, n: int, score: Callable[[EvalResult], float]) -> AgentBuilder:
1140
- self._agent.best_of_n = max(1, n)
1141
- self._agent.best_of_score = score
1142
- # T074: Validate best_of value
1143
- self._validate_best_of(n)
1144
- return self
1145
-
1146
- def max_concurrency(self, n: int) -> AgentBuilder:
1147
- self._agent.set_max_concurrency(n)
1148
- # T074: Validate concurrency value
1149
- self._validate_concurrency(n)
1150
- return self
1151
-
1152
- def calls(self, func: Callable[..., Any]) -> AgentBuilder:
1153
- function_registry.register(func)
1154
- self._agent.calls_func = func
1155
- return self
1156
-
1157
- def with_tools(self, funcs: Iterable[Callable[..., Any]]) -> AgentBuilder:
1158
- self._agent.tools.update(funcs)
1159
- return self
1160
-
1161
- def with_context(self, provider: Any) -> AgentBuilder:
1162
- """Configure a custom context provider for this agent (Phase 3 security fix).
1163
-
1164
- Context providers control what artifacts an agent can see, enforcing
1165
- visibility filtering at the security boundary layer.
1166
-
1167
- Args:
1168
- provider: ContextProvider instance for this agent
1169
-
1170
- Returns:
1171
- self for method chaining
1172
-
1173
- Examples:
1174
- >>> # Use custom provider for this agent
1175
- >>> agent.with_context(MyCustomProvider())
1176
-
1177
- >>> # Use FilteredContextProvider for declarative filtering
1178
- >>> agent.with_context(
1179
- ... FilteredContextProvider(FilterConfig(tags={"important"}))
1180
- ... )
1181
-
1182
- Note:
1183
- Per-agent provider takes precedence over global provider configured
1184
- on Flock(context_provider=...). If neither is set, DefaultContextProvider
1185
- is used automatically.
1186
-
1187
- See Also:
1188
- - DefaultContextProvider: Default security boundary with visibility enforcement
1189
- - FilteredContextProvider: Declarative filtering with FilterConfig
1190
- """
1191
- self._agent.context_provider = provider
1192
- return self
1193
-
1194
- def with_mcps(
1195
- self,
1196
- servers: (
1197
- Iterable[str]
1198
- | dict[str, MCPServerConfig | list[str]] # Support both new and old format
1199
- | list[str | dict[str, MCPServerConfig | list[str]]]
1200
- ),
1201
- ) -> AgentBuilder:
1202
- """Assign MCP servers to this agent with optional server-specific mount points.
1203
-
1204
- Architecture Decision: AD001 - Two-Level Architecture
1205
- Agents reference servers registered at orchestrator level.
1206
-
1207
- Args:
1208
- servers: One of:
1209
- - List of server names (strings) - no specific mounts
1210
- - Dict mapping server names to MCPServerConfig or list[str] (backward compatible)
1211
- - Mixed list of strings and dicts for flexibility
1212
-
1213
- Returns:
1214
- self for method chaining
1215
-
1216
- Raises:
1217
- ValueError: If any server name is not registered with orchestrator
1218
-
1219
- Examples:
1220
- >>> # Simple: no mount restrictions
1221
- >>> agent.with_mcps(["filesystem", "github"])
1222
-
1223
- >>> # New format: Server-specific config with roots and tool whitelist
1224
- >>> agent.with_mcps({
1225
- ... "filesystem": {
1226
- ... "roots": ["/workspace/dir/data"],
1227
- ... "tool_whitelist": ["read_file"],
1228
- ... },
1229
- ... "github": {}, # No restrictions for github
1230
- ... })
1231
-
1232
- >>> # Old format: Direct list (backward compatible)
1233
- >>> agent.with_mcps({
1234
- ... "filesystem": ["/workspace/dir/data"], # Old format still works
1235
- ... })
1236
-
1237
- >>> # Mixed: backward compatible
1238
- >>> agent.with_mcps([
1239
- ... "github", # No mounts
1240
- ... {"filesystem": {"roots": ["mount1", "mount2"] } }
1241
- ```
1242
- ... ])
1243
- """
1244
- # Parse input into server_names and mounts
1245
- server_set: set[str] = set()
1246
- server_mounts: dict[str, list[str]] = {}
1247
- whitelist = None
1248
-
1249
- if isinstance(servers, dict):
1250
- # Dict format: supports both old and new formats
1251
- # Old: {"server": ["/path1", "/path2"]}
1252
- # New: {"server": {"roots": ["/path1"], "tool_whitelist": ["tool1"]}}
1253
- for server_name, server_config in servers.items():
1254
- server_set.add(server_name)
1255
-
1256
- # Check if it's the old format (direct list) or new format (MCPServerConfig dict)
1257
- if isinstance(server_config, list):
1258
- # Old format: direct list of paths (backward compatibility)
1259
- if len(server_config) > 0:
1260
- server_mounts[server_name] = list(server_config)
1261
- elif isinstance(server_config, dict):
1262
- # New format: MCPServerConfig with optional roots and tool_whitelist
1263
- mounts = server_config.get("roots", None)
1264
- if (
1265
- mounts is not None
1266
- and isinstance(mounts, list)
1267
- and len(mounts) > 0
1268
- ):
1269
- server_mounts[server_name] = list(mounts)
1270
-
1271
- config_whitelist = server_config.get("tool_whitelist", None)
1272
- if (
1273
- config_whitelist is not None
1274
- and isinstance(config_whitelist, list)
1275
- and len(config_whitelist) > 0
1276
- ):
1277
- whitelist = config_whitelist
1278
- elif isinstance(servers, list):
1279
- # List format: can be mixed
1280
- for item in servers:
1281
- if isinstance(item, str):
1282
- # Simple server name
1283
- server_set.add(item)
1284
- elif isinstance(item, dict):
1285
- # Dict with mounts
1286
- for server_name, mounts in item.items():
1287
- server_set.add(server_name)
1288
- if mounts:
1289
- server_mounts[server_name] = list(mounts)
1290
- else:
1291
- raise TypeError(
1292
- f"Invalid server specification: {item}. "
1293
- f"Expected string or dict, got {type(item).__name__}"
1294
- )
1295
- else:
1296
- # Assume it's an iterable of strings (backward compatibility)
1297
- server_set = set(servers)
1298
-
1299
- # Validate all servers exist in orchestrator
1300
- registered_servers = set(self._orchestrator._mcp_configs.keys())
1301
- invalid_servers = server_set - registered_servers
1302
-
1303
- if invalid_servers:
1304
- available = list(registered_servers) if registered_servers else ["none"]
1305
- raise ValueError(
1306
- f"MCP servers not registered: {invalid_servers}. "
1307
- f"Available servers: {available}. "
1308
- f"Register servers using orchestrator.add_mcp() first."
1309
- )
1310
-
1311
- # Store in agent
1312
- self._agent.mcp_server_names = server_set
1313
- self._agent.mcp_server_mounts = server_mounts
1314
- self._agent.tool_whitelist = whitelist
1315
-
1316
- return self
1317
-
1318
- def mount(self, paths: str | list[str], *, validate: bool = False) -> AgentBuilder:
1319
- """Mount agent in specific directories for MCP root access.
1320
-
1321
- .. deprecated:: 0.2.0
1322
- Use `.with_mcps({"server_name": ["/path"]})` instead for server-specific mounts.
1323
- This method applies mounts globally to all MCP servers.
1324
-
1325
- This sets the filesystem roots that MCP servers will operate under for this agent.
1326
- Paths are cumulative across multiple calls.
1327
-
1328
- Args:
1329
- paths: Single path or list of paths to mount
1330
- validate: If True, validate that paths exist (default: False)
1331
-
1332
- Returns:
1333
- AgentBuilder for method chaining
1334
-
1335
- Example:
1336
- >>> # Old way (deprecated)
1337
- >>> agent.with_mcps(["filesystem"]).mount("/workspace/src")
1338
- >>>
1339
- >>> # New way (recommended)
1340
- >>> agent.with_mcps({"filesystem": ["/workspace/src"]})
1341
- """
1342
- import warnings
1343
-
1344
- warnings.warn(
1345
- "Agent.mount() is deprecated. Use .with_mcps({'server': ['/path']}) "
1346
- "for server-specific mounts instead.",
1347
- DeprecationWarning,
1348
- stacklevel=2,
1349
- )
1350
-
1351
- if isinstance(paths, str):
1352
- paths = [paths]
1353
- if validate:
1354
- from pathlib import Path
1355
-
1356
- for path in paths:
1357
- if not Path(path).exists():
1358
- raise ValueError(f"Mount path does not exist: {path}")
1359
-
1360
- # Add to agent's mount points (cumulative) - for backward compatibility
1361
- self._agent.mcp_mount_points.extend(paths)
1362
-
1363
- # Also add to all configured servers for backward compatibility
1364
- for server_name in self._agent.mcp_server_names:
1365
- if server_name not in self._agent.mcp_server_mounts:
1366
- self._agent.mcp_server_mounts[server_name] = []
1367
- self._agent.mcp_server_mounts[server_name].extend(paths)
1368
-
1369
- return self
1370
-
1371
- def labels(self, *labels: str) -> AgentBuilder:
1372
- self._agent.labels.update(labels)
1373
- return self
1374
-
1375
- def tenant(self, tenant_id: str) -> AgentBuilder:
1376
- self._agent.tenant_id = tenant_id
1377
- return self
1378
-
1379
- def prevent_self_trigger(self, enabled: bool = True) -> AgentBuilder:
1380
- """Prevent agent from being triggered by its own outputs.
1381
-
1382
- When enabled (default), the orchestrator will skip scheduling this agent
1383
- for artifacts it produced itself. This prevents infinite feedback loops
1384
- when an agent consumes and publishes the same type.
1385
-
1386
- Args:
1387
- enabled: True to prevent self-triggering (safe default),
1388
- False to allow feedback loops (advanced use case)
1389
-
1390
- Returns:
1391
- AgentBuilder for method chaining
1392
-
1393
- Example:
1394
- # Safe by default (recommended)
1395
- agent.consumes(Document).publishes(Document)
1396
- # Won't trigger on own outputs ✅
1397
-
1398
- # Explicit feedback loop (use with caution!)
1399
- agent.consumes(Data, where=lambda d: d.depth < 10)
1400
- .publishes(Data)
1401
- .prevent_self_trigger(False) # Acknowledge risk
1402
- """
1403
- self._agent.prevent_self_trigger = enabled
1404
- return self
1405
-
1406
- # Runtime helpers ------------------------------------------------------
1407
-
1408
- def run(self, *inputs: BaseModel) -> RunHandle:
1409
- return RunHandle(self._agent, list(inputs))
1410
-
1411
- def then(self, other: AgentBuilder) -> Pipeline:
1412
- return Pipeline([self, other])
1413
-
1414
- # Validation -----------------------------------------------------------
1415
-
1416
- def _validate_self_trigger_risk(self) -> None:
1417
- """T074: Warn if agent consumes and publishes same type (feedback loop risk)."""
1418
- from flock.logging.logging import get_logger
1419
-
1420
- logger = get_logger(__name__)
1421
-
1422
- # Get types agent consumes
1423
- consuming_types = set()
1424
- for sub in self._agent.subscriptions:
1425
- consuming_types.update(sub.type_names)
1426
-
1427
- # Get types agent publishes
1428
- publishing_types = {
1429
- output.spec.type_name
1430
- for group in self._agent.output_groups
1431
- for output in group.outputs
1432
- }
1433
-
1434
- # Check for overlap
1435
- overlap = consuming_types.intersection(publishing_types)
1436
- if overlap and self._agent.prevent_self_trigger:
1437
- logger.warning(
1438
- f"Agent '{self._agent.name}' consumes and publishes {overlap}. "
1439
- f"Feedback loop risk detected. Agent has prevent_self_trigger=True (safe), "
1440
- f"but consider adding filtering: .consumes(Type, where=lambda x: ...) "
1441
- f"or use .prevent_self_trigger(False) for intentional feedback."
1442
- )
1443
-
1444
- def _validate_best_of(self, n: int) -> None:
1445
- """T074: Warn if best_of value is excessively high."""
1446
- from flock.logging.logging import get_logger
1447
-
1448
- logger = get_logger(__name__)
1449
-
1450
- if n > 100:
1451
- logger.warning(
1452
- f"Agent '{self._agent.name}' has best_of({n}) which is very high. "
1453
- f"Typical values are 3-10. High values increase cost and latency. "
1454
- f"Consider reducing unless you have specific requirements."
1455
- )
1456
-
1457
- def _validate_concurrency(self, n: int) -> None:
1458
- """T074: Warn if max_concurrency is excessively high."""
1459
- from flock.logging.logging import get_logger
1460
-
1461
- logger = get_logger(__name__)
1462
-
1463
- if n > 1000:
1464
- logger.warning(
1465
- f"Agent '{self._agent.name}' has max_concurrency({n}) which is very high. "
1466
- f"Typical values are 1-50. Excessive concurrency may cause resource issues. "
1467
- f"Consider reducing unless you have specific infrastructure."
1468
- )
1469
-
1470
- # Utility --------------------------------------------------------------
1471
-
1472
- def _normalize_join(self, value: dict | JoinSpec | None) -> JoinSpec | None:
1473
- if value is None or isinstance(value, JoinSpec):
1474
- return value
1475
- # Phase 2: New JoinSpec API with 'by' and 'within' (time OR count)
1476
- from datetime import timedelta
1477
-
1478
- within_value = value.get("within")
1479
- if isinstance(within_value, (int, float)):
1480
- # Count window or seconds as float - keep as is
1481
- within = (
1482
- int(within_value)
1483
- if isinstance(within_value, int)
1484
- else timedelta(seconds=within_value)
1485
- )
1486
- else:
1487
- # Default to 1 minute time window
1488
- within = timedelta(minutes=1)
1489
- return JoinSpec(
1490
- by=value["by"], # Required
1491
- within=within,
1492
- )
1493
-
1494
- def _normalize_batch(self, value: dict | BatchSpec | None) -> BatchSpec | None:
1495
- if value is None or isinstance(value, BatchSpec):
1496
- return value
1497
- return BatchSpec(
1498
- size=int(value.get("size", 1)),
1499
- within=float(value.get("within", 0.0)),
1500
- by=value.get("by"),
1501
- )
1502
-
1503
- # Properties -----------------------------------------------------------
1504
-
1505
- @property
1506
- def name(self) -> str:
1507
- return self._agent.name
1508
-
1509
- @property
1510
- def agent(self) -> Agent:
1511
- return self._agent
1512
-
1513
-
1514
- class PublishBuilder:
1515
- """Helper returned by `.publishes(...)` to support `.only_for` sugar."""
1516
-
1517
- def __init__(self, parent: AgentBuilder, outputs: Sequence[AgentOutput]) -> None:
1518
- self._parent = parent
1519
- self._outputs = list(outputs)
1520
-
1521
- def only_for(self, *agent_names: str) -> AgentBuilder:
1522
- visibility = only_for(*agent_names)
1523
- for output in self._outputs:
1524
- output.default_visibility = visibility
1525
- return self._parent
1526
-
1527
- def visibility(self, value: Visibility) -> AgentBuilder:
1528
- for output in self._outputs:
1529
- output.default_visibility = value
1530
- return self._parent
1531
-
1532
- def __getattr__(self, item):
1533
- return getattr(self._parent, item)
1534
-
1535
-
1536
- class RunHandle:
1537
- """Represents a chained run starting from a given agent."""
1538
-
1539
- def __init__(self, agent: Agent, inputs: list[BaseModel]) -> None:
1540
- self.agent = agent
1541
- self.inputs = inputs
1542
- self._chain: list[Agent] = [agent]
1543
-
1544
- def then(self, builder: AgentBuilder) -> RunHandle:
1545
- self._chain.append(builder.agent)
1546
- return self
1547
-
1548
- async def execute(self) -> list[Artifact]:
1549
- orchestrator = self.agent._orchestrator
1550
- artifacts = await orchestrator.direct_invoke(self.agent, self.inputs)
1551
- for agent in self._chain[1:]:
1552
- artifacts = await orchestrator.direct_invoke(agent, artifacts)
1553
- return artifacts
1554
-
1555
-
1556
- class Pipeline:
1557
- def __init__(self, builders: Sequence[AgentBuilder]) -> None:
1558
- self.builders = list(builders)
1559
-
1560
- def then(self, builder: AgentBuilder) -> Pipeline:
1561
- self.builders.append(builder)
1562
- return self
1563
-
1564
- async def execute(self) -> list[Artifact]:
1565
- orchestrator = self.builders[0].agent._orchestrator
1566
- artifacts: list[Artifact] = []
1567
- for builder in self.builders:
1568
- inputs = artifacts if artifacts else []
1569
- artifacts = await orchestrator.direct_invoke(builder.agent, inputs)
1570
- return artifacts
1571
-
1572
-
1573
- __all__ = [
1574
- "Agent",
1575
- "AgentBuilder",
1576
- "AgentOutput",
1577
- "OutputGroup",
1578
- ]