trinity-agent 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.
trinity/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Trinity — Three minds, one context."""
2
+
3
+ __version__ = "0.1.0"
trinity/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running Trinity as: python -m trinity"""
2
+
3
+ from trinity.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
File without changes
trinity/agents/base.py ADDED
@@ -0,0 +1,67 @@
1
+ """Agent wrapper — abstract base class for provider-specific agent control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from trinity.models import AgentSpec, ContextUsage, DeliberationMessage
8
+
9
+
10
+ class AgentWrapper(ABC):
11
+ """Abstract base for controlling an AI CLI agent.
12
+
13
+ Each provider (Claude, Codex, Gemini) implements this interface.
14
+ Phase 1 uses subprocess-based print mode; Phase 2 adds tmux interactive mode.
15
+ """
16
+
17
+ def __init__(self, spec: AgentSpec):
18
+ self.spec = spec
19
+ self._context_usage = ContextUsage(total=spec.effective_context_budget)
20
+
21
+ @property
22
+ def name(self) -> str:
23
+ return self.spec.name
24
+
25
+ @property
26
+ def context_usage(self) -> ContextUsage:
27
+ return self._context_usage
28
+
29
+ @abstractmethod
30
+ async def start(self, initial_prompt: str = "") -> None:
31
+ """Launch the agent CLI. Optionally inject an initial prompt."""
32
+ ...
33
+
34
+ @abstractmethod
35
+ async def send_and_wait(
36
+ self, prompt: str, timeout: float = 120.0
37
+ ) -> DeliberationMessage:
38
+ """Send a prompt and wait for the agent to respond.
39
+
40
+ Returns a DeliberationMessage with the agent's response.
41
+ """
42
+ ...
43
+
44
+ @abstractmethod
45
+ async def get_context_usage(self) -> ContextUsage:
46
+ """Return current token usage for this agent."""
47
+ ...
48
+
49
+ @abstractmethod
50
+ async def is_alive(self) -> bool:
51
+ """Check if the agent process is still running."""
52
+ ...
53
+
54
+ @abstractmethod
55
+ async def graceful_shutdown(self) -> None:
56
+ """Gracefully stop the agent."""
57
+ ...
58
+
59
+ def _update_usage(self, used: int, total: int | None = None) -> None:
60
+ """Update context usage tracking."""
61
+ self._context_usage = ContextUsage(
62
+ used=used,
63
+ total=total if total is not None else self._context_usage.total,
64
+ )
65
+
66
+ def __repr__(self) -> str:
67
+ return f"{self.__class__.__name__}({self.name!r}, provider={self.spec.provider.value})"
@@ -0,0 +1,424 @@
1
+ """Claude Code agent wrapper — print mode (Phase 1) and interactive mode (Phase 2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import subprocess
9
+ import time
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from trinity.agents.base import AgentWrapper
14
+ from trinity.models import AgentSpec, ContextUsage, DeliberationMessage, MessageRole
15
+
16
+ if TYPE_CHECKING:
17
+ from trinity.completion.base import CompletionDetector
18
+ from trinity.completion.hook import HookDetector
19
+ from trinity.tmux.pane import TmuxPane
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class PrintModeClaudeAgent(AgentWrapper):
25
+ """Claude Code agent using `claude -p --output-format json` subprocess.
26
+
27
+ Phase 1 implementation: each send_and_wait() spawns a new claude -p call.
28
+ No tmux needed. Completion detection is trivial (subprocess blocks).
29
+ Token counts come from the JSON response's `usage` field.
30
+ """
31
+
32
+ def __init__(self, spec: AgentSpec):
33
+ super().__init__(spec)
34
+ self._started = False
35
+ self._message_count = 0
36
+
37
+ async def start(self, initial_prompt: str = "") -> None:
38
+ """Mark agent as started. In print mode, there's no persistent process."""
39
+ self._started = True
40
+ logger.info(f"[{self.name}] Print-mode agent initialized")
41
+
42
+ if initial_prompt:
43
+ # In print mode, we don't pre-inject — just store for context
44
+ logger.debug(f"[{self.name}] Initial prompt stored (will be prepended on first call)")
45
+ self._initial_prompt = initial_prompt
46
+ else:
47
+ self._initial_prompt = ""
48
+
49
+ async def send_and_wait(
50
+ self, prompt: str, timeout: float = 120.0
51
+ ) -> DeliberationMessage:
52
+ """Send prompt via `claude -p` and wait for JSON response."""
53
+ if not self._started:
54
+ raise RuntimeError(f"Agent {self.name} not started. Call start() first.")
55
+
56
+ self._message_count += 1
57
+
58
+ # Build full prompt with role + initial context
59
+ full_prompt = self._build_prompt(prompt)
60
+
61
+ # Build CLI command
62
+ cmd = [
63
+ self.spec.cli_command,
64
+ "-p",
65
+ "--output-format", "json",
66
+ ]
67
+ cmd.extend(self.spec.extra_args)
68
+ cmd.append(full_prompt)
69
+
70
+ logger.info(f"[{self.name}] Sending prompt ({len(full_prompt)} chars)...")
71
+
72
+ start_time = time.time()
73
+ try:
74
+ result = await asyncio.to_thread(
75
+ self._run_subprocess, cmd, timeout
76
+ )
77
+ except subprocess.TimeoutExpired:
78
+ logger.warning(f"[{self.name}] Timeout after {timeout}s")
79
+ return DeliberationMessage(
80
+ source=self.name,
81
+ target="all",
82
+ round_num=0,
83
+ role=MessageRole.OPINION,
84
+ content=f"[Timeout after {timeout}s]",
85
+ metadata={"error": "timeout"},
86
+ )
87
+
88
+ elapsed = time.time() - start_time
89
+ logger.info(f"[{self.name}] Response received in {elapsed:.1f}s")
90
+
91
+ # Parse JSON response
92
+ response_text, usage = self._parse_response(result)
93
+ self._update_usage(**usage)
94
+
95
+ return DeliberationMessage(
96
+ source=self.name,
97
+ target="all",
98
+ round_num=0, # Set by protocol
99
+ role=MessageRole.OPINION,
100
+ content=response_text,
101
+ metadata={
102
+ "elapsed_seconds": elapsed,
103
+ "token_count": usage.get("used", 0),
104
+ "model": result.get("model", "unknown"),
105
+ },
106
+ )
107
+
108
+ async def get_context_usage(self) -> ContextUsage:
109
+ return self._context_usage
110
+
111
+ async def is_alive(self) -> bool:
112
+ return self._started
113
+
114
+ async def graceful_shutdown(self) -> None:
115
+ """No persistent process to shut down in print mode."""
116
+ self._started = False
117
+ logger.info(f"[{self.name}] Print-mode agent stopped")
118
+
119
+ def _build_prompt(self, user_prompt: str) -> str:
120
+ """Build the full prompt with role description."""
121
+ parts: list[str] = []
122
+
123
+ if self.spec.role_prompt:
124
+ parts.append(f"[System Role]\n{self.spec.role_prompt}\n")
125
+
126
+ if self._initial_prompt:
127
+ parts.append(f"[Context]\n{self._initial_prompt}\n")
128
+
129
+ parts.append(user_prompt)
130
+
131
+ return "\n\n".join(parts)
132
+
133
+ def _run_subprocess(self, cmd: list[str], timeout: float) -> dict:
134
+ """Run claude -p in a subprocess (blocking). Returns parsed JSON."""
135
+ proc = subprocess.run(
136
+ cmd,
137
+ capture_output=True,
138
+ text=True,
139
+ timeout=timeout,
140
+ )
141
+
142
+ if proc.returncode != 0:
143
+ logger.error(f"[{self.name}] claude exited with code {proc.returncode}")
144
+ logger.error(f"[{self.name}] stderr: {proc.stderr[:500]}")
145
+ return {
146
+ "result": f"[Error: claude exited with code {proc.returncode}]",
147
+ "usage": {},
148
+ }
149
+
150
+ try:
151
+ return json.loads(proc.stdout)
152
+ except json.JSONDecodeError:
153
+ # If not JSON, treat stdout as plain text response
154
+ return {
155
+ "result": proc.stdout,
156
+ "usage": {},
157
+ }
158
+
159
+ def _parse_response(self, data: dict) -> tuple[str, dict]:
160
+ """Extract response text and usage from Claude JSON output.
161
+
162
+ Returns (response_text, {"used": N, "total": N}).
163
+ """
164
+ response_text = data.get("result", str(data))
165
+
166
+ usage_data = data.get("usage", {})
167
+ input_tokens = usage_data.get("input_tokens", 0)
168
+ output_tokens = usage_data.get("output_tokens", 0)
169
+ total_used = input_tokens + output_tokens
170
+
171
+ # Estimate total from budget
172
+ total = self._context_usage.total
173
+
174
+ return response_text, {"used": total_used, "total": total}
175
+
176
+
177
+ class InteractiveClaudeAgent(AgentWrapper):
178
+ """Claude Code agent in interactive tmux mode.
179
+
180
+ Launches `claude` in a tmux pane, sends prompts via send-keys,
181
+ detects completion via fallback chain (Hook→PromptReturn→Idle),
182
+ and reads responses via capture-pane.
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ spec: AgentSpec,
188
+ pane: "TmuxPane | None" = None,
189
+ detector: "CompletionDetector | None" = None,
190
+ signal_path: "Path | None" = None,
191
+ ):
192
+ super().__init__(spec)
193
+ self._pane = pane
194
+ self._detector = detector
195
+ self._signal_path = signal_path
196
+ self._started = False
197
+ self._prompt_counter = 0
198
+ self._last_response_start_line = 0
199
+
200
+ # Response parsing
201
+ self._sent_text = "" # Track what we sent to strip from response
202
+
203
+ @property
204
+ def pane(self) -> "TmuxPane | None":
205
+ return self._pane
206
+
207
+ @pane.setter
208
+ def pane(self, value: "TmuxPane") -> None:
209
+ self._pane = value
210
+
211
+ @property
212
+ def detector(self) -> "CompletionDetector | None":
213
+ return self._detector
214
+
215
+ @detector.setter
216
+ def detector(self, value: "CompletionDetector") -> None:
217
+ self._detector = value
218
+
219
+ async def start(self, initial_prompt: str = "") -> None:
220
+ """Launch claude CLI in the tmux pane and inject role prompt."""
221
+ if not self._pane:
222
+ raise RuntimeError(f"No tmux pane assigned for agent '{self.name}'")
223
+ if not self._detector:
224
+ raise RuntimeError(f"No completion detector for agent '{self.name}'")
225
+
226
+ # Record current pane line count as baseline
227
+ self._last_response_start_line = len(self._pane.capture(lines=-9999))
228
+
229
+ # Launch claude CLI
230
+ cmd_parts = [self.spec.cli_command]
231
+ cmd_parts.extend(self.spec.extra_args)
232
+ cmd = " ".join(cmd_parts)
233
+
234
+ logger.info(f"[{self.name}] Launching in tmux pane: {cmd}")
235
+ self._pane.send_text(cmd)
236
+
237
+ # Wait for claude to be ready (prompt appears)
238
+ await self._wait_for_ready(timeout=30.0)
239
+
240
+ # Inject role prompt if provided
241
+ if initial_prompt:
242
+ logger.info(f"[{self.name}] Injecting role prompt")
243
+ await self._send_and_wait_for_response(initial_prompt, timeout=60.0)
244
+
245
+ self._started = True
246
+ logger.info(f"[{self.name}] Interactive agent ready")
247
+
248
+ async def send_and_wait(
249
+ self, prompt: str, timeout: float = 120.0
250
+ ) -> DeliberationMessage:
251
+ """Send prompt via send-keys and wait for completion."""
252
+ if not self._started:
253
+ raise RuntimeError(f"Agent {self.name} not started")
254
+
255
+ self._prompt_counter += 1
256
+ logger.info(f"[{self.name}] Sending prompt #{self._prompt_counter}")
257
+
258
+ # Reset hook detector if present
259
+ from trinity.completion.base import FallbackChainDetector
260
+ from trinity.completion.hook import HookDetector
261
+
262
+ if isinstance(self._detector, FallbackChainDetector):
263
+ for d in self._detector.detectors:
264
+ if isinstance(d, HookDetector):
265
+ d.reset()
266
+ elif isinstance(self._detector, HookDetector):
267
+ self._detector.reset()
268
+
269
+ # Record start position in pane output
270
+ pre_lines = self._pane.capture(lines=-9999)
271
+ self._last_response_start_line = len(pre_lines)
272
+
273
+ # Send the prompt
274
+ full_prompt = self._build_prompt(prompt)
275
+ self._sent_text = full_prompt
276
+ self._pane.send_text_heredoc(full_prompt)
277
+
278
+ # Wait for completion
279
+ start_time = time.time()
280
+ result = await self._send_and_wait_for_response(full_prompt, timeout)
281
+ elapsed = time.time() - start_time
282
+
283
+ # Extract response text (strip what we sent)
284
+ response_text = self._extract_response(result.output)
285
+
286
+ # Parse token usage if possible
287
+ usage = self._parse_usage_from_output(result.output)
288
+ self._update_usage(**usage)
289
+
290
+ return DeliberationMessage(
291
+ source=self.name,
292
+ target="all",
293
+ round_num=0, # Set by protocol
294
+ role=MessageRole.OPINION,
295
+ content=response_text,
296
+ metadata={
297
+ "elapsed_seconds": elapsed,
298
+ "token_count": usage.get("used", 0),
299
+ "detector": result.detector_name,
300
+ "prompt_num": self._prompt_counter,
301
+ },
302
+ )
303
+
304
+ async def get_context_usage(self) -> ContextUsage:
305
+ return self._context_usage
306
+
307
+ async def is_alive(self) -> bool:
308
+ if not self._pane:
309
+ return False
310
+ return self._started and self._pane.is_alive()
311
+
312
+ async def graceful_shutdown(self) -> None:
313
+ """Send /exit to claude, then kill the pane."""
314
+ if self._pane and self._started:
315
+ try:
316
+ self._pane.send_text("/exit")
317
+ await asyncio.sleep(1.0)
318
+ except Exception:
319
+ pass
320
+ self._started = False
321
+ logger.info(f"[{self.name}] Interactive agent stopped")
322
+
323
+ def _build_prompt(self, user_prompt: str) -> str:
324
+ """Build prompt text. For interactive mode, just the user prompt."""
325
+ return user_prompt
326
+
327
+ async def _wait_for_ready(self, timeout: float = 30.0) -> None:
328
+ """Wait for Claude CLI to be ready (prompt appears)."""
329
+ import re
330
+
331
+ prompt_pattern = re.compile(r"[>❯$]\s*$", re.MULTILINE)
332
+ deadline = time.monotonic() + timeout
333
+
334
+ while time.monotonic() < deadline:
335
+ lines = self._pane.capture(lines=-5)
336
+ tail = "\n".join(lines)
337
+ if prompt_pattern.search(tail):
338
+ logger.debug(f"[{self.name}] Claude CLI ready")
339
+ return
340
+ await asyncio.sleep(0.5)
341
+
342
+ logger.warning(f"[{self.name}] Timed out waiting for CLI ready")
343
+
344
+ async def _send_and_wait_for_response(
345
+ self, prompt: str, timeout: float = 120.0
346
+ ) -> "CompletionResult":
347
+ """Send a prompt and wait for the completion detector to signal done."""
348
+ from trinity.completion.base import CompletionResult
349
+
350
+ if not self._detector or not self._pane:
351
+ # Fallback: just wait a fixed time
352
+ await asyncio.sleep(min(timeout, 10.0))
353
+ output = "\n".join(self._pane.capture(lines=-200))
354
+ return CompletionResult(
355
+ completed=True,
356
+ output=output,
357
+ detector_name="fallback",
358
+ )
359
+
360
+ result = await self._detector.wait_for_completion(
361
+ self._pane, timeout=timeout
362
+ )
363
+ return result
364
+
365
+ def _extract_response(self, raw_output: str) -> str:
366
+ """Extract the agent's response from the raw pane output.
367
+
368
+ Strips the sent prompt and any prompt characters from the output.
369
+ """
370
+ # Get lines after what we sent
371
+ lines = raw_output.splitlines()
372
+
373
+ # Try to find where our prompt was
374
+ response_lines = []
375
+ found_prompt = False
376
+
377
+ for line in lines:
378
+ # Skip lines that are part of what we sent
379
+ if not found_prompt:
380
+ # Look for the last occurrence of our sent text
381
+ if self._sent_text and self._sent_text[:50] in line:
382
+ found_prompt = True
383
+ continue
384
+ response_lines.append(line)
385
+
386
+ if not response_lines:
387
+ # Fallback: return last ~100 lines (skip prompt characters)
388
+ response_lines = lines[-100:]
389
+
390
+ # Clean up: remove trailing prompt characters
391
+ cleaned = []
392
+ import re
393
+
394
+ prompt_re = re.compile(r"^[>❯$]\s*$")
395
+ for line in reversed(response_lines):
396
+ if prompt_re.match(line.strip()):
397
+ continue
398
+ cleaned.append(line)
399
+
400
+ cleaned.reverse()
401
+
402
+ text = "\n".join(cleaned).strip()
403
+ return text if text else raw_output.strip()
404
+
405
+ def _parse_usage_from_output(self, output: str) -> dict:
406
+ """Try to extract token usage from the pane output."""
407
+ import re
408
+
409
+ # Look for patterns like "Tokens: 1234/200000" or "usage: 1234"
410
+ patterns = [
411
+ r"[Tt]okens?:\s*(\d+)[/\s]+(\d+)",
412
+ r"[Uu]sage:\s*(\d+)",
413
+ r"input_tokens[\":\s]+(\d+)",
414
+ ]
415
+
416
+ for pattern in patterns:
417
+ match = re.search(pattern, output)
418
+ if match:
419
+ groups = match.groups()
420
+ used = int(groups[0])
421
+ total = int(groups[1]) if len(groups) > 1 else self._context_usage.total
422
+ return {"used": used, "total": total}
423
+
424
+ return {"used": 0, "total": self._context_usage.total}