tsugite-cli 0.3.3__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.
- tsugite/__init__.py +6 -0
- tsugite/agent_composition.py +163 -0
- tsugite/agent_inheritance.py +479 -0
- tsugite/agent_preparation.py +236 -0
- tsugite/agent_runner/__init__.py +45 -0
- tsugite/agent_runner/helpers.py +106 -0
- tsugite/agent_runner/history_integration.py +248 -0
- tsugite/agent_runner/metrics.py +100 -0
- tsugite/agent_runner/runner.py +1879 -0
- tsugite/agent_runner/validation.py +70 -0
- tsugite/agent_utils.py +167 -0
- tsugite/attachments/__init__.py +65 -0
- tsugite/attachments/auto_context.py +199 -0
- tsugite/attachments/base.py +34 -0
- tsugite/attachments/file.py +51 -0
- tsugite/attachments/inline.py +31 -0
- tsugite/attachments/storage.py +178 -0
- tsugite/attachments/url.py +59 -0
- tsugite/attachments/youtube.py +101 -0
- tsugite/benchmark/__init__.py +62 -0
- tsugite/benchmark/config.py +183 -0
- tsugite/benchmark/core.py +292 -0
- tsugite/benchmark/discovery.py +377 -0
- tsugite/benchmark/evaluators.py +671 -0
- tsugite/benchmark/execution.py +657 -0
- tsugite/benchmark/metrics.py +204 -0
- tsugite/benchmark/reports.py +420 -0
- tsugite/benchmark/utils.py +288 -0
- tsugite/builtin_agents/chat-assistant.md +53 -0
- tsugite/builtin_agents/default.md +140 -0
- tsugite/builtin_agents.py +5 -0
- tsugite/cache.py +195 -0
- tsugite/cli/__init__.py +1042 -0
- tsugite/cli/agents.py +148 -0
- tsugite/cli/attachments.py +193 -0
- tsugite/cli/benchmark.py +663 -0
- tsugite/cli/cache.py +113 -0
- tsugite/cli/config.py +272 -0
- tsugite/cli/helpers.py +534 -0
- tsugite/cli/history.py +193 -0
- tsugite/cli/init.py +387 -0
- tsugite/cli/mcp.py +193 -0
- tsugite/cli/tools.py +419 -0
- tsugite/config.py +204 -0
- tsugite/console.py +48 -0
- tsugite/constants.py +21 -0
- tsugite/core/__init__.py +19 -0
- tsugite/core/agent.py +774 -0
- tsugite/core/executor.py +300 -0
- tsugite/core/memory.py +67 -0
- tsugite/core/tools.py +271 -0
- tsugite/docker_cli.py +270 -0
- tsugite/events/__init__.py +55 -0
- tsugite/events/base.py +46 -0
- tsugite/events/bus.py +62 -0
- tsugite/events/events.py +224 -0
- tsugite/exceptions.py +40 -0
- tsugite/history/__init__.py +29 -0
- tsugite/history/index.py +210 -0
- tsugite/history/models.py +106 -0
- tsugite/history/storage.py +157 -0
- tsugite/mcp_client.py +219 -0
- tsugite/mcp_config.py +174 -0
- tsugite/md_agents.py +751 -0
- tsugite/models.py +257 -0
- tsugite/renderer.py +151 -0
- tsugite/shell_tool_config.py +265 -0
- tsugite/templates/assistant.md +14 -0
- tsugite/tools/__init__.py +265 -0
- tsugite/tools/agents.py +312 -0
- tsugite/tools/edit_strategies.py +393 -0
- tsugite/tools/fs.py +329 -0
- tsugite/tools/http.py +239 -0
- tsugite/tools/interactive.py +430 -0
- tsugite/tools/shell.py +129 -0
- tsugite/tools/shell_tools.py +214 -0
- tsugite/tools/tasks.py +339 -0
- tsugite/tsugite.py +7 -0
- tsugite/ui/__init__.py +46 -0
- tsugite/ui/base.py +638 -0
- tsugite/ui/chat.py +265 -0
- tsugite/ui/chat.tcss +92 -0
- tsugite/ui/chat_history.py +286 -0
- tsugite/ui/helpers.py +102 -0
- tsugite/ui/jsonl.py +125 -0
- tsugite/ui/live_template.py +529 -0
- tsugite/ui/plain.py +419 -0
- tsugite/ui/textual_chat.py +642 -0
- tsugite/ui/textual_handler.py +225 -0
- tsugite/ui/widgets/__init__.py +6 -0
- tsugite/ui/widgets/base_scroll_log.py +27 -0
- tsugite/ui/widgets/message_list.py +121 -0
- tsugite/ui/widgets/thought_log.py +80 -0
- tsugite/ui_context.py +90 -0
- tsugite/utils.py +367 -0
- tsugite/xdg.py +104 -0
- tsugite_cli-0.3.3.dist-info/METADATA +325 -0
- tsugite_cli-0.3.3.dist-info/RECORD +101 -0
- tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
- tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
- tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
tsugite/ui/chat.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Chat session management for interactive conversations with agents."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from tsugite.config import load_config
|
|
11
|
+
from tsugite.md_agents import parse_agent_file
|
|
12
|
+
from tsugite.ui import CustomUILogger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChatTurn(BaseModel):
|
|
16
|
+
"""Represents one turn in a conversation."""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(
|
|
19
|
+
extra="forbid",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
timestamp: datetime
|
|
23
|
+
user_message: str
|
|
24
|
+
agent_response: str
|
|
25
|
+
tool_calls: List[str] = Field(default_factory=list)
|
|
26
|
+
token_count: Optional[int] = None
|
|
27
|
+
cost: Optional[float] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ChatManager:
|
|
31
|
+
"""Manages chat sessions with conversation history."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
agent_path: Path,
|
|
36
|
+
model_override: Optional[str] = None,
|
|
37
|
+
max_history: int = 50,
|
|
38
|
+
custom_logger: Optional[CustomUILogger] = None,
|
|
39
|
+
stream: bool = False,
|
|
40
|
+
disable_history: bool = False,
|
|
41
|
+
resume_conversation_id: Optional[str] = None,
|
|
42
|
+
):
|
|
43
|
+
"""Initialize chat manager.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
agent_path: Path to agent markdown file
|
|
47
|
+
model_override: Override agent's default model
|
|
48
|
+
max_history: Maximum turns to keep in context
|
|
49
|
+
custom_logger: Optional custom logger for UI
|
|
50
|
+
stream: Whether to stream responses in real-time
|
|
51
|
+
disable_history: Disable conversation history persistence
|
|
52
|
+
resume_conversation_id: Optional conversation ID to resume (skips auto-generation)
|
|
53
|
+
"""
|
|
54
|
+
self.agent_path = agent_path
|
|
55
|
+
self.model_override = model_override
|
|
56
|
+
self.max_history = max_history
|
|
57
|
+
self.custom_logger = custom_logger
|
|
58
|
+
self.stream = stream
|
|
59
|
+
self.conversation_history: List[ChatTurn] = []
|
|
60
|
+
self.session_start = datetime.now()
|
|
61
|
+
|
|
62
|
+
# History support
|
|
63
|
+
self.conversation_id: Optional[str] = resume_conversation_id
|
|
64
|
+
config = load_config()
|
|
65
|
+
history_enabled = getattr(config, "history_enabled", True) and not disable_history
|
|
66
|
+
|
|
67
|
+
# Only create new conversation if not resuming
|
|
68
|
+
if history_enabled and not resume_conversation_id:
|
|
69
|
+
try:
|
|
70
|
+
from tsugite.ui.chat_history import start_conversation
|
|
71
|
+
|
|
72
|
+
agent = parse_agent_file(agent_path)
|
|
73
|
+
model = model_override or agent.config.model or "unknown"
|
|
74
|
+
|
|
75
|
+
self.conversation_id = start_conversation(
|
|
76
|
+
agent_name=agent.config.name or agent_path.stem,
|
|
77
|
+
model=model,
|
|
78
|
+
timestamp=self.session_start,
|
|
79
|
+
)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
# Don't fail if history can't be initialized
|
|
82
|
+
print(f"Warning: Failed to initialize conversation history: {e}")
|
|
83
|
+
self.conversation_id = None
|
|
84
|
+
|
|
85
|
+
def load_from_history(self, conversation_id: str, turns: List[Any]) -> None:
|
|
86
|
+
"""Load conversation history from JSONL storage.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
conversation_id: Conversation ID to resume
|
|
90
|
+
turns: List of Turn objects from history
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
RuntimeError: If loading fails
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
from tsugite.history import Turn
|
|
97
|
+
|
|
98
|
+
self.conversation_id = conversation_id
|
|
99
|
+
self.conversation_history = []
|
|
100
|
+
|
|
101
|
+
# Convert Turn objects from history to ChatTurn objects
|
|
102
|
+
for turn in turns:
|
|
103
|
+
if not isinstance(turn, Turn):
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
chat_turn = ChatTurn(
|
|
107
|
+
timestamp=turn.timestamp,
|
|
108
|
+
user_message=turn.user,
|
|
109
|
+
agent_response=turn.assistant,
|
|
110
|
+
tool_calls=turn.tools or [],
|
|
111
|
+
token_count=turn.tokens,
|
|
112
|
+
cost=turn.cost,
|
|
113
|
+
)
|
|
114
|
+
self.conversation_history.append(chat_turn)
|
|
115
|
+
|
|
116
|
+
# Update session_start to first turn's timestamp if available
|
|
117
|
+
if self.conversation_history:
|
|
118
|
+
self.session_start = self.conversation_history[0].timestamp
|
|
119
|
+
|
|
120
|
+
# Prune if history exceeds max_history
|
|
121
|
+
if len(self.conversation_history) > self.max_history:
|
|
122
|
+
self.conversation_history = self.conversation_history[-self.max_history :]
|
|
123
|
+
|
|
124
|
+
except Exception as e:
|
|
125
|
+
raise RuntimeError(f"Failed to load conversation from history: {e}")
|
|
126
|
+
|
|
127
|
+
def add_turn(
|
|
128
|
+
self,
|
|
129
|
+
user_message: str,
|
|
130
|
+
agent_response: str,
|
|
131
|
+
tool_calls: List[str] = None,
|
|
132
|
+
token_count: Optional[int] = None,
|
|
133
|
+
cost: Optional[float] = None,
|
|
134
|
+
execution_steps: Optional[list] = None,
|
|
135
|
+
messages: Optional[list] = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Add a turn to conversation history."""
|
|
138
|
+
turn = ChatTurn(
|
|
139
|
+
timestamp=datetime.now(),
|
|
140
|
+
user_message=user_message,
|
|
141
|
+
agent_response=agent_response,
|
|
142
|
+
tool_calls=tool_calls or [],
|
|
143
|
+
token_count=token_count,
|
|
144
|
+
cost=cost,
|
|
145
|
+
)
|
|
146
|
+
self.conversation_history.append(turn)
|
|
147
|
+
|
|
148
|
+
# Save to persistent history if enabled
|
|
149
|
+
if self.conversation_id:
|
|
150
|
+
try:
|
|
151
|
+
from tsugite.ui.chat_history import save_chat_turn
|
|
152
|
+
|
|
153
|
+
save_chat_turn(
|
|
154
|
+
conversation_id=self.conversation_id,
|
|
155
|
+
user_message=user_message,
|
|
156
|
+
agent_response=agent_response,
|
|
157
|
+
tool_calls=tool_calls or [],
|
|
158
|
+
token_count=token_count,
|
|
159
|
+
cost=cost,
|
|
160
|
+
timestamp=turn.timestamp,
|
|
161
|
+
execution_steps=execution_steps,
|
|
162
|
+
messages=messages,
|
|
163
|
+
)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
# Don't fail the turn if history save fails
|
|
166
|
+
print(f"Warning: Failed to save turn to history: {e}")
|
|
167
|
+
|
|
168
|
+
# Prune old history if needed
|
|
169
|
+
if len(self.conversation_history) > self.max_history:
|
|
170
|
+
self.conversation_history = self.conversation_history[-self.max_history :]
|
|
171
|
+
|
|
172
|
+
def run_turn(self, user_input: str) -> str:
|
|
173
|
+
"""Execute one chat turn with the agent.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
user_input: User's message
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Agent's response
|
|
180
|
+
"""
|
|
181
|
+
# Import here to avoid circular dependency
|
|
182
|
+
from tsugite.agent_runner import run_agent
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
result = run_agent(
|
|
186
|
+
agent_path=self.agent_path,
|
|
187
|
+
prompt=user_input,
|
|
188
|
+
model_override=self.model_override,
|
|
189
|
+
custom_logger=self.custom_logger,
|
|
190
|
+
context={"chat_history": self.conversation_history},
|
|
191
|
+
return_token_usage=True,
|
|
192
|
+
stream=self.stream,
|
|
193
|
+
force_text_mode=True, # Enable text mode for chat UI
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Handle tuple return (response, token_count, cost) or (response, token_count) or string return
|
|
197
|
+
response = None
|
|
198
|
+
token_count = None
|
|
199
|
+
cost = None
|
|
200
|
+
|
|
201
|
+
if isinstance(result, tuple):
|
|
202
|
+
if len(result) == 3:
|
|
203
|
+
response, token_count, cost = result
|
|
204
|
+
elif len(result) == 2:
|
|
205
|
+
response, token_count = result
|
|
206
|
+
else:
|
|
207
|
+
response = result[0]
|
|
208
|
+
else:
|
|
209
|
+
response = result
|
|
210
|
+
|
|
211
|
+
self.add_turn(user_input, response, token_count=token_count, cost=cost)
|
|
212
|
+
return response
|
|
213
|
+
|
|
214
|
+
except Exception as e:
|
|
215
|
+
error_msg = f"Error: {str(e)}"
|
|
216
|
+
self.add_turn(user_input, error_msg)
|
|
217
|
+
return error_msg
|
|
218
|
+
|
|
219
|
+
def clear_history(self) -> None:
|
|
220
|
+
"""Clear conversation history."""
|
|
221
|
+
self.conversation_history = []
|
|
222
|
+
|
|
223
|
+
def save_conversation(self, path: Path) -> None:
|
|
224
|
+
"""Save conversation to JSON file."""
|
|
225
|
+
agent = parse_agent_file(self.agent_path)
|
|
226
|
+
|
|
227
|
+
data = {
|
|
228
|
+
"agent": agent.config.name or str(self.agent_path),
|
|
229
|
+
"model": self.model_override or agent.config.model,
|
|
230
|
+
"created_at": self.session_start.isoformat(),
|
|
231
|
+
"turns": [turn.model_dump(mode="json") for turn in self.conversation_history],
|
|
232
|
+
"metadata": {
|
|
233
|
+
"total_turns": len(self.conversation_history),
|
|
234
|
+
"agent_path": str(self.agent_path),
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
path.write_text(json.dumps(data, indent=2))
|
|
239
|
+
|
|
240
|
+
def load_conversation(self, path: Path) -> None:
|
|
241
|
+
"""Load conversation from JSON file."""
|
|
242
|
+
data = json.loads(path.read_text())
|
|
243
|
+
|
|
244
|
+
self.conversation_history = []
|
|
245
|
+
|
|
246
|
+
for turn_data in data.get("turns", []):
|
|
247
|
+
turn = ChatTurn.model_validate(turn_data)
|
|
248
|
+
self.conversation_history.append(turn)
|
|
249
|
+
|
|
250
|
+
if "created_at" in data:
|
|
251
|
+
self.session_start = datetime.fromisoformat(data["created_at"])
|
|
252
|
+
|
|
253
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
254
|
+
"""Get session statistics."""
|
|
255
|
+
total_tokens = sum(turn.token_count for turn in self.conversation_history if turn.token_count)
|
|
256
|
+
total_cost = sum(turn.cost for turn in self.conversation_history if turn.cost)
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
"total_turns": len(self.conversation_history),
|
|
260
|
+
"total_tokens": total_tokens if total_tokens > 0 else None,
|
|
261
|
+
"total_cost": total_cost if total_cost > 0 else None,
|
|
262
|
+
"session_duration": (datetime.now() - self.session_start).total_seconds(),
|
|
263
|
+
"agent": str(self.agent_path),
|
|
264
|
+
"model": self.model_override or "default",
|
|
265
|
+
}
|
tsugite/ui/chat.tcss
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/* Textual CSS for Chat UI */
|
|
2
|
+
|
|
3
|
+
Screen {
|
|
4
|
+
background: $background;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
Header {
|
|
8
|
+
dock: top;
|
|
9
|
+
height: 3;
|
|
10
|
+
background: $primary-background;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* Top pane: Thought log showing execution summaries */
|
|
14
|
+
ThoughtLog {
|
|
15
|
+
height: 35%;
|
|
16
|
+
border: solid $accent;
|
|
17
|
+
padding: 1;
|
|
18
|
+
background: $surface;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
ThoughtLog:focus {
|
|
22
|
+
border: double $accent;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ThoughtLog > .scrollbar {
|
|
26
|
+
width: 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ThoughtLog > .scrollbar:focus {
|
|
30
|
+
width: 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
ThoughtEntry {
|
|
34
|
+
width: 100%;
|
|
35
|
+
height: auto;
|
|
36
|
+
margin-bottom: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Bottom pane: Clean chat conversation */
|
|
40
|
+
MessageList {
|
|
41
|
+
height: 1fr;
|
|
42
|
+
border: solid $primary;
|
|
43
|
+
padding: 1;
|
|
44
|
+
background: $surface;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
MessageList:focus {
|
|
48
|
+
border: double $primary;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
MessageList > .scrollbar {
|
|
52
|
+
width: 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
MessageList > .scrollbar:focus {
|
|
56
|
+
width: 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
Message {
|
|
60
|
+
width: 100%;
|
|
61
|
+
height: auto;
|
|
62
|
+
margin-bottom: 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.separator {
|
|
66
|
+
color: $text-muted;
|
|
67
|
+
height: 1;
|
|
68
|
+
margin: 1 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.status {
|
|
72
|
+
color: $warning;
|
|
73
|
+
height: auto;
|
|
74
|
+
margin: 1 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Input {
|
|
78
|
+
border: solid $success;
|
|
79
|
+
background: $surface;
|
|
80
|
+
height: 3;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
Input:focus {
|
|
84
|
+
border: double $success;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
Footer {
|
|
88
|
+
dock: bottom;
|
|
89
|
+
height: 1;
|
|
90
|
+
background: $primary-background;
|
|
91
|
+
color: $text-muted;
|
|
92
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Chat history integration helpers."""
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from tsugite.config import load_config
|
|
8
|
+
from tsugite.history import (
|
|
9
|
+
ConversationMetadata,
|
|
10
|
+
IndexEntry,
|
|
11
|
+
Turn,
|
|
12
|
+
generate_conversation_id,
|
|
13
|
+
load_conversation,
|
|
14
|
+
query_index,
|
|
15
|
+
save_turn_to_history,
|
|
16
|
+
update_index,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_machine_name() -> str:
|
|
21
|
+
"""Get machine name for conversation tracking.
|
|
22
|
+
|
|
23
|
+
Checks config for custom machine_name, falls back to hostname.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Machine name string
|
|
27
|
+
"""
|
|
28
|
+
config = load_config()
|
|
29
|
+
|
|
30
|
+
# Check config for override
|
|
31
|
+
if hasattr(config, "machine_name") and config.machine_name:
|
|
32
|
+
return config.machine_name
|
|
33
|
+
|
|
34
|
+
# Auto-detect from hostname
|
|
35
|
+
try:
|
|
36
|
+
return socket.gethostname()
|
|
37
|
+
except Exception:
|
|
38
|
+
return "unknown"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def start_conversation(
|
|
42
|
+
agent_name: str,
|
|
43
|
+
model: str,
|
|
44
|
+
timestamp: Optional[datetime] = None,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Start a new conversation and save metadata.
|
|
47
|
+
|
|
48
|
+
Creates conversation ID and saves initial metadata line to JSONL file.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
agent_name: Name of the agent
|
|
52
|
+
model: Model identifier (e.g., "openai:gpt-4o")
|
|
53
|
+
timestamp: Optional timestamp (defaults to now)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Conversation ID
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
RuntimeError: If save fails
|
|
60
|
+
"""
|
|
61
|
+
if timestamp is None:
|
|
62
|
+
timestamp = datetime.now(timezone.utc)
|
|
63
|
+
|
|
64
|
+
conversation_id = generate_conversation_id(agent_name, timestamp)
|
|
65
|
+
machine = get_machine_name()
|
|
66
|
+
|
|
67
|
+
# Create ConversationMetadata model
|
|
68
|
+
metadata = ConversationMetadata(
|
|
69
|
+
id=conversation_id,
|
|
70
|
+
agent=agent_name,
|
|
71
|
+
model=model,
|
|
72
|
+
machine=machine,
|
|
73
|
+
created_at=timestamp,
|
|
74
|
+
timestamp=timestamp,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
save_turn_to_history(conversation_id, metadata)
|
|
78
|
+
|
|
79
|
+
# Initialize index entry using IndexEntry model
|
|
80
|
+
index_metadata = IndexEntry(
|
|
81
|
+
agent=agent_name,
|
|
82
|
+
model=model,
|
|
83
|
+
machine=machine,
|
|
84
|
+
created_at=timestamp,
|
|
85
|
+
updated_at=timestamp,
|
|
86
|
+
turn_count=0,
|
|
87
|
+
total_tokens=0,
|
|
88
|
+
total_cost=0.0,
|
|
89
|
+
)
|
|
90
|
+
update_index(conversation_id, index_metadata)
|
|
91
|
+
|
|
92
|
+
return conversation_id
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def save_chat_turn(
|
|
96
|
+
conversation_id: str,
|
|
97
|
+
user_message: str,
|
|
98
|
+
agent_response: str,
|
|
99
|
+
tool_calls: List[str],
|
|
100
|
+
token_count: Optional[int] = None,
|
|
101
|
+
cost: Optional[float] = None,
|
|
102
|
+
timestamp: Optional[datetime] = None,
|
|
103
|
+
execution_steps: Optional[list] = None,
|
|
104
|
+
messages: Optional[list] = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Save a chat turn to history.
|
|
107
|
+
|
|
108
|
+
Appends turn to JSONL file and updates index.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
conversation_id: Conversation ID
|
|
112
|
+
user_message: User's message
|
|
113
|
+
agent_response: Agent's response
|
|
114
|
+
tool_calls: List of tool calls made
|
|
115
|
+
token_count: Number of tokens used
|
|
116
|
+
cost: Cost of the turn
|
|
117
|
+
timestamp: Optional timestamp (defaults to now)
|
|
118
|
+
execution_steps: Optional list of execution step objects (StepResult)
|
|
119
|
+
messages: Optional full LiteLLM message history
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
RuntimeError: If save fails
|
|
123
|
+
"""
|
|
124
|
+
if timestamp is None:
|
|
125
|
+
timestamp = datetime.now(timezone.utc)
|
|
126
|
+
|
|
127
|
+
# Convert execution_steps to dicts if provided
|
|
128
|
+
steps_dicts = None
|
|
129
|
+
if execution_steps:
|
|
130
|
+
steps_dicts = []
|
|
131
|
+
for step in execution_steps:
|
|
132
|
+
if hasattr(step, "__dict__"):
|
|
133
|
+
# Convert dataclass/object to dict
|
|
134
|
+
step_dict = {
|
|
135
|
+
"step_number": getattr(step, "step_number", None),
|
|
136
|
+
"thought": getattr(step, "thought", ""),
|
|
137
|
+
"code": getattr(step, "code", ""),
|
|
138
|
+
"output": getattr(step, "output", ""),
|
|
139
|
+
"error": getattr(step, "error", None),
|
|
140
|
+
"tools_called": getattr(step, "tools_called", []),
|
|
141
|
+
}
|
|
142
|
+
steps_dicts.append(step_dict)
|
|
143
|
+
elif isinstance(step, dict):
|
|
144
|
+
steps_dicts.append(step)
|
|
145
|
+
|
|
146
|
+
# Create Turn model
|
|
147
|
+
turn = Turn(
|
|
148
|
+
timestamp=timestamp,
|
|
149
|
+
user=user_message,
|
|
150
|
+
assistant=agent_response,
|
|
151
|
+
tools=tool_calls,
|
|
152
|
+
tokens=token_count,
|
|
153
|
+
cost=cost,
|
|
154
|
+
steps=steps_dicts,
|
|
155
|
+
messages=messages,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
save_turn_to_history(conversation_id, turn)
|
|
159
|
+
|
|
160
|
+
# Update index with cumulative stats
|
|
161
|
+
from tsugite.history import get_conversation_metadata
|
|
162
|
+
|
|
163
|
+
metadata = get_conversation_metadata(conversation_id)
|
|
164
|
+
|
|
165
|
+
if metadata:
|
|
166
|
+
# metadata is always IndexEntry
|
|
167
|
+
updated_metadata = IndexEntry(
|
|
168
|
+
agent=metadata.agent,
|
|
169
|
+
model=metadata.model,
|
|
170
|
+
machine=metadata.machine,
|
|
171
|
+
created_at=metadata.created_at,
|
|
172
|
+
updated_at=timestamp,
|
|
173
|
+
turn_count=metadata.turn_count + 1,
|
|
174
|
+
total_tokens=(metadata.total_tokens or 0) + (token_count or 0),
|
|
175
|
+
total_cost=(metadata.total_cost or 0.0) + (cost or 0.0),
|
|
176
|
+
)
|
|
177
|
+
update_index(conversation_id, updated_metadata)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def format_conversation_for_display(turns: List[Union[ConversationMetadata, Turn]]) -> str:
|
|
181
|
+
"""Format conversation turns for display.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
turns: List of ConversationMetadata/Turn models from load_conversation()
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Formatted string for display
|
|
188
|
+
"""
|
|
189
|
+
lines = []
|
|
190
|
+
|
|
191
|
+
for turn in turns:
|
|
192
|
+
if isinstance(turn, ConversationMetadata):
|
|
193
|
+
# Header from ConversationMetadata model
|
|
194
|
+
lines.append("=" * 60)
|
|
195
|
+
lines.append(f"Conversation: {turn.id}")
|
|
196
|
+
lines.append(f"Agent: {turn.agent}")
|
|
197
|
+
lines.append(f"Model: {turn.model}")
|
|
198
|
+
lines.append(f"Machine: {turn.machine}")
|
|
199
|
+
lines.append(f"Created: {turn.created_at}")
|
|
200
|
+
lines.append("=" * 60)
|
|
201
|
+
lines.append("")
|
|
202
|
+
|
|
203
|
+
elif isinstance(turn, Turn):
|
|
204
|
+
# Turn from Turn model
|
|
205
|
+
lines.append(f"[{turn.timestamp}]")
|
|
206
|
+
lines.append(f"User: {turn.user}")
|
|
207
|
+
lines.append("")
|
|
208
|
+
lines.append(f"Assistant: {turn.assistant}")
|
|
209
|
+
|
|
210
|
+
if turn.tools:
|
|
211
|
+
lines.append(f" Tools: {', '.join(turn.tools)}")
|
|
212
|
+
|
|
213
|
+
# Display execution steps if available
|
|
214
|
+
if turn.steps:
|
|
215
|
+
lines.append("")
|
|
216
|
+
lines.append(" Execution Steps:")
|
|
217
|
+
for step in turn.steps:
|
|
218
|
+
step_num = step.get("step_number", "?")
|
|
219
|
+
thought = step.get("thought", "").strip()
|
|
220
|
+
code = step.get("code", "").strip()
|
|
221
|
+
output = step.get("output", "").strip()
|
|
222
|
+
error = step.get("error")
|
|
223
|
+
tools_called = step.get("tools_called", [])
|
|
224
|
+
|
|
225
|
+
lines.append(f" Step {step_num}:")
|
|
226
|
+
if thought:
|
|
227
|
+
lines.append(f" Thought: {thought[:100]}{'...' if len(thought) > 100 else ''}")
|
|
228
|
+
if tools_called:
|
|
229
|
+
lines.append(f" Tools: {', '.join(tools_called)}")
|
|
230
|
+
if code:
|
|
231
|
+
# Show first few lines of code
|
|
232
|
+
code_lines = code.split("\n")
|
|
233
|
+
if len(code_lines) <= 3:
|
|
234
|
+
lines.append(f" Code: {code}")
|
|
235
|
+
else:
|
|
236
|
+
lines.append(f" Code: {code_lines[0]}")
|
|
237
|
+
lines.append(f" ... ({len(code_lines) - 1} more lines)")
|
|
238
|
+
if output:
|
|
239
|
+
output_preview = output[:150].replace("\n", " ")
|
|
240
|
+
lines.append(f" Output: {output_preview}{'...' if len(output) > 150 else ''}")
|
|
241
|
+
if error:
|
|
242
|
+
lines.append(f" Error: {error}")
|
|
243
|
+
|
|
244
|
+
lines.append(f" Tokens: {turn.tokens or 0} | Cost: ${turn.cost or 0.0:.4f}")
|
|
245
|
+
lines.append("")
|
|
246
|
+
lines.append("-" * 60)
|
|
247
|
+
lines.append("")
|
|
248
|
+
|
|
249
|
+
return "\n".join(lines)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def get_latest_conversation() -> Optional[str]:
|
|
253
|
+
"""Get the most recent conversation ID.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Conversation ID of the most recent conversation, or None if no conversations exist
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
RuntimeError: If query fails
|
|
260
|
+
"""
|
|
261
|
+
try:
|
|
262
|
+
results = query_index(limit=1)
|
|
263
|
+
if results:
|
|
264
|
+
return results[0].get("conversation_id")
|
|
265
|
+
return None
|
|
266
|
+
except Exception as e:
|
|
267
|
+
raise RuntimeError(f"Failed to query conversation index: {e}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def load_conversation_history(conversation_id: str) -> List[Turn]:
|
|
271
|
+
"""Load conversation history turns (without metadata).
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
conversation_id: Conversation ID to load
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
List of Turn objects (excludes ConversationMetadata)
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
FileNotFoundError: If conversation doesn't exist
|
|
281
|
+
RuntimeError: If load fails
|
|
282
|
+
"""
|
|
283
|
+
records = load_conversation(conversation_id)
|
|
284
|
+
|
|
285
|
+
# Filter out metadata, return only Turn objects
|
|
286
|
+
return [record for record in records if isinstance(record, Turn)]
|