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 +3 -0
- trinity/__main__.py +6 -0
- trinity/agents/__init__.py +0 -0
- trinity/agents/base.py +67 -0
- trinity/agents/claude_agent.py +424 -0
- trinity/agents/codex_agent.py +201 -0
- trinity/agents/factory.py +126 -0
- trinity/agents/gemini_agent.py +204 -0
- trinity/cli.py +401 -0
- trinity/completion/__init__.py +0 -0
- trinity/completion/base.py +139 -0
- trinity/completion/hook.py +115 -0
- trinity/completion/idle.py +82 -0
- trinity/completion/prompt.py +88 -0
- trinity/config.py +215 -0
- trinity/context/__init__.py +0 -0
- trinity/context/monitor.py +139 -0
- trinity/context/rotator.py +153 -0
- trinity/context/shared.py +211 -0
- trinity/deliberation/__init__.py +0 -0
- trinity/deliberation/consensus.py +171 -0
- trinity/deliberation/distributor.py +113 -0
- trinity/deliberation/protocol.py +218 -0
- trinity/error_handler.py +252 -0
- trinity/health/__init__.py +0 -0
- trinity/health/checker.py +154 -0
- trinity/logging.py +66 -0
- trinity/models.py +159 -0
- trinity/orchestrator.py +227 -0
- trinity/retry.py +175 -0
- trinity/tmux/__init__.py +0 -0
- trinity/tmux/pane.py +134 -0
- trinity/tmux/session.py +158 -0
- trinity/workspace/__init__.py +1 -0
- trinity/workspace/isolation.py +207 -0
- trinity/workspace/managed_home.py +205 -0
- trinity_agent-0.1.0.dist-info/METADATA +145 -0
- trinity_agent-0.1.0.dist-info/RECORD +41 -0
- trinity_agent-0.1.0.dist-info/WHEEL +4 -0
- trinity_agent-0.1.0.dist-info/entry_points.txt +2 -0
- trinity_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
trinity/__init__.py
ADDED
trinity/__main__.py
ADDED
|
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}
|