nc1709 1.15.4__py3-none-any.whl → 1.18.8__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.
- nc1709/__init__.py +1 -1
- nc1709/agent/core.py +172 -19
- nc1709/agent/permissions.py +2 -2
- nc1709/agent/tools/bash_tool.py +295 -8
- nc1709/cli.py +435 -19
- nc1709/cli_ui.py +137 -52
- nc1709/conversation_logger.py +416 -0
- nc1709/llm_adapter.py +62 -4
- nc1709/plugins/agents/database_agent.py +695 -0
- nc1709/plugins/agents/django_agent.py +11 -4
- nc1709/plugins/agents/docker_agent.py +11 -4
- nc1709/plugins/agents/fastapi_agent.py +11 -4
- nc1709/plugins/agents/git_agent.py +11 -4
- nc1709/plugins/agents/nextjs_agent.py +11 -4
- nc1709/plugins/agents/ollama_agent.py +574 -0
- nc1709/plugins/agents/test_agent.py +702 -0
- nc1709/prompts/unified_prompt.py +156 -14
- nc1709/requirements_tracker.py +526 -0
- nc1709/thinking_messages.py +337 -0
- nc1709/version_check.py +6 -2
- nc1709/web/server.py +63 -3
- nc1709/web/templates/index.html +819 -140
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/METADATA +10 -7
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/RECORD +28 -22
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/WHEEL +0 -0
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/entry_points.txt +0 -0
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/licenses/LICENSE +0 -0
- {nc1709-1.15.4.dist-info → nc1709-1.18.8.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversation Logger for NC1709
|
|
3
|
+
Logs all conversations per session with user tracking.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Dict, Any, List
|
|
11
|
+
from dataclasses import dataclass, field, asdict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ConversationEntry:
|
|
16
|
+
"""A single entry in a conversation"""
|
|
17
|
+
timestamp: str
|
|
18
|
+
role: str # 'user', 'assistant', 'tool', 'system', 'error'
|
|
19
|
+
content: str
|
|
20
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> Dict:
|
|
23
|
+
return asdict(self)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, data: Dict) -> "ConversationEntry":
|
|
27
|
+
return cls(**data)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SessionInfo:
|
|
32
|
+
"""Information about a session"""
|
|
33
|
+
session_id: str
|
|
34
|
+
started_at: str
|
|
35
|
+
ip_address: Optional[str] = None
|
|
36
|
+
user_agent: Optional[str] = None
|
|
37
|
+
user_id: Optional[str] = None
|
|
38
|
+
working_directory: Optional[str] = None
|
|
39
|
+
mode: str = "remote" # 'remote', 'local', 'agent'
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> Dict:
|
|
42
|
+
return asdict(self)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, data: Dict) -> "SessionInfo":
|
|
46
|
+
return cls(**data)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConversationLogger:
|
|
50
|
+
"""
|
|
51
|
+
Logs conversations to files per session.
|
|
52
|
+
|
|
53
|
+
Features:
|
|
54
|
+
- Per-session log files
|
|
55
|
+
- IP address tracking (for remote mode)
|
|
56
|
+
- User agent tracking
|
|
57
|
+
- Tool call logging
|
|
58
|
+
- Error logging
|
|
59
|
+
- JSON format for easy parsing
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# Default log directory
|
|
63
|
+
LOG_DIR = "logs"
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
base_dir: Optional[Path] = None,
|
|
68
|
+
session_id: Optional[str] = None,
|
|
69
|
+
ip_address: Optional[str] = None,
|
|
70
|
+
user_agent: Optional[str] = None,
|
|
71
|
+
user_id: Optional[str] = None,
|
|
72
|
+
mode: str = "remote"
|
|
73
|
+
):
|
|
74
|
+
"""
|
|
75
|
+
Initialize the conversation logger.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
base_dir: Base directory for logs (defaults to ~/.nc1709)
|
|
79
|
+
session_id: Unique session ID (auto-generated if not provided)
|
|
80
|
+
ip_address: Client IP address (for remote mode)
|
|
81
|
+
user_agent: Client user agent string
|
|
82
|
+
user_id: Optional user identifier
|
|
83
|
+
mode: Operation mode (remote, local, agent)
|
|
84
|
+
"""
|
|
85
|
+
# Determine base directory
|
|
86
|
+
if base_dir:
|
|
87
|
+
self.base_dir = Path(base_dir)
|
|
88
|
+
else:
|
|
89
|
+
self.base_dir = Path.home() / ".nc1709"
|
|
90
|
+
|
|
91
|
+
self.log_dir = self.base_dir / self.LOG_DIR
|
|
92
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
# Session info
|
|
95
|
+
self.session_id = session_id or self._generate_session_id()
|
|
96
|
+
self.session_info = SessionInfo(
|
|
97
|
+
session_id=self.session_id,
|
|
98
|
+
started_at=datetime.now().isoformat(),
|
|
99
|
+
ip_address=ip_address,
|
|
100
|
+
user_agent=user_agent,
|
|
101
|
+
user_id=user_id,
|
|
102
|
+
working_directory=str(Path.cwd()),
|
|
103
|
+
mode=mode
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Conversation entries
|
|
107
|
+
self.entries: List[ConversationEntry] = []
|
|
108
|
+
|
|
109
|
+
# Log file path
|
|
110
|
+
self.log_file = self._get_log_file_path()
|
|
111
|
+
|
|
112
|
+
# Initialize log file with session info
|
|
113
|
+
self._init_log_file()
|
|
114
|
+
|
|
115
|
+
def _generate_session_id(self) -> str:
|
|
116
|
+
"""Generate a unique session ID"""
|
|
117
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
118
|
+
unique = uuid.uuid4().hex[:8]
|
|
119
|
+
return f"{timestamp}_{unique}"
|
|
120
|
+
|
|
121
|
+
def _get_log_file_path(self) -> Path:
|
|
122
|
+
"""Get the log file path for this session"""
|
|
123
|
+
# Organize by date
|
|
124
|
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
125
|
+
date_dir = self.log_dir / date_str
|
|
126
|
+
date_dir.mkdir(exist_ok=True)
|
|
127
|
+
|
|
128
|
+
return date_dir / f"session_{self.session_id}.json"
|
|
129
|
+
|
|
130
|
+
def _init_log_file(self) -> None:
|
|
131
|
+
"""Initialize the log file with session info"""
|
|
132
|
+
data = {
|
|
133
|
+
"session": self.session_info.to_dict(),
|
|
134
|
+
"entries": []
|
|
135
|
+
}
|
|
136
|
+
self._write_log(data)
|
|
137
|
+
|
|
138
|
+
def _write_log(self, data: Dict) -> None:
|
|
139
|
+
"""Write data to log file"""
|
|
140
|
+
try:
|
|
141
|
+
self.log_file.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
142
|
+
except Exception as e:
|
|
143
|
+
# Silently fail - logging shouldn't break the app
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
def _append_entry(self, entry: ConversationEntry) -> None:
|
|
147
|
+
"""Append an entry to the log file"""
|
|
148
|
+
self.entries.append(entry)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
# Read current log
|
|
152
|
+
if self.log_file.exists():
|
|
153
|
+
data = json.loads(self.log_file.read_text())
|
|
154
|
+
else:
|
|
155
|
+
data = {"session": self.session_info.to_dict(), "entries": []}
|
|
156
|
+
|
|
157
|
+
# Append entry
|
|
158
|
+
data["entries"].append(entry.to_dict())
|
|
159
|
+
|
|
160
|
+
# Write back
|
|
161
|
+
self._write_log(data)
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
def log_user_message(self, message: str, metadata: Optional[Dict] = None) -> None:
|
|
166
|
+
"""Log a user message"""
|
|
167
|
+
entry = ConversationEntry(
|
|
168
|
+
timestamp=datetime.now().isoformat(),
|
|
169
|
+
role="user",
|
|
170
|
+
content=message,
|
|
171
|
+
metadata=metadata or {}
|
|
172
|
+
)
|
|
173
|
+
self._append_entry(entry)
|
|
174
|
+
|
|
175
|
+
def log_assistant_message(self, message: str, metadata: Optional[Dict] = None) -> None:
|
|
176
|
+
"""Log an assistant response"""
|
|
177
|
+
entry = ConversationEntry(
|
|
178
|
+
timestamp=datetime.now().isoformat(),
|
|
179
|
+
role="assistant",
|
|
180
|
+
content=message,
|
|
181
|
+
metadata=metadata or {}
|
|
182
|
+
)
|
|
183
|
+
self._append_entry(entry)
|
|
184
|
+
|
|
185
|
+
def log_tool_call(
|
|
186
|
+
self,
|
|
187
|
+
tool_name: str,
|
|
188
|
+
parameters: Dict,
|
|
189
|
+
result: Optional[str] = None,
|
|
190
|
+
success: bool = True,
|
|
191
|
+
duration_ms: Optional[int] = None
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Log a tool call"""
|
|
194
|
+
entry = ConversationEntry(
|
|
195
|
+
timestamp=datetime.now().isoformat(),
|
|
196
|
+
role="tool",
|
|
197
|
+
content=result or "",
|
|
198
|
+
metadata={
|
|
199
|
+
"tool_name": tool_name,
|
|
200
|
+
"parameters": parameters,
|
|
201
|
+
"success": success,
|
|
202
|
+
"duration_ms": duration_ms
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
self._append_entry(entry)
|
|
206
|
+
|
|
207
|
+
def log_error(self, error: str, context: Optional[Dict] = None) -> None:
|
|
208
|
+
"""Log an error"""
|
|
209
|
+
entry = ConversationEntry(
|
|
210
|
+
timestamp=datetime.now().isoformat(),
|
|
211
|
+
role="error",
|
|
212
|
+
content=error,
|
|
213
|
+
metadata=context or {}
|
|
214
|
+
)
|
|
215
|
+
self._append_entry(entry)
|
|
216
|
+
|
|
217
|
+
def log_system(self, message: str, metadata: Optional[Dict] = None) -> None:
|
|
218
|
+
"""Log a system message"""
|
|
219
|
+
entry = ConversationEntry(
|
|
220
|
+
timestamp=datetime.now().isoformat(),
|
|
221
|
+
role="system",
|
|
222
|
+
content=message,
|
|
223
|
+
metadata=metadata or {}
|
|
224
|
+
)
|
|
225
|
+
self._append_entry(entry)
|
|
226
|
+
|
|
227
|
+
def update_session_info(self, **kwargs) -> None:
|
|
228
|
+
"""Update session info (e.g., when IP becomes available)"""
|
|
229
|
+
for key, value in kwargs.items():
|
|
230
|
+
if hasattr(self.session_info, key):
|
|
231
|
+
setattr(self.session_info, key, value)
|
|
232
|
+
|
|
233
|
+
# Update log file
|
|
234
|
+
try:
|
|
235
|
+
if self.log_file.exists():
|
|
236
|
+
data = json.loads(self.log_file.read_text())
|
|
237
|
+
data["session"] = self.session_info.to_dict()
|
|
238
|
+
self._write_log(data)
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
def get_session_summary(self) -> Dict:
|
|
243
|
+
"""Get a summary of the current session"""
|
|
244
|
+
user_messages = sum(1 for e in self.entries if e.role == "user")
|
|
245
|
+
assistant_messages = sum(1 for e in self.entries if e.role == "assistant")
|
|
246
|
+
tool_calls = sum(1 for e in self.entries if e.role == "tool")
|
|
247
|
+
errors = sum(1 for e in self.entries if e.role == "error")
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
"session_id": self.session_id,
|
|
251
|
+
"started_at": self.session_info.started_at,
|
|
252
|
+
"ip_address": self.session_info.ip_address,
|
|
253
|
+
"user_messages": user_messages,
|
|
254
|
+
"assistant_messages": assistant_messages,
|
|
255
|
+
"tool_calls": tool_calls,
|
|
256
|
+
"errors": errors,
|
|
257
|
+
"total_entries": len(self.entries)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def list_sessions(
|
|
262
|
+
cls,
|
|
263
|
+
base_dir: Optional[Path] = None,
|
|
264
|
+
date: Optional[str] = None,
|
|
265
|
+
limit: int = 20
|
|
266
|
+
) -> List[Dict]:
|
|
267
|
+
"""
|
|
268
|
+
List recent sessions.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
base_dir: Base directory for logs
|
|
272
|
+
date: Filter by date (YYYY-MM-DD format)
|
|
273
|
+
limit: Maximum number of sessions to return
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
List of session summaries
|
|
277
|
+
"""
|
|
278
|
+
if base_dir:
|
|
279
|
+
log_dir = Path(base_dir) / cls.LOG_DIR
|
|
280
|
+
else:
|
|
281
|
+
log_dir = Path.home() / ".nc1709" / cls.LOG_DIR
|
|
282
|
+
|
|
283
|
+
if not log_dir.exists():
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
sessions = []
|
|
287
|
+
|
|
288
|
+
# Get date directories
|
|
289
|
+
if date:
|
|
290
|
+
date_dirs = [log_dir / date] if (log_dir / date).exists() else []
|
|
291
|
+
else:
|
|
292
|
+
date_dirs = sorted(log_dir.iterdir(), reverse=True)
|
|
293
|
+
|
|
294
|
+
for date_dir in date_dirs:
|
|
295
|
+
if not date_dir.is_dir():
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
for log_file in sorted(date_dir.glob("session_*.json"), reverse=True):
|
|
299
|
+
try:
|
|
300
|
+
data = json.loads(log_file.read_text())
|
|
301
|
+
session = data.get("session", {})
|
|
302
|
+
entries = data.get("entries", [])
|
|
303
|
+
|
|
304
|
+
sessions.append({
|
|
305
|
+
"session_id": session.get("session_id"),
|
|
306
|
+
"started_at": session.get("started_at"),
|
|
307
|
+
"ip_address": session.get("ip_address"),
|
|
308
|
+
"mode": session.get("mode"),
|
|
309
|
+
"entry_count": len(entries),
|
|
310
|
+
"file": str(log_file)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
if len(sessions) >= limit:
|
|
314
|
+
return sessions
|
|
315
|
+
except Exception:
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
return sessions
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def load_session(cls, session_id: str, base_dir: Optional[Path] = None) -> Optional[Dict]:
|
|
322
|
+
"""
|
|
323
|
+
Load a specific session by ID.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
session_id: The session ID to load
|
|
327
|
+
base_dir: Base directory for logs
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Full session data or None if not found
|
|
331
|
+
"""
|
|
332
|
+
if base_dir:
|
|
333
|
+
log_dir = Path(base_dir) / cls.LOG_DIR
|
|
334
|
+
else:
|
|
335
|
+
log_dir = Path.home() / ".nc1709" / cls.LOG_DIR
|
|
336
|
+
|
|
337
|
+
if not log_dir.exists():
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
# Search all date directories
|
|
341
|
+
for date_dir in log_dir.iterdir():
|
|
342
|
+
if not date_dir.is_dir():
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
log_file = date_dir / f"session_{session_id}.json"
|
|
346
|
+
if log_file.exists():
|
|
347
|
+
try:
|
|
348
|
+
return json.loads(log_file.read_text())
|
|
349
|
+
except Exception:
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# Global logger instance (initialized per session)
|
|
356
|
+
_current_logger: Optional[ConversationLogger] = None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def get_logger() -> Optional[ConversationLogger]:
|
|
360
|
+
"""Get the current conversation logger"""
|
|
361
|
+
return _current_logger
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def init_logger(
|
|
365
|
+
session_id: Optional[str] = None,
|
|
366
|
+
ip_address: Optional[str] = None,
|
|
367
|
+
user_agent: Optional[str] = None,
|
|
368
|
+
user_id: Optional[str] = None,
|
|
369
|
+
mode: str = "remote"
|
|
370
|
+
) -> ConversationLogger:
|
|
371
|
+
"""Initialize a new conversation logger for this session"""
|
|
372
|
+
global _current_logger
|
|
373
|
+
_current_logger = ConversationLogger(
|
|
374
|
+
session_id=session_id,
|
|
375
|
+
ip_address=ip_address,
|
|
376
|
+
user_agent=user_agent,
|
|
377
|
+
user_id=user_id,
|
|
378
|
+
mode=mode
|
|
379
|
+
)
|
|
380
|
+
return _current_logger
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def log_user(message: str, metadata: Optional[Dict] = None) -> None:
|
|
384
|
+
"""Convenience function to log user message"""
|
|
385
|
+
if _current_logger:
|
|
386
|
+
_current_logger.log_user_message(message, metadata)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def log_assistant(message: str, metadata: Optional[Dict] = None) -> None:
|
|
390
|
+
"""Convenience function to log assistant message"""
|
|
391
|
+
if _current_logger:
|
|
392
|
+
_current_logger.log_assistant_message(message, metadata)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def log_tool(
|
|
396
|
+
tool_name: str,
|
|
397
|
+
parameters: Dict,
|
|
398
|
+
result: Optional[str] = None,
|
|
399
|
+
success: bool = True,
|
|
400
|
+
duration_ms: Optional[int] = None
|
|
401
|
+
) -> None:
|
|
402
|
+
"""Convenience function to log tool call"""
|
|
403
|
+
if _current_logger:
|
|
404
|
+
_current_logger.log_tool_call(tool_name, parameters, result, success, duration_ms)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def log_error(error: str, context: Optional[Dict] = None) -> None:
|
|
408
|
+
"""Convenience function to log error"""
|
|
409
|
+
if _current_logger:
|
|
410
|
+
_current_logger.log_error(error, context)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def log_system(message: str, metadata: Optional[Dict] = None) -> None:
|
|
414
|
+
"""Convenience function to log system message"""
|
|
415
|
+
if _current_logger:
|
|
416
|
+
_current_logger.log_system(message, metadata)
|
nc1709/llm_adapter.py
CHANGED
|
@@ -273,13 +273,45 @@ class LLMAdapter:
|
|
|
273
273
|
|
|
274
274
|
except Exception as e:
|
|
275
275
|
last_error = e
|
|
276
|
+
error_str = str(e).lower()
|
|
277
|
+
|
|
278
|
+
# Identify recoverable vs non-recoverable errors
|
|
279
|
+
is_network_error = any(x in error_str for x in [
|
|
280
|
+
"connection", "timeout", "refused", "reset", "network",
|
|
281
|
+
"503", "502", "504", "429", "rate limit", "overloaded"
|
|
282
|
+
])
|
|
283
|
+
|
|
276
284
|
if attempt < max_retries - 1:
|
|
277
285
|
wait_time = 2 ** attempt # Exponential backoff: 1, 2, 4 seconds
|
|
278
|
-
|
|
279
|
-
|
|
286
|
+
|
|
287
|
+
if is_network_error:
|
|
288
|
+
print(f"⚠️ Network error (attempt {attempt + 1}/{max_retries}): {e}")
|
|
289
|
+
print(f" This may be a temporary issue. Retrying in {wait_time} seconds...")
|
|
290
|
+
else:
|
|
291
|
+
print(f"⚠️ LLM request failed (attempt {attempt + 1}/{max_retries}): {e}")
|
|
292
|
+
print(f" Retrying in {wait_time} seconds...")
|
|
293
|
+
|
|
280
294
|
time.sleep(wait_time)
|
|
281
295
|
continue
|
|
282
296
|
|
|
297
|
+
# Provide helpful error message based on error type
|
|
298
|
+
error_str = str(last_error).lower()
|
|
299
|
+
if "connection refused" in error_str or "cannot connect" in error_str:
|
|
300
|
+
raise RuntimeError(
|
|
301
|
+
f"Cannot connect to Ollama at {api_base}. "
|
|
302
|
+
f"Please ensure Ollama is running: 'ollama serve'"
|
|
303
|
+
)
|
|
304
|
+
elif "timeout" in error_str:
|
|
305
|
+
raise RuntimeError(
|
|
306
|
+
f"Request timed out after {max_retries} attempts. "
|
|
307
|
+
f"The model may be loading or the server is overloaded."
|
|
308
|
+
)
|
|
309
|
+
elif "429" in error_str or "rate limit" in error_str:
|
|
310
|
+
raise RuntimeError(
|
|
311
|
+
f"Rate limited after {max_retries} attempts. "
|
|
312
|
+
f"Please wait a moment before trying again."
|
|
313
|
+
)
|
|
314
|
+
|
|
283
315
|
raise RuntimeError(f"LLM completion failed after {max_retries} attempts: {last_error}")
|
|
284
316
|
|
|
285
317
|
def _stream_completion(
|
|
@@ -416,13 +448,39 @@ class LLMAdapter:
|
|
|
416
448
|
|
|
417
449
|
except Exception as e:
|
|
418
450
|
last_error = e
|
|
451
|
+
error_str = str(e).lower()
|
|
452
|
+
|
|
453
|
+
# Identify recoverable errors
|
|
454
|
+
is_recoverable = any(x in error_str for x in [
|
|
455
|
+
"connection", "timeout", "refused", "reset", "network",
|
|
456
|
+
"503", "502", "504", "429", "rate limit", "overloaded"
|
|
457
|
+
])
|
|
458
|
+
|
|
419
459
|
if attempt < max_retries - 1:
|
|
420
460
|
wait_time = 2 ** attempt
|
|
421
|
-
|
|
422
|
-
|
|
461
|
+
|
|
462
|
+
if is_recoverable:
|
|
463
|
+
print(f"⚠️ Network error (attempt {attempt + 1}/{max_retries}): {e}")
|
|
464
|
+
print(f" Retrying in {wait_time} seconds...")
|
|
465
|
+
else:
|
|
466
|
+
print(f"⚠️ LLM request failed (attempt {attempt + 1}/{max_retries}): {e}")
|
|
467
|
+
print(f" Retrying in {wait_time} seconds...")
|
|
468
|
+
|
|
423
469
|
time.sleep(wait_time)
|
|
424
470
|
continue
|
|
425
471
|
|
|
472
|
+
# Provide helpful error message
|
|
473
|
+
error_str = str(last_error).lower()
|
|
474
|
+
if "connection refused" in error_str or "cannot connect" in error_str:
|
|
475
|
+
raise RuntimeError(
|
|
476
|
+
f"Cannot connect to Ollama at {api_base}. "
|
|
477
|
+
f"Please ensure Ollama is running: 'ollama serve'"
|
|
478
|
+
)
|
|
479
|
+
elif "timeout" in error_str:
|
|
480
|
+
raise RuntimeError(
|
|
481
|
+
f"Request timed out. The model may be loading or overloaded."
|
|
482
|
+
)
|
|
483
|
+
|
|
426
484
|
raise RuntimeError(f"LLM chat failed after {max_retries} attempts: {last_error}")
|
|
427
485
|
|
|
428
486
|
def clear_history(self) -> None:
|