pyagent-patterns 0.1.0__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.
Files changed (34) hide show
  1. pyagent_patterns/__init__.py +20 -0
  2. pyagent_patterns/advanced/__init__.py +8 -0
  3. pyagent_patterns/advanced/human_in_the_loop.py +103 -0
  4. pyagent_patterns/advanced/react.py +132 -0
  5. pyagent_patterns/advanced/swarm.py +106 -0
  6. pyagent_patterns/advanced/talker_reasoner.py +92 -0
  7. pyagent_patterns/advisor.py +166 -0
  8. pyagent_patterns/base.py +215 -0
  9. pyagent_patterns/composite.py +105 -0
  10. pyagent_patterns/guardrails.py +165 -0
  11. pyagent_patterns/orchestration/__init__.py +9 -0
  12. pyagent_patterns/orchestration/fan_out_fan_in.py +76 -0
  13. pyagent_patterns/orchestration/hierarchical.py +110 -0
  14. pyagent_patterns/orchestration/orchestrator_workers.py +97 -0
  15. pyagent_patterns/orchestration/pipeline.py +57 -0
  16. pyagent_patterns/orchestration/supervisor.py +88 -0
  17. pyagent_patterns/py.typed +0 -0
  18. pyagent_patterns/recovery.py +175 -0
  19. pyagent_patterns/registry.py +71 -0
  20. pyagent_patterns/resolution/__init__.py +9 -0
  21. pyagent_patterns/resolution/cross_reflection.py +79 -0
  22. pyagent_patterns/resolution/debate.py +103 -0
  23. pyagent_patterns/resolution/evaluator_optimizer.py +108 -0
  24. pyagent_patterns/resolution/self_reflection.py +80 -0
  25. pyagent_patterns/resolution/voting.py +93 -0
  26. pyagent_patterns/streaming.py +119 -0
  27. pyagent_patterns/structural/__init__.py +8 -0
  28. pyagent_patterns/structural/blackboard.py +137 -0
  29. pyagent_patterns/structural/layered.py +70 -0
  30. pyagent_patterns/structural/role_based.py +61 -0
  31. pyagent_patterns/structural/topology.py +123 -0
  32. pyagent_patterns-0.1.0.dist-info/METADATA +59 -0
  33. pyagent_patterns-0.1.0.dist-info/RECORD +34 -0
  34. pyagent_patterns-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,20 @@
