flock-core 0.4.0b21__py3-none-any.whl → 0.4.0b23__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.

@@ -0,0 +1,482 @@
1
+ # src/flock/routers/conditional/conditional_router.py
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import Field, model_validator
8
+
9
+ from flock.core.context.context import FlockContext
10
+ from flock.core.flock_agent import FlockAgent
11
+ from flock.core.flock_registry import flock_component, get_registry
12
+ from flock.core.flock_router import (
13
+ FlockRouter,
14
+ FlockRouterConfig,
15
+ HandOffRequest,
16
+ )
17
+ from flock.core.logging.logging import get_logger
18
+
19
+ logger = get_logger("router.conditional")
20
+
21
+
22
+ class ConditionalRouterConfig(FlockRouterConfig):
23
+ """Configuration for the ConditionalRouter."""
24
+
25
+ condition_context_key: str = Field(
26
+ default="flock.condition",
27
+ description="Context key containing the value to evaluate the condition against.",
28
+ )
29
+
30
+ # --- Define ONE type of condition check ---
31
+ condition_callable: (
32
+ str | Callable[[Any], tuple[bool, str | None]] | None
33
+ ) = Field(
34
+ default=None,
35
+ description="A callable (or registered name) that takes the context value and returns a tuple containing: (bool: True if condition passed, False otherwise, Optional[str]: Feedback message if condition failed).",
36
+ )
37
+ # String Checks
38
+ expected_string: str | None = Field(
39
+ default=None, description="String value to compare against."
40
+ )
41
+ string_mode: Literal[
42
+ "equals",
43
+ "contains",
44
+ "regex",
45
+ "startswith",
46
+ "endswith",
47
+ "not_equals",
48
+ "not_contains",
49
+ ] = Field(default="equals", description="How to compare strings.")
50
+ ignore_case: bool = Field(
51
+ default=True, description="Ignore case during string comparison."
52
+ )
53
+ # Length Checks (String or List)
54
+ min_length: int | None = Field(
55
+ default=None,
56
+ description="Minimum length for strings or items for lists.",
57
+ )
58
+ max_length: int | None = Field(
59
+ default=None,
60
+ description="Maximum length for strings or items for lists.",
61
+ )
62
+ # Number Checks
63
+ expected_number: int | float | None = Field(
64
+ default=None, description="Number to compare against."
65
+ )
66
+ number_mode: Literal["<", "<=", "==", "!=", ">=", ">"] = Field(
67
+ default="==", description="How to compare numbers."
68
+ )
69
+ # List Checks
70
+ min_items: int | None = Field(
71
+ default=None, description="Minimum number of items in a list."
72
+ )
73
+ max_items: int | None = Field(
74
+ default=None, description="Maximum number of items in a list."
75
+ )
76
+ # Type Check
77
+ expected_type_name: str | None = Field(
78
+ default=None,
79
+ description="Registered name of the expected Python type (e.g., 'str', 'list', 'MyCustomType').",
80
+ )
81
+ # Boolean Check
82
+ expected_bool: bool | None = Field(
83
+ default=None, description="Expected boolean value (True or False)."
84
+ )
85
+ # Existence Check
86
+ check_exists: bool | None = Field(
87
+ default=None,
88
+ description="If True, succeeds if key exists; if False, succeeds if key *doesn't* exist. Ignores value.",
89
+ )
90
+
91
+ # --- Routing Targets ---
92
+ success_agent: str | None = Field(
93
+ default=None,
94
+ description="Agent name to route to if the condition evaluates to True.",
95
+ )
96
+ failure_agent: str | None = Field(
97
+ default=None,
98
+ description="Agent name to route to if the condition evaluates to False (after retries, if enabled).",
99
+ )
100
+ retry_agent: str | None = Field(
101
+ default=None,
102
+ description="Agent name to route to if the condition evaluates to False (during retries, if enabled).",
103
+ )
104
+
105
+ # --- Optional Retry Logic (for Failure Path) ---
106
+ retry_on_failure: bool = Field(
107
+ default=False,
108
+ description="If True, route back to the retry_agent on failure before going to failure_agent.",
109
+ )
110
+ max_retries: int = Field(
111
+ default=1,
112
+ description="Maximum number of times to retry the current agent on failure.",
113
+ )
114
+ feedback_context_key: str | None = Field(
115
+ default="flock.assertion_feedback", # Useful if paired with AssertionCheckerModule
116
+ description="Optional context key containing feedback message to potentially include when retrying.",
117
+ )
118
+ retry_count_context_key_prefix: str = Field(
119
+ default="flock.conditional_retry_count_",
120
+ description="Internal prefix for context key storing retry attempts per agent.",
121
+ )
122
+
123
+ # --- Validator to ensure only one condition type is set ---
124
+ @model_validator(mode="after")
125
+ def check_exclusive_condition(self) -> "ConditionalRouterConfig":
126
+ conditions_set = [
127
+ self.condition_callable is not None,
128
+ self.expected_string is not None
129
+ or self.min_length is not None
130
+ or self.max_length is not None, # String/Length group
131
+ self.expected_number is not None, # Number group
132
+ self.min_items is not None
133
+ or self.max_items is not None, # List size group
134
+ self.expected_type_name is not None, # Type group
135
+ self.expected_bool is not None, # Bool group
136
+ self.check_exists is not None, # Existence group
137
+ ]
138
+ if sum(conditions_set) > 1:
139
+ raise ValueError(
140
+ "Only one type of condition (callable, string/length, number, list size, type, boolean, exists) can be configured per ConditionalRouter."
141
+ )
142
+ if sum(conditions_set) == 0:
143
+ raise ValueError(
144
+ "At least one condition type must be configured for ConditionalRouter."
145
+ )
146
+ return self
147
+
148
+
149
+ @flock_component
150
+ class ConditionalRouter(FlockRouter):
151
+ """Routes workflow based on evaluating a condition against a value in the FlockContext.
152
+ Supports various built-in checks (string, number, list, type, bool, existence)
153
+ or a custom callable. Can optionally retry the current agent on failure.
154
+ """
155
+
156
+ name: str = "conditional_router"
157
+ config: ConditionalRouterConfig = Field(
158
+ default_factory=ConditionalRouterConfig
159
+ )
160
+
161
+ def _evaluate_condition(self, value: Any) -> tuple[bool, str | None]:
162
+ """Evaluates the condition based on the router's configuration.
163
+
164
+ Returns:
165
+ Tuple[bool, Optional[str]]: A tuple containing:
166
+ - bool: True if the condition passed, False otherwise.
167
+ - Optional[str]: A feedback message if the condition failed, otherwise None.
168
+ """
169
+ cfg = self.config
170
+ condition_passed = False
171
+ feedback = cfg.feedback_on_failure # Default feedback
172
+ condition_type = "unknown"
173
+
174
+ try:
175
+ # 0. Check Existence first (simplest)
176
+ if cfg.check_exists is not None:
177
+ condition_type = "existence"
178
+ value_exists = value is not None
179
+ condition_passed = (
180
+ value_exists if cfg.check_exists else not value_exists
181
+ )
182
+ if not condition_passed:
183
+ feedback = f"Existence check failed: Expected key '{cfg.condition_context_key}' to {'exist' if cfg.check_exists else 'not exist or be None'}, but it was {'found' if value_exists else 'missing/None'}."
184
+
185
+ # 1. Custom Callable
186
+ elif cfg.condition_callable:
187
+ condition_type = "callable"
188
+ callable_func = cfg.condition_callable
189
+ if isinstance(callable_func, str): # Lookup registered callable
190
+ registry = get_registry()
191
+ try:
192
+ callable_func = registry.get_callable(callable_func)
193
+ except KeyError:
194
+ feedback = f"Condition callable '{cfg.condition_callable}' not found in registry."
195
+ logger.error(feedback)
196
+ return False, feedback # Treat as failure
197
+
198
+ if callable(callable_func):
199
+ eval_result = callable_func(value)
200
+ if (
201
+ isinstance(eval_result, tuple)
202
+ and len(eval_result) == 2
203
+ and isinstance(eval_result[0], bool)
204
+ ):
205
+ condition_passed, custom_feedback = eval_result
206
+ if not condition_passed and isinstance(
207
+ custom_feedback, str
208
+ ):
209
+ feedback = custom_feedback
210
+ elif isinstance(eval_result, bool):
211
+ condition_passed = eval_result
212
+ if not condition_passed:
213
+ feedback = f"Callable condition '{getattr(callable_func, '__name__', 'anonymous')}' returned False."
214
+ else:
215
+ feedback = f"Condition callable '{getattr(callable_func, '__name__', 'anonymous')}' returned unexpected type: {type(eval_result)}."
216
+ logger.warning(feedback)
217
+ return False, feedback # Treat as failure
218
+ else:
219
+ feedback = f"Configured condition_callable '{cfg.condition_callable}' is not callable."
220
+ logger.error(feedback)
221
+ return False, feedback
222
+
223
+ # 2. String / Length Checks
224
+ elif (
225
+ cfg.expected_string is not None
226
+ or cfg.min_length is not None
227
+ or cfg.max_length is not None
228
+ ):
229
+ condition_type = "string/length"
230
+ if not isinstance(value, str):
231
+ feedback = f"Cannot perform string/length check on non-string value: {type(value)}."
232
+ logger.warning(feedback)
233
+ return False, feedback
234
+ s_value = value
235
+ val_len = len(s_value)
236
+ length_passed = True
237
+ length_feedback = []
238
+ if cfg.min_length is not None and val_len < cfg.min_length:
239
+ length_passed = False
240
+ length_feedback.append(
241
+ f"length {val_len} is less than minimum {cfg.min_length}"
242
+ )
243
+ if cfg.max_length is not None and val_len > cfg.max_length:
244
+ length_passed = False
245
+ length_feedback.append(
246
+ f"length {val_len} is greater than maximum {cfg.max_length}"
247
+ )
248
+
249
+ content_passed = True
250
+ content_feedback = ""
251
+ if cfg.expected_string is not None:
252
+ expected = cfg.expected_string
253
+ s1 = s_value if not cfg.ignore_case else s_value.lower()
254
+ s2 = expected if not cfg.ignore_case else expected.lower()
255
+ mode = cfg.string_mode
256
+ if mode == "equals":
257
+ content_passed = s1 == s2
258
+ elif mode == "contains":
259
+ content_passed = s2 in s1
260
+ elif mode == "startswith":
261
+ content_passed = s1.startswith(s2)
262
+ elif mode == "endswith":
263
+ content_passed = s1.endswith(s2)
264
+ elif mode == "not_equals":
265
+ content_passed = s1 != s2
266
+ elif mode == "not_contains":
267
+ content_passed = s2 not in s1
268
+ elif mode == "regex":
269
+ content_passed = bool(re.search(expected, value))
270
+ else:
271
+ content_passed = False
272
+ if not content_passed:
273
+ content_feedback = f"String content check '{mode}' failed against expected '{expected}' (ignore_case={cfg.ignore_case})."
274
+
275
+ condition_passed = length_passed and content_passed
276
+ if not condition_passed:
277
+ feedback_parts = length_feedback + (
278
+ [content_feedback] if content_feedback else []
279
+ )
280
+ feedback = (
281
+ "; ".join(feedback_parts)
282
+ if feedback_parts
283
+ else "String/length condition failed."
284
+ )
285
+
286
+ # 3. Number Check
287
+ elif cfg.expected_number is not None:
288
+ condition_type = "number"
289
+ if not isinstance(value, (int, float)):
290
+ feedback = f"Cannot perform number check on non-numeric value: {type(value)}."
291
+ logger.warning(feedback)
292
+ return False, feedback
293
+ num_value = value
294
+ expected = cfg.expected_number
295
+ mode = cfg.number_mode
296
+ op_map = {
297
+ "<": lambda a, b: a < b,
298
+ "<=": lambda a, b: a <= b,
299
+ "==": lambda a, b: a == b,
300
+ "!=": lambda a, b: a != b,
301
+ ">=": lambda a, b: a >= b,
302
+ ">": lambda a, b: a > b,
303
+ }
304
+ if mode in op_map:
305
+ condition_passed = op_map[mode](num_value, expected)
306
+ if not condition_passed:
307
+ feedback = f"Number check failed: {num_value} {mode} {expected} is false."
308
+ else:
309
+ condition_passed = False
310
+ feedback = f"Invalid number comparison mode: {mode}"
311
+
312
+ # 4. List Size Check
313
+ elif cfg.min_items is not None or cfg.max_items is not None:
314
+ condition_type = "list size"
315
+ if not isinstance(value, list):
316
+ feedback = f"Cannot perform list size check on non-list value: {type(value)}."
317
+ logger.warning(feedback)
318
+ return False, feedback
319
+ list_len = len(value)
320
+ size_passed = True
321
+ size_feedback = []
322
+ if cfg.min_items is not None and list_len < cfg.min_items:
323
+ size_passed = False
324
+ size_feedback.append(
325
+ f"list size {list_len} is less than minimum {cfg.min_items}"
326
+ )
327
+ if cfg.max_items is not None and list_len > cfg.max_items:
328
+ size_passed = False
329
+ size_feedback.append(
330
+ f"list size {list_len} is greater than maximum {cfg.max_items}"
331
+ )
332
+ condition_passed = size_passed
333
+ if not condition_passed:
334
+ feedback = "; ".join(size_feedback)
335
+
336
+ # 5. Type Check
337
+ elif cfg.expected_type_name is not None:
338
+ condition_type = "type"
339
+ registry = get_registry()
340
+ try:
341
+ expected_type = registry.get_type(cfg.expected_type_name)
342
+ condition_passed = isinstance(value, expected_type)
343
+ if not condition_passed:
344
+ feedback = f"Type check failed: Value type '{type(value).__name__}' is not instance of expected '{cfg.expected_type_name}'."
345
+ except KeyError:
346
+ feedback = f"Expected type '{cfg.expected_type_name}' not found in registry."
347
+ logger.error(feedback)
348
+ return False, feedback
349
+
350
+ # 6. Boolean Check
351
+ elif cfg.expected_bool is not None:
352
+ condition_type = "boolean"
353
+ if not isinstance(value, bool):
354
+ feedback = f"Cannot perform boolean check on non-bool value: {type(value)}."
355
+ logger.warning(feedback)
356
+ return False, feedback
357
+ condition_passed = value == cfg.expected_bool
358
+ if not condition_passed:
359
+ feedback = f"Boolean check failed: Value '{value}' is not expected '{cfg.expected_bool}'."
360
+
361
+ logger.debug(
362
+ f"Condition check '{condition_type}' result: {condition_passed}"
363
+ )
364
+ return condition_passed, feedback if not condition_passed else None
365
+
366
+ except Exception as e:
367
+ feedback = (
368
+ f"Error evaluating condition type '{condition_type}': {e}"
369
+ )
370
+ logger.error(feedback, exc_info=True)
371
+ return (
372
+ False,
373
+ feedback,
374
+ ) # Treat evaluation errors as condition failure
375
+
376
+ async def route(
377
+ self,
378
+ current_agent: FlockAgent,
379
+ result: dict[str, Any],
380
+ context: FlockContext,
381
+ ) -> HandOffRequest:
382
+ cfg = self.config
383
+ condition_value = context.get_variable(cfg.condition_context_key, None)
384
+ feedback_value = context.get_variable(cfg.feedback_context_key, None)
385
+
386
+ logger.debug(
387
+ f"Routing based on condition key '{cfg.condition_context_key}', value: {str(condition_value)[:100]}..."
388
+ )
389
+
390
+ # Evaluate the condition and get feedback on failure
391
+ condition_passed, feedback_msg = self._evaluate_condition(
392
+ condition_value
393
+ )
394
+
395
+ if condition_passed:
396
+ # --- Success Path ---
397
+ logger.info(
398
+ f"Condition PASSED for agent '{current_agent.name}'. Routing to success path."
399
+ )
400
+ # Reset retry count if applicable
401
+ if cfg.retry_on_failure:
402
+ retry_key = (
403
+ f"{cfg.retry_count_context_key_prefix}{current_agent.name}"
404
+ )
405
+ if retry_key in context.state:
406
+ del context.state[retry_key]
407
+ logger.debug(
408
+ f"Reset retry count for agent '{current_agent.name}'."
409
+ )
410
+
411
+ # Clear feedback from context on success
412
+ if (
413
+ cfg.feedback_context_key
414
+ and cfg.feedback_context_key in context.state
415
+ ):
416
+ del context.state[cfg.feedback_context_key]
417
+ logger.debug(
418
+ f"Cleared feedback key '{cfg.feedback_context_key}' on success."
419
+ )
420
+
421
+ next_agent = cfg.success_agent or "" # Stop chain if None
422
+ logger.debug(f"Success route target: '{next_agent}'")
423
+ return HandOffRequest(next_agent=next_agent)
424
+
425
+ else:
426
+ # --- Failure Path ---
427
+ logger.warning(
428
+ f"Condition FAILED for agent '{current_agent.name}'. Reason: {feedback_msg}"
429
+ )
430
+
431
+ if cfg.retry_on_failure:
432
+ # --- Retry Logic ---
433
+ retry_key = (
434
+ f"{cfg.retry_count_context_key_prefix}{current_agent.name}"
435
+ )
436
+ retry_count = context.get_variable(retry_key, 0)
437
+
438
+ if retry_count < cfg.max_retries:
439
+ next_retry_count = retry_count + 1
440
+ context.set_variable(retry_key, next_retry_count)
441
+ logger.info(
442
+ f"Routing back to agent '{current_agent.name}' for retry #{next_retry_count}/{cfg.max_retries}."
443
+ )
444
+
445
+ # Add specific feedback to context if retry is enabled
446
+ if cfg.feedback_context_key:
447
+ context.set_variable(
448
+ cfg.feedback_context_key,
449
+ feedback_msg or cfg.feedback_on_failure,
450
+ )
451
+ logger.debug(
452
+ f"Set feedback key '{cfg.feedback_context_key}': {feedback_msg or cfg.feedback_on_failure}"
453
+ )
454
+
455
+ return HandOffRequest(
456
+ next_agent=current_agent.name, # Route back to self
457
+ output_to_input_merge_strategy="add", # Make feedback available
458
+ )
459
+ else:
460
+ # --- Max Retries Exceeded ---
461
+ logger.error(
462
+ f"Max retries ({cfg.max_retries}) exceeded for agent '{current_agent.name}'."
463
+ )
464
+ if retry_key in context.state:
465
+ del context.state[retry_key] # Reset count
466
+ # Clear feedback before final failure route? Optional.
467
+ # if cfg.feedback_context_key in context.state: del context.state[cfg.feedback_context_key]
468
+ next_agent = cfg.failure_agent or ""
469
+ logger.debug(
470
+ f"Failure route target (after retries): '{next_agent}'"
471
+ )
472
+ return HandOffRequest(next_agent=next_agent)
473
+ else:
474
+ # --- No Retry Logic ---
475
+ next_agent = (
476
+ cfg.failure_agent or ""
477
+ ) # Use failure agent or stop
478
+ logger.debug(f"Failure route target (no retry): '{next_agent}'")
479
+ # Optionally add feedback even if not retrying?
480
+ # if cfg.feedback_context_key:
481
+ # context.set_variable(cfg.feedback_context_key, feedback_msg or cfg.feedback_on_failure)
482
+ return HandOffRequest(next_agent=next_agent)
@@ -72,5 +72,7 @@ class DefaultRouter(FlockRouter):
72
72
  if callable(handoff):
