flock-core 0.5.10__py3-none-any.whl → 0.5.20__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of flock-core might be problematic. Click here for more details.
- flock/__init__.py +1 -1
- flock/agent/__init__.py +30 -0
- flock/agent/builder_helpers.py +192 -0
- flock/agent/builder_validator.py +169 -0
- flock/agent/component_lifecycle.py +325 -0
- flock/agent/context_resolver.py +141 -0
- flock/agent/mcp_integration.py +212 -0
- flock/agent/output_processor.py +304 -0
- flock/api/__init__.py +20 -0
- flock/api/models.py +283 -0
- flock/{service.py → api/service.py} +121 -63
- flock/cli.py +2 -2
- flock/components/__init__.py +41 -0
- flock/components/agent/__init__.py +22 -0
- flock/{components.py → components/agent/base.py} +4 -3
- flock/{utility/output_utility_component.py → components/agent/output_utility.py} +12 -7
- flock/components/orchestrator/__init__.py +22 -0
- flock/{orchestrator_component.py → components/orchestrator/base.py} +5 -293
- flock/components/orchestrator/circuit_breaker.py +95 -0
- flock/components/orchestrator/collection.py +143 -0
- flock/components/orchestrator/deduplication.py +78 -0
- flock/core/__init__.py +30 -0
- flock/core/agent.py +953 -0
- flock/{artifacts.py → core/artifacts.py} +1 -1
- flock/{context_provider.py → core/context_provider.py} +3 -3
- flock/core/orchestrator.py +1102 -0
- flock/{store.py → core/store.py} +99 -454
- flock/{subscription.py → core/subscription.py} +1 -1
- flock/dashboard/collector.py +5 -5
- flock/dashboard/graph_builder.py +7 -7
- flock/dashboard/routes/__init__.py +21 -0
- flock/dashboard/routes/control.py +327 -0
- flock/dashboard/routes/helpers.py +340 -0
- flock/dashboard/routes/themes.py +76 -0
- flock/dashboard/routes/traces.py +521 -0
- flock/dashboard/routes/websocket.py +108 -0
- flock/dashboard/service.py +44 -1294
- flock/engines/dspy/__init__.py +20 -0
- flock/engines/dspy/artifact_materializer.py +216 -0
- flock/engines/dspy/signature_builder.py +474 -0
- flock/engines/dspy/streaming_executor.py +858 -0
- flock/engines/dspy_engine.py +45 -1330
- flock/engines/examples/simple_batch_engine.py +2 -2
- flock/examples.py +7 -7
- flock/logging/logging.py +1 -16
- flock/models/__init__.py +10 -0
- flock/models/system_artifacts.py +33 -0
- flock/orchestrator/__init__.py +45 -0
- flock/{artifact_collector.py → orchestrator/artifact_collector.py} +3 -3
- flock/orchestrator/artifact_manager.py +168 -0
- flock/{batch_accumulator.py → orchestrator/batch_accumulator.py} +2 -2
- flock/orchestrator/component_runner.py +389 -0
- flock/orchestrator/context_builder.py +167 -0
- flock/{correlation_engine.py → orchestrator/correlation_engine.py} +2 -2
- flock/orchestrator/event_emitter.py +167 -0
- flock/orchestrator/initialization.py +184 -0
- flock/orchestrator/lifecycle_manager.py +226 -0
- flock/orchestrator/mcp_manager.py +202 -0
- flock/orchestrator/scheduler.py +189 -0
- flock/orchestrator/server_manager.py +234 -0
- flock/orchestrator/tracing.py +147 -0
- flock/storage/__init__.py +10 -0
- flock/storage/artifact_aggregator.py +158 -0
- flock/storage/in_memory/__init__.py +6 -0
- flock/storage/in_memory/artifact_filter.py +114 -0
- flock/storage/in_memory/history_aggregator.py +115 -0
- flock/storage/sqlite/__init__.py +10 -0
- flock/storage/sqlite/agent_history_queries.py +154 -0
- flock/storage/sqlite/consumption_loader.py +100 -0
- flock/storage/sqlite/query_builder.py +112 -0
- flock/storage/sqlite/query_params_builder.py +91 -0
- flock/storage/sqlite/schema_manager.py +168 -0
- flock/storage/sqlite/summary_queries.py +194 -0
- flock/utils/__init__.py +14 -0
- flock/utils/async_utils.py +67 -0
- flock/{runtime.py → utils/runtime.py} +3 -3
- flock/utils/time_utils.py +53 -0
- flock/utils/type_resolution.py +38 -0
- flock/{utilities.py → utils/utilities.py} +2 -2
- flock/utils/validation.py +57 -0
- flock/utils/visibility.py +79 -0
- flock/utils/visibility_utils.py +134 -0
- {flock_core-0.5.10.dist-info → flock_core-0.5.20.dist-info}/METADATA +69 -61
- {flock_core-0.5.10.dist-info → flock_core-0.5.20.dist-info}/RECORD +89 -31
- flock/agent.py +0 -1578
- flock/orchestrator.py +0 -1746
- /flock/{visibility.py → core/visibility.py} +0 -0
- /flock/{helper → utils}/cli_helper.py +0 -0
- {flock_core-0.5.10.dist-info → flock_core-0.5.20.dist-info}/WHEEL +0 -0
- {flock_core-0.5.10.dist-info → flock_core-0.5.20.dist-info}/entry_points.txt +0 -0
- {flock_core-0.5.10.dist-info → flock_core-0.5.20.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
|
-
]
|