1
+ """PyAgent Patterns — 18 reusable multi-agent orchestration patterns for LLMs."""
2
+
3
+ from pyagent_patterns.base import Agent, Context, Message, MockLLM, Pattern, Result, Role
4
+ from pyagent_patterns.composite import CompositePattern
5
+ from pyagent_patterns.registry import get_pattern_class, list_patterns, register_pattern
6
+
7
+ __all__ = [
8
+ "Agent",
9
+ "Context",
10
+ "CompositePattern",
11
+ "Message",
12
+ "MockLLM",
13
+ "Pattern",
14
+ "Result",
15
+ "Role",
16
+ "get_pattern_class",
17
+ "list_patterns",
18
+ "register_pattern",
19
+ ]
20
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """Tier 4: Advanced/Emergent patterns — TalkerReasoner, Swarm, HumanInTheLoop, ReAct."""
2
+
3
+ from pyagent_patterns.advanced.human_in_the_loop import HumanInTheLoop
4
+ from pyagent_patterns.advanced.react import ReAct
5
+ from pyagent_patterns.advanced.swarm import Swarm
6
+ from pyagent_patterns.advanced.talker_reasoner import TalkerReasoner
7
+
8
+ __all__ = ["TalkerReasoner", "Swarm", "HumanInTheLoop", "ReAct"]
@@ -0,0 +1,103 @@
1
+ """Human-in-the-Loop pattern: approval gates at critical decision points.
2
+
3
+ An agent processes the task, then a human approval function decides
4
+ whether to accept, reject, or modify the output before it proceeds.
5
+
6
+ LLM calls: 1 agent + optional revision calls
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Callable
12
+
13
+ from pyagent_patterns.base import Agent, Context, Message, Pattern, Result
14
+
15
+
16
+ class HumanDecision:
17
+ """Result of a human review."""
18
+
19
+ def __init__(
20
+ self,
21
+ approved: bool,
22
+ feedback: str = "",
23
+ modified_output: str | None = None,
24
+ ) -> None:
25
+ self.approved = approved
26
+ self.feedback = feedback
27
+ self.modified_output = modified_output
28
+
29
+
30
+ # Type alias for the human review callback
31
+ HumanReviewFn = Callable[[str, dict[str, object]], HumanDecision]
32
+
33
+
34
+ def auto_approve(output: str, metadata: dict[str, object]) -> HumanDecision:
35
+ """Default: auto-approve all outputs (for testing)."""
36
+ return HumanDecision(approved=True)
37
+
38
+
39
+ class HumanInTheLoop(Pattern):
40
+ """Agent with human approval gate.
41
+
42
+ Args:
43
+ agent: The LLM agent that processes the task.
44
+ review_fn: Callable that presents output to human and returns decision.
45
+ Signature: (output: str, metadata: dict) -> HumanDecision
46
+ max_revisions: Maximum number of revision attempts after rejection.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ agent: Agent,
52
+ review_fn: HumanReviewFn = auto_approve,
53
+ max_revisions: int = 3,
54
+ ) -> None:
55
+ self._agent = agent
56
+ self._review_fn = review_fn
57
+ self._max_revisions = max_revisions
58
+
59
+ @property
60
+ def pattern_type(self) -> str:
61
+ return "human_in_the_loop"
62
+
63
+ async def _execute(self, ctx: Context) -> Result:
64
+ messages: list[Message] = []
65
+
66
+ # Initial agent response
67
+ response = await self._agent.run(ctx.messages)
68
+ messages.append(response)
69
+ current_output = response.content
70
+
71
+ for revision in range(self._max_revisions + 1):
72
+ # Human review
73
+ decision = self._review_fn(current_output, {"revision": revision, "task": ctx.task})
74
+
75
+ if decision.approved:
76
+ final_output = decision.modified_output or current_output
77
+ return Result(
78
+ output=final_output,
79
+ messages=messages,
80
+ metadata={
81
+ "approved": True,
82
+ "revisions": revision,
83
+ "human_modified": decision.modified_output is not None,
84
+ },
85
+ )
86
+
87
+ if revision < self._max_revisions:
88
+ # Revise based on human feedback
89
+ revision_prompt = Message.user(
90
+ f"Revise your output based on this feedback:\n\n"
91
+ f"Your output:\n{current_output}\n\n"
92
+ f"Feedback:\n{decision.feedback}"
93
+ )
94
+ response = await self._agent.run([revision_prompt])
95
+ messages.append(response)
96
+ current_output = response.content
97
+
98
+ # Max revisions exceeded — return last output with rejection flag
99
+ return Result(
100
+ output=current_output,
101
+ messages=messages,
102
+ metadata={"approved": False, "revisions": self._max_revisions},
103
+ )
@@ -0,0 +1,132 @@
1
+ """ReAct pattern: Reason → Act → Observe cycle.
2
+
3
+ The agent iteratively reasons about the task, takes an action (e.g., tool call),
4
+ observes the result, then reasons again. Continues until the task is solved
5
+ or max steps reached.
6
+
7
+ LLM calls: 1 per step × max_steps
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable
13
+
14
+ from pyagent_patterns.base import Agent, Context, Message, Pattern, Result
15
+
16
+
17
+ # Tool function type: (action_input: str) -> str
18
+ ToolFn = Callable[[str], str]
19
+
20
+
21
+ class ReAct(Pattern):
22
+ """Reasoning + Acting loop with tool use.
23
+
24
+ Args:
25
+ agent: The reasoning agent.
26
+ tools: Mapping of tool names to callable functions.
27
+ max_steps: Maximum number of Thought→Action→Observation cycles.
28
+ finish_token: Token in agent response that signals task completion.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ agent: Agent,
34
+ tools: dict[str, ToolFn] | None = None,
35
+ max_steps: int = 5,
36
+ finish_token: str = "FINISH",
37
+ ) -> None:
38
+ self._agent = agent
39
+ self._tools = tools or {}
40
+ self._max_steps = max_steps
41
+ self._finish_token = finish_token
42
+
43
+ @property
44
+ def pattern_type(self) -> str:
45
+ return "react"
46
+
47
+ async def _execute(self, ctx: Context) -> Result:
48
+ messages: list[Message] = []
49
+ trace: list[dict[str, Any]] = []
50
+
51
+ tool_list = ", ".join(self._tools.keys()) if self._tools else "none"
52
+ system_prompt = (
53
+ f"You are a ReAct agent. Available tools: {tool_list}\n\n"
54
+ f"On each step, respond in this EXACT format:\n"
55
+ f"Thought: [your reasoning]\n"
56
+ f"Action: [tool_name(input)] OR {self._finish_token}[final answer]\n\n"
57
+ f"After receiving an observation, continue reasoning.\n"
58
+ f"When you have the final answer, use: {self._finish_token}[your answer]"
59
+ )
60
+
61
+ conversation: list[Message] = [
62
+ Message.system(system_prompt),
63
+ Message.user(ctx.task),
64
+ ]
65
+
66
+ for step in range(1, self._max_steps + 1):
67
+ # Agent reasons and decides action
68
+ response = await self._agent.run(conversation)
69
+ messages.append(response)
70
+ conversation.append(response)
71
+
72
+ step_data: dict[str, Any] = {"step": step, "response": response.content}
73
+
74
+ # Check for finish
75
+ if self._finish_token in response.content:
76
+ # Extract final answer
77
+ parts = response.content.split(self._finish_token, 1)
78
+ final_answer = parts[1].strip() if len(parts) > 1 else response.content
79
+ step_data["action"] = "finish"
80
+ trace.append(step_data)
81
+ break
82
+
83
+ # Parse action
84
+ action_name, action_input = self._parse_action(response.content)
85
+ step_data["action"] = action_name
86
+ step_data["action_input"] = action_input
87
+
88
+ # Execute tool
89
+ if action_name and action_name in self._tools:
90
+ try:
91
+ observation = self._tools[action_name](action_input)
92
+ except Exception as e:
93
+ observation = f"Error: {e}"
94
+ step_data["observation"] = observation
95
+ else:
96
+ observation = f"Unknown tool: {action_name}. Available: {tool_list}"
97
+ step_data["observation"] = observation
98
+
99
+ trace.append(step_data)
100
+
101
+ # Feed observation back
102
+ obs_msg = Message.user(f"Observation: {observation}")
103
+ conversation.append(obs_msg)
104
+ messages.append(obs_msg)
105
+ else:
106
+ final_answer = messages[-1].content if messages else ""
107
+
108
+ return Result(
109
+ output=final_answer,
110
+ messages=messages,
111
+ metadata={
112
+ "steps": len(trace),
113
+ "max_steps": self._max_steps,
114
+ "trace": trace,
115
+ "tools_used": [t["action"] for t in trace if t.get("action") != "finish"],
116
+ },
117
+ )
118
+
119
+ @staticmethod
120
+ def _parse_action(content: str) -> tuple[str | None, str]:
121
+ """Parse 'Action: tool_name(input)' from agent response."""
122
+ for line in content.split("\n"):
123
+ line = line.strip()
124
+ if line.lower().startswith("action:"):
125
+ action_text = line.split(":", 1)[1].strip()
126
+ # Parse tool_name(input)
127
+ if "(" in action_text and action_text.endswith(")"):
128
+ name = action_text[: action_text.index("(")]
129
+ inp = action_text[action_text.index("(") + 1 : -1]
130
+ return name.strip(), inp.strip().strip('"').strip("'")
131
+ return action_text, ""
132
+ return None, ""
@@ -0,0 +1,106 @@
1
+ """Swarm pattern: emergent behavior from many agents with local rules, no controller.
2
+
3
+ Each agent operates independently with simple local rules.
4
+ Global behavior emerges from agent interactions. No central orchestrator.
5
+
6
+ LLM calls: N agents × rounds
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import random
13
+
14
+ from pyagent_patterns.base import Agent, Context, Message, Pattern, Result
15
+
16
+
17
+ class Swarm(Pattern):
18
+ """Decentralized swarm: agents interact locally, behavior emerges globally.
19
+
20
+ Args:
21
+ agents: Pool of swarm agents (all follow same local rules).
22
+ rounds: Number of interaction rounds.
23
+ neighbor_count: How many random peers each agent interacts with per round.
24
+ aggregation: How to produce final output from swarm state.
25
+ "last" = last round's outputs, "vote" = majority vote.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ agents: list[Agent],
31
+ rounds: int = 3,
32
+ neighbor_count: int = 2,
33
+ aggregation: str = "last",
34
+ ) -> None:
35
+ if len(agents) < 2:
36
+ raise ValueError("Swarm requires at least 2 agents")
37
+ self._agents = agents
38
+ self._rounds = rounds
39
+ self._neighbor_count = min(neighbor_count, len(agents) - 1)
40
+ self._aggregation = aggregation
41
+
42
+ @property
43
+ def pattern_type(self) -> str:
44
+ return "swarm"
45
+
46
+ async def _execute(self, ctx: Context) -> Result:
47
+ messages: list[Message] = []
48
+ # Initialize each agent's state
49
+ states: dict[str, str] = {}
50
+
51
+ # Round 0: each agent independently responds to the task
52
+ init_tasks = [agent.run([Message.user(ctx.task)]) for agent in self._agents]
53
+ init_results = await asyncio.gather(*init_tasks)
54
+ for agent, result in zip(self._agents, init_results):
55
+ states[agent.name] = result.content
56
+ messages.append(result)
57
+
58
+ # Subsequent rounds: agents interact with random neighbors
59
+ for round_num in range(1, self._rounds + 1):
60
+ new_states: dict[str, str] = {}
61
+
62
+ async def _update_agent(agent: Agent) -> tuple[str, str]:
63
+ # Select random neighbors
64
+ others = [a for a in self._agents if a.name != agent.name]
65
+ neighbors = random.sample(others, min(self._neighbor_count, len(others)))
66
+
67
+ neighbor_views = "\n".join(
68
+ f"- {n.name}: {states[n.name]}" for n in neighbors
69
+ )
70
+ prompt = Message.user(
71
+ f"Task: {ctx.task}\n\n"
72
+ f"Your current response: {states[agent.name]}\n\n"
73
+ f"Neighbor responses:\n{neighbor_views}\n\n"
74
+ f"Update your response considering your neighbors' views."
75
+ )
76
+ result = await agent.run([prompt])
77
+ return agent.name, result.content
78
+
79
+ update_tasks = [_update_agent(agent) for agent in self._agents]
80
+ updates = await asyncio.gather(*update_tasks)
81
+ for name, content in updates:
82
+ new_states[name] = content
83
+ messages.append(Message.assistant(content, name=name))
84
+
85
+ states = new_states
86
+
87
+ # Aggregate final output
88
+ if self._aggregation == "vote":
89
+ from collections import Counter
90
+
91
+ first_lines = [s.split("\n")[0].strip() for s in states.values()]
92
+ winner = Counter(first_lines).most_common(1)[0][0]
93
+ output = winner
94
+ else:
95
+ output = "\n\n".join(f"[{name}]: {content}" for name, content in states.items())
96
+
97
+ return Result(
98
+ output=output,
99
+ messages=messages,
100
+ metadata={
101
+ "agents": len(self._agents),
102
+ "rounds": self._rounds,
103
+ "aggregation": self._aggregation,
104
+ "final_states": states,
105
+ },
106
+ )
@@ -0,0 +1,92 @@
1
+ """Talker-Reasoner pattern: System 1 (fast/cheap) + System 2 (slow/expensive).
2
+
3
+ Inspired by Kahneman's dual-process theory and Google DeepMind's 2024 paper.
4
+ A fast "talker" handles routine queries; a slow "reasoner" is activated
5
+ only for complex queries that exceed a complexity threshold.
6
+
7
+ LLM calls: 1 (easy) or 2 (hard: classifier + reasoner) or 3 (with classifier)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pyagent_patterns.base import Agent, Context, Message, Pattern, Result
13
+
14
+
15
+ class TalkerReasoner(Pattern):
16
+ """Dual-process: fast intuition (System 1) + slow deliberation (System 2).
17
+
18
+ Args:
19
+ talker: Fast, cheap agent for routine queries (System 1).
20
+ reasoner: Slow, expensive agent for complex queries (System 2).
21
+ classifier: Optional agent that decides talker vs reasoner.
22
+ If None, always starts with talker and escalates on uncertainty keywords.
23
+ complexity_threshold: Keywords in talker output that trigger escalation to reasoner.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ talker: Agent,
29
+ reasoner: Agent,
30
+ classifier: Agent | None = None,
31
+ complexity_threshold: list[str] | None = None,
32
+ ) -> None:
33
+ self._talker = talker
34
+ self._reasoner = reasoner
35
+ self._classifier = classifier
36
+ self._complexity_keywords = complexity_threshold or [
37
+ "I'm not sure",
38
+ "I don't know",
39
+ "complex",
40
+ "need to think",
41
+ "uncertain",
42
+ "ESCALATE",
43
+ ]
44
+
45
+ @property
46
+ def pattern_type(self) -> str:
47
+ return "talker_reasoner"
48
+
49
+ async def _execute(self, ctx: Context) -> Result:
50
+ messages: list[Message] = []
51
+
52
+ if self._classifier:
53
+ # Use classifier to decide
54
+ classify_prompt = Message.user(
55
+ f"Is this query simple or complex? "
56
+ f"Respond with exactly 'SIMPLE' or 'COMPLEX'.\n\n"
57
+ f"Query: {ctx.task}"
58
+ )
59
+ classification = await self._classifier.run([classify_prompt])
60
+ messages.append(classification)
61
+ use_reasoner = "COMPLEX" in classification.content.upper()
62
+ else:
63
+ use_reasoner = False
64
+
65
+ if not use_reasoner:
66
+ # System 1: fast talker
67
+ talker_result = await self._talker.run(ctx.messages)
68
+ messages.append(talker_result)
69
+
70
+ # Check if talker signals uncertainty → escalate to reasoner
71
+ should_escalate = any(
72
+ kw.lower() in talker_result.content.lower() for kw in self._complexity_keywords
73
+ )
74
+
75
+ if should_escalate:
76
+ use_reasoner = True
77
+ else:
78
+ return Result(
79
+ output=talker_result.content,
80
+ messages=messages,
81
+ metadata={"system": "talker", "escalated": False},
82
+ )
83
+
84
+ # System 2: slow reasoner
85
+ reasoner_result = await self._reasoner.run(ctx.messages)
86
+ messages.append(reasoner_result)
87
+
88
+ return Result(
89
+ output=reasoner_result.content,
90
+ messages=messages,
91
+ metadata={"system": "reasoner", "escalated": not bool(self._classifier)},
92
+ )
@@ -0,0 +1,166 @@
1
+ """PatternAdvisor: recommend the best pattern based on task constraints.
2
+
3
+ Based on: Augment 2026 "Five Decision Rules" for pattern selection.
4
+
5
+ Decision factors:
6
+ 1. Task complexity (simple vs multi-step vs adversarial)
7
+ 2. Quality requirement (draft vs production)
8
+ 3. Budget constraint (tokens/$)
9
+ 4. Latency requirement (real-time vs batch)
10
+ 5. Reliability requirement (best-effort vs fault-tolerant)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+
18
+
19
+ class Quality(str, Enum):
20
+ DRAFT = "draft"
21
+ STANDARD = "standard"
22
+ HIGH = "high"
23
+ CRITICAL = "critical"
24
+
25
+
26
+ class Latency(str, Enum):
27
+ REALTIME = "realtime" # < 2s
28
+ INTERACTIVE = "interactive" # < 10s
29
+ BATCH = "batch" # minutes OK
30
+
31
+
32
+ @dataclass
33
+ class Constraints:
34
+ """Task constraints for pattern selection."""
35
+
36
+ quality: Quality = Quality.STANDARD
37
+ latency: Latency = Latency.INTERACTIVE
38
+ max_cost_usd: float = 0.05
39
+ fault_tolerant: bool = False
40
+ multi_step: bool = False
41
+
42
+
43
+ @dataclass
44
+ class Recommendation:
45
+ """A pattern recommendation with reasoning."""
46
+
47
+ pattern: str
48
+ reason: str
49
+ estimated_calls: int
50
+ estimated_cost_range: str
51
+ alternatives: list[str]
52
+
53
+
54
+ class PatternAdvisor:
55
+ """Recommend patterns based on task description and constraints.
56
+
57
+ Usage:
58
+ advisor = PatternAdvisor()
59
+ rec = advisor.recommend("Write and review code", Constraints(quality=Quality.HIGH))
60
+ print(rec.pattern, rec.reason)
61
+ """
62
+
63
+ def recommend(self, task: str, constraints: Constraints | None = None) -> Recommendation:
64
+ """Recommend the best pattern for the given task and constraints."""
65
+ c = constraints or Constraints()
66
+ task_lower = task.lower()
67
+
68
+ # Decision tree based on Augment 2026 "Five Decision Rules"
69
+
70
+ # Rule 1: Simple single-step tasks → Pipeline or single agent
71
+ if not c.multi_step and c.quality in (Quality.DRAFT, Quality.STANDARD):
72
+ if c.latency == Latency.REALTIME:
73
+ return Recommendation(
74
+ pattern="pipeline",
75
+ reason="Simple task with real-time latency → minimal sequential processing",
76
+ estimated_calls=1,
77
+ estimated_cost_range="$0.001-0.003",
78
+ alternatives=["talker_reasoner"],
79
+ )
80
+
81
+ # Rule 2: Cost-sensitive → Route to cheapest viable model
82
+ if c.max_cost_usd < 0.01:
83
+ return Recommendation(
84
+ pattern="talker_reasoner",
85
+ reason="Tight budget → use cheap model for easy, expensive only when needed",
86
+ estimated_calls=1,
87
+ estimated_cost_range="$0.001-0.005",
88
+ alternatives=["pipeline"],
89
+ )
90
+
91
+ # Rule 3: High reliability / fault tolerance → Voting or Fan-Out
92
+ if c.fault_tolerant:
93
+ return Recommendation(
94
+ pattern="voting",
95
+ reason="Fault-tolerant requirement → multiple independent agents with consensus",
96
+ estimated_calls=3,
97
+ estimated_cost_range="$0.006-0.012",
98
+ alternatives=["fan_out_fan_in"],
99
+ )
100
+
101
+ # Rule 4: High quality → Reflection, Debate, or Evaluator
102
+ if c.quality in (Quality.HIGH, Quality.CRITICAL):
103
+ # Check for adversarial/debate keywords
104
+ if any(w in task_lower for w in ["compare", "pros and cons", "debate", "argue", "versus"]):
105
+ return Recommendation(
106
+ pattern="debate",
107
+ reason="High quality + adversarial task → structured debate with judge",
108
+ estimated_calls=7,
109
+ estimated_cost_range="$0.014-0.028",
110
+ alternatives=["evaluator_optimizer", "cross_reflection"],
111
+ )
112
+
113
+ # Check for code/writing that benefits from review
114
+ if any(w in task_lower for w in ["write", "code", "generate", "create", "draft"]):
115
+ return Recommendation(
116
+ pattern="self_reflection",
117
+ reason="High quality creative/code task → generate-critique-refine loop",
118
+ estimated_calls=4,
119
+ estimated_cost_range="$0.004-0.012",
120
+ alternatives=["cross_reflection", "evaluator_optimizer"],
121
+ )
122
+
123
+ return Recommendation(
124
+ pattern="evaluator_optimizer",
125
+ reason="High quality task → explicit evaluation criteria with optimization",
126
+ estimated_calls=4,
127
+ estimated_cost_range="$0.004-0.008",
128
+ alternatives=["self_reflection"],
129
+ )
130
+
131
+ # Rule 5: Multi-step/complex → Supervisor, Hierarchical, or Pipeline
132
+ if c.multi_step or any(w in task_lower for w in ["steps", "process", "workflow", "pipeline"]):
133
+ if any(w in task_lower for w in ["team", "delegate", "manage", "coordinate"]):
134
+ return Recommendation(
135
+ pattern="hierarchical",
136
+ reason="Multi-step with team coordination → hierarchical delegation",
137
+ estimated_calls=7,
138
+ estimated_cost_range="$0.010-0.020",
139
+ alternatives=["supervisor", "orchestrator_workers"],
140
+ )
141
+
142
+ if any(w in task_lower for w in ["classify", "route", "triage", "categorize"]):
143
+ return Recommendation(
144
+ pattern="supervisor",
145
+ reason="Multi-step with classification → supervisor routes to specialists",
146
+ estimated_calls=3,
147
+ estimated_cost_range="$0.004-0.008",
148
+ alternatives=["pipeline"],
149
+ )
150
+
151
+ return Recommendation(
152
+ pattern="pipeline",
153
+ reason="Multi-step sequential task → stage-by-stage processing",
154
+ estimated_calls=4,
155
+ estimated_cost_range="$0.004-0.008",
156
+ alternatives=["supervisor"],
157
+ )
158
+
159
+ # Default: Pipeline (safest general-purpose)
160
+ return Recommendation(
161
+ pattern="pipeline",
162
+ reason="General-purpose task → sequential pipeline with composable stages",
163
+ estimated_calls=2,
164
+ estimated_cost_range="$0.002-0.004",
165
+ alternatives=["supervisor", "self_reflection"],
166
+ )