flock-core 0.5.7__py3-none-any.whl → 0.5.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of flock-core might be problematic. Click here for more details.
- flock/agent.py +336 -80
- flock/artifacts.py +2 -2
- flock/components.py +38 -30
- flock/correlation_engine.py +3 -6
- flock/dashboard/collector.py +9 -9
- flock/dashboard/events.py +8 -8
- flock/dashboard/service.py +7 -7
- flock/engines/dspy_engine.py +560 -64
- flock/engines/examples/simple_batch_engine.py +36 -20
- flock/examples.py +2 -2
- flock/helper/cli_helper.py +2 -2
- flock/logging/formatters/themed_formatter.py +3 -1
- flock/mcp/config.py +1 -2
- flock/mcp/tool.py +1 -2
- flock/orchestrator.py +2 -2
- flock/store.py +2 -2
- flock/utilities.py +1 -1
- flock/visibility.py +3 -3
- {flock_core-0.5.7.dist-info → flock_core-0.5.9.dist-info}/METADATA +99 -4
- {flock_core-0.5.7.dist-info → flock_core-0.5.9.dist-info}/RECORD +23 -23
- {flock_core-0.5.7.dist-info → flock_core-0.5.9.dist-info}/WHEEL +0 -0
- {flock_core-0.5.7.dist-info → flock_core-0.5.9.dist-info}/entry_points.txt +0 -0
- {flock_core-0.5.7.dist-info → flock_core-0.5.9.dist-info}/licenses/LICENSE +0 -0
flock/agent.py
CHANGED
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import os
|
|
7
|
-
from collections.abc import Sequence
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
from typing import TYPE_CHECKING, Any, TypedDict
|
|
10
10
|
|
|
@@ -64,6 +64,21 @@ class MCPServerConfig(TypedDict, total=False):
|
|
|
64
64
|
class AgentOutput:
|
|
65
65
|
spec: ArtifactSpec
|
|
66
66
|
default_visibility: Visibility
|
|
67
|
+
count: int = 1 # Number of artifacts to generate (fan-out)
|
|
68
|
+
filter_predicate: Callable[[BaseModel], bool] | None = None # Where clause
|
|
69
|
+
validate_predicate: Callable[[BaseModel], bool] | list[tuple[Callable, str]] | None = (
|
|
70
|
+
None # Validation logic
|
|
71
|
+
)
|
|
72
|
+
group_description: str | None = None # Group description override
|
|
73
|
+
|
|
74
|
+
def __post_init__(self):
|
|
75
|
+
"""Validate field constraints."""
|
|
76
|
+
if self.count < 1:
|
|
77
|
+
raise ValueError(f"count must be >= 1, got {self.count}")
|
|
78
|
+
|
|
79
|
+
def is_many(self) -> bool:
|
|
80
|
+
"""Return True if this output generates multiple artifacts (count > 1)."""
|
|
81
|
+
return self.count > 1
|
|
67
82
|
|
|
68
83
|
def apply(
|
|
69
84
|
self,
|
|
@@ -85,6 +100,27 @@ class AgentOutput:
|
|
|
85
100
|
)
|
|
86
101
|
|
|
87
102
|
|
|
103
|
+
@dataclass
|
|
104
|
+
class OutputGroup:
|
|
105
|
+
"""Represents one .publishes() call.
|
|
106
|
+
|
|
107
|
+
Each OutputGroup triggers one engine execution that generates
|
|
108
|
+
all artifacts in the group together.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
outputs: list[AgentOutput]
|
|
112
|
+
shared_visibility: Visibility | None = None
|
|
113
|
+
group_description: str | None = None # Group-level description override
|
|
114
|
+
|
|
115
|
+
def is_single_call(self) -> bool:
|
|
116
|
+
"""True if this is one engine call generating multiple artifacts.
|
|
117
|
+
|
|
118
|
+
Currently always returns True as each group = one engine call.
|
|
119
|
+
Future: Could return False for parallel sub-groups.
|
|
120
|
+
"""
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
|
|
88
124
|
class Agent(metaclass=AutoTracedMeta):
|
|
89
125
|
"""Executable agent constructed via `AgentBuilder`.
|
|
90
126
|
|
|
@@ -96,7 +132,7 @@ class Agent(metaclass=AutoTracedMeta):
|
|
|
96
132
|
self.description: str | None = None
|
|
97
133
|
self._orchestrator = orchestrator
|
|
98
134
|
self.subscriptions: list[Subscription] = []
|
|
99
|
-
self.
|
|
135
|
+
self.output_groups: list[OutputGroup] = []
|
|
100
136
|
self.utilities: list[AgentComponent] = []
|
|
101
137
|
self.engines: list[EngineComponent] = []
|
|
102
138
|
self.best_of_n: int = 1
|
|
@@ -115,6 +151,11 @@ class Agent(metaclass=AutoTracedMeta):
|
|
|
115
151
|
self.mcp_server_mounts: dict[str, list[str]] = {} # Server-specific mount points
|
|
116
152
|
self.tool_whitelist: list[str] | None = None
|
|
117
153
|
|
|
154
|
+
@property
|
|
155
|
+
def outputs(self) -> list[AgentOutput]:
|
|
156
|
+
"""Backwards compatibility: return flat list of all outputs from all groups."""
|
|
157
|
+
return [output for group in self.output_groups for output in group.outputs]
|
|
158
|
+
|
|
118
159
|
@property
|
|
119
160
|
def identity(self) -> AgentIdentity:
|
|
120
161
|
return AgentIdentity(name=self.name, labels=self.labels, tenant_id=self.tenant_id)
|
|
@@ -160,9 +201,40 @@ class Agent(metaclass=AutoTracedMeta):
|
|
|
160
201
|
processed_inputs = await self._run_pre_consume(ctx, artifacts)
|
|
161
202
|
eval_inputs = EvalInputs(artifacts=processed_inputs, state=dict(ctx.state))
|
|
162
203
|
eval_inputs = await self._run_pre_evaluate(ctx, eval_inputs)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
204
|
+
|
|
205
|
+
# Phase 3: Call engine ONCE PER OutputGroup
|
|
206
|
+
all_outputs: list[Artifact] = []
|
|
207
|
+
|
|
208
|
+
if not self.output_groups:
|
|
209
|
+
# No output groups: Utility agents that don't publish
|
|
210
|
+
# Create empty OutputGroup for engines that may have side effects
|
|
211
|
+
empty_group = OutputGroup(outputs=[], group_description=None)
|
|
212
|
+
result = await self._run_engines(ctx, eval_inputs, empty_group)
|
|
213
|
+
# Run post_evaluate hooks for utility components (e.g., metrics)
|
|
214
|
+
result = await self._run_post_evaluate(ctx, eval_inputs, result)
|
|
215
|
+
# Utility agents return empty list (no outputs declared)
|
|
216
|
+
outputs = []
|
|
217
|
+
else:
|
|
218
|
+
# Loop over each output group
|
|
219
|
+
for group_idx, output_group in enumerate(self.output_groups):
|
|
220
|
+
# Prepare group-specific context
|
|
221
|
+
group_ctx = self._prepare_group_context(ctx, group_idx, output_group)
|
|
222
|
+
|
|
223
|
+
# Phase 7: Single evaluation path with auto-detection
|
|
224
|
+
# Engine's evaluate() auto-detects batch/fan-out from ctx and output_group
|
|
225
|
+
result = await self._run_engines(group_ctx, eval_inputs, output_group)
|
|
226
|
+
|
|
227
|
+
result = await self._run_post_evaluate(group_ctx, eval_inputs, result)
|
|
228
|
+
|
|
229
|
+
# Extract outputs for THIS group only
|
|
230
|
+
group_outputs = await self._make_outputs_for_group(
|
|
231
|
+
group_ctx, result, output_group
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
all_outputs.extend(group_outputs)
|
|
235
|
+
|
|
236
|
+
outputs = all_outputs
|
|
237
|
+
|
|
166
238
|
await self._run_post_publish(ctx, outputs)
|
|
167
239
|
if self.calls_func:
|
|
168
240
|
await self._invoke_call(ctx, outputs or processed_inputs)
|
|
@@ -302,7 +374,19 @@ class Agent(metaclass=AutoTracedMeta):
|
|
|
302
374
|
raise
|
|
303
375
|
return current
|
|
304
376
|
|
|
305
|
-
async def _run_engines(
|
|
377
|
+
async def _run_engines(
|
|
378
|
+
self, ctx: Context, inputs: EvalInputs, output_group: OutputGroup
|
|
379
|
+
) -> EvalResult:
|
|
380
|
+
"""Execute engines for a specific OutputGroup.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
ctx: Execution context
|
|
384
|
+
inputs: EvalInputs with input artifacts
|
|
385
|
+
output_group: The OutputGroup defining what artifacts to produce
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
EvalResult with artifacts matching output_group specifications
|
|
389
|
+
"""
|
|
306
390
|
engines = self._resolve_engines()
|
|
307
391
|
if not engines:
|
|
308
392
|
return EvalResult(artifacts=inputs.artifacts, state=inputs.state)
|
|
@@ -313,26 +397,10 @@ class Agent(metaclass=AutoTracedMeta):
|
|
|
313
397
|
accumulated_metrics: dict[str, float] = {}
|
|
314
398
|
for engine in engines:
|
|
315
399
|
current_inputs = await engine.on_pre_evaluate(self, ctx, current_inputs)
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
"Agent %s: routing %d artifacts to %s.evaluate_batch",
|
|
321
|
-
self.name,
|
|
322
|
-
len(current_inputs.artifacts),
|
|
323
|
-
engine.__class__.__name__,
|
|
324
|
-
)
|
|
325
|
-
result = await engine.evaluate_batch(self, ctx, current_inputs)
|
|
326
|
-
else:
|
|
327
|
-
result = await engine.evaluate(self, ctx, current_inputs)
|
|
328
|
-
except NotImplementedError:
|
|
329
|
-
if use_batch_mode:
|
|
330
|
-
logger.exception(
|
|
331
|
-
"Agent %s: engine %s does not implement evaluate_batch()",
|
|
332
|
-
self.name,
|
|
333
|
-
engine.__class__.__name__,
|
|
334
|
-
)
|
|
335
|
-
raise
|
|
400
|
+
|
|
401
|
+
# Phase 7: Single evaluation path with auto-detection
|
|
402
|
+
# Engine's evaluate() auto-detects batching via ctx.is_batch
|
|
403
|
+
result = await engine.evaluate(self, ctx, current_inputs, output_group)
|
|
336
404
|
|
|
337
405
|
# AUTO-WRAP: If engine returns BaseModel instead of EvalResult, wrap it
|
|
338
406
|
from flock.runtime import EvalResult as ER
|
|
@@ -392,29 +460,177 @@ class Agent(metaclass=AutoTracedMeta):
|
|
|
392
460
|
return current
|
|
393
461
|
|
|
394
462
|
async def _make_outputs(self, ctx: Context, result: EvalResult) -> list[Artifact]:
|
|
395
|
-
if not self.
|
|
463
|
+
if not self.output_groups:
|
|
396
464
|
# Utility agents may not publish anything
|
|
397
465
|
return list(result.artifacts)
|
|
398
466
|
|
|
399
467
|
produced: list[Artifact] = []
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
468
|
+
|
|
469
|
+
# For Phase 2: Iterate ALL output_groups (even though we only have 1 engine call)
|
|
470
|
+
# Phase 3 will modify this to call engine once PER group
|
|
471
|
+
for output_group in self.output_groups:
|
|
472
|
+
for output_decl in output_group.outputs:
|
|
473
|
+
# Phase 6: Find the matching artifact from engine result to preserve its ID
|
|
474
|
+
matching_artifact = self._find_matching_artifact(output_decl, result)
|
|
475
|
+
|
|
476
|
+
payload = self._select_payload(output_decl, result)
|
|
477
|
+
if payload is None:
|
|
478
|
+
continue
|
|
479
|
+
metadata = {
|
|
480
|
+
"correlation_id": ctx.correlation_id,
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
# Phase 6: Preserve artifact ID from engine (for streaming message preview)
|
|
484
|
+
if matching_artifact:
|
|
485
|
+
metadata["artifact_id"] = matching_artifact.id
|
|
486
|
+
|
|
487
|
+
artifact = output_decl.apply(payload, produced_by=self.name, metadata=metadata)
|
|
488
|
+
produced.append(artifact)
|
|
489
|
+
await ctx.board.publish(artifact)
|
|
490
|
+
|
|
491
|
+
return produced
|
|
492
|
+
|
|
493
|
+
def _prepare_group_context(
|
|
494
|
+
self, ctx: Context, group_idx: int, output_group: OutputGroup
|
|
495
|
+
) -> Context:
|
|
496
|
+
"""Phase 3: Prepare context specific to this OutputGroup.
|
|
497
|
+
|
|
498
|
+
Creates a modified context for this group's engine call, potentially
|
|
499
|
+
with group-specific instructions or metadata.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
ctx: Base context
|
|
503
|
+
group_idx: Index of this group (0-based)
|
|
504
|
+
output_group: The OutputGroup being processed
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Context for this group (may be the same instance or modified)
|
|
508
|
+
"""
|
|
509
|
+
# For now, return the same context
|
|
510
|
+
# Phase 4 will add group-specific system prompts here
|
|
511
|
+
# Future: ctx.clone() and add group_description to system prompt
|
|
512
|
+
return ctx
|
|
513
|
+
|
|
514
|
+
async def _make_outputs_for_group(
|
|
515
|
+
self, ctx: Context, result: EvalResult, output_group: OutputGroup
|
|
516
|
+
) -> list[Artifact]:
|
|
517
|
+
"""Phase 3/5: Validate, filter, and publish artifacts for specific OutputGroup.
|
|
518
|
+
|
|
519
|
+
This function:
|
|
520
|
+
1. Validates that the engine fulfilled its contract (produced expected count)
|
|
521
|
+
2. Applies WHERE filtering (reduces artifacts, no error)
|
|
522
|
+
3. Applies VALIDATE checks (raises ValueError if validation fails)
|
|
523
|
+
4. Applies visibility (static or dynamic)
|
|
524
|
+
5. Publishes artifacts to the board
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
ctx: Context for this group
|
|
528
|
+
result: EvalResult from engine for THIS group
|
|
529
|
+
output_group: OutputGroup defining expected outputs
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
List of artifacts matching this group's outputs
|
|
533
|
+
|
|
534
|
+
Raises:
|
|
535
|
+
ValueError: If engine violated contract or validation failed
|
|
536
|
+
"""
|
|
537
|
+
produced: list[Artifact] = []
|
|
538
|
+
|
|
539
|
+
for output_decl in output_group.outputs:
|
|
540
|
+
# 1. Find ALL matching artifacts for this type
|
|
541
|
+
from flock.registry import type_registry
|
|
542
|
+
|
|
543
|
+
expected_canonical = type_registry.resolve_name(output_decl.spec.type_name)
|
|
544
|
+
|
|
545
|
+
matching_artifacts: list[Artifact] = []
|
|
546
|
+
for artifact in result.artifacts:
|
|
547
|
+
try:
|
|
548
|
+
artifact_canonical = type_registry.resolve_name(artifact.type)
|
|
549
|
+
if artifact_canonical == expected_canonical:
|
|
550
|
+
matching_artifacts.append(artifact)
|
|
551
|
+
except Exception:
|
|
552
|
+
if artifact.type == output_decl.spec.type_name:
|
|
553
|
+
matching_artifacts.append(artifact)
|
|
554
|
+
|
|
555
|
+
# 2. STRICT VALIDATION: Engine must produce exactly what was promised
|
|
556
|
+
# (This happens BEFORE filtering so engine contract is validated first)
|
|
557
|
+
expected_count = output_decl.count
|
|
558
|
+
actual_count = len(matching_artifacts)
|
|
559
|
+
|
|
560
|
+
if actual_count != expected_count:
|
|
561
|
+
raise ValueError(
|
|
562
|
+
f"Engine contract violation in agent '{self.name}': "
|
|
563
|
+
f"Expected {expected_count} artifact(s) of type '{output_decl.spec.type_name}', "
|
|
564
|
+
f"but engine produced {actual_count}. "
|
|
565
|
+
f"Check your engine implementation to ensure it generates the correct number of outputs."
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# 3. Apply WHERE filtering (Phase 5)
|
|
569
|
+
# Filtering reduces the number of published artifacts (this is intentional)
|
|
570
|
+
# NOTE: Predicates expect Pydantic model instances, not dicts
|
|
571
|
+
model_cls = type_registry.resolve(output_decl.spec.type_name)
|
|
572
|
+
|
|
573
|
+
if output_decl.filter_predicate:
|
|
574
|
+
original_count = len(matching_artifacts)
|
|
575
|
+
filtered = []
|
|
576
|
+
for a in matching_artifacts:
|
|
577
|
+
# Reconstruct Pydantic model from payload dict
|
|
578
|
+
model_instance = model_cls(**a.payload)
|
|
579
|
+
if output_decl.filter_predicate(model_instance):
|
|
580
|
+
filtered.append(a)
|
|
581
|
+
matching_artifacts = filtered
|
|
582
|
+
logger.debug(
|
|
583
|
+
f"Agent {self.name}: WHERE filter reduced artifacts from "
|
|
584
|
+
f"{original_count} to {len(matching_artifacts)} for type {output_decl.spec.type_name}"
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
# 4. Apply VALIDATE checks (Phase 5)
|
|
588
|
+
# Validation failures raise errors (fail-fast)
|
|
589
|
+
if output_decl.validate_predicate:
|
|
590
|
+
if callable(output_decl.validate_predicate):
|
|
591
|
+
# Single predicate
|
|
592
|
+
for artifact in matching_artifacts:
|
|
593
|
+
# Reconstruct Pydantic model from payload dict
|
|
594
|
+
model_instance = model_cls(**artifact.payload)
|
|
595
|
+
if not output_decl.validate_predicate(model_instance):
|
|
596
|
+
raise ValueError(
|
|
597
|
+
f"Validation failed for {output_decl.spec.type_name} "
|
|
598
|
+
f"in agent '{self.name}'"
|
|
599
|
+
)
|
|
600
|
+
elif isinstance(output_decl.validate_predicate, list):
|
|
601
|
+
# List of (callable, error_msg) tuples
|
|
602
|
+
for artifact in matching_artifacts:
|
|
603
|
+
# Reconstruct Pydantic model from payload dict
|
|
604
|
+
model_instance = model_cls(**artifact.payload)
|
|
605
|
+
for check, error_msg in output_decl.validate_predicate:
|
|
606
|
+
if not check(model_instance):
|
|
607
|
+
raise ValueError(f"{error_msg}: {output_decl.spec.type_name}")
|
|
608
|
+
|
|
609
|
+
# 5. Apply visibility and publish artifacts (Phase 5)
|
|
610
|
+
for artifact_from_engine in matching_artifacts:
|
|
611
|
+
metadata = {
|
|
612
|
+
"correlation_id": ctx.correlation_id,
|
|
613
|
+
"artifact_id": artifact_from_engine.id, # Preserve engine's ID
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
# Determine visibility (static or dynamic)
|
|
617
|
+
visibility = output_decl.default_visibility
|
|
618
|
+
if callable(visibility):
|
|
619
|
+
# Dynamic visibility based on artifact content
|
|
620
|
+
# Reconstruct Pydantic model from payload dict
|
|
621
|
+
model_instance = model_cls(**artifact_from_engine.payload)
|
|
622
|
+
visibility = visibility(model_instance)
|
|
623
|
+
|
|
624
|
+
# Override metadata visibility
|
|
625
|
+
metadata["visibility"] = visibility
|
|
626
|
+
|
|
627
|
+
# Re-wrap the artifact with agent metadata
|
|
628
|
+
artifact = output_decl.apply(
|
|
629
|
+
artifact_from_engine.payload, produced_by=self.name, metadata=metadata
|
|
630
|
+
)
|
|
631
|
+
produced.append(artifact)
|
|
632
|
+
await ctx.board.publish(artifact)
|
|
633
|
+
|
|
418
634
|
return produced
|
|
419
635
|
|
|
420
636
|
async def _run_post_publish(self, ctx: Context, artifacts: Sequence[Artifact]) -> None:
|
|
@@ -706,43 +922,34 @@ class AgentBuilder:
|
|
|
706
922
|
return self
|
|
707
923
|
|
|
708
924
|
def publishes(
|
|
709
|
-
self,
|
|
925
|
+
self,
|
|
926
|
+
*types: type[BaseModel],
|
|
927
|
+
visibility: Visibility | Callable[[BaseModel], Visibility] | None = None,
|
|
928
|
+
fan_out: int | None = None,
|
|
929
|
+
where: Callable[[BaseModel], bool] | None = None,
|
|
930
|
+
validate: Callable[[BaseModel], bool] | list[tuple[Callable, str]] | None = None,
|
|
931
|
+
description: str | None = None,
|
|
710
932
|
) -> PublishBuilder:
|
|
711
933
|
"""Declare which artifact types this agent produces.
|
|
712
934
|
|
|
713
|
-
Configures the output types and default visibility controls for artifacts
|
|
714
|
-
published by this agent. Can chain with .where() for conditional publishing.
|
|
715
|
-
|
|
716
935
|
Args:
|
|
717
936
|
*types: Artifact types (Pydantic models) to publish
|
|
718
|
-
visibility: Default visibility control
|
|
719
|
-
|
|
937
|
+
visibility: Default visibility control OR callable for dynamic visibility
|
|
938
|
+
fan_out: Number of artifacts to publish (applies to ALL types)
|
|
939
|
+
where: Filter predicate for output artifacts
|
|
940
|
+
validate: Validation predicate(s) - callable or list of (callable, error_msg) tuples
|
|
941
|
+
description: Group-level description override
|
|
720
942
|
|
|
721
943
|
Returns:
|
|
722
944
|
PublishBuilder for conditional publishing configuration
|
|
723
945
|
|
|
724
946
|
Examples:
|
|
725
|
-
>>> #
|
|
726
|
-
>>> agent.publishes(
|
|
727
|
-
|
|
728
|
-
>>> #
|
|
729
|
-
>>> agent.publishes(
|
|
730
|
-
|
|
731
|
-
>>> # Private outputs (only specific agents can see)
|
|
732
|
-
>>> agent.publishes(
|
|
733
|
-
... SecretData,
|
|
734
|
-
... visibility=PrivateVisibility(agents={"admin", "auditor"})
|
|
735
|
-
... )
|
|
736
|
-
|
|
737
|
-
>>> # Tenant-isolated outputs
|
|
738
|
-
>>> agent.publishes(
|
|
739
|
-
... Invoice,
|
|
740
|
-
... visibility=TenantVisibility()
|
|
741
|
-
... )
|
|
742
|
-
|
|
743
|
-
>>> # Conditional publishing with chaining
|
|
744
|
-
>>> (agent.publishes(Alert)
|
|
745
|
-
... .where(lambda result: result.severity == "critical"))
|
|
947
|
+
>>> agent.publishes(Report) # Publish 1 Report
|
|
948
|
+
>>> agent.publishes(Task, Task, Task) # Publish 3 Tasks (duplicate counting)
|
|
949
|
+
>>> agent.publishes(Task, fan_out=3) # Same as above (sugar syntax)
|
|
950
|
+
>>> agent.publishes(Task, where=lambda t: t.priority > 5) # With filtering
|
|
951
|
+
>>> agent.publishes(Report, validate=lambda r: r.score > 0) # With validation
|
|
952
|
+
>>> agent.publishes(Task, description="Special instructions") # With description
|
|
746
953
|
|
|
747
954
|
See Also:
|
|
748
955
|
- PublicVisibility: Default, visible to all agents
|
|
@@ -750,14 +957,59 @@ class AgentBuilder:
|
|
|
750
957
|
- TenantVisibility: Multi-tenant isolation
|
|
751
958
|
- LabelledVisibility: Role-based access control
|
|
752
959
|
"""
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
960
|
+
# Validate fan_out if provided
|
|
961
|
+
if fan_out is not None and fan_out < 1:
|
|
962
|
+
raise ValueError(f"fan_out must be >= 1, got {fan_out}")
|
|
963
|
+
|
|
964
|
+
# Resolve visibility
|
|
965
|
+
resolved_visibility = (
|
|
966
|
+
ensure_visibility(visibility) if not callable(visibility) else visibility
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
# Create AgentOutput objects for this group
|
|
970
|
+
outputs: list[AgentOutput] = []
|
|
971
|
+
|
|
972
|
+
if fan_out is not None:
|
|
973
|
+
# Apply fan_out to ALL types
|
|
974
|
+
for model in types:
|
|
975
|
+
spec = ArtifactSpec.from_model(model)
|
|
976
|
+
output = AgentOutput(
|
|
977
|
+
spec=spec,
|
|
978
|
+
default_visibility=resolved_visibility,
|
|
979
|
+
count=fan_out,
|
|
980
|
+
filter_predicate=where,
|
|
981
|
+
validate_predicate=validate,
|
|
982
|
+
group_description=description,
|
|
983
|
+
)
|
|
984
|
+
outputs.append(output)
|
|
985
|
+
else:
|
|
986
|
+
# Create separate AgentOutput for each type (including duplicates)
|
|
987
|
+
# This preserves order: .publishes(A, B, A) → [A, B, A] (3 outputs)
|
|
988
|
+
for model in types:
|
|
989
|
+
spec = ArtifactSpec.from_model(model)
|
|
990
|
+
output = AgentOutput(
|
|
991
|
+
spec=spec,
|
|
992
|
+
default_visibility=resolved_visibility,
|
|
993
|
+
count=1,
|
|
994
|
+
filter_predicate=where,
|
|
995
|
+
validate_predicate=validate,
|
|
996
|
+
group_description=description,
|
|
997
|
+
)
|
|
998
|
+
outputs.append(output)
|
|
999
|
+
|
|
1000
|
+
# Create OutputGroup from outputs
|
|
1001
|
+
group = OutputGroup(
|
|
1002
|
+
outputs=outputs,
|
|
1003
|
+
shared_visibility=resolved_visibility if not callable(resolved_visibility) else None,
|
|
1004
|
+
group_description=description,
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
# Append to agent's output_groups
|
|
1008
|
+
self._agent.output_groups.append(group)
|
|
1009
|
+
|
|
1010
|
+
# Validate configuration
|
|
760
1011
|
self._validate_self_trigger_risk()
|
|
1012
|
+
|
|
761
1013
|
return PublishBuilder(self, outputs)
|
|
762
1014
|
|
|
763
1015
|
def with_utilities(self, *components: AgentComponent) -> AgentBuilder:
|
|
@@ -1088,7 +1340,9 @@ class AgentBuilder:
|
|
|
1088
1340
|
consuming_types.update(sub.type_names)
|
|
1089
1341
|
|
|
1090
1342
|
# Get types agent publishes
|
|
1091
|
-
publishing_types = {
|
|
1343
|
+
publishing_types = {
|
|
1344
|
+
output.spec.type_name for group in self._agent.output_groups for output in group.outputs
|
|
1345
|
+
}
|
|
1092
1346
|
|
|
1093
1347
|
# Check for overlap
|
|
1094
1348
|
overlap = consuming_types.intersection(publishing_types)
|
|
@@ -1232,4 +1486,6 @@ class Pipeline:
|
|
|
1232
1486
|
__all__ = [
|
|
1233
1487
|
"Agent",
|
|
1234
1488
|
"AgentBuilder",
|
|
1489
|
+
"AgentOutput",
|
|
1490
|
+
"OutputGroup",
|
|
1235
1491
|
]
|
flock/artifacts.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from datetime import
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
6
|
from typing import Any
|
|
7
7
|
from uuid import UUID, uuid4
|
|
8
8
|
|
|
@@ -23,7 +23,7 @@ class Artifact(BaseModel):
|
|
|
23
23
|
partition_key: str | None = None
|
|
24
24
|
tags: set[str] = Field(default_factory=set)
|
|
25
25
|
visibility: Visibility = Field(default_factory=lambda: ensure_visibility(None))
|
|
26
|
-
created_at: datetime = Field(default_factory=lambda: datetime.now(
|
|
26
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
27
27
|
version: int = 1
|
|
28
28
|
|
|
29
29
|
def model_dump_payload(self) -> dict[str, Any]: # pragma: no cover - convenience
|
flock/components.py
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import TYPE_CHECKING, Any
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
6
6
|
|
|
7
7
|
from pydantic import BaseModel, Field, create_model
|
|
8
8
|
from pydantic._internal._model_construction import ModelMetaclass
|
|
9
|
-
from typing_extensions import
|
|
9
|
+
from typing_extensions import TypeVar
|
|
10
10
|
|
|
11
11
|
from flock.logging.auto_trace import AutoTracedMeta
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ from flock.logging.auto_trace import AutoTracedMeta
|
|
|
14
14
|
if TYPE_CHECKING: # pragma: no cover - type checking only
|
|
15
15
|
from uuid import UUID
|
|
16
16
|
|
|
17
|
-
from flock.agent import Agent
|
|
17
|
+
from flock.agent import Agent, OutputGroup
|
|
18
18
|
from flock.artifacts import Artifact
|
|
19
19
|
from flock.runtime import Context, EvalInputs, EvalResult
|
|
20
20
|
|
|
@@ -109,41 +109,49 @@ class EngineComponent(AgentComponent):
|
|
|
109
109
|
default_factory=set, description="Artifact types to exclude from context"
|
|
110
110
|
)
|
|
111
111
|
|
|
112
|
-
async def evaluate(
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
async def evaluate(
|
|
113
|
+
self, agent: Agent, ctx: Context, inputs: EvalInputs, output_group: OutputGroup
|
|
114
|
+
) -> EvalResult:
|
|
115
|
+
"""Universal evaluation method with auto-detection of batch and fan-out modes.
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
This single method handles ALL evaluation scenarios:
|
|
118
|
+
- Single artifact → single output
|
|
119
|
+
- Batch processing (ctx.is_batch=True) → list[Type] signatures
|
|
120
|
+
- Fan-out (output_group.outputs[*].count > 1) → multiple artifacts
|
|
121
|
+
- Multi-output (len(output_group.outputs) > 1) → multiple types
|
|
118
122
|
|
|
119
|
-
|
|
123
|
+
Auto-detection happens automatically:
|
|
124
|
+
- Batching: Detected via ctx.is_batch flag
|
|
125
|
+
- Fan-out: Detected via output_group.outputs[*].count
|
|
126
|
+
- Multi-input: Detected via len(inputs.artifacts)
|
|
127
|
+
- Multi-output: Detected via len(output_group.outputs)
|
|
120
128
|
|
|
121
129
|
Args:
|
|
122
130
|
agent: Agent instance executing this engine
|
|
123
|
-
ctx: Execution context (ctx.is_batch
|
|
124
|
-
inputs: EvalInputs with
|
|
131
|
+
ctx: Execution context (check ctx.is_batch for batch mode)
|
|
132
|
+
inputs: EvalInputs with input artifacts
|
|
133
|
+
output_group: OutputGroup defining what artifacts to produce
|
|
134
|
+
(inspect outputs[*].count for fan-out detection)
|
|
125
135
|
|
|
126
136
|
Returns:
|
|
127
|
-
EvalResult with
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
...
|
|
135
|
-
...
|
|
136
|
-
...
|
|
137
|
+
EvalResult with artifacts matching output_group specifications
|
|
138
|
+
|
|
139
|
+
Implementation Guide:
|
|
140
|
+
>>> async def evaluate(self, agent, ctx, inputs, output_group):
|
|
141
|
+
... # Auto-detect batching from context
|
|
142
|
+
... batched = bool(getattr(ctx, "is_batch", False))
|
|
143
|
+
...
|
|
144
|
+
... # Fan-out is auto-detected from output_group
|
|
145
|
+
... # Your signature building should check:
|
|
146
|
+
... # - output_group.outputs[i].count > 1 for fan-out
|
|
147
|
+
... # - len(output_group.outputs) > 1 for multi-output
|
|
148
|
+
...
|
|
149
|
+
... # Build signature adapting to all modes
|
|
150
|
+
... signature = self._build_signature(inputs, output_group, batched)
|
|
151
|
+
... result = await self._execute(signature, inputs)
|
|
152
|
+
... return EvalResult.from_objects(*result, agent=agent)
|
|
137
153
|
"""
|
|
138
|
-
raise NotImplementedError
|
|
139
|
-
f"{self.__class__.__name__} does not support batch processing.\n\n"
|
|
140
|
-
f"To fix this:\n"
|
|
141
|
-
f"1. Remove BatchSpec from agent subscription, OR\n"
|
|
142
|
-
f"2. Implement evaluate_batch() in {self.__class__.__name__}, OR\n"
|
|
143
|
-
f"3. Use a batch-aware engine (e.g., CustomBatchEngine)\n\n"
|
|
144
|
-
f"Agent: {agent.name}\n"
|
|
145
|
-
f"Engine: {self.__class__.__name__}"
|
|
146
|
-
)
|
|
154
|
+
raise NotImplementedError
|
|
147
155
|
|
|
148
156
|
async def fetch_conversation_context(
|
|
149
157
|
self,
|
flock/correlation_engine.py
CHANGED
|
@@ -11,7 +11,7 @@ Supports JoinSpec-based correlation:
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
from collections import defaultdict
|
|
14
|
-
from datetime import datetime, timedelta
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
15
|
from typing import TYPE_CHECKING, Any
|
|
16
16
|
|
|
17
17
|
|
|
@@ -52,9 +52,7 @@ class CorrelationGroup:
|
|
|
52
52
|
def add_artifact(self, artifact: Artifact, current_sequence: int) -> None:
|
|
53
53
|
"""Add artifact to this correlation group's waiting pool."""
|
|
54
54
|
if self.created_at_time is None:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
self.created_at_time = datetime.now(timezone.utc)
|
|
55
|
+
self.created_at_time = datetime.now(UTC)
|
|
58
56
|
|
|
59
57
|
self.waiting_artifacts[artifact.type].append(artifact)
|
|
60
58
|
|
|
@@ -74,9 +72,8 @@ class CorrelationGroup:
|
|
|
74
72
|
# Time window: expired if current time exceeds created + window
|
|
75
73
|
if self.created_at_time is None:
|
|
76
74
|
return False
|
|
77
|
-
from datetime import timezone
|
|
78
75
|
|
|
79
|
-
elapsed = datetime.now(
|
|
76
|
+
elapsed = datetime.now(UTC) - self.created_at_time
|
|
80
77
|
return elapsed > self.window_spec
|
|
81
78
|
return False
|
|
82
79
|
|