73
73
  handoff = handoff(context, result)
74
74
  if isinstance(handoff, str):
75
- handoff = HandOffRequest(next_agent=handoff, hand_off_mode="match")
75
+ handoff = HandOffRequest(
76
+ next_agent=handoff, output_to_input_merge_strategy="match"
77
+ )
76
78
  return handoff
@@ -0,0 +1,114 @@
1
+ # src/flock/routers/correction/correction_router.py (New File)
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import Field
6
+
7
+ from flock.core.context.context import FlockContext
8
+ from flock.core.flock_agent import FlockAgent
9
+ from flock.core.flock_registry import flock_component
10
+ from flock.core.flock_router import (
11
+ FlockRouter,
12
+ FlockRouterConfig,
13
+ HandOffRequest,
14
+ )
15
+ from flock.core.logging.logging import get_logger
16
+
17
+ logger = get_logger("router.correction")
18
+
19
+
20
+ class FeedbackRetryRouterConfig(FlockRouterConfig):
21
+ max_retries: int = Field(
22
+ default=1,
23
+ description="Maximum number of times to retry the same agent on failure.",
24
+ )
25
+ feedback_context_key: str = Field(
26
+ default="flock.assertion_feedback",
27
+ description="Context key containing feedback from AssertionCheckerModule.",
28
+ )
29
+ retry_count_context_key_prefix: str = Field(
30
+ default="flock.retry_count_",
31
+ description="Prefix for context key storing retry attempts per agent.",
32
+ )
33
+ fallback_agent: str | None = Field(
34
+ None, description="Agent to route to if max_retries is exceeded."
35
+ )
36
+
37
+
38
+ @flock_component
39
+ class FeedbackRetryRouter(FlockRouter):
40
+ """Routes based on assertion feedback in the context.
41
+
42
+ If feedback exists for the current agent and retries are not exhausted,
43
+ it routes back to the same agent, adding the feedback to its input.
44
+ Otherwise, it can route to a fallback agent or stop the chain.
45
+ """
46
+
47
+ name: str = "feedback_retry_router"
48
+ config: FeedbackRetryRouterConfig = Field(
49
+ default_factory=FeedbackRetryRouterConfig
50
+ )
51
+
52
+ async def route(
53
+ self,
54
+ current_agent: FlockAgent,
55
+ result: dict[str, Any],
56
+ context: FlockContext,
57
+ ) -> HandOffRequest:
58
+ feedback = context.get_variable(self.config.feedback_context_key)
59
+
60
+ if feedback:
61
+ logger.warning(
62
+ f"Assertion feedback detected for agent '{current_agent.name}'. Attempting retry."
63
+ )
64
+
65
+ retry_key = f"{self.config.retry_count_context_key_prefix}{current_agent.name}"
66
+ retry_count = context.get_variable(retry_key, 0)
67
+ logger.warning(f"Feedback: {feedback} - Retry Count {retry_count}")
68
+
69
+ if retry_count < self.config.max_retries:
70
+ logger.info(
71
+ f"Routing back to agent '{current_agent.name}' for retry #{retry_count + 1}"
72
+ )
73
+ context.set_variable(retry_key, retry_count + 1)
74
+ context.set_variable(
75
+ f"{current_agent.name}_prev_result", result
76
+ )
77
+ # Add feedback to the *next* agent's input (which is the same agent)
78
+ # Requires the agent's signature to potentially accept a 'feedback' input field.
79
+ return HandOffRequest(
80
+ next_agent=current_agent.name,
81
+ output_to_input_merge_strategy="match", # Add feedback to existing context/previous results
82
+ add_input_fields=[
83
+ f"{self.config.feedback_context_key} | Feedback for prev result",
84
+ f"{current_agent.name}_prev_result | Previous Result",
85
+ ],
86
+ add_description=f"Try to fix the previous result based on the feedback.",
87
+ override_context=None, # Context already updated with feedback and retry count
88
+ )
89
+ else:
90
+ logger.error(
91
+ f"Max retries ({self.config.max_retries}) exceeded for agent '{current_agent.name}'."
92
+ )
93
+ # Max retries exceeded, route to fallback or stop
94
+ if self.config.fallback_agent:
95
+ logger.info(
96
+ f"Routing to fallback agent '{self.config.fallback_agent}'"
97
+ )
98
+ # Clear feedback before going to fallback? Optional.
99
+ if self.config.feedback_context_key in context.state:
100
+ del context.state[self.config.feedback_context_key]
101
+ return HandOffRequest(next_agent=self.config.fallback_agent)
102
+ else:
103
+ logger.info("No fallback agent defined. Stopping workflow.")
104
+ return HandOffRequest(next_agent="") # Stop the chain
105
+
106
+ else:
107
+ # No feedback, assertions passed or module not configured for feedback
108
+ logger.debug(
109
+ f"No assertion feedback for agent '{current_agent.name}'. Proceeding normally."
110
+ )
111
+ # Default behavior: Stop the chain if no other routing is defined
112
+ # In a real system, you might chain this with another router (e.g., LLMRouter)
113
+ # to decide the *next different* agent if assertions passed.
114
+ return HandOffRequest(next_agent="") # Stop or pass to next router