omnimancer-cli 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.
- omnimancer/__init__.py +42 -0
- omnimancer/__main__.py +9 -0
- omnimancer/cli/__init__.py +8 -0
- omnimancer/cli/agent_manager.py +826 -0
- omnimancer/cli/agent_wizard.py +944 -0
- omnimancer/cli/approval_formatter.py +734 -0
- omnimancer/cli/approval_integration.py +734 -0
- omnimancer/cli/approval_prompt.py +1286 -0
- omnimancer/cli/batch_approval_display.py +617 -0
- omnimancer/cli/batch_approval_filters.py +729 -0
- omnimancer/cli/batch_operation_monitor.py +775 -0
- omnimancer/cli/commands/performance_cmd.py +624 -0
- omnimancer/cli/commands.py +757 -0
- omnimancer/cli/completion_handler.py +281 -0
- omnimancer/cli/config_setup_wizard.py +783 -0
- omnimancer/cli/dynamic_commands.py +371 -0
- omnimancer/cli/handlers/__init__.py +18 -0
- omnimancer/cli/handlers/agent_handler.py +865 -0
- omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
- omnimancer/cli/handlers/custom_agent_handler.py +352 -0
- omnimancer/cli/handlers/permissions_handler.py +1609 -0
- omnimancer/cli/interface.py +6695 -0
- omnimancer/cli/performance_dashboard.py +612 -0
- omnimancer/core/__init__.py +34 -0
- omnimancer/core/agent/__init__.py +256 -0
- omnimancer/core/agent/agent_config.py +715 -0
- omnimancer/core/agent/agent_switcher.py +668 -0
- omnimancer/core/agent/approval_context.py +460 -0
- omnimancer/core/agent/approval_dialog.py +902 -0
- omnimancer/core/agent/approval_interface.py +628 -0
- omnimancer/core/agent/approval_manager.py +760 -0
- omnimancer/core/agent/cli_integration.py +470 -0
- omnimancer/core/agent/config.py +845 -0
- omnimancer/core/agent/custom_agent_integration.py +654 -0
- omnimancer/core/agent/diff_renderer.py +706 -0
- omnimancer/core/agent/file_content_display.py +503 -0
- omnimancer/core/agent/file_modification_workflow.py +658 -0
- omnimancer/core/agent/file_system_manager.py +1790 -0
- omnimancer/core/agent/input_handler.py +614 -0
- omnimancer/core/agent/metrics_collector.py +771 -0
- omnimancer/core/agent/optimization_engine.py +729 -0
- omnimancer/core/agent/performance_monitor.py +717 -0
- omnimancer/core/agent/persona.py +870 -0
- omnimancer/core/agent/persona_validator.py +672 -0
- omnimancer/core/agent/program_executor.py +748 -0
- omnimancer/core/agent/proposed_changes_integration.py +663 -0
- omnimancer/core/agent/read_before_write_errors.py +492 -0
- omnimancer/core/agent/read_before_write_ui.py +647 -0
- omnimancer/core/agent/rich_renderer.py +763 -0
- omnimancer/core/agent/status_config.py +238 -0
- omnimancer/core/agent/status_core.py +446 -0
- omnimancer/core/agent/status_integrated_engine.py +540 -0
- omnimancer/core/agent/status_manager.py +878 -0
- omnimancer/core/agent/status_ui_components.py +535 -0
- omnimancer/core/agent/template_applicator.py +548 -0
- omnimancer/core/agent/terminal_status_listener.py +394 -0
- omnimancer/core/agent/token_tracker.py +679 -0
- omnimancer/core/agent/types.py +60 -0
- omnimancer/core/agent/web_client.py +745 -0
- omnimancer/core/agent/workflow_orchestrator.py +826 -0
- omnimancer/core/agent_engine.py +1625 -0
- omnimancer/core/agent_mode_manager.py +706 -0
- omnimancer/core/agent_progress_ui.py +545 -0
- omnimancer/core/chat_manager.py +141 -0
- omnimancer/core/config_manager.py +2199 -0
- omnimancer/core/config_migration.py +772 -0
- omnimancer/core/config_provider.py +1974 -0
- omnimancer/core/config_security_validator.py +393 -0
- omnimancer/core/config_validator.py +768 -0
- omnimancer/core/conversation_manager.py +352 -0
- omnimancer/core/engine.py +787 -0
- omnimancer/core/enhanced_input.py +523 -0
- omnimancer/core/env_loader.py +83 -0
- omnimancer/core/fallback_manager.py +568 -0
- omnimancer/core/health_monitor.py +462 -0
- omnimancer/core/history_manager.py +462 -0
- omnimancer/core/mcp_integration_layer.py +878 -0
- omnimancer/core/models.py +1889 -0
- omnimancer/core/provider_capabilities.py +403 -0
- omnimancer/core/provider_initializer.py +450 -0
- omnimancer/core/provider_registry.py +432 -0
- omnimancer/core/security/__init__.py +16 -0
- omnimancer/core/security/approval_workflow.py +406 -0
- omnimancer/core/security/audit_logger.py +511 -0
- omnimancer/core/security/permission_controller.py +511 -0
- omnimancer/core/security/sandbox_manager.py +379 -0
- omnimancer/core/security/security_manager.py +530 -0
- omnimancer/core/setup_wizard.py +345 -0
- omnimancer/core/setup_wizard_core.py +449 -0
- omnimancer/core/setup_wizard_guide_generator.py +376 -0
- omnimancer/core/setup_wizard_provider_setup.py +454 -0
- omnimancer/core/setup_wizard_ui.py +317 -0
- omnimancer/core/setup_wizard_validation.py +371 -0
- omnimancer/core/signal_handler.py +174 -0
- omnimancer/mcp/__init__.py +11 -0
- omnimancer/mcp/client.py +725 -0
- omnimancer/mcp/manager.py +649 -0
- omnimancer/providers/__init__.py +24 -0
- omnimancer/providers/azure.py +608 -0
- omnimancer/providers/base.py +262 -0
- omnimancer/providers/bedrock.py +711 -0
- omnimancer/providers/claude.py +426 -0
- omnimancer/providers/claude_code.py +406 -0
- omnimancer/providers/cohere.py +412 -0
- omnimancer/providers/factory.py +988 -0
- omnimancer/providers/gemini.py +708 -0
- omnimancer/providers/mistral.py +554 -0
- omnimancer/providers/ollama.py +510 -0
- omnimancer/providers/openai.py +362 -0
- omnimancer/providers/openrouter.py +709 -0
- omnimancer/providers/perplexity.py +476 -0
- omnimancer/providers/vertex.py +617 -0
- omnimancer/providers/xai.py +484 -0
- omnimancer/ui/__init__.py +6 -0
- omnimancer/ui/cancellation_handler.py +235 -0
- omnimancer/ui/progress_indicator.py +318 -0
- omnimancer/utils/__init__.py +22 -0
- omnimancer/utils/error_handler.py +589 -0
- omnimancer/utils/errors.py +667 -0
- omnimancer/utils/retry.py +238 -0
- omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
- omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
- omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
- omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
- omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Switcher for managing persona transitions and state preservation.
|
|
3
|
+
|
|
4
|
+
This module provides the switching mechanism that preserves session state
|
|
5
|
+
and handles transitions between personas.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import pickle
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from threading import Lock
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
from .config import AgentConfig
|
|
21
|
+
from .persona import AgentPersona, PersonaManager, PersonaStatus
|
|
22
|
+
from .status_core import AgentStatus
|
|
23
|
+
from .status_manager import UnifiedStatusManager as AgentStatusManager
|
|
24
|
+
from .status_manager import (
|
|
25
|
+
get_status_manager,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SwitchState(Enum):
|
|
32
|
+
"""States for the switching process."""
|
|
33
|
+
|
|
34
|
+
IDLE = "idle"
|
|
35
|
+
PREPARING = "preparing"
|
|
36
|
+
SWITCHING = "switching"
|
|
37
|
+
VALIDATING = "validating"
|
|
38
|
+
COMPLETE = "complete"
|
|
39
|
+
ERROR = "error"
|
|
40
|
+
ROLLBACK = "rollback"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SwitchValidationError(Exception):
|
|
44
|
+
"""Exception raised when switch validation fails."""
|
|
45
|
+
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SwitchOperationError(Exception):
|
|
50
|
+
"""Exception raised when switch operation fails."""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class SessionState:
|
|
57
|
+
"""
|
|
58
|
+
Session state that needs to be preserved during persona switches.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
# Core conversation state
|
|
62
|
+
conversation_history: List[Dict[str, Any]] = field(default_factory=list)
|
|
63
|
+
current_context: Dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
# User preferences and settings
|
|
66
|
+
user_preferences: Dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
active_tools: List[str] = field(default_factory=list)
|
|
68
|
+
disabled_tools: List[str] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
# Session metadata
|
|
71
|
+
session_id: str = ""
|
|
72
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
73
|
+
last_modified: datetime = field(default_factory=datetime.now)
|
|
74
|
+
|
|
75
|
+
# Persona-specific data
|
|
76
|
+
persona_data: Dict[str, Any] = field(default_factory=dict)
|
|
77
|
+
|
|
78
|
+
# Operation state
|
|
79
|
+
pending_operations: List[Dict[str, Any]] = field(default_factory=list)
|
|
80
|
+
active_operations: List[Dict[str, Any]] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
# Security and permissions
|
|
83
|
+
permission_overrides: Dict[str, Any] = field(default_factory=dict)
|
|
84
|
+
approval_history: List[Dict[str, Any]] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
def update_timestamp(self) -> None:
|
|
87
|
+
"""Update the last modified timestamp."""
|
|
88
|
+
self.last_modified = datetime.now()
|
|
89
|
+
|
|
90
|
+
def to_json(self) -> str:
|
|
91
|
+
"""Serialize state to JSON."""
|
|
92
|
+
data = asdict(self)
|
|
93
|
+
# Convert datetime objects to ISO format
|
|
94
|
+
data["created_at"] = self.created_at.isoformat()
|
|
95
|
+
data["last_modified"] = self.last_modified.isoformat()
|
|
96
|
+
return json.dumps(data, default=str)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_json(cls, json_str: str) -> "SessionState":
|
|
100
|
+
"""Deserialize state from JSON."""
|
|
101
|
+
data = json.loads(json_str)
|
|
102
|
+
# Convert ISO format strings back to datetime objects
|
|
103
|
+
if "created_at" in data:
|
|
104
|
+
data["created_at"] = datetime.fromisoformat(data["created_at"])
|
|
105
|
+
if "last_modified" in data:
|
|
106
|
+
data["last_modified"] = datetime.fromisoformat(data["last_modified"])
|
|
107
|
+
return cls(**data)
|
|
108
|
+
|
|
109
|
+
def to_pickle(self) -> bytes:
|
|
110
|
+
"""Serialize state to pickle format."""
|
|
111
|
+
return pickle.dumps(self)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_pickle(cls, pickle_data: bytes) -> "SessionState":
|
|
115
|
+
"""Deserialize state from pickle format."""
|
|
116
|
+
return pickle.loads(pickle_data)
|
|
117
|
+
|
|
118
|
+
def get_hash(self) -> str:
|
|
119
|
+
"""Get a hash of the current state for validation."""
|
|
120
|
+
# Create a copy without timestamps for consistent hashing
|
|
121
|
+
data = asdict(self)
|
|
122
|
+
# Remove timestamp fields that shouldn't affect hash
|
|
123
|
+
data.pop("created_at", None)
|
|
124
|
+
data.pop("last_modified", None)
|
|
125
|
+
|
|
126
|
+
# Sort for consistent ordering
|
|
127
|
+
state_str = json.dumps(data, sort_keys=True, default=str)
|
|
128
|
+
return hashlib.sha256(state_str.encode()).hexdigest()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class SwitchContext:
|
|
133
|
+
"""Context for a persona switch operation."""
|
|
134
|
+
|
|
135
|
+
from_persona: Optional[AgentPersona]
|
|
136
|
+
to_persona: AgentPersona
|
|
137
|
+
session_state: SessionState
|
|
138
|
+
validation_checks: List[str] = field(default_factory=list)
|
|
139
|
+
transition_hooks: List[Callable] = field(default_factory=list)
|
|
140
|
+
rollback_state: Optional[SessionState] = None
|
|
141
|
+
error_message: Optional[str] = None
|
|
142
|
+
switch_reason: str = ""
|
|
143
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class AgentSwitcher:
|
|
147
|
+
"""
|
|
148
|
+
Manages persona switching with state preservation and validation.
|
|
149
|
+
|
|
150
|
+
This class handles the complex process of switching between agent personas
|
|
151
|
+
while preserving session state and ensuring clean transitions.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
persona_manager: PersonaManager,
|
|
157
|
+
agent_config: Optional[AgentConfig] = None,
|
|
158
|
+
status_manager: Optional[AgentStatusManager] = None,
|
|
159
|
+
state_storage_path: Optional[Path] = None,
|
|
160
|
+
):
|
|
161
|
+
"""
|
|
162
|
+
Initialize the AgentSwitcher.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
persona_manager: PersonaManager instance for managing personas
|
|
166
|
+
agent_config: Agent configuration instance
|
|
167
|
+
status_manager: Status manager for tracking agent state
|
|
168
|
+
state_storage_path: Path for persistent state storage
|
|
169
|
+
"""
|
|
170
|
+
self.persona_manager = persona_manager
|
|
171
|
+
self.agent_config = agent_config
|
|
172
|
+
self.status_manager = status_manager or get_status_manager()
|
|
173
|
+
self.state_storage_path = (
|
|
174
|
+
state_storage_path or Path.home() / ".omnimancer" / "session_states"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Ensure storage directory exists
|
|
178
|
+
self.state_storage_path.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
|
|
180
|
+
# Current state
|
|
181
|
+
self.current_state = SwitchState.IDLE
|
|
182
|
+
self.current_session_state: Optional[SessionState] = None
|
|
183
|
+
self.switch_history: List[SwitchContext] = []
|
|
184
|
+
|
|
185
|
+
# Thread safety
|
|
186
|
+
self._lock = Lock()
|
|
187
|
+
|
|
188
|
+
# Hooks and validators
|
|
189
|
+
self._pre_switch_hooks: List[Callable] = []
|
|
190
|
+
self._post_switch_hooks: List[Callable] = []
|
|
191
|
+
self._validation_hooks: List[Callable] = []
|
|
192
|
+
|
|
193
|
+
# Register default hooks
|
|
194
|
+
self._register_default_hooks()
|
|
195
|
+
|
|
196
|
+
def _register_default_hooks(self) -> None:
|
|
197
|
+
"""Register default transition and validation hooks."""
|
|
198
|
+
# Default pre-switch hooks
|
|
199
|
+
self._pre_switch_hooks.append(self._save_current_state)
|
|
200
|
+
|
|
201
|
+
# Default post-switch hooks
|
|
202
|
+
self._post_switch_hooks.append(self._restore_state)
|
|
203
|
+
self._post_switch_hooks.append(self._update_status_manager)
|
|
204
|
+
|
|
205
|
+
# Default validation hooks
|
|
206
|
+
self._validation_hooks.append(self._validate_persona_available)
|
|
207
|
+
self._validation_hooks.append(self._validate_no_active_operations)
|
|
208
|
+
self._validation_hooks.append(self._validate_state_integrity)
|
|
209
|
+
|
|
210
|
+
def switch_persona(
|
|
211
|
+
self, target_persona_id: str, reason: str = "", force: bool = False
|
|
212
|
+
) -> Tuple[bool, str]:
|
|
213
|
+
"""
|
|
214
|
+
Switch to a target persona with state preservation.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
target_persona_id: ID of the persona to switch to
|
|
218
|
+
reason: Reason for the switch (for logging)
|
|
219
|
+
force: Force switch even if validation fails
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Tuple of (success, message)
|
|
223
|
+
"""
|
|
224
|
+
with self._lock:
|
|
225
|
+
try:
|
|
226
|
+
# Set state to preparing
|
|
227
|
+
self.current_state = SwitchState.PREPARING
|
|
228
|
+
|
|
229
|
+
# Get target persona
|
|
230
|
+
target_persona = self.persona_manager.get_persona(target_persona_id)
|
|
231
|
+
if not target_persona:
|
|
232
|
+
raise SwitchValidationError(
|
|
233
|
+
f"Persona not found: {target_persona_id}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Get current persona
|
|
237
|
+
current_persona = self.persona_manager.active_persona
|
|
238
|
+
|
|
239
|
+
# Create switch context
|
|
240
|
+
context = SwitchContext(
|
|
241
|
+
from_persona=current_persona,
|
|
242
|
+
to_persona=target_persona,
|
|
243
|
+
session_state=self._capture_current_state(),
|
|
244
|
+
switch_reason=reason,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Validation phase
|
|
248
|
+
if not force:
|
|
249
|
+
self.current_state = SwitchState.VALIDATING
|
|
250
|
+
validation_result = self._validate_switch(context)
|
|
251
|
+
if not validation_result[0]:
|
|
252
|
+
self.current_state = SwitchState.ERROR
|
|
253
|
+
context.error_message = validation_result[1]
|
|
254
|
+
self.switch_history.append(context)
|
|
255
|
+
return validation_result
|
|
256
|
+
|
|
257
|
+
# Pre-switch phase
|
|
258
|
+
self.current_state = SwitchState.SWITCHING
|
|
259
|
+
for hook in self._pre_switch_hooks:
|
|
260
|
+
try:
|
|
261
|
+
hook(context)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
logger.error(f"Pre-switch hook failed: {e}")
|
|
264
|
+
if not force:
|
|
265
|
+
self._rollback(context)
|
|
266
|
+
return (False, f"Pre-switch hook failed: {e}")
|
|
267
|
+
|
|
268
|
+
# Perform the switch
|
|
269
|
+
if current_persona:
|
|
270
|
+
self.persona_manager.deactivate_persona()
|
|
271
|
+
|
|
272
|
+
success = self.persona_manager.activate_persona(target_persona_id)
|
|
273
|
+
if not success:
|
|
274
|
+
self._rollback(context)
|
|
275
|
+
return (
|
|
276
|
+
False,
|
|
277
|
+
f"Failed to activate persona: {target_persona_id}",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Post-switch phase
|
|
281
|
+
for hook in self._post_switch_hooks:
|
|
282
|
+
try:
|
|
283
|
+
hook(context)
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.error(f"Post-switch hook failed: {e}")
|
|
286
|
+
# Post-switch failures are logged but don't rollback
|
|
287
|
+
|
|
288
|
+
# Complete
|
|
289
|
+
self.current_state = SwitchState.COMPLETE
|
|
290
|
+
self.switch_history.append(context)
|
|
291
|
+
|
|
292
|
+
logger.info(f"Successfully switched to persona: {target_persona_id}")
|
|
293
|
+
return (True, f"Switched to {target_persona.name}")
|
|
294
|
+
|
|
295
|
+
except Exception as e:
|
|
296
|
+
logger.error(f"Persona switch failed: {e}")
|
|
297
|
+
self.current_state = SwitchState.ERROR
|
|
298
|
+
return (False, str(e))
|
|
299
|
+
finally:
|
|
300
|
+
# Reset to idle after a short delay
|
|
301
|
+
self.current_state = SwitchState.IDLE
|
|
302
|
+
|
|
303
|
+
def _capture_current_state(self) -> SessionState:
|
|
304
|
+
"""Capture the current session state."""
|
|
305
|
+
if self.current_session_state:
|
|
306
|
+
return self.current_session_state
|
|
307
|
+
|
|
308
|
+
# Create new session state
|
|
309
|
+
state = SessionState()
|
|
310
|
+
|
|
311
|
+
# Capture from active persona if available
|
|
312
|
+
active_persona = self.persona_manager.active_persona
|
|
313
|
+
if active_persona:
|
|
314
|
+
state.persona_data = {
|
|
315
|
+
"persona_id": active_persona.id,
|
|
316
|
+
"session_data": active_persona._session_data.copy(),
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
# TODO: Capture conversation history from the main engine
|
|
320
|
+
# This would need integration with the main conversation loop
|
|
321
|
+
|
|
322
|
+
return state
|
|
323
|
+
|
|
324
|
+
def _validate_switch(self, context: SwitchContext) -> Tuple[bool, str]:
|
|
325
|
+
"""
|
|
326
|
+
Validate that a switch can be performed.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
context: Switch context
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Tuple of (valid, message)
|
|
333
|
+
"""
|
|
334
|
+
for validator in self._validation_hooks:
|
|
335
|
+
try:
|
|
336
|
+
result = validator(context)
|
|
337
|
+
if not result[0]:
|
|
338
|
+
context.validation_checks.append(f"Failed: {validator.__name__}")
|
|
339
|
+
return result
|
|
340
|
+
context.validation_checks.append(f"Passed: {validator.__name__}")
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.error(f"Validation hook error: {e}")
|
|
343
|
+
return (False, f"Validation error: {e}")
|
|
344
|
+
|
|
345
|
+
return (True, "All validations passed")
|
|
346
|
+
|
|
347
|
+
def _validate_persona_available(self, context: SwitchContext) -> Tuple[bool, str]:
|
|
348
|
+
"""Validate that target persona is available."""
|
|
349
|
+
if context.to_persona.status not in [
|
|
350
|
+
PersonaStatus.AVAILABLE,
|
|
351
|
+
PersonaStatus.ACTIVE,
|
|
352
|
+
]:
|
|
353
|
+
return (
|
|
354
|
+
False,
|
|
355
|
+
f"Persona {context.to_persona.id} is not available (status: {context.to_persona.status})",
|
|
356
|
+
)
|
|
357
|
+
return (True, "Persona available")
|
|
358
|
+
|
|
359
|
+
def _validate_no_active_operations(
|
|
360
|
+
self, context: SwitchContext
|
|
361
|
+
) -> Tuple[bool, str]:
|
|
362
|
+
"""Validate no active operations are in progress."""
|
|
363
|
+
if context.session_state.active_operations:
|
|
364
|
+
return (
|
|
365
|
+
False,
|
|
366
|
+
f"Cannot switch: {len(context.session_state.active_operations)} operations in progress",
|
|
367
|
+
)
|
|
368
|
+
return (True, "No active operations")
|
|
369
|
+
|
|
370
|
+
def _validate_state_integrity(self, context: SwitchContext) -> Tuple[bool, str]:
|
|
371
|
+
"""Validate state integrity."""
|
|
372
|
+
try:
|
|
373
|
+
# Test serialization/deserialization
|
|
374
|
+
json_str = context.session_state.to_json()
|
|
375
|
+
restored = SessionState.from_json(json_str)
|
|
376
|
+
|
|
377
|
+
# Verify hash matches
|
|
378
|
+
if context.session_state.get_hash() != restored.get_hash():
|
|
379
|
+
return (False, "State integrity check failed")
|
|
380
|
+
|
|
381
|
+
return (True, "State integrity verified")
|
|
382
|
+
except Exception as e:
|
|
383
|
+
return (False, f"State validation error: {e}")
|
|
384
|
+
|
|
385
|
+
def _save_current_state(self, context: SwitchContext) -> None:
|
|
386
|
+
"""Save current state before switch."""
|
|
387
|
+
# Create rollback state
|
|
388
|
+
context.rollback_state = context.session_state
|
|
389
|
+
|
|
390
|
+
# Persist to disk if we have a from_persona
|
|
391
|
+
if context.from_persona:
|
|
392
|
+
state_file = (
|
|
393
|
+
self.state_storage_path / f"{context.from_persona.id}_state.json"
|
|
394
|
+
)
|
|
395
|
+
try:
|
|
396
|
+
with open(state_file, "w") as f:
|
|
397
|
+
f.write(context.session_state.to_json())
|
|
398
|
+
logger.debug(f"Saved state for persona: {context.from_persona.id}")
|
|
399
|
+
except Exception as e:
|
|
400
|
+
logger.error(f"Failed to save state: {e}")
|
|
401
|
+
|
|
402
|
+
def _restore_state(self, context: SwitchContext) -> None:
|
|
403
|
+
"""Restore state after switch."""
|
|
404
|
+
# Check if we have saved state for the target persona
|
|
405
|
+
state_file = self.state_storage_path / f"{context.to_persona.id}_state.json"
|
|
406
|
+
|
|
407
|
+
if state_file.exists():
|
|
408
|
+
try:
|
|
409
|
+
with open(state_file, "r") as f:
|
|
410
|
+
saved_state = SessionState.from_json(f.read())
|
|
411
|
+
|
|
412
|
+
# Merge with current session state
|
|
413
|
+
context.session_state.persona_data = saved_state.persona_data
|
|
414
|
+
context.session_state.user_preferences.update(
|
|
415
|
+
saved_state.user_preferences
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# Restore to persona
|
|
419
|
+
if "session_data" in saved_state.persona_data:
|
|
420
|
+
for key, value in saved_state.persona_data["session_data"].items():
|
|
421
|
+
context.to_persona.set_session_data(key, value)
|
|
422
|
+
|
|
423
|
+
logger.debug(f"Restored state for persona: {context.to_persona.id}")
|
|
424
|
+
except Exception as e:
|
|
425
|
+
logger.error(f"Failed to restore state: {e}")
|
|
426
|
+
|
|
427
|
+
# Update current session state
|
|
428
|
+
self.current_session_state = context.session_state
|
|
429
|
+
|
|
430
|
+
def _update_status_manager(self, context: SwitchContext) -> None:
|
|
431
|
+
"""Update status manager after switch."""
|
|
432
|
+
if self.status_manager:
|
|
433
|
+
try:
|
|
434
|
+
import asyncio
|
|
435
|
+
|
|
436
|
+
# Try to get running loop
|
|
437
|
+
try:
|
|
438
|
+
loop = asyncio.get_running_loop()
|
|
439
|
+
|
|
440
|
+
# Update old persona status
|
|
441
|
+
if context.from_persona:
|
|
442
|
+
loop.create_task(
|
|
443
|
+
self.status_manager.set_agent_status(
|
|
444
|
+
agent_id=f"persona_{context.from_persona.id}",
|
|
445
|
+
status=AgentStatus.DISABLED,
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# Update new persona status
|
|
450
|
+
loop.create_task(
|
|
451
|
+
self.status_manager.set_agent_status(
|
|
452
|
+
agent_id=f"persona_{context.to_persona.id}",
|
|
453
|
+
status=AgentStatus.ENABLED,
|
|
454
|
+
metadata={
|
|
455
|
+
"switch_reason": context.switch_reason,
|
|
456
|
+
"from_persona": (
|
|
457
|
+
context.from_persona.id
|
|
458
|
+
if context.from_persona
|
|
459
|
+
else None
|
|
460
|
+
),
|
|
461
|
+
},
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
except RuntimeError:
|
|
465
|
+
# No event loop running, skip async status updates
|
|
466
|
+
logger.debug("No event loop available for status update")
|
|
467
|
+
|
|
468
|
+
except Exception as e:
|
|
469
|
+
# Catch all exceptions to prevent status update failures from affecting the switch
|
|
470
|
+
logger.debug(f"Status manager update failed: {e}")
|
|
471
|
+
|
|
472
|
+
def _rollback(self, context: SwitchContext) -> None:
|
|
473
|
+
"""Rollback a failed switch."""
|
|
474
|
+
self.current_state = SwitchState.ROLLBACK
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
# Restore original persona if we had one
|
|
478
|
+
if context.from_persona:
|
|
479
|
+
self.persona_manager.activate_persona(context.from_persona.id)
|
|
480
|
+
|
|
481
|
+
# Restore original state
|
|
482
|
+
if context.rollback_state:
|
|
483
|
+
self.current_session_state = context.rollback_state
|
|
484
|
+
|
|
485
|
+
logger.info("Successfully rolled back persona switch")
|
|
486
|
+
except Exception as e:
|
|
487
|
+
logger.error(f"Rollback failed: {e}")
|
|
488
|
+
finally:
|
|
489
|
+
self.current_state = SwitchState.IDLE
|
|
490
|
+
|
|
491
|
+
def add_pre_switch_hook(self, hook: Callable) -> None:
|
|
492
|
+
"""Add a pre-switch hook."""
|
|
493
|
+
self._pre_switch_hooks.append(hook)
|
|
494
|
+
|
|
495
|
+
def add_post_switch_hook(self, hook: Callable) -> None:
|
|
496
|
+
"""Add a post-switch hook."""
|
|
497
|
+
self._post_switch_hooks.append(hook)
|
|
498
|
+
|
|
499
|
+
def add_validation_hook(self, hook: Callable) -> None:
|
|
500
|
+
"""Add a validation hook."""
|
|
501
|
+
self._validation_hooks.append(hook)
|
|
502
|
+
|
|
503
|
+
def get_switch_history(self) -> List[SwitchContext]:
|
|
504
|
+
"""Get the switch history."""
|
|
505
|
+
return self.switch_history.copy()
|
|
506
|
+
|
|
507
|
+
def get_current_state(self) -> SwitchState:
|
|
508
|
+
"""Get the current switch state."""
|
|
509
|
+
return self.current_state
|
|
510
|
+
|
|
511
|
+
def can_switch(self, target_persona_id: str) -> Tuple[bool, str]:
|
|
512
|
+
"""
|
|
513
|
+
Check if a switch to target persona is possible.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
target_persona_id: ID of target persona
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
Tuple of (can_switch, reason)
|
|
520
|
+
"""
|
|
521
|
+
# Check if persona exists
|
|
522
|
+
target_persona = self.persona_manager.get_persona(target_persona_id)
|
|
523
|
+
if not target_persona:
|
|
524
|
+
return (False, f"Persona not found: {target_persona_id}")
|
|
525
|
+
|
|
526
|
+
# Check if already active
|
|
527
|
+
if self.persona_manager.active_persona == target_persona:
|
|
528
|
+
return (False, "Persona already active")
|
|
529
|
+
|
|
530
|
+
# Check if switch is in progress
|
|
531
|
+
if self.current_state != SwitchState.IDLE:
|
|
532
|
+
return (
|
|
533
|
+
False,
|
|
534
|
+
f"Switch in progress (state: {self.current_state.value})",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Create mock context for validation
|
|
538
|
+
mock_context = SwitchContext(
|
|
539
|
+
from_persona=self.persona_manager.active_persona,
|
|
540
|
+
to_persona=target_persona,
|
|
541
|
+
session_state=self._capture_current_state(),
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Run validations
|
|
545
|
+
return self._validate_switch(mock_context)
|
|
546
|
+
|
|
547
|
+
def handle_active_operation_switch(
|
|
548
|
+
self, target_persona_id: str, operation_handler: Callable
|
|
549
|
+
) -> Tuple[bool, str]:
|
|
550
|
+
"""
|
|
551
|
+
Handle switching during an active operation.
|
|
552
|
+
|
|
553
|
+
This is an edge case handler that attempts to gracefully handle
|
|
554
|
+
switches when operations are in progress.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
target_persona_id: Target persona ID
|
|
558
|
+
operation_handler: Callable to handle active operations
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
Tuple of (success, message)
|
|
562
|
+
"""
|
|
563
|
+
try:
|
|
564
|
+
# Capture current state (within lock)
|
|
565
|
+
with self._lock:
|
|
566
|
+
current_state = self._capture_current_state()
|
|
567
|
+
|
|
568
|
+
if current_state.active_operations:
|
|
569
|
+
logger.warning(
|
|
570
|
+
f"Attempting switch with {len(current_state.active_operations)} active operations"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Call the operation handler
|
|
574
|
+
handled = operation_handler(current_state.active_operations)
|
|
575
|
+
if not handled:
|
|
576
|
+
return (False, "Could not handle active operations")
|
|
577
|
+
|
|
578
|
+
# Clear active operations after handling
|
|
579
|
+
current_state.active_operations.clear()
|
|
580
|
+
|
|
581
|
+
# Proceed with normal switch (outside of lock to avoid deadlock)
|
|
582
|
+
return self.switch_persona(
|
|
583
|
+
target_persona_id,
|
|
584
|
+
reason="Switch with operation handling",
|
|
585
|
+
force=False,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
except Exception as e:
|
|
589
|
+
logger.error(f"Failed to handle active operation switch: {e}")
|
|
590
|
+
return (False, str(e))
|
|
591
|
+
|
|
592
|
+
def export_session_state(self, format: str = "json") -> Optional[str]:
|
|
593
|
+
"""
|
|
594
|
+
Export current session state.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
format: Export format ('json' or 'pickle')
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
Exported state string or None
|
|
601
|
+
"""
|
|
602
|
+
if not self.current_session_state:
|
|
603
|
+
self.current_session_state = self._capture_current_state()
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
if format == "json":
|
|
607
|
+
return self.current_session_state.to_json()
|
|
608
|
+
elif format == "pickle":
|
|
609
|
+
import base64
|
|
610
|
+
|
|
611
|
+
return base64.b64encode(self.current_session_state.to_pickle()).decode()
|
|
612
|
+
else:
|
|
613
|
+
logger.error(f"Unknown export format: {format}")
|
|
614
|
+
return None
|
|
615
|
+
except Exception as e:
|
|
616
|
+
logger.error(f"Failed to export session state: {e}")
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
def import_session_state(self, state_data: str, format: str = "json") -> bool:
|
|
620
|
+
"""
|
|
621
|
+
Import session state.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
state_data: State data string
|
|
625
|
+
format: Import format ('json' or 'pickle')
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
Success status
|
|
629
|
+
"""
|
|
630
|
+
try:
|
|
631
|
+
if format == "json":
|
|
632
|
+
self.current_session_state = SessionState.from_json(state_data)
|
|
633
|
+
elif format == "pickle":
|
|
634
|
+
import base64
|
|
635
|
+
|
|
636
|
+
pickle_data = base64.b64decode(state_data.encode())
|
|
637
|
+
self.current_session_state = SessionState.from_pickle(pickle_data)
|
|
638
|
+
else:
|
|
639
|
+
logger.error(f"Unknown import format: {format}")
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
return True
|
|
643
|
+
except Exception as e:
|
|
644
|
+
logger.error(f"Failed to import session state: {e}")
|
|
645
|
+
return False
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# Global instance for easy access
|
|
649
|
+
_global_agent_switcher: Optional[AgentSwitcher] = None
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def get_agent_switcher(
|
|
653
|
+
persona_manager: Optional[PersonaManager] = None,
|
|
654
|
+
) -> AgentSwitcher:
|
|
655
|
+
"""Get the global agent switcher instance."""
|
|
656
|
+
global _global_agent_switcher
|
|
657
|
+
if _global_agent_switcher is None:
|
|
658
|
+
from .persona import get_persona_manager
|
|
659
|
+
|
|
660
|
+
pm = persona_manager or get_persona_manager()
|
|
661
|
+
_global_agent_switcher = AgentSwitcher(pm)
|
|
662
|
+
return _global_agent_switcher
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def set_agent_switcher(switcher: AgentSwitcher) -> None:
|
|
666
|
+
"""Set the global agent switcher instance."""
|
|
667
|
+
global _global_agent_switcher
|
|
668
|
+
_global_agent_switcher = switcher
|