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,97 @@
1
+ """Orchestrator-Workers pattern: central LLM dynamically delegates tasks to workers.
2
+
3
+ Unlike Supervisor (static routes) or Hierarchical (fixed teams), the Orchestrator
4
+ dynamically decides how many workers to spawn and what each should do.
5
+
6
+ LLM calls: 1 planning + N workers (dynamic) + 1 synthesis
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+
14
+ from pyagent_patterns.base import Agent, Context, Message, Pattern, Result
15
+
16
+
17
+ class OrchestratorWorkers(Pattern):
18
+ """Dynamic task delegation: orchestrator plans → workers execute → synthesize.
19
+
20
+ Args:
21
+ orchestrator: Agent that plans the work and synthesizes results.
22
+ workers: Pool of available worker agents. The orchestrator selects from these.
23
+ """
24
+
25
+ def __init__(self, orchestrator: Agent, workers: list[Agent]) -> None:
26
+ self._orchestrator = orchestrator
27
+ self._workers = {w.name: w for w in workers}
28
+
29
+ @property
30
+ def pattern_type(self) -> str:
31
+ return "orchestrator_workers"
32
+
33
+ async def _execute(self, ctx: Context) -> Result:
34
+ messages: list[Message] = []
35
+ worker_names = list(self._workers.keys())
36
+
37
+ # Step 1: Orchestrator plans the work
38
+ plan_prompt = Message.user(
39
+ f"You have these workers available: {', '.join(worker_names)}.\n"
40
+ f"Plan how to accomplish this task by assigning subtasks to workers.\n"
41
+ f"Respond as JSON: {{\"assignments\": [{{\"worker\": \"name\", \"subtask\": \"description\"}}]}}\n\n"
42
+ f"Task: {ctx.task}"
43
+ )
44
+ plan_msg = await self._orchestrator.run([plan_prompt])
45
+ messages.append(plan_msg)
46
+
47
+ # Step 2: Parse assignments and dispatch
48
+ assignments = self._parse_assignments(plan_msg.content)
49
+ worker_results: list[tuple[str, str]] = []
50
+
51
+ async def _run_assignment(worker_name: str, subtask: str) -> tuple[str, str]:
52
+ worker = self._workers.get(worker_name)
53
+ if worker is None:
54
+ # Fallback to first available worker
55
+ worker = next(iter(self._workers.values()))
56
+ result = await worker.run([Message.user(subtask)])
57
+ return worker.name, result.content
58
+
59
+ tasks = [_run_assignment(a["worker"], a["subtask"]) for a in assignments]
60
+ results = await asyncio.gather(*tasks)
61
+ for name, content in results:
62
+ messages.append(Message.assistant(content, name=name))
63
+ worker_results.append((name, content))
64
+
65
+ # Step 3: Orchestrator synthesizes
66
+ results_summary = "\n\n".join(
67
+ f"--- {name} ---\n{content}" for name, content in worker_results
68
+ )
69
+ synthesis_prompt = Message.user(
70
+ f"Synthesize these worker results into a final response:\n\n{results_summary}"
71
+ )
72
+ final = await self._orchestrator.run([synthesis_prompt])
73
+ messages.append(final)
74
+
75
+ return Result(
76
+ output=final.content,
77
+ messages=messages,
78
+ metadata={
79
+ "assignments": assignments,
80
+ "workers_used": len(assignments),
81
+ },
82
+ )
83
+
84
+ @staticmethod
85
+ def _parse_assignments(content: str) -> list[dict[str, str]]:
86
+ """Parse orchestrator's JSON assignment plan. Falls back gracefully."""
87
+ try:
88
+ # Try to extract JSON from the response
89
+ start = content.find("{")
90
+ end = content.rfind("}") + 1
91
+ if start >= 0 and end > start:
92
+ data = json.loads(content[start:end])
93
+ return data.get("assignments", [])
94
+ except (json.JSONDecodeError, KeyError):
95
+ pass
96
+ # Fallback: single assignment to first worker
97
+ return [{"worker": "default", "subtask": content}]
@@ -0,0 +1,57 @@
1
+ """Pipeline pattern: sequential chain of agents where each stage's output feeds the next.
2
+
3
+ Each agent processes the output of the previous stage.
4
+ Ideal for document processing, ETL, and multi-step transformations.
5
+
6
+ LLM calls: N (one per stage)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import AsyncIterator
12
+
13
+ from pyagent_patterns.base import Agent, Context, Message, Pattern, Result
14
+
15
+
16
+ class Pipeline(Pattern):
17
+ """Sequential stage chain — output of stage N becomes input of stage N+1.
18
+
19
+ Args:
20
+ stages: Ordered list of agents, each processing the previous output.
21
+ """
22
+
23
+ def __init__(self, stages: list[Agent]) -> None:
24
+ if not stages:
25
+ raise ValueError("Pipeline requires at least one stage")
26
+ self._stages = stages
27
+
28
+ @property
29
+ def pattern_type(self) -> str:
30
+ return "pipeline"
31
+
32
+ async def _execute(self, ctx: Context) -> Result:
33
+ messages: list[Message] = []
34
+ current_input = ctx.task
35
+
36
+ for i, stage in enumerate(self._stages):
37
+ stage_msg = Message.user(current_input)
38
+ response = await stage.run([stage_msg])
39
+ messages.append(response)
40
+ current_input = response.content
41
+
42
+ return Result(
43
+ output=current_input,
44
+ messages=messages,
45
+ metadata={"stages": len(self._stages), "stage_names": [s.name for s in self._stages]},
46
+ )
47
+
48
+ async def stream(self, task: str, context: Context | None = None) -> AsyncIterator[str]:
49
+ """Stream stage completions as they finish."""
50
+ ctx = context or Context(task=task)
51
+ current_input = task
52
+
53
+ for i, stage in enumerate(self._stages):
54
+ stage_msg = Message.user(current_input)
55
+ response = await stage.run([stage_msg])
56
+ current_input = response.content
57
+ yield f"[Stage {i + 1}/{len(self._stages)} — {stage.name}] {current_input}"
@@ -0,0 +1,88 @@
1
+ """Supervisor pattern: classify input → route to specialist agent → collect result.
2
+
3
+ The Supervisor inspects the incoming task, classifies it into a category,
4
+ dispatches to the appropriate specialist agent, and optionally formats
5
+ the final response.
6
+
7
+ LLM calls: 2-3 (classify + specialist + optional format)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pyagent_patterns.base import Agent, Context, Message, Pattern, Result
13
+
14
+
15
+ class Supervisor(Pattern):
16
+ """Classify → route → collect orchestration pattern.
17
+
18
+ Args:
19
+ classifier: Agent that classifies the task into a route key.
20
+ routes: Mapping of route keys to specialist agents.
21
+ formatter: Optional agent that formats the final response.
22
+ default_route: Key to use when classification doesn't match any route.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ classifier: Agent,
28
+ routes: dict[str, Agent],
29
+ formatter: Agent | None = None,
30
+ default_route: str | None = None,
31
+ ) -> None:
32
+ self._classifier = classifier
33
+ self._routes = routes
34
+ self._formatter = formatter
35
+ self._default_route = default_route
36
+
37
+ @property
38
+ def pattern_type(self) -> str:
39
+ return "supervisor"
40
+
41
+ async def _execute(self, ctx: Context) -> Result:
42
+ messages: list[Message] = []
43
+
44
+ # Step 1: Classify the task
45
+ classify_prompt = (
46
+ f"Classify the following task into exactly one of these categories: "
47
+ f"{', '.join(self._routes.keys())}.\n"
48
+ f"Respond with ONLY the category name, nothing else.\n\n"
49
+ f"Task: {ctx.task}"
50
+ )
51
+ classify_msg = Message.user(classify_prompt)
52
+ classification = await self._classifier.run([classify_msg])
53
+ messages.append(classification)
54
+
55
+ # Step 2: Route to specialist
56
+ route_key = classification.content.strip().lower()
57
+ agent = self._routes.get(route_key)
58
+ if agent is None and self._default_route:
59
+ agent = self._routes.get(self._default_route)
60
+ route_key = self._default_route
61
+ if agent is None:
62
+ # Fallback: use first available route
63
+ route_key = next(iter(self._routes))
64
+ agent = self._routes[route_key]
65
+
66
+ specialist_msg = await agent.run(ctx.messages)
67
+ messages.append(specialist_msg)
68
+
69
+ # Step 3: Optional formatting
70
+ output = specialist_msg.content
71
+ if self._formatter:
72
+ format_msgs = [
73
+ Message.user(
74
+ f"Format the following response for the user:\n\n{specialist_msg.content}"
75
+ )
76
+ ]
77
+ formatted = await self._formatter.run(format_msgs)
78
+ messages.append(formatted)
79
+ output = formatted.content
80
+
81
+ return Result(
82
+ output=output,
83
+ messages=messages,
84
+ metadata={
85
+ "route_key": route_key,
86
+ "classifier_output": classification.content,
87
+ },
88
+ )
File without changes
@@ -0,0 +1,175 @@
1
+ """Error Recovery: BoundedExecution and CircuitBreaker for multi-agent patterns.
2
+
3
+ BoundedExecution: wrap any pattern with max iterations, token, and timeout limits.
4
+ CircuitBreaker: stop cascading failures with failure threshold + reset timeout.
5
+
6
+ Based on: arxiv:2604.11378 "three-level recovery protocol"
7
+ Augment 2026 "Bounded Execution" emergent pattern
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from enum import Enum
16
+ from typing import Any
17
+
18
+ from pyagent_patterns.base import Context, Pattern, Result
19
+
20
+
21
+ class CircuitState(str, Enum):
22
+ CLOSED = "closed" # Normal operation
23
+ OPEN = "open" # Failing, rejecting requests
24
+ HALF_OPEN = "half_open" # Testing if recovered
25
+
26
+
27
+ @dataclass
28
+ class BoundedExecution:
29
+ """Wrap a pattern with resource limits.
30
+
31
+ Three-level recovery:
32
+ 1. Retry with same pattern
33
+ 2. Fallback to a cheaper/simpler pattern
34
+ 3. Graceful degradation (return partial result)
35
+
36
+ Args:
37
+ pattern: The primary pattern to execute.
38
+ fallback: Optional simpler pattern to use on failure.
39
+ max_retries: Maximum retry attempts before escalating.
40
+ timeout_seconds: Maximum wall-clock time for the entire execution.
41
+ max_tokens: Maximum total tokens before stopping.
42
+ """
43
+
44
+ pattern: Pattern
45
+ fallback: Pattern | None = None
46
+ max_retries: int = 2
47
+ timeout_seconds: float = 300.0
48
+ max_tokens: int = 100_000
49
+
50
+ async def run(self, task: str, context: Context | None = None) -> Result:
51
+ """Execute with bounded resources and three-level recovery."""
52
+ start = time.perf_counter()
53
+
54
+ # Level 1: Try primary pattern with retries
55
+ last_error: Exception | None = None
56
+ for attempt in range(1, self.max_retries + 1):
57
+ elapsed = time.perf_counter() - start
58
+ if elapsed > self.timeout_seconds:
59
+ break
60
+
61
+ try:
62
+ remaining = self.timeout_seconds - elapsed
63
+ result = await asyncio.wait_for(
64
+ self.pattern.run(task, context),
65
+ timeout=remaining,
66
+ )
67
+ if result.token_estimate <= self.max_tokens:
68
+ result.metadata["recovery_level"] = 0
69
+ result.metadata["attempts"] = attempt
70
+ return result
71
+ # Token limit exceeded — try next level
72
+ last_error = Exception(f"Token limit exceeded: {result.token_estimate}/{self.max_tokens}")
73
+ break
74
+ except asyncio.TimeoutError:
75
+ last_error = asyncio.TimeoutError(f"Timeout after {self.timeout_seconds}s")
76
+ break
77
+ except Exception as e:
78
+ last_error = e
79
+ continue
80
+
81
+ # Level 2: Fallback pattern
82
+ if self.fallback:
83
+ try:
84
+ remaining = max(0.0, self.timeout_seconds - (time.perf_counter() - start))
85
+ result = await asyncio.wait_for(
86
+ self.fallback.run(task, context),
87
+ timeout=remaining if remaining > 0 else 30.0,
88
+ )
89
+ result.metadata["recovery_level"] = 1
90
+ result.metadata["fallback_reason"] = str(last_error)
91
+ return result
92
+ except Exception:
93
+ pass
94
+
95
+ # Level 3: Graceful degradation
96
+ return Result(
97
+ output=f"[Degraded] Unable to complete task after {self.max_retries} attempts. Last error: {last_error}",
98
+ metadata={
99
+ "recovery_level": 2,
100
+ "degraded": True,
101
+ "last_error": str(last_error),
102
+ },
103
+ )
104
+
105
+
106
+ class CircuitBreaker:
107
+ """Prevent cascading failures in multi-agent systems.
108
+
109
+ When a pattern fails repeatedly, the circuit opens and rejects
110
+ requests immediately. After a reset timeout, it enters half-open
111
+ state and allows one test request through.
112
+
113
+ Args:
114
+ failure_threshold: Number of consecutive failures before opening.
115
+ reset_timeout_seconds: Seconds before transitioning from open to half-open.
116
+ fallback_result: Result to return when circuit is open.
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ failure_threshold: int = 3,
122
+ reset_timeout_seconds: float = 60.0,
123
+ fallback_result: str = "[Circuit Open] Service temporarily unavailable.",
124
+ ) -> None:
125
+ self._threshold = failure_threshold
126
+ self._reset_timeout = reset_timeout_seconds
127
+ self._fallback_result = fallback_result
128
+ self._state = CircuitState.CLOSED
129
+ self._failure_count = 0
130
+ self._last_failure_time = 0.0
131
+
132
+ @property
133
+ def state(self) -> CircuitState:
134
+ if self._state == CircuitState.OPEN:
135
+ if time.time() - self._last_failure_time > self._reset_timeout:
136
+ self._state = CircuitState.HALF_OPEN
137
+ return self._state
138
+
139
+ async def execute(self, pattern: Pattern, task: str, context: Context | None = None) -> Result:
140
+ """Execute a pattern through the circuit breaker."""
141
+ current_state = self.state
142
+
143
+ if current_state == CircuitState.OPEN:
144
+ return Result(
145
+ output=self._fallback_result,
146
+ metadata={"circuit_state": "open", "failure_count": self._failure_count},
147
+ )
148
+
149
+ try:
150
+ result = await pattern.run(task, context)
151
+ self._on_success()
152
+ result.metadata["circuit_state"] = self._state.value
153
+ return result
154
+ except Exception as e:
155
+ self._on_failure()
156
+ if self._state == CircuitState.OPEN:
157
+ return Result(
158
+ output=self._fallback_result,
159
+ metadata={
160
+ "circuit_state": "open",
161
+ "failure_count": self._failure_count,
162
+ "last_error": str(e),
163
+ },
164
+ )
165
+ raise
166
+
167
+ def _on_success(self) -> None:
168
+ self._failure_count = 0
169
+ self._state = CircuitState.CLOSED
170
+
171
+ def _on_failure(self) -> None:
172
+ self._failure_count += 1
173
+ self._last_failure_time = time.time()
174
+ if self._failure_count >= self._threshold:
175
+ self._state = CircuitState.OPEN
@@ -0,0 +1,71 @@
1
+ """Pattern registry: discover and instantiate patterns by name."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pyagent_patterns.base import Pattern
8
+
9
+ _REGISTRY: dict[str, type[Pattern]] = {}
10
+
11
+
12
+ def register_pattern(name: str, cls: type[Pattern]) -> None:
13
+ """Register a pattern class under a name."""
14
+ _REGISTRY[name] = cls
15
+
16
+
17
+ def get_pattern_class(name: str) -> type[Pattern] | None:
18
+ """Look up a pattern class by name."""
19
+ return _REGISTRY.get(name)
20
+
21
+
22
+ def list_patterns() -> list[str]:
23
+ """Return all registered pattern names."""
24
+ return sorted(_REGISTRY.keys())
25
+
26
+
27
+ def _auto_register() -> None:
28
+ """Auto-register all built-in patterns."""
29
+ from pyagent_patterns.advanced.human_in_the_loop import HumanInTheLoop
30
+ from pyagent_patterns.advanced.react import ReAct
31
+ from pyagent_patterns.advanced.swarm import Swarm
32
+ from pyagent_patterns.advanced.talker_reasoner import TalkerReasoner
33
+ from pyagent_patterns.orchestration.fan_out_fan_in import FanOutFanIn
34
+ from pyagent_patterns.orchestration.hierarchical import Hierarchical
35
+ from pyagent_patterns.orchestration.orchestrator_workers import OrchestratorWorkers
36
+ from pyagent_patterns.orchestration.pipeline import Pipeline
37
+ from pyagent_patterns.orchestration.supervisor import Supervisor
38
+ from pyagent_patterns.resolution.cross_reflection import CrossReflection
39
+ from pyagent_patterns.resolution.debate import Debate
40
+ from pyagent_patterns.resolution.evaluator_optimizer import EvaluatorOptimizer
41
+ from pyagent_patterns.resolution.self_reflection import SelfReflection
42
+ from pyagent_patterns.resolution.voting import Voting
43
+ from pyagent_patterns.structural.blackboard import Blackboard
44
+ from pyagent_patterns.structural.layered import Layered
45
+ from pyagent_patterns.structural.role_based import RoleBased
46
+ from pyagent_patterns.structural.topology import Topology
47
+
48
+ for name, cls in [
49
+ ("supervisor", Supervisor),
50
+ ("pipeline", Pipeline),
51
+ ("fan_out_fan_in", FanOutFanIn),
52
+ ("hierarchical", Hierarchical),
53
+ ("orchestrator_workers", OrchestratorWorkers),
54
+ ("self_reflection", SelfReflection),
55
+ ("cross_reflection", CrossReflection),
56
+ ("debate", Debate),
57
+ ("voting", Voting),
58
+ ("evaluator_optimizer", EvaluatorOptimizer),
59
+ ("role_based", RoleBased),
60
+ ("layered", Layered),
61
+ ("topology", Topology),
62
+ ("blackboard", Blackboard),
63
+ ("talker_reasoner", TalkerReasoner),
64
+ ("swarm", Swarm),
65
+ ("human_in_the_loop", HumanInTheLoop),
66
+ ("react", ReAct),
67
+ ]:
68
+ register_pattern(name, cls)
69
+
70
+
71
+ _auto_register()
@@ -0,0 +1,9 @@
1
+ """Tier 2: Resolution patterns — Reflection, CrossReflection, Debate, Voting, EvaluatorOptimizer."""
2
+
3
+ from pyagent_patterns.resolution.cross_reflection import CrossReflection
4
+ from pyagent_patterns.resolution.debate import Debate
5
+ from pyagent_patterns.resolution.evaluator_optimizer import EvaluatorOptimizer
6
+ from pyagent_patterns.resolution.self_reflection import SelfReflection
7
+ from pyagent_patterns.resolution.voting import Voting
8
+
9
+ __all__ = ["SelfReflection", "CrossReflection", "Debate", "Voting", "EvaluatorOptimizer"]
@@ -0,0 +1,79 @@
1
+ """Cross-Reflection pattern: Agent A generates → Agent B reviews → Agent A revises.
2
+
3
+ Unlike Self-Reflection where one agent critiques itself, Cross-Reflection
4
+ uses a separate peer agent to provide independent feedback, reducing bias.
5
+
6
+ LLM calls: 3 minimum (generate + review + revise)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pyagent_patterns.base import Agent, Context, Message, Pattern, Result
12
+
13
+
14
+ class CrossReflection(Pattern):
15
+ """Peer review: one agent generates, another reviews, generator revises.
16
+
17
+ Args:
18
+ generator: Agent that produces the initial output and revisions.
19
+ reviewer: Agent that reviews and provides feedback.
20
+ max_rounds: Maximum number of generate-review-revise cycles.
21
+ stop_phrase: If reviewer says this, stop early.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ generator: Agent,
27
+ reviewer: Agent,
28
+ max_rounds: int = 2,
29
+ stop_phrase: str = "APPROVED",
30
+ ) -> None:
31
+ self._generator = generator
32
+ self._reviewer = reviewer
33
+ self._max_rounds = max_rounds
34
+ self._stop_phrase = stop_phrase
35
+
36
+ @property
37
+ def pattern_type(self) -> str:
38
+ return "cross_reflection"
39
+
40
+ async def _execute(self, ctx: Context) -> Result:
41
+ messages: list[Message] = []
42
+ current_output = ""
43
+
44
+ for round_num in range(1, self._max_rounds + 1):
45
+ # Generate or revise
46
+ if round_num == 1:
47
+ gen_prompt = Message.user(ctx.task)
48
+ else:
49
+ gen_prompt = Message.user(
50
+ f"Revise based on peer feedback:\n\n"
51
+ f"Your output:\n{current_output}\n\n"
52
+ f"Peer feedback:\n{review_text}"
53
+ )
54
+ gen_result = await self._generator.run([gen_prompt])
55
+ messages.append(gen_result)
56
+ current_output = gen_result.content
57
+
58
+ # Peer review
59
+ review_prompt = Message.user(
60
+ f"Review this output from your peer. Provide constructive feedback. "
61
+ f"If the output is satisfactory, respond with '{self._stop_phrase}'.\n\n"
62
+ f"{current_output}"
63
+ )
64
+ review_result = await self._reviewer.run([review_prompt])
65
+ messages.append(review_result)
66
+ review_text = review_result.content
67
+
68
+ if self._stop_phrase in review_result.content:
69
+ break
70
+
71
+ return Result(
72
+ output=current_output,
73
+ messages=messages,
74
+ metadata={
75
+ "rounds": round_num,
76
+ "generator": self._generator.name,
77
+ "reviewer": self._reviewer.name,
78
+ },
79
+ )
@@ -0,0 +1,103 @@
1
+ """Debate pattern: adversarial multi-round argumentation with a judge.
2
+
3
+ Multiple debater agents argue different positions over several rounds.
4
+ A judge agent evaluates arguments and renders a final decision.
5
+
6
+ LLM calls: D debaters × R rounds + 1 judge = D*R + 1
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pyagent_patterns.base import Agent, Context, Message, Pattern, Result
12
+
13
+
14
+ class Debate(Pattern):
15
+ """Structured adversarial debate with judge resolution.
16
+
17
+ Args:
18
+ debaters: List of agents, each arguing a different position.
19
+ judge: Agent that evaluates arguments and renders final decision.
20
+ rounds: Number of argumentation rounds.
21
+ positions: Optional list of position labels (e.g., ["BUY", "SELL"]).
22
+ If not provided, positions are assigned as "Position 1", "Position 2", etc.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ debaters: list[Agent],
28
+ judge: Agent,
29
+ rounds: int = 3,
30
+ positions: list[str] | None = None,
31
+ ) -> None:
32
+ if len(debaters) < 2:
33
+ raise ValueError("Debate requires at least 2 debaters")
34
+ self._debaters = debaters
35
+ self._judge = judge
36
+ self._rounds = rounds
37
+ self._positions = positions or [f"Position {i + 1}" for i in range(len(debaters))]
38
+
39
+ @property
40
+ def pattern_type(self) -> str:
41
+ return "debate"
42
+
43
+ async def _execute(self, ctx: Context) -> Result:
44
+ messages: list[Message] = []
45
+ debate_log: list[dict[str, str]] = []
46
+
47
+ for round_num in range(1, self._rounds + 1):
48
+ round_args: list[str] = []
49
+
50
+ for i, debater in enumerate(self._debaters):
51
+ position = self._positions[i]
52
+ if round_num == 1:
53
+ prompt = Message.user(
54
+ f"You are arguing for '{position}' on this topic: {ctx.task}\n"
55
+ f"Present your opening argument."
56
+ )
57
+ else:
58
+ opponent_args = "\n".join(
59
+ f"- {self._positions[j]}: {a}"
60
+ for j, a in enumerate(round_args_prev)
61
+ if j != i
62
+ )
63
+ prompt = Message.user(
64
+ f"You are arguing for '{position}'. Round {round_num}.\n"
65
+ f"Counter these arguments:\n{opponent_args}\n\n"
66
+ f"Strengthen your position."
67
+ )
68
+
69
+ arg_result = await debater.run([prompt])
70
+ messages.append(arg_result)
71
+ round_args.append(arg_result.content)
72
+ debate_log.append({
73
+ "round": round_num,
74
+ "debater": debater.name,
75
+ "position": position,
76
+ "argument": arg_result.content,
77
+ })
78
+
79
+ round_args_prev = round_args
80
+
81
+ # Judge evaluates all arguments
82
+ full_debate = "\n\n".join(
83
+ f"[Round {entry['round']}] {entry['position']} ({entry['debater']}):\n{entry['argument']}"
84
+ for entry in debate_log
85
+ )
86
+ judge_prompt = Message.user(
87
+ f"You are the judge. Evaluate these arguments and render a final decision.\n"
88
+ f"Topic: {ctx.task}\n\n{full_debate}\n\n"
89
+ f"State your decision and reasoning."
90
+ )
91
+ verdict = await self._judge.run([judge_prompt])
92
+ messages.append(verdict)
93
+
94
+ return Result(
95
+ output=verdict.content,
96
+ messages=messages,
97
+ metadata={
98
+ "rounds": self._rounds,
99
+ "debaters": [d.name for d in self._debaters],
100
+ "positions": self._positions,
101
+ "debate_log": debate_log,
102
+ },
103
+ )