sudosu 0.1.5__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.
sudosu/core/session.py ADDED
@@ -0,0 +1,205 @@
1
+ """Session management for conversation continuity with SHARED thread model.
2
+
3
+ This module tracks conversation sessions to enable memory continuity
4
+ with the backend. All agents in a session share the SAME thread_id,
5
+ enabling seamless handoffs where sub-agents see the full conversation
6
+ context.
7
+
8
+ Key concepts:
9
+ - session_id: Unique ID for this CLI instance
10
+ - thread_id: SAME as session_id (shared across all agents)
11
+ - active_agent: The agent currently handling the conversation
12
+ """
13
+
14
+ import time
15
+ import uuid
16
+ from dataclasses import dataclass, field
17
+ from typing import Optional
18
+
19
+
20
+ @dataclass
21
+ class ConversationSession:
22
+ """Tracks a SHARED conversation session across all agents."""
23
+
24
+ session_id: str
25
+ thread_id: str
26
+ created_at: float = field(default_factory=time.time)
27
+ last_activity: float = field(default_factory=time.time)
28
+ message_count: int = 0
29
+ active_agent: str = "sudosu"
30
+ is_routed: bool = False
31
+
32
+ def touch(self):
33
+ """Update last activity and increment message count."""
34
+ self.last_activity = time.time()
35
+ self.message_count += 1
36
+
37
+ @property
38
+ def duration_seconds(self) -> float:
39
+ """Get the duration of this conversation in seconds."""
40
+ return self.last_activity - self.created_at
41
+
42
+ @property
43
+ def is_active(self) -> bool:
44
+ """Check if conversation has been active recently (within 30 minutes)."""
45
+ return (time.time() - self.last_activity) < 1800
46
+
47
+
48
+ class SessionManager:
49
+ """Manages conversation sessions with SHARED thread model.
50
+
51
+ Key concepts:
52
+ - session_id: Unique ID for this CLI instance
53
+ - thread_id: SAME as session_id (shared across all agents)
54
+ - active_agent: The agent currently handling the conversation
55
+
56
+ All agents share the same conversation thread, enabling seamless
57
+ handoffs where sub-agents see the full context of what was discussed
58
+ with the orchestrator.
59
+ """
60
+
61
+ def __init__(self):
62
+ """Initialize session manager with unique session ID."""
63
+ # Unique ID for this CLI session
64
+ self.session_id = str(uuid.uuid4())
65
+ # SHARED THREAD: Single thread for the entire session
66
+ self.thread_id = self.session_id
67
+ # Track which agent is currently active
68
+ self.active_agent: str = "sudosu" # Default to orchestrator
69
+ # Track if we're in a routed conversation
70
+ self.is_routed: bool = False
71
+ # Message count for the shared conversation
72
+ self.message_count: int = 0
73
+ # Session creation time
74
+ self.created_at: float = time.time()
75
+ # Last activity time
76
+ self.last_activity: float = time.time()
77
+
78
+ def get_thread_id(self) -> str:
79
+ """Get the shared thread ID for this session.
80
+
81
+ All agents use the same thread_id to share conversation history.
82
+ """
83
+ return self.thread_id
84
+
85
+ def set_active_agent(self, agent_name: str, via_routing: bool = False):
86
+ """Set the currently active agent.
87
+
88
+ Args:
89
+ agent_name: Name of the agent now handling the conversation
90
+ via_routing: True if this was set via routing from sudosu
91
+ """
92
+ self.active_agent = agent_name
93
+ self.is_routed = via_routing
94
+
95
+ def get_active_agent(self) -> str:
96
+ """Get the currently active agent."""
97
+ return self.active_agent or "sudosu"
98
+
99
+ def reset_to_orchestrator(self):
100
+ """Reset back to the default sudosu orchestrator."""
101
+ self.active_agent = "sudosu"
102
+ self.is_routed = False
103
+
104
+ def increment_message_count(self):
105
+ """Increment the shared message count and update activity."""
106
+ self.message_count += 1
107
+ self.last_activity = time.time()
108
+
109
+ def clear_session(self) -> str:
110
+ """Clear the session by generating a new thread_id.
111
+
112
+ This starts a fresh conversation for all agents while keeping
113
+ the same session_id.
114
+
115
+ Returns:
116
+ New thread_id for the fresh conversation
117
+ """
118
+ # Create new thread with unique suffix to start fresh
119
+ self.thread_id = f"{self.session_id}:{uuid.uuid4().hex[:8]}"
120
+ self.message_count = 0
121
+ self.active_agent = "sudosu"
122
+ self.is_routed = False
123
+ self.last_activity = time.time()
124
+ return self.thread_id
125
+
126
+ def get_stats(self) -> dict:
127
+ """Get statistics about current session.
128
+
129
+ Returns:
130
+ Dict with session statistics
131
+ """
132
+ duration = time.time() - self.created_at
133
+ return {
134
+ "session_id": self.session_id,
135
+ "thread_id": self.thread_id,
136
+ "active_agent": self.active_agent,
137
+ "is_routed": self.is_routed,
138
+ "message_count": self.message_count,
139
+ "duration_seconds": duration,
140
+ }
141
+
142
+ # Legacy compatibility methods
143
+ def get_or_create_conversation(self, agent_name: str) -> "SessionManager":
144
+ """Legacy method - returns self since we use shared thread.
145
+
146
+ This maintains compatibility with existing code that expects
147
+ a conversation object.
148
+ """
149
+ # Update active agent tracking
150
+ self.set_active_agent(agent_name)
151
+ return self
152
+
153
+ def clear_conversation(self, agent_name: str) -> Optional[str]:
154
+ """Clear the shared conversation.
155
+
156
+ Since all agents share the same thread, this clears everything.
157
+ The agent_name is kept for backward compatibility.
158
+ """
159
+ return self.clear_session()
160
+
161
+ def clear_all_conversations(self) -> int:
162
+ """Clear the shared conversation.
163
+
164
+ Returns 1 since there's only one shared conversation.
165
+ """
166
+ self.clear_session()
167
+ return 1
168
+
169
+ def get_active_conversation(self) -> Optional["SessionManager"]:
170
+ """Get the current session (self) for compatibility."""
171
+ return self if self.message_count > 0 else None
172
+
173
+ @property
174
+ def conversations(self) -> dict:
175
+ """Legacy property - returns dict with self if active."""
176
+ if self.message_count > 0:
177
+ return {self.active_agent: self}
178
+ return {}
179
+
180
+ @property
181
+ def agent_name(self) -> str:
182
+ """Legacy property for compatibility."""
183
+ return self.active_agent
184
+
185
+
186
+ # Global session manager instance
187
+ _session_manager: Optional[SessionManager] = None
188
+
189
+
190
+ def get_session_manager() -> SessionManager:
191
+ """Get or create the global session manager.
192
+
193
+ Returns:
194
+ The global SessionManager instance
195
+ """
196
+ global _session_manager
197
+ if _session_manager is None:
198
+ _session_manager = SessionManager()
199
+ return _session_manager
200
+
201
+
202
+ def reset_session_manager():
203
+ """Reset the global session manager (useful for testing)."""
204
+ global _session_manager
205
+ _session_manager = None
@@ -0,0 +1,373 @@
1
+ """Local tool execution for Sudosu client."""
2
+
3
+ import fnmatch
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ # Special routing result marker
11
+ ROUTING_MARKER = "_sudosu_routing"
12
+
13
+ # Special consultation routing marker
14
+ CONSULTATION_ROUTING_MARKER = "_sudosu_consultation_route"
15
+
16
+
17
+ async def execute_tool(tool_name: str, args: dict, cwd: str) -> dict:
18
+ """
19
+ Execute a tool locally and return the result.
20
+
21
+ Args:
22
+ tool_name: Name of the tool to execute
23
+ args: Tool arguments
24
+ cwd: Current working directory
25
+
26
+ Returns:
27
+ Tool execution result
28
+ """
29
+ executors = {
30
+ "write_file": tool_write_file,
31
+ "read_file": tool_read_file,
32
+ "list_directory": tool_list_directory,
33
+ "run_command": tool_run_command,
34
+ "search_files": tool_search_files,
35
+ "route_to_agent": tool_route_to_agent,
36
+ "consult_orchestrator": tool_consult_orchestrator,
37
+ }
38
+
39
+ executor = executors.get(tool_name)
40
+ if not executor:
41
+ return {"error": f"Unknown tool: {tool_name}"}
42
+
43
+ return await executor(args, cwd)
44
+
45
+
46
+ async def tool_consult_orchestrator(args: dict, cwd: str) -> dict: # noqa: ARG001
47
+ """
48
+ Handle consultation with the orchestrator.
49
+
50
+ Note: This is a stub - actual consultation is handled by the backend.
51
+ The backend intercepts this tool call and evaluates the consultation
52
+ before returning a decision.
53
+
54
+ Args:
55
+ args: {"situation": str, "user_request": str}
56
+ cwd: Current working directory (unused)
57
+
58
+ Returns:
59
+ Consultation result (handled by backend)
60
+ """
61
+ # This should not be reached - backend handles this tool
62
+ return {
63
+ "output": "Consultation handled by backend",
64
+ }
65
+
66
+
67
+ async def tool_route_to_agent(args: dict, cwd: str) -> dict: # noqa: ARG001
68
+ """
69
+ Handle routing to another agent.
70
+
71
+ This returns a special marker that the CLI intercepts to perform
72
+ the actual agent handoff.
73
+
74
+ Args:
75
+ args: {"agent_name": str, "message": str}
76
+ cwd: Current working directory (unused for routing)
77
+
78
+ Returns:
79
+ Special routing result with marker
80
+ """
81
+ agent_name = args.get("agent_name")
82
+ message = args.get("message", "")
83
+
84
+ if not agent_name:
85
+ return {"error": "Missing 'agent_name' argument"}
86
+
87
+ # Return special routing marker that CLI will intercept
88
+ # The output message must clearly indicate completion to prevent
89
+ # the LLM from calling route_to_agent again in a loop
90
+ return {
91
+ ROUTING_MARKER: True,
92
+ "agent_name": agent_name,
93
+ "message": message,
94
+ "output": f"SUCCESS: Request has been routed to @{agent_name}. The handoff is complete. Do not call route_to_agent again. Simply confirm the routing to the user and stop.",
95
+ }
96
+
97
+
98
+ def _validate_path(path: str, cwd: str) -> tuple[bool, str, Path]:
99
+ """
100
+ Validate that a path is within the allowed directory.
101
+
102
+ Returns:
103
+ Tuple of (is_valid, error_message, resolved_path)
104
+ """
105
+ try:
106
+ cwd_path = Path(cwd).resolve()
107
+ full_path = (cwd_path / path).resolve()
108
+
109
+ # Check if path is within cwd
110
+ full_path.relative_to(cwd_path)
111
+
112
+ return True, "", full_path
113
+ except ValueError:
114
+ return False, "Path must be within current directory", Path()
115
+ except Exception as e:
116
+ return False, str(e), Path()
117
+
118
+
119
+ async def tool_write_file(args: dict, cwd: str) -> dict:
120
+ """
121
+ Write content to a file.
122
+
123
+ Args:
124
+ args: {"file_path": str or "path": str, "content": str}
125
+ cwd: Current working directory
126
+
127
+ Returns:
128
+ Result dict with success status
129
+ """
130
+ # Support both file_path (backend) and path (legacy) naming
131
+ path = args.get("file_path") or args.get("path")
132
+ content = args.get("content")
133
+
134
+ if not path:
135
+ return {"error": "Missing 'path' argument"}
136
+ if content is None:
137
+ return {"error": "Missing 'content' argument"}
138
+
139
+ # Validate path
140
+ is_valid, error, full_path = _validate_path(path, cwd)
141
+ if not is_valid:
142
+ return {"error": error}
143
+
144
+ try:
145
+ # Create parent directories if needed
146
+ full_path.parent.mkdir(parents=True, exist_ok=True)
147
+
148
+ # Write file
149
+ with open(full_path, "w", encoding="utf-8") as f:
150
+ f.write(content)
151
+
152
+ return {
153
+ "success": True,
154
+ "path": str(full_path),
155
+ "message": f"File written successfully: {path}",
156
+ "output": f"File written successfully: {path}",
157
+ }
158
+ except PermissionError:
159
+ return {"error": f"Permission denied: {path}", "output": f"Error: Permission denied: {path}"}
160
+ except Exception as e:
161
+ return {"error": f"Failed to write file: {e}", "output": f"Error: Failed to write file: {e}"}
162
+
163
+
164
+ async def tool_read_file(args: dict, cwd: str) -> dict:
165
+ """
166
+ Read content from a file.
167
+
168
+ Args:
169
+ args: {"file_path": str or "path": str}
170
+ cwd: Current working directory
171
+
172
+ Returns:
173
+ Result dict with file content
174
+ """
175
+ # Support both file_path (backend) and path (legacy) naming
176
+ path = args.get("file_path") or args.get("path")
177
+
178
+ if not path:
179
+ return {"error": "Missing 'path' argument"}
180
+
181
+ # Validate path
182
+ is_valid, error, full_path = _validate_path(path, cwd)
183
+ if not is_valid:
184
+ return {"error": error}
185
+
186
+ if not full_path.exists():
187
+ return {"error": f"File not found: {path}"}
188
+
189
+ if not full_path.is_file():
190
+ return {"error": f"Not a file: {path}"}
191
+
192
+ try:
193
+ with open(full_path, "r", encoding="utf-8") as f:
194
+ content = f.read()
195
+
196
+ return {
197
+ "success": True,
198
+ "content": content,
199
+ "path": str(full_path),
200
+ "output": content,
201
+ }
202
+ except PermissionError:
203
+ return {"error": f"Permission denied: {path}", "output": f"Error: Permission denied: {path}"}
204
+ except UnicodeDecodeError:
205
+ return {"error": f"Cannot read file (binary?): {path}", "output": f"Error: Cannot read file (binary?): {path}"}
206
+ except Exception as e:
207
+ return {"error": f"Failed to read file: {e}", "output": f"Error: Failed to read file: {e}"}
208
+
209
+
210
+ async def tool_list_directory(args: dict, cwd: str) -> dict:
211
+ """
212
+ List directory contents.
213
+
214
+ Args:
215
+ args: {"directory_path": str or "path": str} (optional, defaults to ".")
216
+ cwd: Current working directory
217
+
218
+ Returns:
219
+ Result dict with directory listing
220
+ """
221
+ # Support both directory_path (backend) and path (legacy) naming
222
+ path = args.get("directory_path") or args.get("path", ".")
223
+
224
+ # Validate path
225
+ is_valid, error, full_path = _validate_path(path, cwd)
226
+ if not is_valid:
227
+ return {"error": error}
228
+
229
+ if not full_path.exists():
230
+ return {"error": f"Directory not found: {path}"}
231
+
232
+ if not full_path.is_dir():
233
+ return {"error": f"Not a directory: {path}"}
234
+
235
+ try:
236
+ items = []
237
+ for item in sorted(full_path.iterdir()):
238
+ items.append({
239
+ "name": item.name,
240
+ "type": "dir" if item.is_dir() else "file",
241
+ "size": item.stat().st_size if item.is_file() else None,
242
+ })
243
+
244
+ # Format output as a listing
245
+ output_lines = []
246
+ for item in items:
247
+ prefix = "[DIR]" if item["type"] == "dir" else "[FILE]"
248
+ output_lines.append(f"{prefix} {item['name']}")
249
+
250
+ return {
251
+ "success": True,
252
+ "path": str(full_path),
253
+ "items": items,
254
+ "output": "\n".join(output_lines) if output_lines else "Empty directory",
255
+ }
256
+ except PermissionError:
257
+ return {"error": f"Permission denied: {path}", "output": f"Error: Permission denied: {path}"}
258
+ except Exception as e:
259
+ return {"error": f"Failed to list directory: {e}", "output": f"Error: Failed to list directory: {e}"}
260
+
261
+
262
+ async def tool_run_command(args: dict, cwd: str) -> dict:
263
+ """
264
+ Run a shell command (with restrictions).
265
+
266
+ Args:
267
+ args: {"command": str}
268
+ cwd: Current working directory
269
+
270
+ Returns:
271
+ Result dict with command output
272
+ """
273
+ command = args.get("command")
274
+
275
+ if not command:
276
+ return {"error": "Missing 'command' argument"}
277
+
278
+ # Restricted commands for safety
279
+ blocked_patterns = [
280
+ "rm -rf /",
281
+ "sudo",
282
+ "> /dev/",
283
+ "mkfs",
284
+ "dd if=",
285
+ ]
286
+
287
+ for pattern in blocked_patterns:
288
+ if pattern in command:
289
+ return {
290
+ "error": f"Command blocked for safety: contains '{pattern}'",
291
+ "output": f"Error: Command blocked for safety: contains '{pattern}'",
292
+ }
293
+
294
+ try:
295
+ result = subprocess.run(
296
+ command,
297
+ shell=True,
298
+ capture_output=True,
299
+ text=True,
300
+ timeout=60,
301
+ cwd=cwd,
302
+ check=False,
303
+ )
304
+
305
+ # Combine stdout and stderr for output
306
+ output = result.stdout
307
+ if result.stderr:
308
+ output += f"\n[stderr]: {result.stderr}"
309
+
310
+ return {
311
+ "success": result.returncode == 0,
312
+ "stdout": result.stdout,
313
+ "stderr": result.stderr,
314
+ "returncode": result.returncode,
315
+ "output": output.strip() if output else "(no output)",
316
+ }
317
+ except subprocess.TimeoutExpired:
318
+ return {"error": "Command timed out (60s limit)", "output": "Error: Command timed out (60s limit)"}
319
+ except Exception as e:
320
+ return {"error": f"Failed to run command: {e}", "output": f"Error: Failed to run command: {e}"}
321
+
322
+
323
+ async def tool_search_files(args: dict, cwd: str) -> dict:
324
+ """
325
+ Search for files matching a pattern.
326
+
327
+ Args:
328
+ args: {"pattern": str, "directory": str (optional)}
329
+ cwd: Current working directory
330
+
331
+ Returns:
332
+ Result dict with matching files
333
+ """
334
+ pattern = args.get("pattern")
335
+ directory = args.get("directory", ".")
336
+
337
+ if not pattern:
338
+ return {"error": "Missing 'pattern' argument"}
339
+
340
+ # Validate path
341
+ is_valid, error, full_path = _validate_path(directory, cwd)
342
+ if not is_valid:
343
+ return {"error": error}
344
+
345
+ if not full_path.exists():
346
+ return {"error": f"Directory not found: {directory}"}
347
+
348
+ if not full_path.is_dir():
349
+ return {"error": f"Not a directory: {directory}"}
350
+
351
+ try:
352
+ matches = []
353
+ # Use glob for ** patterns, fnmatch for simple patterns
354
+ if "**" in pattern:
355
+ for match in full_path.glob(pattern):
356
+ matches.append(str(match.relative_to(full_path)))
357
+ else:
358
+ for root, dirs, files in os.walk(full_path):
359
+ for filename in files:
360
+ if fnmatch.fnmatch(filename, pattern):
361
+ rel_path = os.path.relpath(os.path.join(root, filename), full_path)
362
+ matches.append(rel_path)
363
+
364
+ return {
365
+ "success": True,
366
+ "matches": matches,
367
+ "count": len(matches),
368
+ "output": "\n".join(matches) if matches else "No matches found",
369
+ }
370
+ except PermissionError:
371
+ return {"error": f"Permission denied: {directory}"}
372
+ except OSError as e:
373
+ return {"error": f"Failed to search: {e}"}