trinity-agent 0.2.1__tar.gz → 0.3.0__tar.gz

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 (97) hide show
  1. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/PKG-INFO +1 -1
  2. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/pyproject.toml +1 -1
  3. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/__init__.py +1 -1
  4. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/cli.py +9 -4
  5. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/deliberation/consensus.py +32 -0
  6. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/deliberation/distributor.py +6 -7
  7. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/deliberation/protocol.py +291 -218
  8. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/orchestrator.py +11 -0
  9. trinity_agent-0.3.0/src/trinity/tui/app.py +584 -0
  10. trinity_agent-0.3.0/src/trinity/tui/events.py +76 -0
  11. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/tui/session.py +39 -54
  12. trinity_agent-0.3.0/src/trinity/tui/theme.py +88 -0
  13. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_tui.py +194 -0
  14. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_tui_session.py +45 -0
  15. trinity_agent-0.2.1/src/trinity/tui/app.py +0 -393
  16. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/.gitignore +0 -0
  17. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/LICENSE +0 -0
  18. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/README.md +0 -0
  19. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/docs/checkpoint.md +0 -0
  20. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/docs/phase-6-plan.md +0 -0
  21. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/docs/reference-architecture.md +0 -0
  22. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/docs/test-results/phase-1-T.md +0 -0
  23. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/docs/test-results/phase-2-T.md +0 -0
  24. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/docs/test-results/phase-3-T.md +0 -0
  25. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/docs/test-results/phase-4-T.md +0 -0
  26. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/docs/test-results/phase-5-T.md +0 -0
  27. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/docs/test-results/phase-6-T.md +0 -0
  28. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/__main__.py +0 -0
  29. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/agents/__init__.py +0 -0
  30. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/agents/base.py +0 -0
  31. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/agents/claude_agent.py +0 -0
  32. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/agents/codex_agent.py +0 -0
  33. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/agents/factory.py +0 -0
  34. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/agents/gemini_agent.py +0 -0
  35. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/completion/__init__.py +0 -0
  36. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/completion/base.py +0 -0
  37. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/completion/hook.py +0 -0
  38. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/completion/idle.py +0 -0
  39. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/completion/prompt.py +0 -0
  40. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/config.py +0 -0
  41. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/context/__init__.py +0 -0
  42. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/context/monitor.py +0 -0
  43. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/context/rotator.py +0 -0
  44. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/context/shared.py +0 -0
  45. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/deliberation/__init__.py +0 -0
  46. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/error_handler.py +0 -0
  47. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/health/__init__.py +0 -0
  48. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/health/checker.py +0 -0
  49. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/logging.py +0 -0
  50. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/models.py +0 -0
  51. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/retry.py +0 -0
  52. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/setup/__init__.py +0 -0
  53. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/setup/detector.py +0 -0
  54. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/setup/wizard.py +0 -0
  55. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/tmux/__init__.py +0 -0
  56. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/tmux/layout.py +0 -0
  57. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/tmux/pane.py +0 -0
  58. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/tmux/session.py +0 -0
  59. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/tui/__init__.py +0 -0
  60. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/workspace/__init__.py +0 -0
  61. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/workspace/isolation.py +0 -0
  62. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/src/trinity/workspace/managed_home.py +0 -0
  63. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/templates/trinity.config.example +0 -0
  64. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/conftest.py +0 -0
  65. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_agent_factory.py +0 -0
  66. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_claude_agent.py +0 -0
  67. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_cli.py +0 -0
  68. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_cli_detector.py +0 -0
  69. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_cli_v2.py +0 -0
  70. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_codex_agent.py +0 -0
  71. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_completion.py +0 -0
  72. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_config.py +0 -0
  73. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_consensus_v2.py +0 -0
  74. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_context_monitor.py +0 -0
  75. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_deliberation.py +0 -0
  76. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_e2e.py +0 -0
  77. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_error_handling.py +0 -0
  78. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_gemini_agent.py +0 -0
  79. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_health_checker.py +0 -0
  80. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_interactive_claude.py +0 -0
  81. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_logging.py +0 -0
  82. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_managed_home.py +0 -0
  83. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_models.py +0 -0
  84. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_multi_provider.py +0 -0
  85. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_orchestrator.py +0 -0
  86. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_protocol.py +0 -0
  87. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_protocol_v2.py +0 -0
  88. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_retry.py +0 -0
  89. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_session_handoff.py +0 -0
  90. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_session_rotator.py +0 -0
  91. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_setup_wizard.py +0 -0
  92. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_shared_context.py +0 -0
  93. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_tmux.py +0 -0
  94. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_tmux_integration.py +0 -0
  95. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_tmux_layout.py +0 -0
  96. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/tests/test_workspace.py +0 -0
  97. {trinity_agent-0.2.1 → trinity_agent-0.3.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trinity-agent
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Three minds, one context — Multi-agent AI orchestrator for Claude Code, Codex, and Gemini CLI.
5
5
  Project-URL: Homepage, https://github.com/hongdangmoo49/Trinity
6
6
  Project-URL: Repository, https://github.com/hongdangmoo49/Trinity
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "trinity-agent"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Three minds, one context — Multi-agent AI orchestrator for Claude Code, Codex, and Gemini CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """Trinity — Three minds, one context."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.0"
@@ -45,11 +45,16 @@ def find_config_path() -> Path | None:
45
45
  return None
46
46
 
47
47
 
48
- def load_config() -> TrinityConfig:
49
- """Load config from file or return default."""
48
+ def load_config(silent: bool = False) -> TrinityConfig:
49
+ """Load config from file or return default.
50
+
51
+ Args:
52
+ silent: If True, suppress the "Loaded config from..." message.
53
+ """
50
54
  config_path = find_config_path()
51
55
  if config_path:
52
- console.print(f"[dim]Loaded config from {config_path}[/dim]")
56
+ if not silent:
57
+ console.print(f"[dim]Loaded config from {config_path}[/dim]")
53
58
  return TrinityConfig.load(config_path)
54
59
  return TrinityConfig.default_config()
55
60
 
@@ -78,7 +83,7 @@ def _run_interactive_tui() -> None:
78
83
  """Launch the interactive TUI session."""
79
84
  from trinity.tui.session import InteractiveSession
80
85
 
81
- config = load_config()
86
+ config = load_config(silent=True)
82
87
 
83
88
  # If no .trinity/ directory exists, suggest running init first
84
89
  state_dir = config.effective_state_dir
@@ -163,9 +163,41 @@ class ConsensusEngine:
163
163
  ) -> str:
164
164
  """Build a human-readable consensus summary."""
165
165
  if reached:
166
+ # Extract key content from opinions for a meaningful summary
167
+ key_points = self._extract_key_points(opinions)
168
+ if key_points:
169
+ return (
170
+ f"Consensus reached ({len(agreeing)}/{len(opinions)} agree). "
171
+ f"Key points: {key_points}"
172
+ )
166
173
  return f"Consensus reached. Agreeing: {', '.join(agreeing)}."
167
174
  else:
168
175
  return (
169
176
  f"Consensus not reached ({len(agreeing)}/{len(opinions)} agree). "
170
177
  f"Need another round of deliberation."
171
178
  )
179
+
180
+ def _extract_key_points(self, opinions: dict[str, str]) -> str:
181
+ """Extract key points from agent opinions for summary.
182
+
183
+ Takes the first opinion and truncates to a reasonable length.
184
+ """
185
+ if not opinions:
186
+ return ""
187
+
188
+ # Use the first opinion as representative
189
+ for text in opinions.values():
190
+ # Clean and truncate
191
+ clean = text.strip()
192
+ # Take first meaningful sentence(s), up to 200 chars
193
+ if len(clean) > 200:
194
+ # Truncate at last sentence boundary within limit
195
+ truncated = clean[:200]
196
+ last_period = truncated.rfind(".")
197
+ if last_period > 50:
198
+ clean = truncated[: last_period + 1]
199
+ else:
200
+ clean = truncated + "..."
201
+ return clean
202
+
203
+ return ""
@@ -94,16 +94,15 @@ class TaskDistributor:
94
94
  if matched_strengths:
95
95
  strengths_text = ", ".join(matched_strengths)
96
96
  task_desc = (
97
- f"Based on the agreed conclusion, handle: {strengths_text}. "
98
- f"The consensus is: {consensus[:200]}. "
99
- f"Focus on your strengths in {strengths_text} and deliver concrete output."
97
+ f"[{agent_name}] Handle: {strengths_text}. "
98
+ f"Consensus: {consensus[:150]}"
100
99
  )
101
100
  else:
102
- # No specific strength matched — assign a general task
101
+ # No specific strength matched — assign based on role
102
+ role_short = role.split(".")[0] if "." in role else role[:50]
103
103
  task_desc = (
104
- f"Based on the agreed conclusion, contribute to implementation. "
105
- f"The consensus is: {consensus[:200]}. "
106
- f"Identify what you can best contribute and execute."
104
+ f"[{agent_name}] As {role_short}, execute on the agreed conclusion. "
105
+ f"Consensus: {consensus[:100]}"
107
106
  )
108
107
 
109
108
  return TaskAssignment(
@@ -1,218 +1,291 @@
1
- """Deliberation protocol — round-based deliberation loop."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import logging
7
- import time
8
-
9
- from trinity.agents.base import AgentWrapper
10
- from trinity.context.shared import SharedContextEngine
11
- from trinity.deliberation.consensus import ConsensusEngine
12
- from trinity.deliberation.distributor import TaskDistributor
13
- from trinity.models import (
14
- ConsensusResult,
15
- DeliberationMessage,
16
- DeliberationResult,
17
- MessageRole,
18
- TaskAssignment,
19
- )
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
-
24
- class DeliberationProtocol:
25
- """Round-based deliberation: opinions → counter → consensus → tasks.
26
-
27
- Each round:
28
- 1. Build a round-specific prompt for all agents
29
- 2. Send to all agents in parallel (asyncio.gather)
30
- 3. Collect responses write to shared.md
31
- 4. Check for consensus
32
- 5. If reached, distribute tasks. Otherwise, next round.
33
- """
34
-
35
- def __init__(
36
- self,
37
- agents: dict[str, AgentWrapper],
38
- shared: SharedContextEngine,
39
- consensus_engine: ConsensusEngine | None = None,
40
- distributor: TaskDistributor | None = None,
41
- max_rounds: int = 5,
42
- round_timeout: float = 120.0,
43
- tmux_manager=None,
44
- ):
45
- self.agents = agents
46
- self.shared = shared
47
- self.consensus_engine = consensus_engine or ConsensusEngine()
48
- self.distributor = distributor or TaskDistributor()
49
- self.max_rounds = max_rounds
50
- self.round_timeout = round_timeout
51
- self.tmux_manager = tmux_manager
52
-
53
- async def run(self, user_prompt: str) -> DeliberationResult:
54
- """Execute full deliberation loop."""
55
- start_time = time.time()
56
- agent_names = list(self.agents.keys())
57
-
58
- # Initialize shared.md
59
- self.shared.initialize(goal=user_prompt, agent_names=agent_names)
60
-
61
- consensus: ConsensusResult | None = None
62
- round_num = 0
63
-
64
- for round_num in range(1, self.max_rounds + 1):
65
- logger.info(f"=== Round {round_num}/{self.max_rounds} ===")
66
-
67
- # Update tmux pane titles to show round progress
68
- self._update_pane_titles(f"Round {round_num}/{self.max_rounds}")
69
-
70
- # Build prompt for this round
71
- round_prompt = self._build_round_prompt(round_num, user_prompt)
72
-
73
- # Collect opinions from all agents in parallel
74
- opinions = await self._collect_opinions(round_num, round_prompt)
75
-
76
- # Write opinions to shared.md
77
- for name, msg in opinions.items():
78
- self.shared.append_opinion(name, round_num, msg.content)
79
-
80
- # Update message round_num (it was set to 0 in agent)
81
- for name, msg in opinions.items():
82
- msg.round_num = round_num
83
-
84
- # Check consensus
85
- opinion_texts = {name: msg.content for name, msg in opinions.items()}
86
- consensus = self.consensus_engine.evaluate(opinion_texts)
87
-
88
- if consensus.reached:
89
- logger.info(f"Consensus reached at round {round_num}!")
90
- self.shared.update_consensus(consensus.summary)
91
- self._update_pane_titles("✓ Consensus!")
92
- break
93
-
94
- logger.info(f"No consensus yet. Continuing to round {round_num + 1}.")
95
-
96
- # Update pane titles for task distribution phase
97
- self._update_pane_titles("Distributing tasks...")
98
-
99
- # If no consensus after all rounds, force conclusion
100
- if consensus and not consensus.reached:
101
- logger.warning(f"Max rounds ({self.max_rounds}) reached. Forcing conclusion.")
102
- consensus = ConsensusResult(
103
- reached=True, # Force it
104
- agreement_count=consensus.agreement_count,
105
- total_agents=consensus.total_agents,
106
- opinions=consensus.opinions,
107
- summary=f"Forced conclusion after {self.max_rounds} rounds. "
108
- f"Majority opinion selected.",
109
- )
110
- self.shared.update_consensus(consensus.summary)
111
-
112
- # Distribute tasks
113
- tasks = self.distributor.distribute(
114
- consensus_text=consensus.summary if consensus else user_prompt,
115
- agents={name: ag.spec for name, ag in self.agents.items()},
116
- )
117
-
118
- # Write tasks to shared.md
119
- task_dict = {t.agent_name: t.task_description for t in tasks}
120
- self.shared.update_tasks(task_dict)
121
-
122
- # Calculate totals
123
- total_tokens = sum(
124
- ag.context_usage.used for ag in self.agents.values()
125
- )
126
- elapsed = time.time() - start_time
127
-
128
- return DeliberationResult(
129
- user_prompt=user_prompt,
130
- rounds_completed=round_num,
131
- consensus=consensus,
132
- tasks=tasks,
133
- total_tokens_used=total_tokens,
134
- duration_seconds=elapsed,
135
- )
136
-
137
- async def _collect_opinions(
138
- self, round_num: int, prompt: str
139
- ) -> dict[str, DeliberationMessage]:
140
- """Send prompt to all agents in parallel and collect responses."""
141
- tasks = {
142
- name: agent.send_and_wait(prompt, timeout=self.round_timeout)
143
- for name, agent in self.agents.items()
144
- }
145
-
146
- results = await asyncio.gather(
147
- *tasks.values(), return_exceptions=True
148
- )
149
-
150
- opinions: dict[str, DeliberationMessage] = {}
151
- for name, result in zip(tasks.keys(), results):
152
- if isinstance(result, Exception):
153
- logger.error(f"[{name}] Error in round {round_num}: {result}")
154
- opinions[name] = DeliberationMessage(
155
- source=name,
156
- target="all",
157
- round_num=round_num,
158
- role=MessageRole.OPINION,
159
- content=f"[Error: {result}]",
160
- )
161
- elif isinstance(result, DeliberationMessage):
162
- result.round_num = round_num
163
- opinions[name] = result
164
- else:
165
- logger.warning(f"[{name}] Unexpected result type: {type(result)}")
166
-
167
- return opinions
168
-
169
- def _build_round_prompt(self, round_num: int, user_prompt: str) -> str:
170
- """Build the prompt for a specific deliberation round."""
171
- if round_num == 1:
172
- return (
173
- f"Read the shared context below for background.\n\n"
174
- f"User's request: {user_prompt}\n\n"
175
- f"Share your initial opinion. Be specific and concise.\n"
176
- f"State your recommendation and key reasoning.\n"
177
- f"Keep your response under 500 words."
178
- )
179
- else:
180
- # Read previous round opinions from shared.md
181
- prev_section = self.shared.read_section(f"Round {round_num - 1} Opinions")
182
- prev_context = prev_section or "(previous round opinions not available)"
183
-
184
- return (
185
- f"Previous round opinions:\n\n"
186
- f"{prev_context}\n\n"
187
- f"---\n\n"
188
- f"For each other agent's opinion above, state whether you AGREE or DISAGREE "
189
- f"and explain why. If you disagree, propose an alternative.\n"
190
- f"End your response with either 'I AGREE with [name]' or your counter-proposal.\n"
191
- f"Keep your response under 300 words."
192
- )
193
-
194
- def _update_pane_titles(self, status_text: str) -> None:
195
- """Update tmux pane titles to show round progress (Phase 2 feature)."""
196
- if not self.tmux_manager:
197
- return
198
-
199
- import subprocess
200
-
201
- for name in self.agents:
202
- pane = self.tmux_manager.get_pane(name)
203
- if pane:
204
- try:
205
- subprocess.run(
206
- [
207
- "tmux",
208
- "select-pane",
209
- "-t",
210
- pane.pane_id,
211
- "-T",
212
- f"{name}: {status_text}",
213
- ],
214
- capture_output=True,
215
- timeout=5,
216
- )
217
- except Exception:
218
- pass # Non-critical — don't fail deliberation for title update
1
+ """Deliberation protocol — round-based deliberation loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from typing import Callable
9
+
10
+ from trinity.agents.base import AgentWrapper
11
+ from trinity.context.shared import SharedContextEngine
12
+ from trinity.deliberation.consensus import ConsensusEngine
13
+ from trinity.deliberation.distributor import TaskDistributor
14
+ from trinity.models import (
15
+ ConsensusResult,
16
+ DeliberationMessage,
17
+ DeliberationResult,
18
+ MessageRole,
19
+ TaskAssignment,
20
+ )
21
+ from trinity.tui.events import TUIEvent, TUIEventType
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class DeliberationProtocol:
27
+ """Round-based deliberation: opinions → counter → consensus → tasks.
28
+
29
+ Each round:
30
+ 1. Build a round-specific prompt for all agents
31
+ 2. Send to all agents in parallel (with per-agent completion streaming)
32
+ 3. Collect responses write to shared.md
33
+ 4. Check for consensus
34
+ 5. If reached, distribute tasks. Otherwise, next round.
35
+
36
+ When an event_callback is provided, events are emitted for each
37
+ agent completion, round transition, and consensus evaluation,
38
+ enabling real-time TUI updates.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ agents: dict[str, AgentWrapper],
44
+ shared: SharedContextEngine,
45
+ consensus_engine: ConsensusEngine | None = None,
46
+ distributor: TaskDistributor | None = None,
47
+ max_rounds: int = 5,
48
+ round_timeout: float = 120.0,
49
+ tmux_manager=None,
50
+ event_callback: Callable[[TUIEvent], None] | None = None,
51
+ ):
52
+ self.agents = agents
53
+ self.shared = shared
54
+ self.consensus_engine = consensus_engine or ConsensusEngine()
55
+ self.distributor = distributor or TaskDistributor()
56
+ self.max_rounds = max_rounds
57
+ self.round_timeout = round_timeout
58
+ self.tmux_manager = tmux_manager
59
+ self._event_callback = event_callback
60
+
61
+ def _emit(self, event_type: TUIEventType, **kwargs) -> None:
62
+ """Emit a TUI event if callback is registered."""
63
+ if self._event_callback:
64
+ self._event_callback(TUIEvent(type=event_type, data=kwargs))
65
+
66
+ async def run(self, user_prompt: str) -> DeliberationResult:
67
+ """Execute full deliberation loop."""
68
+ start_time = time.time()
69
+ agent_names = list(self.agents.keys())
70
+
71
+ # Initialize shared.md
72
+ self.shared.initialize(goal=user_prompt, agent_names=agent_names)
73
+
74
+ consensus: ConsensusResult | None = None
75
+ round_num = 0
76
+
77
+ for round_num in range(1, self.max_rounds + 1):
78
+ logger.info(f"=== Round {round_num}/{self.max_rounds} ===")
79
+
80
+ # Emit round start
81
+ self._emit(TUIEventType.ROUND_START, round_num=round_num)
82
+
83
+ # Update tmux pane titles to show round progress
84
+ self._update_pane_titles(f"Round {round_num}/{self.max_rounds}")
85
+
86
+ # Build prompt for this round
87
+ round_prompt = self._build_round_prompt(round_num, user_prompt)
88
+
89
+ # Collect opinions from all agents (with per-agent streaming)
90
+ opinions = await self._collect_opinions(round_num, round_prompt)
91
+
92
+ # Write opinions to shared.md
93
+ for name, msg in opinions.items():
94
+ self.shared.append_opinion(name, round_num, msg.content)
95
+
96
+ # Update message round_num (it was set to 0 in agent)
97
+ for name, msg in opinions.items():
98
+ msg.round_num = round_num
99
+
100
+ # Check consensus
101
+ self._emit(TUIEventType.CONSENSUS_CHECKING, round_num=round_num)
102
+
103
+ opinion_texts = {name: msg.content for name, msg in opinions.items()}
104
+ consensus = self.consensus_engine.evaluate(opinion_texts)
105
+
106
+ if consensus.reached:
107
+ logger.info(f"Consensus reached at round {round_num}!")
108
+ self.shared.update_consensus(consensus.summary)
109
+ self._update_pane_titles("✓ Consensus!")
110
+
111
+ self._emit(
112
+ TUIEventType.CONSENSUS_RESULT,
113
+ reached=True,
114
+ agreement_count=consensus.agreement_count,
115
+ total_agents=consensus.total_agents,
116
+ summary=consensus.summary,
117
+ round_num=round_num,
118
+ )
119
+ break
120
+
121
+ logger.info(f"No consensus yet. Continuing to round {round_num + 1}.")
122
+
123
+ self._emit(
124
+ TUIEventType.CONSENSUS_RESULT,
125
+ reached=False,
126
+ agreement_count=consensus.agreement_count,
127
+ total_agents=consensus.total_agents,
128
+ summary="",
129
+ round_num=round_num,
130
+ )
131
+
132
+ # Update pane titles for task distribution phase
133
+ self._update_pane_titles("Distributing tasks...")
134
+
135
+ # If no consensus after all rounds, force conclusion
136
+ if consensus and not consensus.reached:
137
+ logger.warning(f"Max rounds ({self.max_rounds}) reached. Forcing conclusion.")
138
+ consensus = ConsensusResult(
139
+ reached=True, # Force it
140
+ agreement_count=consensus.agreement_count,
141
+ total_agents=consensus.total_agents,
142
+ opinions=consensus.opinions,
143
+ summary=f"Forced conclusion after {self.max_rounds} rounds. "
144
+ f"Majority opinion selected.",
145
+ )
146
+ self.shared.update_consensus(consensus.summary)
147
+
148
+ # Distribute tasks
149
+ tasks = self.distributor.distribute(
150
+ consensus_text=consensus.summary if consensus else user_prompt,
151
+ agents={name: ag.spec for name, ag in self.agents.items()},
152
+ )
153
+
154
+ # Write tasks to shared.md
155
+ task_dict = {t.agent_name: t.task_description for t in tasks}
156
+ self.shared.update_tasks(task_dict)
157
+
158
+ # Calculate totals
159
+ total_tokens = sum(
160
+ ag.context_usage.used for ag in self.agents.values()
161
+ )
162
+ elapsed = time.time() - start_time
163
+
164
+ self._emit(TUIEventType.DELIBERATION_DONE)
165
+
166
+ return DeliberationResult(
167
+ user_prompt=user_prompt,
168
+ rounds_completed=round_num,
169
+ consensus=consensus,
170
+ tasks=tasks,
171
+ total_tokens_used=total_tokens,
172
+ duration_seconds=elapsed,
173
+ )
174
+
175
+ async def _collect_opinions(
176
+ self, round_num: int, prompt: str
177
+ ) -> dict[str, DeliberationMessage]:
178
+ """Send prompt to all agents in parallel and collect responses.
179
+
180
+ Uses asyncio.wait(FIRST_COMPLETED) instead of asyncio.gather
181
+ to enable per-agent completion streaming via events.
182
+ """
183
+ # Create tasks with agent names attached
184
+ pending: set[asyncio.Task] = set()
185
+ task_to_name: dict[asyncio.Task, str] = {}
186
+
187
+ for name, agent in self.agents.items():
188
+ coro = agent.send_and_wait(prompt, timeout=self.round_timeout)
189
+ task = asyncio.ensure_future(coro)
190
+ task_to_name[task] = name
191
+ pending.add(task)
192
+ self._emit(TUIEventType.AGENT_THINKING, agent=name, round_num=round_num)
193
+
194
+ opinions: dict[str, DeliberationMessage] = {}
195
+
196
+ while pending:
197
+ done, pending = await asyncio.wait(
198
+ pending, return_when=asyncio.FIRST_COMPLETED
199
+ )
200
+
201
+ for task in done:
202
+ name = task_to_name[task]
203
+ try:
204
+ result = task.result()
205
+ except Exception as exc:
206
+ logger.error(f"[{name}] Error in round {round_num}: {exc}")
207
+ opinions[name] = DeliberationMessage(
208
+ source=name,
209
+ target="all",
210
+ round_num=round_num,
211
+ role=MessageRole.OPINION,
212
+ content=f"[Error: {exc}]",
213
+ )
214
+ self._emit(
215
+ TUIEventType.AGENT_ERROR,
216
+ agent=name,
217
+ error=str(exc),
218
+ round_num=round_num,
219
+ )
220
+ continue
221
+
222
+ if isinstance(result, DeliberationMessage):
223
+ result.round_num = round_num
224
+ opinions[name] = result
225
+ self._emit(
226
+ TUIEventType.AGENT_RESPONDED,
227
+ agent=name,
228
+ content=result.content,
229
+ round_num=round_num,
230
+ )
231
+ else:
232
+ logger.warning(f"[{name}] Unexpected result type: {type(result)}")
233
+ self._emit(
234
+ TUIEventType.AGENT_ERROR,
235
+ agent=name,
236
+ error=f"Unexpected result type: {type(result)}",
237
+ round_num=round_num,
238
+ )
239
+
240
+ return opinions
241
+
242
+ def _build_round_prompt(self, round_num: int, user_prompt: str) -> str:
243
+ """Build the prompt for a specific deliberation round."""
244
+ if round_num == 1:
245
+ return (
246
+ f"Read the shared context below for background.\n\n"
247
+ f"User's request: {user_prompt}\n\n"
248
+ f"Share your initial opinion. Be specific and concise.\n"
249
+ f"State your recommendation and key reasoning.\n"
250
+ f"Keep your response under 500 words."
251
+ )
252
+ else:
253
+ # Read previous round opinions from shared.md
254
+ prev_section = self.shared.read_section(f"Round {round_num - 1} Opinions")
255
+ prev_context = prev_section or "(previous round opinions not available)"
256
+
257
+ return (
258
+ f"Previous round opinions:\n\n"
259
+ f"{prev_context}\n\n"
260
+ f"---\n\n"
261
+ f"For each other agent's opinion above, state whether you AGREE or DISAGREE "
262
+ f"and explain why. If you disagree, propose an alternative.\n"
263
+ f"End your response with either 'I AGREE with [name]' or your counter-proposal.\n"
264
+ f"Keep your response under 300 words."
265
+ )
266
+
267
+ def _update_pane_titles(self, status_text: str) -> None:
268
+ """Update tmux pane titles to show round progress (Phase 2 feature)."""
269
+ if not self.tmux_manager:
270
+ return
271
+
272
+ import subprocess
273
+
274
+ for name in self.agents:
275
+ pane = self.tmux_manager.get_pane(name)
276
+ if pane:
277
+ try:
278
+ subprocess.run(
279
+ [
280
+ "tmux",
281
+ "select-pane",
282
+ "-t",
283
+ pane.pane_id,
284
+ "-T",
285
+ f"{name}: {status_text}",
286
+ ],
287
+ capture_output=True,
288
+ timeout=5,
289
+ )
290
+ except Exception:
291
+ pass # Non-critical — don't fail deliberation for title update