aiecs 1.0.1__py3-none-any.whl → 1.7.6__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.
Potentially problematic release.
This version of aiecs might be problematic. Click here for more details.
- aiecs/__init__.py +13 -16
- aiecs/__main__.py +7 -7
- aiecs/aiecs_client.py +269 -75
- aiecs/application/executors/operation_executor.py +79 -54
- aiecs/application/knowledge_graph/__init__.py +7 -0
- aiecs/application/knowledge_graph/builder/__init__.py +37 -0
- aiecs/application/knowledge_graph/builder/data_quality.py +302 -0
- aiecs/application/knowledge_graph/builder/data_reshaping.py +293 -0
- aiecs/application/knowledge_graph/builder/document_builder.py +369 -0
- aiecs/application/knowledge_graph/builder/graph_builder.py +490 -0
- aiecs/application/knowledge_graph/builder/import_optimizer.py +396 -0
- aiecs/application/knowledge_graph/builder/schema_inference.py +462 -0
- aiecs/application/knowledge_graph/builder/schema_mapping.py +563 -0
- aiecs/application/knowledge_graph/builder/structured_pipeline.py +1384 -0
- aiecs/application/knowledge_graph/builder/text_chunker.py +317 -0
- aiecs/application/knowledge_graph/extractors/__init__.py +27 -0
- aiecs/application/knowledge_graph/extractors/base.py +98 -0
- aiecs/application/knowledge_graph/extractors/llm_entity_extractor.py +422 -0
- aiecs/application/knowledge_graph/extractors/llm_relation_extractor.py +347 -0
- aiecs/application/knowledge_graph/extractors/ner_entity_extractor.py +241 -0
- aiecs/application/knowledge_graph/fusion/__init__.py +78 -0
- aiecs/application/knowledge_graph/fusion/ab_testing.py +395 -0
- aiecs/application/knowledge_graph/fusion/abbreviation_expander.py +327 -0
- aiecs/application/knowledge_graph/fusion/alias_index.py +597 -0
- aiecs/application/knowledge_graph/fusion/alias_matcher.py +384 -0
- aiecs/application/knowledge_graph/fusion/cache_coordinator.py +343 -0
- aiecs/application/knowledge_graph/fusion/entity_deduplicator.py +433 -0
- aiecs/application/knowledge_graph/fusion/entity_linker.py +511 -0
- aiecs/application/knowledge_graph/fusion/evaluation_dataset.py +240 -0
- aiecs/application/knowledge_graph/fusion/knowledge_fusion.py +632 -0
- aiecs/application/knowledge_graph/fusion/matching_config.py +489 -0
- aiecs/application/knowledge_graph/fusion/name_normalizer.py +352 -0
- aiecs/application/knowledge_graph/fusion/relation_deduplicator.py +183 -0
- aiecs/application/knowledge_graph/fusion/semantic_name_matcher.py +464 -0
- aiecs/application/knowledge_graph/fusion/similarity_pipeline.py +534 -0
- aiecs/application/knowledge_graph/pattern_matching/__init__.py +21 -0
- aiecs/application/knowledge_graph/pattern_matching/pattern_matcher.py +342 -0
- aiecs/application/knowledge_graph/pattern_matching/query_executor.py +366 -0
- aiecs/application/knowledge_graph/profiling/__init__.py +12 -0
- aiecs/application/knowledge_graph/profiling/query_plan_visualizer.py +195 -0
- aiecs/application/knowledge_graph/profiling/query_profiler.py +223 -0
- aiecs/application/knowledge_graph/reasoning/__init__.py +27 -0
- aiecs/application/knowledge_graph/reasoning/evidence_synthesis.py +341 -0
- aiecs/application/knowledge_graph/reasoning/inference_engine.py +500 -0
- aiecs/application/knowledge_graph/reasoning/logic_form_parser.py +163 -0
- aiecs/application/knowledge_graph/reasoning/logic_parser/__init__.py +79 -0
- aiecs/application/knowledge_graph/reasoning/logic_parser/ast_builder.py +513 -0
- aiecs/application/knowledge_graph/reasoning/logic_parser/ast_nodes.py +913 -0
- aiecs/application/knowledge_graph/reasoning/logic_parser/ast_validator.py +866 -0
- aiecs/application/knowledge_graph/reasoning/logic_parser/error_handler.py +475 -0
- aiecs/application/knowledge_graph/reasoning/logic_parser/parser.py +396 -0
- aiecs/application/knowledge_graph/reasoning/logic_parser/query_context.py +208 -0
- aiecs/application/knowledge_graph/reasoning/logic_query_integration.py +170 -0
- aiecs/application/knowledge_graph/reasoning/query_planner.py +855 -0
- aiecs/application/knowledge_graph/reasoning/reasoning_engine.py +518 -0
- aiecs/application/knowledge_graph/retrieval/__init__.py +27 -0
- aiecs/application/knowledge_graph/retrieval/query_intent_classifier.py +211 -0
- aiecs/application/knowledge_graph/retrieval/retrieval_strategies.py +592 -0
- aiecs/application/knowledge_graph/retrieval/strategy_types.py +23 -0
- aiecs/application/knowledge_graph/search/__init__.py +59 -0
- aiecs/application/knowledge_graph/search/hybrid_search.py +457 -0
- aiecs/application/knowledge_graph/search/reranker.py +293 -0
- aiecs/application/knowledge_graph/search/reranker_strategies.py +535 -0
- aiecs/application/knowledge_graph/search/text_similarity.py +392 -0
- aiecs/application/knowledge_graph/traversal/__init__.py +15 -0
- aiecs/application/knowledge_graph/traversal/enhanced_traversal.py +305 -0
- aiecs/application/knowledge_graph/traversal/path_scorer.py +271 -0
- aiecs/application/knowledge_graph/validators/__init__.py +13 -0
- aiecs/application/knowledge_graph/validators/relation_validator.py +239 -0
- aiecs/application/knowledge_graph/visualization/__init__.py +11 -0
- aiecs/application/knowledge_graph/visualization/graph_visualizer.py +313 -0
- aiecs/common/__init__.py +9 -0
- aiecs/common/knowledge_graph/__init__.py +17 -0
- aiecs/common/knowledge_graph/runnable.py +471 -0
- aiecs/config/__init__.py +20 -5
- aiecs/config/config.py +762 -31
- aiecs/config/graph_config.py +131 -0
- aiecs/config/tool_config.py +399 -0
- aiecs/core/__init__.py +29 -13
- aiecs/core/interface/__init__.py +2 -2
- aiecs/core/interface/execution_interface.py +22 -22
- aiecs/core/interface/storage_interface.py +37 -88
- aiecs/core/registry/__init__.py +31 -0
- aiecs/core/registry/service_registry.py +92 -0
- aiecs/domain/__init__.py +270 -1
- aiecs/domain/agent/__init__.py +191 -0
- aiecs/domain/agent/base_agent.py +3870 -0
- aiecs/domain/agent/exceptions.py +99 -0
- aiecs/domain/agent/graph_aware_mixin.py +569 -0
- aiecs/domain/agent/hybrid_agent.py +1435 -0
- aiecs/domain/agent/integration/__init__.py +29 -0
- aiecs/domain/agent/integration/context_compressor.py +216 -0
- aiecs/domain/agent/integration/context_engine_adapter.py +587 -0
- aiecs/domain/agent/integration/protocols.py +281 -0
- aiecs/domain/agent/integration/retry_policy.py +218 -0
- aiecs/domain/agent/integration/role_config.py +213 -0
- aiecs/domain/agent/knowledge_aware_agent.py +1892 -0
- aiecs/domain/agent/lifecycle.py +291 -0
- aiecs/domain/agent/llm_agent.py +692 -0
- aiecs/domain/agent/memory/__init__.py +12 -0
- aiecs/domain/agent/memory/conversation.py +1124 -0
- aiecs/domain/agent/migration/__init__.py +14 -0
- aiecs/domain/agent/migration/conversion.py +163 -0
- aiecs/domain/agent/migration/legacy_wrapper.py +86 -0
- aiecs/domain/agent/models.py +884 -0
- aiecs/domain/agent/observability.py +479 -0
- aiecs/domain/agent/persistence.py +449 -0
- aiecs/domain/agent/prompts/__init__.py +29 -0
- aiecs/domain/agent/prompts/builder.py +159 -0
- aiecs/domain/agent/prompts/formatters.py +187 -0
- aiecs/domain/agent/prompts/template.py +255 -0
- aiecs/domain/agent/registry.py +253 -0
- aiecs/domain/agent/tool_agent.py +444 -0
- aiecs/domain/agent/tools/__init__.py +15 -0
- aiecs/domain/agent/tools/schema_generator.py +364 -0
- aiecs/domain/community/__init__.py +155 -0
- aiecs/domain/community/agent_adapter.py +469 -0
- aiecs/domain/community/analytics.py +432 -0
- aiecs/domain/community/collaborative_workflow.py +648 -0
- aiecs/domain/community/communication_hub.py +634 -0
- aiecs/domain/community/community_builder.py +320 -0
- aiecs/domain/community/community_integration.py +796 -0
- aiecs/domain/community/community_manager.py +803 -0
- aiecs/domain/community/decision_engine.py +849 -0
- aiecs/domain/community/exceptions.py +231 -0
- aiecs/domain/community/models/__init__.py +33 -0
- aiecs/domain/community/models/community_models.py +234 -0
- aiecs/domain/community/resource_manager.py +461 -0
- aiecs/domain/community/shared_context_manager.py +589 -0
- aiecs/domain/context/__init__.py +40 -10
- aiecs/domain/context/context_engine.py +1910 -0
- aiecs/domain/context/conversation_models.py +87 -53
- aiecs/domain/context/graph_memory.py +582 -0
- aiecs/domain/execution/model.py +12 -4
- aiecs/domain/knowledge_graph/__init__.py +19 -0
- aiecs/domain/knowledge_graph/models/__init__.py +52 -0
- aiecs/domain/knowledge_graph/models/entity.py +148 -0
- aiecs/domain/knowledge_graph/models/evidence.py +178 -0
- aiecs/domain/knowledge_graph/models/inference_rule.py +184 -0
- aiecs/domain/knowledge_graph/models/path.py +171 -0
- aiecs/domain/knowledge_graph/models/path_pattern.py +171 -0
- aiecs/domain/knowledge_graph/models/query.py +261 -0
- aiecs/domain/knowledge_graph/models/query_plan.py +181 -0
- aiecs/domain/knowledge_graph/models/relation.py +202 -0
- aiecs/domain/knowledge_graph/schema/__init__.py +23 -0
- aiecs/domain/knowledge_graph/schema/entity_type.py +131 -0
- aiecs/domain/knowledge_graph/schema/graph_schema.py +253 -0
- aiecs/domain/knowledge_graph/schema/property_schema.py +143 -0
- aiecs/domain/knowledge_graph/schema/relation_type.py +163 -0
- aiecs/domain/knowledge_graph/schema/schema_manager.py +691 -0
- aiecs/domain/knowledge_graph/schema/type_enums.py +209 -0
- aiecs/domain/task/dsl_processor.py +172 -56
- aiecs/domain/task/model.py +20 -8
- aiecs/domain/task/task_context.py +27 -24
- aiecs/infrastructure/__init__.py +0 -2
- aiecs/infrastructure/graph_storage/__init__.py +11 -0
- aiecs/infrastructure/graph_storage/base.py +837 -0
- aiecs/infrastructure/graph_storage/batch_operations.py +458 -0
- aiecs/infrastructure/graph_storage/cache.py +424 -0
- aiecs/infrastructure/graph_storage/distributed.py +223 -0
- aiecs/infrastructure/graph_storage/error_handling.py +380 -0
- aiecs/infrastructure/graph_storage/graceful_degradation.py +294 -0
- aiecs/infrastructure/graph_storage/health_checks.py +378 -0
- aiecs/infrastructure/graph_storage/in_memory.py +1197 -0
- aiecs/infrastructure/graph_storage/index_optimization.py +446 -0
- aiecs/infrastructure/graph_storage/lazy_loading.py +431 -0
- aiecs/infrastructure/graph_storage/metrics.py +344 -0
- aiecs/infrastructure/graph_storage/migration.py +400 -0
- aiecs/infrastructure/graph_storage/pagination.py +483 -0
- aiecs/infrastructure/graph_storage/performance_monitoring.py +456 -0
- aiecs/infrastructure/graph_storage/postgres.py +1563 -0
- aiecs/infrastructure/graph_storage/property_storage.py +353 -0
- aiecs/infrastructure/graph_storage/protocols.py +76 -0
- aiecs/infrastructure/graph_storage/query_optimizer.py +642 -0
- aiecs/infrastructure/graph_storage/schema_cache.py +290 -0
- aiecs/infrastructure/graph_storage/sqlite.py +1373 -0
- aiecs/infrastructure/graph_storage/streaming.py +487 -0
- aiecs/infrastructure/graph_storage/tenant.py +412 -0
- aiecs/infrastructure/messaging/celery_task_manager.py +92 -54
- aiecs/infrastructure/messaging/websocket_manager.py +51 -35
- aiecs/infrastructure/monitoring/__init__.py +22 -0
- aiecs/infrastructure/monitoring/executor_metrics.py +45 -11
- aiecs/infrastructure/monitoring/global_metrics_manager.py +212 -0
- aiecs/infrastructure/monitoring/structured_logger.py +3 -7
- aiecs/infrastructure/monitoring/tracing_manager.py +63 -35
- aiecs/infrastructure/persistence/__init__.py +14 -1
- aiecs/infrastructure/persistence/context_engine_client.py +184 -0
- aiecs/infrastructure/persistence/database_manager.py +67 -43
- aiecs/infrastructure/persistence/file_storage.py +180 -103
- aiecs/infrastructure/persistence/redis_client.py +74 -21
- aiecs/llm/__init__.py +73 -25
- aiecs/llm/callbacks/__init__.py +11 -0
- aiecs/llm/{custom_callbacks.py → callbacks/custom_callbacks.py} +26 -19
- aiecs/llm/client_factory.py +224 -36
- aiecs/llm/client_resolver.py +155 -0
- aiecs/llm/clients/__init__.py +38 -0
- aiecs/llm/clients/base_client.py +324 -0
- aiecs/llm/clients/google_function_calling_mixin.py +457 -0
- aiecs/llm/clients/googleai_client.py +241 -0
- aiecs/llm/clients/openai_client.py +158 -0
- aiecs/llm/clients/openai_compatible_mixin.py +367 -0
- aiecs/llm/clients/vertex_client.py +897 -0
- aiecs/llm/clients/xai_client.py +201 -0
- aiecs/llm/config/__init__.py +51 -0
- aiecs/llm/config/config_loader.py +272 -0
- aiecs/llm/config/config_validator.py +206 -0
- aiecs/llm/config/model_config.py +143 -0
- aiecs/llm/protocols.py +149 -0
- aiecs/llm/utils/__init__.py +10 -0
- aiecs/llm/utils/validate_config.py +89 -0
- aiecs/main.py +140 -121
- aiecs/scripts/aid/VERSION_MANAGEMENT.md +138 -0
- aiecs/scripts/aid/__init__.py +19 -0
- aiecs/scripts/aid/module_checker.py +499 -0
- aiecs/scripts/aid/version_manager.py +235 -0
- aiecs/scripts/{DEPENDENCY_SYSTEM_SUMMARY.md → dependance_check/DEPENDENCY_SYSTEM_SUMMARY.md} +1 -0
- aiecs/scripts/{README_DEPENDENCY_CHECKER.md → dependance_check/README_DEPENDENCY_CHECKER.md} +1 -0
- aiecs/scripts/dependance_check/__init__.py +15 -0
- aiecs/scripts/dependance_check/dependency_checker.py +1835 -0
- aiecs/scripts/{dependency_fixer.py → dependance_check/dependency_fixer.py} +192 -90
- aiecs/scripts/{download_nlp_data.py → dependance_check/download_nlp_data.py} +203 -71
- aiecs/scripts/dependance_patch/__init__.py +7 -0
- aiecs/scripts/dependance_patch/fix_weasel/__init__.py +11 -0
- aiecs/scripts/{fix_weasel_validator.py → dependance_patch/fix_weasel/fix_weasel_validator.py} +21 -14
- aiecs/scripts/{patch_weasel_library.sh → dependance_patch/fix_weasel/patch_weasel_library.sh} +1 -1
- aiecs/scripts/knowledge_graph/__init__.py +3 -0
- aiecs/scripts/knowledge_graph/run_threshold_experiments.py +212 -0
- aiecs/scripts/migrations/multi_tenancy/README.md +142 -0
- aiecs/scripts/tools_develop/README.md +671 -0
- aiecs/scripts/tools_develop/README_CONFIG_CHECKER.md +273 -0
- aiecs/scripts/tools_develop/TOOLS_CONFIG_GUIDE.md +1287 -0
- aiecs/scripts/tools_develop/TOOL_AUTO_DISCOVERY.md +234 -0
- aiecs/scripts/tools_develop/__init__.py +21 -0
- aiecs/scripts/tools_develop/check_all_tools_config.py +548 -0
- aiecs/scripts/tools_develop/check_type_annotations.py +257 -0
- aiecs/scripts/tools_develop/pre-commit-schema-coverage.sh +66 -0
- aiecs/scripts/tools_develop/schema_coverage.py +511 -0
- aiecs/scripts/tools_develop/validate_tool_schemas.py +475 -0
- aiecs/scripts/tools_develop/verify_executor_config_fix.py +98 -0
- aiecs/scripts/tools_develop/verify_tools.py +352 -0
- aiecs/tasks/__init__.py +0 -1
- aiecs/tasks/worker.py +115 -47
- aiecs/tools/__init__.py +194 -72
- aiecs/tools/apisource/__init__.py +99 -0
- aiecs/tools/apisource/intelligence/__init__.py +19 -0
- aiecs/tools/apisource/intelligence/data_fusion.py +632 -0
- aiecs/tools/apisource/intelligence/query_analyzer.py +417 -0
- aiecs/tools/apisource/intelligence/search_enhancer.py +385 -0
- aiecs/tools/apisource/monitoring/__init__.py +9 -0
- aiecs/tools/apisource/monitoring/metrics.py +330 -0
- aiecs/tools/apisource/providers/__init__.py +112 -0
- aiecs/tools/apisource/providers/base.py +671 -0
- aiecs/tools/apisource/providers/census.py +397 -0
- aiecs/tools/apisource/providers/fred.py +535 -0
- aiecs/tools/apisource/providers/newsapi.py +409 -0
- aiecs/tools/apisource/providers/worldbank.py +352 -0
- aiecs/tools/apisource/reliability/__init__.py +12 -0
- aiecs/tools/apisource/reliability/error_handler.py +363 -0
- aiecs/tools/apisource/reliability/fallback_strategy.py +376 -0
- aiecs/tools/apisource/tool.py +832 -0
- aiecs/tools/apisource/utils/__init__.py +9 -0
- aiecs/tools/apisource/utils/validators.py +334 -0
- aiecs/tools/base_tool.py +415 -21
- aiecs/tools/docs/__init__.py +121 -0
- aiecs/tools/docs/ai_document_orchestrator.py +607 -0
- aiecs/tools/docs/ai_document_writer_orchestrator.py +2350 -0
- aiecs/tools/docs/content_insertion_tool.py +1320 -0
- aiecs/tools/docs/document_creator_tool.py +1323 -0
- aiecs/tools/docs/document_layout_tool.py +1160 -0
- aiecs/tools/docs/document_parser_tool.py +1011 -0
- aiecs/tools/docs/document_writer_tool.py +1829 -0
- aiecs/tools/knowledge_graph/__init__.py +17 -0
- aiecs/tools/knowledge_graph/graph_reasoning_tool.py +807 -0
- aiecs/tools/knowledge_graph/graph_search_tool.py +944 -0
- aiecs/tools/knowledge_graph/kg_builder_tool.py +524 -0
- aiecs/tools/langchain_adapter.py +300 -138
- aiecs/tools/schema_generator.py +455 -0
- aiecs/tools/search_tool/__init__.py +100 -0
- aiecs/tools/search_tool/analyzers.py +581 -0
- aiecs/tools/search_tool/cache.py +264 -0
- aiecs/tools/search_tool/constants.py +128 -0
- aiecs/tools/search_tool/context.py +224 -0
- aiecs/tools/search_tool/core.py +778 -0
- aiecs/tools/search_tool/deduplicator.py +119 -0
- aiecs/tools/search_tool/error_handler.py +242 -0
- aiecs/tools/search_tool/metrics.py +343 -0
- aiecs/tools/search_tool/rate_limiter.py +172 -0
- aiecs/tools/search_tool/schemas.py +275 -0
- aiecs/tools/statistics/__init__.py +80 -0
- aiecs/tools/statistics/ai_data_analysis_orchestrator.py +646 -0
- aiecs/tools/statistics/ai_insight_generator_tool.py +508 -0
- aiecs/tools/statistics/ai_report_orchestrator_tool.py +684 -0
- aiecs/tools/statistics/data_loader_tool.py +555 -0
- aiecs/tools/statistics/data_profiler_tool.py +638 -0
- aiecs/tools/statistics/data_transformer_tool.py +580 -0
- aiecs/tools/statistics/data_visualizer_tool.py +498 -0
- aiecs/tools/statistics/model_trainer_tool.py +507 -0
- aiecs/tools/statistics/statistical_analyzer_tool.py +472 -0
- aiecs/tools/task_tools/__init__.py +49 -36
- aiecs/tools/task_tools/chart_tool.py +200 -184
- aiecs/tools/task_tools/classfire_tool.py +268 -267
- aiecs/tools/task_tools/image_tool.py +175 -131
- aiecs/tools/task_tools/office_tool.py +226 -146
- aiecs/tools/task_tools/pandas_tool.py +477 -121
- aiecs/tools/task_tools/report_tool.py +390 -142
- aiecs/tools/task_tools/research_tool.py +149 -79
- aiecs/tools/task_tools/scraper_tool.py +339 -145
- aiecs/tools/task_tools/stats_tool.py +448 -209
- aiecs/tools/temp_file_manager.py +26 -24
- aiecs/tools/tool_executor/__init__.py +18 -16
- aiecs/tools/tool_executor/tool_executor.py +364 -52
- aiecs/utils/LLM_output_structor.py +74 -48
- aiecs/utils/__init__.py +14 -3
- aiecs/utils/base_callback.py +0 -3
- aiecs/utils/cache_provider.py +696 -0
- aiecs/utils/execution_utils.py +50 -31
- aiecs/utils/prompt_loader.py +1 -0
- aiecs/utils/token_usage_repository.py +37 -11
- aiecs/ws/socket_server.py +14 -4
- {aiecs-1.0.1.dist-info → aiecs-1.7.6.dist-info}/METADATA +52 -15
- aiecs-1.7.6.dist-info/RECORD +337 -0
- aiecs-1.7.6.dist-info/entry_points.txt +13 -0
- aiecs/config/registry.py +0 -19
- aiecs/domain/context/content_engine.py +0 -982
- aiecs/llm/base_client.py +0 -99
- aiecs/llm/openai_client.py +0 -125
- aiecs/llm/vertex_client.py +0 -186
- aiecs/llm/xai_client.py +0 -184
- aiecs/scripts/dependency_checker.py +0 -857
- aiecs/scripts/quick_dependency_check.py +0 -269
- aiecs/tools/task_tools/search_api.py +0 -7
- aiecs-1.0.1.dist-info/RECORD +0 -90
- aiecs-1.0.1.dist-info/entry_points.txt +0 -7
- /aiecs/scripts/{setup_nlp_data.sh → dependance_check/setup_nlp_data.sh} +0 -0
- /aiecs/scripts/{README_WEASEL_PATCH.md → dependance_patch/fix_weasel/README_WEASEL_PATCH.md} +0 -0
- /aiecs/scripts/{fix_weasel_validator.sh → dependance_patch/fix_weasel/fix_weasel_validator.sh} +0 -0
- /aiecs/scripts/{run_weasel_patch.sh → dependance_patch/fix_weasel/run_weasel_patch.sh} +0 -0
- {aiecs-1.0.1.dist-info → aiecs-1.7.6.dist-info}/WHEEL +0 -0
- {aiecs-1.0.1.dist-info → aiecs-1.7.6.dist-info}/licenses/LICENSE +0 -0
- {aiecs-1.0.1.dist-info → aiecs-1.7.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1910 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ContextEngine: Advanced Context and Session Management Engine
|
|
3
|
+
|
|
4
|
+
This engine extends TaskContext capabilities to provide comprehensive
|
|
5
|
+
session management, conversation tracking, and persistent storage for BaseAIService.
|
|
6
|
+
|
|
7
|
+
Key Features:
|
|
8
|
+
1. Multi-session management (extends TaskContext from single task to multiple sessions)
|
|
9
|
+
2. Redis backend storage for persistence and scalability
|
|
10
|
+
3. Conversation history management with optimization
|
|
11
|
+
4. Performance metrics and analytics
|
|
12
|
+
5. Resource and lifecycle management
|
|
13
|
+
6. Integration with BaseServiceCheckpointer
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from aiecs.core.interface.storage_interface import (
|
|
17
|
+
IStorageBackend,
|
|
18
|
+
ICheckpointerBackend,
|
|
19
|
+
)
|
|
20
|
+
from aiecs.domain.task.task_context import TaskContext, ContextUpdate
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import uuid
|
|
24
|
+
from datetime import datetime, timedelta
|
|
25
|
+
from typing import Dict, Any, List, Optional
|
|
26
|
+
from dataclasses import dataclass, asdict, is_dataclass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DateTimeEncoder(json.JSONEncoder):
|
|
30
|
+
"""Custom JSON encoder to handle datetime objects."""
|
|
31
|
+
|
|
32
|
+
def default(self, obj):
|
|
33
|
+
if isinstance(obj, datetime):
|
|
34
|
+
return obj.isoformat()
|
|
35
|
+
return super().default(obj)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Import TaskContext for base functionality
|
|
39
|
+
|
|
40
|
+
# Import core storage interfaces
|
|
41
|
+
|
|
42
|
+
# Redis client import - use existing infrastructure
|
|
43
|
+
try:
|
|
44
|
+
import redis.asyncio as redis
|
|
45
|
+
from aiecs.infrastructure.persistence.redis_client import get_redis_client
|
|
46
|
+
|
|
47
|
+
REDIS_AVAILABLE = True
|
|
48
|
+
except ImportError:
|
|
49
|
+
redis = None # type: ignore[assignment]
|
|
50
|
+
get_redis_client = None # type: ignore[assignment]
|
|
51
|
+
REDIS_AVAILABLE = False
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class SessionMetrics:
|
|
58
|
+
"""Session-level performance metrics."""
|
|
59
|
+
|
|
60
|
+
session_id: str
|
|
61
|
+
user_id: str
|
|
62
|
+
created_at: datetime
|
|
63
|
+
last_activity: datetime
|
|
64
|
+
request_count: int = 0
|
|
65
|
+
error_count: int = 0
|
|
66
|
+
total_processing_time: float = 0.0
|
|
67
|
+
status: str = "active" # active, completed, failed, expired
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
**asdict(self),
|
|
72
|
+
"created_at": self.created_at.isoformat(),
|
|
73
|
+
"last_activity": self.last_activity.isoformat(),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, data: Dict[str, Any]) -> "SessionMetrics":
|
|
78
|
+
data = data.copy()
|
|
79
|
+
data["created_at"] = datetime.fromisoformat(data["created_at"])
|
|
80
|
+
data["last_activity"] = datetime.fromisoformat(data["last_activity"])
|
|
81
|
+
return cls(**data)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ConversationMessage:
|
|
86
|
+
"""Structured conversation message."""
|
|
87
|
+
|
|
88
|
+
role: str # user, assistant, system
|
|
89
|
+
content: str
|
|
90
|
+
timestamp: datetime
|
|
91
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
94
|
+
return {
|
|
95
|
+
"role": self.role,
|
|
96
|
+
"content": self.content,
|
|
97
|
+
"timestamp": self.timestamp.isoformat(),
|
|
98
|
+
"metadata": self.metadata or {},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ConversationMessage":
|
|
103
|
+
data = data.copy()
|
|
104
|
+
data["timestamp"] = datetime.fromisoformat(data["timestamp"])
|
|
105
|
+
return cls(**data)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class CompressionConfig:
|
|
110
|
+
"""
|
|
111
|
+
Configuration for conversation compression.
|
|
112
|
+
|
|
113
|
+
Provides flexible control over compression behavior with multiple strategies
|
|
114
|
+
to manage conversation history size and reduce token usage.
|
|
115
|
+
|
|
116
|
+
**Compression Strategies:**
|
|
117
|
+
- truncate: Fast truncation, keeps most recent N messages (no LLM required)
|
|
118
|
+
- summarize: LLM-based summarization of older messages
|
|
119
|
+
- semantic: Embedding-based deduplication of similar messages
|
|
120
|
+
- hybrid: Combination of multiple strategies applied sequentially
|
|
121
|
+
|
|
122
|
+
**Key Features:**
|
|
123
|
+
- Automatic compression triggers based on message count
|
|
124
|
+
- Custom prompt templates for summarization
|
|
125
|
+
- Configurable similarity thresholds for semantic deduplication
|
|
126
|
+
- Performance timeouts to prevent long-running operations
|
|
127
|
+
|
|
128
|
+
Attributes:
|
|
129
|
+
strategy: Compression strategy to use. One of: "truncate", "summarize", "semantic", "hybrid"
|
|
130
|
+
max_messages: Maximum messages to keep (for truncation strategy)
|
|
131
|
+
keep_recent: Always keep N most recent messages (applies to all strategies)
|
|
132
|
+
summary_prompt_template: Custom prompt template for summarization (uses {messages} placeholder)
|
|
133
|
+
summary_max_tokens: Maximum tokens for summary output
|
|
134
|
+
include_summary_in_history: Whether to add summary as system message in history
|
|
135
|
+
similarity_threshold: Similarity threshold for semantic deduplication (0.0-1.0)
|
|
136
|
+
embedding_model: Embedding model name for semantic deduplication
|
|
137
|
+
hybrid_strategies: List of strategies to combine for hybrid mode (default: ["truncate", "summarize"])
|
|
138
|
+
auto_compress_enabled: Enable automatic compression when threshold exceeded
|
|
139
|
+
auto_compress_threshold: Message count threshold to trigger auto-compression
|
|
140
|
+
auto_compress_target: Target message count after auto-compression
|
|
141
|
+
compression_timeout: Maximum time for compression operation in seconds
|
|
142
|
+
|
|
143
|
+
Examples:
|
|
144
|
+
# Example 1: Basic truncation configuration
|
|
145
|
+
config = CompressionConfig(
|
|
146
|
+
strategy="truncate",
|
|
147
|
+
max_messages=50,
|
|
148
|
+
keep_recent=10
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Example 2: LLM-based summarization
|
|
152
|
+
config = CompressionConfig(
|
|
153
|
+
strategy="summarize",
|
|
154
|
+
keep_recent=10,
|
|
155
|
+
summary_max_tokens=500,
|
|
156
|
+
include_summary_in_history=True
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Example 3: Semantic deduplication
|
|
160
|
+
config = CompressionConfig(
|
|
161
|
+
strategy="semantic",
|
|
162
|
+
keep_recent=10,
|
|
163
|
+
similarity_threshold=0.95,
|
|
164
|
+
embedding_model="text-embedding-ada-002"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Example 4: Hybrid strategy (truncate then summarize)
|
|
168
|
+
config = CompressionConfig(
|
|
169
|
+
strategy="hybrid",
|
|
170
|
+
hybrid_strategies=["truncate", "summarize"],
|
|
171
|
+
keep_recent=10,
|
|
172
|
+
summary_max_tokens=500
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Example 5: Auto-compression enabled
|
|
176
|
+
config = CompressionConfig(
|
|
177
|
+
auto_compress_enabled=True,
|
|
178
|
+
auto_compress_threshold=100,
|
|
179
|
+
auto_compress_target=50,
|
|
180
|
+
strategy="summarize",
|
|
181
|
+
keep_recent=10
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Example 6: Custom summarization prompt
|
|
185
|
+
config = CompressionConfig(
|
|
186
|
+
strategy="summarize",
|
|
187
|
+
summary_prompt_template=(
|
|
188
|
+
"Summarize the following conversation focusing on "
|
|
189
|
+
"key decisions and action items:\n\n{messages}"
|
|
190
|
+
),
|
|
191
|
+
summary_max_tokens=300
|
|
192
|
+
)
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
# Strategy selection
|
|
196
|
+
strategy: str = "truncate" # truncate, summarize, semantic, hybrid
|
|
197
|
+
|
|
198
|
+
# Truncation settings
|
|
199
|
+
max_messages: int = 50 # Maximum messages to keep
|
|
200
|
+
keep_recent: int = 10 # Always keep N most recent messages
|
|
201
|
+
|
|
202
|
+
# Summarization settings (LLM-based)
|
|
203
|
+
summary_prompt_template: Optional[str] = None # Custom prompt template
|
|
204
|
+
summary_max_tokens: int = 500 # Max tokens for summary
|
|
205
|
+
include_summary_in_history: bool = True # Add summary as system message
|
|
206
|
+
|
|
207
|
+
# Semantic deduplication settings (embedding-based)
|
|
208
|
+
similarity_threshold: float = 0.95 # Messages above this similarity are duplicates
|
|
209
|
+
embedding_model: str = "text-embedding-ada-002" # Embedding model to use
|
|
210
|
+
|
|
211
|
+
# Hybrid strategy settings
|
|
212
|
+
hybrid_strategies: Optional[List[str]] = None # Strategies to combine (default: ["truncate", "summarize"])
|
|
213
|
+
|
|
214
|
+
# Auto-compression triggers
|
|
215
|
+
auto_compress_enabled: bool = False # Enable automatic compression
|
|
216
|
+
auto_compress_threshold: int = 100 # Trigger when message count exceeds this
|
|
217
|
+
auto_compress_target: int = 50 # Target message count after compression
|
|
218
|
+
|
|
219
|
+
# Performance settings
|
|
220
|
+
compression_timeout: float = 30.0 # Max time for compression operation (seconds)
|
|
221
|
+
|
|
222
|
+
def __post_init__(self):
|
|
223
|
+
"""Validate and set defaults."""
|
|
224
|
+
if self.hybrid_strategies is None:
|
|
225
|
+
self.hybrid_strategies = ["truncate", "summarize"]
|
|
226
|
+
|
|
227
|
+
# Validate strategy
|
|
228
|
+
valid_strategies = ["truncate", "summarize", "semantic", "hybrid"]
|
|
229
|
+
if self.strategy not in valid_strategies:
|
|
230
|
+
raise ValueError(f"Invalid strategy '{self.strategy}'. " f"Must be one of: {', '.join(valid_strategies)}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class ContextEngine(IStorageBackend, ICheckpointerBackend):
|
|
234
|
+
"""
|
|
235
|
+
Advanced Context and Session Management Engine.
|
|
236
|
+
|
|
237
|
+
Implements core storage interfaces to provide comprehensive session management
|
|
238
|
+
with Redis backend storage for BaseAIService and BaseServiceCheckpointer.
|
|
239
|
+
|
|
240
|
+
This implementation follows the middleware's core interface pattern,
|
|
241
|
+
enabling dependency inversion and clean architecture.
|
|
242
|
+
|
|
243
|
+
**Key Features:**
|
|
244
|
+
- Multi-session management with Redis backend
|
|
245
|
+
- Conversation history management with compression
|
|
246
|
+
- Performance metrics and analytics
|
|
247
|
+
- Resource and lifecycle management
|
|
248
|
+
- Integration with BaseServiceCheckpointer
|
|
249
|
+
|
|
250
|
+
**Compression Strategies:**
|
|
251
|
+
- truncate: Fast truncation (no LLM required)
|
|
252
|
+
- summarize: LLM-based summarization
|
|
253
|
+
- semantic: Embedding-based deduplication
|
|
254
|
+
- hybrid: Combination of multiple strategies
|
|
255
|
+
|
|
256
|
+
Examples:
|
|
257
|
+
# Example 1: Basic ContextEngine initialization
|
|
258
|
+
engine = ContextEngine()
|
|
259
|
+
await engine.initialize()
|
|
260
|
+
|
|
261
|
+
# Create session
|
|
262
|
+
session = await engine.create_session(
|
|
263
|
+
session_id="session-123",
|
|
264
|
+
user_id="user-456"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Add conversation messages
|
|
268
|
+
await engine.add_conversation_message(
|
|
269
|
+
session_id="session-123",
|
|
270
|
+
role="user",
|
|
271
|
+
content="Hello, I need help"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Example 2: ContextEngine with compression (truncation strategy)
|
|
275
|
+
from aiecs.domain.context.context_engine import CompressionConfig
|
|
276
|
+
|
|
277
|
+
compression_config = CompressionConfig(
|
|
278
|
+
strategy="truncate",
|
|
279
|
+
max_messages=50,
|
|
280
|
+
keep_recent=10 # Always keep 10 most recent messages
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
engine = ContextEngine(compression_config=compression_config)
|
|
284
|
+
await engine.initialize()
|
|
285
|
+
|
|
286
|
+
# Add many messages
|
|
287
|
+
for i in range(100):
|
|
288
|
+
await engine.add_conversation_message(
|
|
289
|
+
session_id="session-123",
|
|
290
|
+
role="user" if i % 2 == 0 else "assistant",
|
|
291
|
+
content=f"Message {i}"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Compress conversation (truncates to 10 most recent)
|
|
295
|
+
result = await engine.compress_conversation("session-123")
|
|
296
|
+
print(f"Compressed from {result['original_count']} to {result['compressed_count']} messages")
|
|
297
|
+
|
|
298
|
+
# Example 3: ContextEngine with LLM-based summarization
|
|
299
|
+
from aiecs.llm import OpenAIClient
|
|
300
|
+
|
|
301
|
+
llm_client = OpenAIClient()
|
|
302
|
+
|
|
303
|
+
compression_config = CompressionConfig(
|
|
304
|
+
strategy="summarize",
|
|
305
|
+
keep_recent=10, # Keep 10 most recent messages
|
|
306
|
+
summary_max_tokens=500,
|
|
307
|
+
include_summary_in_history=True
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
engine = ContextEngine(
|
|
311
|
+
compression_config=compression_config,
|
|
312
|
+
llm_client=llm_client # Required for summarization
|
|
313
|
+
)
|
|
314
|
+
await engine.initialize()
|
|
315
|
+
|
|
316
|
+
# Add conversation
|
|
317
|
+
for i in range(50):
|
|
318
|
+
await engine.add_conversation_message(
|
|
319
|
+
session_id="session-123",
|
|
320
|
+
role="user" if i % 2 == 0 else "assistant",
|
|
321
|
+
content=f"Message {i}: Important information about topic {i % 5}"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Compress using summarization
|
|
325
|
+
result = await engine.compress_conversation("session-123", strategy="summarize")
|
|
326
|
+
print(f"Compressed: {result['original_count']} -> {result['compressed_count']} messages")
|
|
327
|
+
print(f"Compression ratio: {result['compression_ratio']:.1%}")
|
|
328
|
+
|
|
329
|
+
# Example 4: ContextEngine with semantic deduplication
|
|
330
|
+
compression_config = CompressionConfig(
|
|
331
|
+
strategy="semantic",
|
|
332
|
+
keep_recent=10,
|
|
333
|
+
similarity_threshold=0.95, # Remove messages >95% similar
|
|
334
|
+
embedding_model="text-embedding-ada-002"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
engine = ContextEngine(
|
|
338
|
+
compression_config=compression_config,
|
|
339
|
+
llm_client=llm_client # Required for embeddings
|
|
340
|
+
)
|
|
341
|
+
await engine.initialize()
|
|
342
|
+
|
|
343
|
+
# Add conversation with similar messages
|
|
344
|
+
messages = [
|
|
345
|
+
"What's the weather?",
|
|
346
|
+
"What's the weather today?",
|
|
347
|
+
"Tell me about the weather",
|
|
348
|
+
"What's the temperature?"
|
|
349
|
+
]
|
|
350
|
+
for msg in messages:
|
|
351
|
+
await engine.add_conversation_message(
|
|
352
|
+
session_id="session-123",
|
|
353
|
+
role="user",
|
|
354
|
+
content=msg
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Compress using semantic deduplication
|
|
358
|
+
result = await engine.compress_conversation("session-123", strategy="semantic")
|
|
359
|
+
print(f"Removed {result['original_count'] - result['compressed_count']} similar messages")
|
|
360
|
+
|
|
361
|
+
# Example 5: ContextEngine with hybrid compression
|
|
362
|
+
compression_config = CompressionConfig(
|
|
363
|
+
strategy="hybrid",
|
|
364
|
+
hybrid_strategies=["truncate", "summarize"], # Apply truncate then summarize
|
|
365
|
+
keep_recent=10,
|
|
366
|
+
summary_max_tokens=500
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
engine = ContextEngine(
|
|
370
|
+
compression_config=compression_config,
|
|
371
|
+
llm_client=llm_client
|
|
372
|
+
)
|
|
373
|
+
await engine.initialize()
|
|
374
|
+
|
|
375
|
+
# Compress using hybrid strategy
|
|
376
|
+
result = await engine.compress_conversation("session-123", strategy="hybrid")
|
|
377
|
+
|
|
378
|
+
# Example 6: Auto-compression on message limit
|
|
379
|
+
compression_config = CompressionConfig(
|
|
380
|
+
auto_compress_enabled=True,
|
|
381
|
+
auto_compress_threshold=100, # Trigger at 100 messages
|
|
382
|
+
auto_compress_target=50, # Compress to 50 messages
|
|
383
|
+
strategy="summarize",
|
|
384
|
+
keep_recent=10
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
engine = ContextEngine(
|
|
388
|
+
compression_config=compression_config,
|
|
389
|
+
llm_client=llm_client
|
|
390
|
+
)
|
|
391
|
+
await engine.initialize()
|
|
392
|
+
|
|
393
|
+
# Add messages - auto-compression triggers at 100
|
|
394
|
+
for i in range(105):
|
|
395
|
+
await engine.add_conversation_message(
|
|
396
|
+
session_id="session-123",
|
|
397
|
+
role="user" if i % 2 == 0 else "assistant",
|
|
398
|
+
content=f"Message {i}"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Check if auto-compression was triggered
|
|
402
|
+
result = await engine.auto_compress_on_limit("session-123")
|
|
403
|
+
if result:
|
|
404
|
+
print(f"Auto-compressed: {result['original_count']} -> {result['compressed_count']}")
|
|
405
|
+
|
|
406
|
+
# Example 7: Custom compression prompt template
|
|
407
|
+
compression_config = CompressionConfig(
|
|
408
|
+
strategy="summarize",
|
|
409
|
+
summary_prompt_template=(
|
|
410
|
+
"Summarize the following conversation focusing on key decisions, "
|
|
411
|
+
"action items, and important facts. Keep it concise:\n\n{messages}"
|
|
412
|
+
),
|
|
413
|
+
summary_max_tokens=300
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
engine = ContextEngine(
|
|
417
|
+
compression_config=compression_config,
|
|
418
|
+
llm_client=llm_client
|
|
419
|
+
)
|
|
420
|
+
await engine.initialize()
|
|
421
|
+
|
|
422
|
+
# Compress with custom prompt
|
|
423
|
+
result = await engine.compress_conversation("session-123")
|
|
424
|
+
|
|
425
|
+
# Example 8: Get compressed context in different formats
|
|
426
|
+
engine = ContextEngine(compression_config=compression_config, llm_client=llm_client)
|
|
427
|
+
await engine.initialize()
|
|
428
|
+
|
|
429
|
+
# Get as formatted string
|
|
430
|
+
context_string = await engine.get_compressed_context(
|
|
431
|
+
session_id="session-123",
|
|
432
|
+
format="string",
|
|
433
|
+
compress_first=True # Compress before returning
|
|
434
|
+
)
|
|
435
|
+
print(context_string)
|
|
436
|
+
|
|
437
|
+
# Get as messages list
|
|
438
|
+
messages = await engine.get_compressed_context(
|
|
439
|
+
session_id="session-123",
|
|
440
|
+
format="messages",
|
|
441
|
+
compress_first=False # Use existing compressed version
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Get as dictionary
|
|
445
|
+
context_dict = await engine.get_compressed_context(
|
|
446
|
+
session_id="session-123",
|
|
447
|
+
format="dict"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Example 9: Runtime compression config override
|
|
451
|
+
engine = ContextEngine(
|
|
452
|
+
compression_config=CompressionConfig(strategy="truncate"),
|
|
453
|
+
llm_client=llm_client
|
|
454
|
+
)
|
|
455
|
+
await engine.initialize()
|
|
456
|
+
|
|
457
|
+
# Override compression config for specific operation
|
|
458
|
+
custom_config = CompressionConfig(
|
|
459
|
+
strategy="summarize",
|
|
460
|
+
summary_max_tokens=1000
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
result = await engine.compress_conversation(
|
|
464
|
+
session_id="session-123",
|
|
465
|
+
config_override=custom_config
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# Example 10: Compression with custom LLM client
|
|
469
|
+
class CustomLLMClient:
|
|
470
|
+
provider_name = "custom"
|
|
471
|
+
|
|
472
|
+
async def generate_text(self, messages, **kwargs):
|
|
473
|
+
# Custom summarization logic
|
|
474
|
+
return LLMResponse(content="Custom summary...")
|
|
475
|
+
|
|
476
|
+
async def get_embeddings(self, texts, model):
|
|
477
|
+
# Custom embedding logic
|
|
478
|
+
return [[0.1] * 1536 for _ in texts]
|
|
479
|
+
|
|
480
|
+
custom_llm = CustomLLMClient()
|
|
481
|
+
|
|
482
|
+
compression_config = CompressionConfig(strategy="semantic")
|
|
483
|
+
engine = ContextEngine(
|
|
484
|
+
compression_config=compression_config,
|
|
485
|
+
llm_client=custom_llm # Custom LLM client for compression
|
|
486
|
+
)
|
|
487
|
+
await engine.initialize()
|
|
488
|
+
|
|
489
|
+
# Compress using custom LLM client
|
|
490
|
+
result = await engine.compress_conversation("session-123", strategy="semantic")
|
|
491
|
+
"""
|
|
492
|
+
|
|
493
|
+
def __init__(
|
|
494
|
+
self,
|
|
495
|
+
use_existing_redis: bool = True,
|
|
496
|
+
compression_config: Optional[CompressionConfig] = None,
|
|
497
|
+
llm_client: Optional[Any] = None,
|
|
498
|
+
):
|
|
499
|
+
"""
|
|
500
|
+
Initialize ContextEngine.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
use_existing_redis: Whether to use the existing Redis client from infrastructure
|
|
504
|
+
(已弃用: 现在总是创建独立的 RedisClient 实例以避免事件循环冲突)
|
|
505
|
+
compression_config: Optional compression configuration for conversation compression
|
|
506
|
+
llm_client: Optional LLM client for summarization and embeddings (must implement LLMClientProtocol)
|
|
507
|
+
"""
|
|
508
|
+
self.use_existing_redis = use_existing_redis
|
|
509
|
+
self.redis_client: Optional[redis.Redis] = None
|
|
510
|
+
self._redis_client_wrapper: Optional[Any] = None # RedisClient 包装器实例
|
|
511
|
+
|
|
512
|
+
# Fallback to memory storage if Redis not available
|
|
513
|
+
self._memory_sessions: Dict[str, SessionMetrics] = {}
|
|
514
|
+
self._memory_conversations: Dict[str, List[ConversationMessage]] = {}
|
|
515
|
+
self._memory_contexts: Dict[str, TaskContext] = {}
|
|
516
|
+
self._memory_checkpoints: Dict[str, Dict[str, Any]] = {}
|
|
517
|
+
|
|
518
|
+
# Configuration
|
|
519
|
+
self.session_ttl = 3600 * 24 # 24 hours default TTL
|
|
520
|
+
self.conversation_limit = 1000 # Max messages per conversation
|
|
521
|
+
self.checkpoint_ttl = 3600 * 24 * 7 # 7 days for checkpoints
|
|
522
|
+
|
|
523
|
+
# Compression configuration (Phase 6)
|
|
524
|
+
self.compression_config = compression_config or CompressionConfig()
|
|
525
|
+
self.llm_client = llm_client
|
|
526
|
+
|
|
527
|
+
# Metrics
|
|
528
|
+
self._global_metrics = {
|
|
529
|
+
"total_sessions": 0,
|
|
530
|
+
"active_sessions": 0,
|
|
531
|
+
"total_messages": 0,
|
|
532
|
+
"total_checkpoints": 0,
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
logger.info(f"ContextEngine initialized with compression strategy: {self.compression_config.strategy}")
|
|
536
|
+
|
|
537
|
+
async def initialize(self) -> bool:
|
|
538
|
+
"""Initialize Redis connection and validate setup."""
|
|
539
|
+
if not REDIS_AVAILABLE:
|
|
540
|
+
logger.warning("Redis not available, using memory storage")
|
|
541
|
+
return True
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
# ✅ 修复方案:在当前事件循环中创建新的 RedisClient 实例
|
|
545
|
+
#
|
|
546
|
+
# 问题根源:
|
|
547
|
+
# - 全局 RedisClient 单例在应用启动的事件循环A中创建
|
|
548
|
+
# - ContextEngine 可能在不同的事件循环B中被初始化(例如在请求处理中)
|
|
549
|
+
# - redis.asyncio 的连接池绑定到创建时的事件循环
|
|
550
|
+
# - 跨事件循环使用会导致 "Task got Future attached to a different loop" 错误
|
|
551
|
+
#
|
|
552
|
+
# 解决方案:
|
|
553
|
+
# - 为每个 ContextEngine 实例创建独立的 RedisClient
|
|
554
|
+
# - 使用 RedisClient 包装器保持架构一致性
|
|
555
|
+
# - 在当前事件循环中初始化,确保事件循环匹配
|
|
556
|
+
|
|
557
|
+
from aiecs.infrastructure.persistence.redis_client import (
|
|
558
|
+
RedisClient,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# 创建专属的 RedisClient 实例(在当前事件循环中)
|
|
562
|
+
self._redis_client_wrapper = RedisClient()
|
|
563
|
+
await self._redis_client_wrapper.initialize()
|
|
564
|
+
|
|
565
|
+
# 获取底层 redis.Redis 客户端用于现有代码
|
|
566
|
+
self.redis_client = await self._redis_client_wrapper.get_client()
|
|
567
|
+
|
|
568
|
+
# Test connection
|
|
569
|
+
await self.redis_client.ping()
|
|
570
|
+
logger.info("ContextEngine connected to Redis successfully using RedisClient wrapper in current event loop")
|
|
571
|
+
return True
|
|
572
|
+
|
|
573
|
+
except Exception as e:
|
|
574
|
+
logger.error(f"Failed to connect to Redis: {e}")
|
|
575
|
+
logger.warning("Falling back to memory storage")
|
|
576
|
+
self.redis_client = None
|
|
577
|
+
self._redis_client_wrapper = None
|
|
578
|
+
return False
|
|
579
|
+
|
|
580
|
+
async def close(self):
|
|
581
|
+
"""Close Redis connection."""
|
|
582
|
+
if hasattr(self, "_redis_client_wrapper") and self._redis_client_wrapper:
|
|
583
|
+
# 使用 RedisClient 包装器的 close 方法
|
|
584
|
+
await self._redis_client_wrapper.close()
|
|
585
|
+
self._redis_client_wrapper = None
|
|
586
|
+
self.redis_client = None
|
|
587
|
+
elif self.redis_client:
|
|
588
|
+
# 兼容性处理:直接关闭 redis 客户端
|
|
589
|
+
await self.redis_client.close()
|
|
590
|
+
self.redis_client = None
|
|
591
|
+
|
|
592
|
+
# ==================== Session Management ====================
|
|
593
|
+
|
|
594
|
+
async def create_session(self, session_id: str, user_id: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
595
|
+
"""Create a new session."""
|
|
596
|
+
now = datetime.utcnow()
|
|
597
|
+
session = SessionMetrics(
|
|
598
|
+
session_id=session_id,
|
|
599
|
+
user_id=user_id,
|
|
600
|
+
created_at=now,
|
|
601
|
+
last_activity=now,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# Store session
|
|
605
|
+
await self._store_session(session)
|
|
606
|
+
|
|
607
|
+
# Create associated TaskContext
|
|
608
|
+
task_context = TaskContext(
|
|
609
|
+
{
|
|
610
|
+
"user_id": user_id,
|
|
611
|
+
"chat_id": session_id,
|
|
612
|
+
"metadata": metadata or {},
|
|
613
|
+
}
|
|
614
|
+
)
|
|
615
|
+
await self._store_task_context(session_id, task_context)
|
|
616
|
+
|
|
617
|
+
# Update metrics
|
|
618
|
+
self._global_metrics["total_sessions"] += 1
|
|
619
|
+
self._global_metrics["active_sessions"] += 1
|
|
620
|
+
|
|
621
|
+
logger.info(f"Created session {session_id} for user {user_id}")
|
|
622
|
+
return session.to_dict()
|
|
623
|
+
|
|
624
|
+
async def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
625
|
+
"""Get session by ID."""
|
|
626
|
+
if self.redis_client:
|
|
627
|
+
try:
|
|
628
|
+
data = await self.redis_client.hget("sessions", session_id) # type: ignore[misc]
|
|
629
|
+
if data:
|
|
630
|
+
session = SessionMetrics.from_dict(json.loads(data))
|
|
631
|
+
return session.to_dict()
|
|
632
|
+
except Exception as e:
|
|
633
|
+
logger.error(f"Failed to get session from Redis: {e}")
|
|
634
|
+
|
|
635
|
+
# Fallback to memory
|
|
636
|
+
memory_session: Optional[SessionMetrics] = self._memory_sessions.get(session_id)
|
|
637
|
+
return memory_session.to_dict() if memory_session else None
|
|
638
|
+
|
|
639
|
+
async def update_session(
|
|
640
|
+
self,
|
|
641
|
+
session_id: str,
|
|
642
|
+
updates: Optional[Dict[str, Any]] = None,
|
|
643
|
+
increment_requests: bool = False,
|
|
644
|
+
add_processing_time: float = 0.0,
|
|
645
|
+
mark_error: bool = False,
|
|
646
|
+
) -> bool:
|
|
647
|
+
"""Update session with activity and metrics."""
|
|
648
|
+
session_data = await self.get_session(session_id)
|
|
649
|
+
if not session_data:
|
|
650
|
+
return False
|
|
651
|
+
|
|
652
|
+
# Convert dict to SessionMetrics if needed
|
|
653
|
+
session: SessionMetrics
|
|
654
|
+
if isinstance(session_data, dict):
|
|
655
|
+
session = SessionMetrics.from_dict(session_data)
|
|
656
|
+
else:
|
|
657
|
+
session = session_data
|
|
658
|
+
|
|
659
|
+
# Update activity
|
|
660
|
+
session.last_activity = datetime.utcnow()
|
|
661
|
+
|
|
662
|
+
# Update metrics
|
|
663
|
+
if increment_requests:
|
|
664
|
+
session.request_count += 1
|
|
665
|
+
if add_processing_time > 0:
|
|
666
|
+
session.total_processing_time += add_processing_time
|
|
667
|
+
if mark_error:
|
|
668
|
+
session.error_count += 1
|
|
669
|
+
|
|
670
|
+
# Apply custom updates
|
|
671
|
+
if updates:
|
|
672
|
+
for key, value in updates.items():
|
|
673
|
+
if hasattr(session, key):
|
|
674
|
+
setattr(session, key, value)
|
|
675
|
+
|
|
676
|
+
# Store updated session
|
|
677
|
+
await self._store_session(session)
|
|
678
|
+
return True
|
|
679
|
+
|
|
680
|
+
async def end_session(self, session_id: str, status: str = "completed") -> bool:
|
|
681
|
+
"""End a session and update metrics."""
|
|
682
|
+
session_data = await self.get_session(session_id)
|
|
683
|
+
if not session_data:
|
|
684
|
+
return False
|
|
685
|
+
|
|
686
|
+
# Convert dict to SessionMetrics if needed
|
|
687
|
+
session = SessionMetrics.from_dict(session_data) if isinstance(session_data, dict) else session_data
|
|
688
|
+
session.status = status
|
|
689
|
+
session.last_activity = datetime.utcnow()
|
|
690
|
+
|
|
691
|
+
# Store final state
|
|
692
|
+
await self._store_session(session)
|
|
693
|
+
|
|
694
|
+
# Update global metrics
|
|
695
|
+
self._global_metrics["active_sessions"] = max(0, self._global_metrics["active_sessions"] - 1)
|
|
696
|
+
|
|
697
|
+
logger.info(f"Ended session {session_id} with status: {status}")
|
|
698
|
+
return True
|
|
699
|
+
|
|
700
|
+
async def _store_session(self, session: SessionMetrics):
|
|
701
|
+
"""Store session to Redis or memory."""
|
|
702
|
+
if self.redis_client:
|
|
703
|
+
try:
|
|
704
|
+
await self.redis_client.hset( # type: ignore[misc]
|
|
705
|
+
"sessions",
|
|
706
|
+
session.session_id,
|
|
707
|
+
json.dumps(session.to_dict(), cls=DateTimeEncoder),
|
|
708
|
+
)
|
|
709
|
+
await self.redis_client.expire("sessions", self.session_ttl) # type: ignore[misc]
|
|
710
|
+
return
|
|
711
|
+
except Exception as e:
|
|
712
|
+
logger.error(f"Failed to store session to Redis: {e}")
|
|
713
|
+
|
|
714
|
+
# Fallback to memory
|
|
715
|
+
self._memory_sessions[session.session_id] = session
|
|
716
|
+
|
|
717
|
+
# ==================== Conversation Management ====================
|
|
718
|
+
|
|
719
|
+
async def add_conversation_message(
|
|
720
|
+
self,
|
|
721
|
+
session_id: str,
|
|
722
|
+
role: str,
|
|
723
|
+
content: str,
|
|
724
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
725
|
+
) -> bool:
|
|
726
|
+
"""Add message to conversation history."""
|
|
727
|
+
message = ConversationMessage(
|
|
728
|
+
role=role,
|
|
729
|
+
content=content,
|
|
730
|
+
timestamp=datetime.utcnow(),
|
|
731
|
+
metadata=metadata,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
# Store message
|
|
735
|
+
await self._store_conversation_message(session_id, message)
|
|
736
|
+
|
|
737
|
+
# Update session activity
|
|
738
|
+
await self.update_session(session_id)
|
|
739
|
+
|
|
740
|
+
# Update global metrics
|
|
741
|
+
self._global_metrics["total_messages"] += 1
|
|
742
|
+
|
|
743
|
+
return True
|
|
744
|
+
|
|
745
|
+
async def get_conversation_history(self, session_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
|
746
|
+
"""Get conversation history for a session."""
|
|
747
|
+
if self.redis_client:
|
|
748
|
+
try:
|
|
749
|
+
messages_data = await self.redis_client.lrange(f"conversation:{session_id}", -limit, -1) # type: ignore[misc]
|
|
750
|
+
# Since lpush adds to the beginning, we need to reverse to get
|
|
751
|
+
# chronological order
|
|
752
|
+
messages = [ConversationMessage.from_dict(json.loads(msg)) for msg in reversed(messages_data)]
|
|
753
|
+
return [msg.to_dict() for msg in messages]
|
|
754
|
+
except Exception as e:
|
|
755
|
+
logger.error(f"Failed to get conversation from Redis: {e}")
|
|
756
|
+
|
|
757
|
+
# Fallback to memory
|
|
758
|
+
messages = self._memory_conversations.get(session_id, [])
|
|
759
|
+
message_list = messages[-limit:] if limit > 0 else messages
|
|
760
|
+
return [msg.to_dict() for msg in message_list]
|
|
761
|
+
|
|
762
|
+
async def _store_conversation_message(self, session_id: str, message: ConversationMessage):
|
|
763
|
+
"""Store conversation message to Redis or memory."""
|
|
764
|
+
if self.redis_client:
|
|
765
|
+
try:
|
|
766
|
+
# Add to list
|
|
767
|
+
await self.redis_client.lpush( # type: ignore[misc]
|
|
768
|
+
f"conversation:{session_id}",
|
|
769
|
+
json.dumps(message.to_dict(), cls=DateTimeEncoder),
|
|
770
|
+
)
|
|
771
|
+
# Trim to limit
|
|
772
|
+
await self.redis_client.ltrim(f"conversation:{session_id}", -self.conversation_limit, -1) # type: ignore[misc]
|
|
773
|
+
# Set TTL
|
|
774
|
+
await self.redis_client.expire(f"conversation:{session_id}", self.session_ttl)
|
|
775
|
+
return
|
|
776
|
+
except Exception as e:
|
|
777
|
+
logger.error(f"Failed to store message to Redis: {e}")
|
|
778
|
+
|
|
779
|
+
# Fallback to memory
|
|
780
|
+
if session_id not in self._memory_conversations:
|
|
781
|
+
self._memory_conversations[session_id] = []
|
|
782
|
+
|
|
783
|
+
self._memory_conversations[session_id].append(message)
|
|
784
|
+
|
|
785
|
+
# Trim to limit
|
|
786
|
+
if len(self._memory_conversations[session_id]) > self.conversation_limit:
|
|
787
|
+
self._memory_conversations[session_id] = self._memory_conversations[session_id][-self.conversation_limit :]
|
|
788
|
+
|
|
789
|
+
# ==================== TaskContext Integration ====================
|
|
790
|
+
|
|
791
|
+
async def get_task_context(self, session_id: str) -> Optional[TaskContext]:
|
|
792
|
+
"""Get TaskContext for a session."""
|
|
793
|
+
if self.redis_client:
|
|
794
|
+
try:
|
|
795
|
+
data = await self.redis_client.hget("task_contexts", session_id) # type: ignore[misc]
|
|
796
|
+
if data:
|
|
797
|
+
context_data = json.loads(data)
|
|
798
|
+
# Reconstruct TaskContext from stored data
|
|
799
|
+
return self._reconstruct_task_context(context_data)
|
|
800
|
+
except Exception as e:
|
|
801
|
+
logger.error(f"Failed to get TaskContext from Redis: {e}")
|
|
802
|
+
|
|
803
|
+
# Fallback to memory
|
|
804
|
+
return self._memory_contexts.get(session_id)
|
|
805
|
+
|
|
806
|
+
def _sanitize_dataclasses(self, obj: Any) -> Any:
|
|
807
|
+
"""
|
|
808
|
+
Recursively convert dataclasses to dictionaries for JSON serialization.
|
|
809
|
+
|
|
810
|
+
This method handles:
|
|
811
|
+
- Dataclass instances -> dict (via asdict)
|
|
812
|
+
- Nested dataclasses in dictionaries
|
|
813
|
+
- Nested dataclasses in lists
|
|
814
|
+
- Other types -> pass through
|
|
815
|
+
|
|
816
|
+
Args:
|
|
817
|
+
obj: Object to sanitize
|
|
818
|
+
|
|
819
|
+
Returns:
|
|
820
|
+
Sanitized object (JSON-serializable)
|
|
821
|
+
"""
|
|
822
|
+
# Handle dataclass instances
|
|
823
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
|
824
|
+
logger.debug(f"Converting dataclass {type(obj).__name__} to dict for serialization")
|
|
825
|
+
# Convert dataclass to dict and recursively sanitize
|
|
826
|
+
return self._sanitize_dataclasses(asdict(obj))
|
|
827
|
+
|
|
828
|
+
# Handle dictionaries
|
|
829
|
+
if isinstance(obj, dict):
|
|
830
|
+
return {key: self._sanitize_dataclasses(value) for key, value in obj.items()}
|
|
831
|
+
|
|
832
|
+
# Handle lists and tuples
|
|
833
|
+
if isinstance(obj, (list, tuple)):
|
|
834
|
+
sanitized_list = [self._sanitize_dataclasses(item) for item in obj]
|
|
835
|
+
return sanitized_list if isinstance(obj, list) else tuple(sanitized_list)
|
|
836
|
+
|
|
837
|
+
# Handle sets
|
|
838
|
+
if isinstance(obj, set):
|
|
839
|
+
return [self._sanitize_dataclasses(item) for item in obj]
|
|
840
|
+
|
|
841
|
+
# All other types pass through
|
|
842
|
+
return obj
|
|
843
|
+
|
|
844
|
+
async def _store_task_context(self, session_id: str, context: TaskContext):
|
|
845
|
+
"""
|
|
846
|
+
Store TaskContext to Redis or memory.
|
|
847
|
+
|
|
848
|
+
Automatically converts dataclasses to dictionaries to ensure
|
|
849
|
+
JSON serialization compatibility.
|
|
850
|
+
"""
|
|
851
|
+
if self.redis_client:
|
|
852
|
+
try:
|
|
853
|
+
# Get context dict and sanitize dataclasses
|
|
854
|
+
context_dict = context.to_dict()
|
|
855
|
+
sanitized_dict = self._sanitize_dataclasses(context_dict)
|
|
856
|
+
|
|
857
|
+
await self.redis_client.hset( # type: ignore[misc]
|
|
858
|
+
"task_contexts",
|
|
859
|
+
session_id,
|
|
860
|
+
json.dumps(sanitized_dict, cls=DateTimeEncoder),
|
|
861
|
+
)
|
|
862
|
+
await self.redis_client.expire("task_contexts", self.session_ttl) # type: ignore[misc]
|
|
863
|
+
return
|
|
864
|
+
except Exception as e:
|
|
865
|
+
logger.error(f"Failed to store TaskContext to Redis: {e}")
|
|
866
|
+
|
|
867
|
+
# Fallback to memory
|
|
868
|
+
self._memory_contexts[session_id] = context
|
|
869
|
+
|
|
870
|
+
def _reconstruct_task_context(self, data: Dict[str, Any]) -> TaskContext:
|
|
871
|
+
"""Reconstruct TaskContext from stored data."""
|
|
872
|
+
# Create new TaskContext with stored data
|
|
873
|
+
context = TaskContext(data)
|
|
874
|
+
|
|
875
|
+
# Restore context history
|
|
876
|
+
if "context_history" in data:
|
|
877
|
+
context.context_history = [
|
|
878
|
+
ContextUpdate(
|
|
879
|
+
timestamp=entry["timestamp"],
|
|
880
|
+
update_type=entry["update_type"],
|
|
881
|
+
data=entry["data"],
|
|
882
|
+
metadata=entry["metadata"],
|
|
883
|
+
)
|
|
884
|
+
for entry in data["context_history"]
|
|
885
|
+
]
|
|
886
|
+
|
|
887
|
+
return context
|
|
888
|
+
|
|
889
|
+
# ==================== Checkpoint Management (for BaseServiceCheckpointer)
|
|
890
|
+
|
|
891
|
+
async def store_checkpoint(
|
|
892
|
+
self,
|
|
893
|
+
thread_id: str,
|
|
894
|
+
checkpoint_id: str,
|
|
895
|
+
checkpoint_data: Dict[str, Any],
|
|
896
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
897
|
+
) -> bool:
|
|
898
|
+
"""
|
|
899
|
+
Store checkpoint data for LangGraph workflows.
|
|
900
|
+
|
|
901
|
+
Automatically converts dataclasses to dictionaries to ensure
|
|
902
|
+
JSON serialization compatibility.
|
|
903
|
+
"""
|
|
904
|
+
# Sanitize checkpoint data to handle dataclasses
|
|
905
|
+
sanitized_data = self._sanitize_dataclasses(checkpoint_data)
|
|
906
|
+
sanitized_metadata = self._sanitize_dataclasses(metadata or {})
|
|
907
|
+
|
|
908
|
+
checkpoint = {
|
|
909
|
+
"checkpoint_id": checkpoint_id,
|
|
910
|
+
"thread_id": thread_id,
|
|
911
|
+
"data": sanitized_data,
|
|
912
|
+
"metadata": sanitized_metadata,
|
|
913
|
+
"created_at": datetime.utcnow().isoformat(),
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if self.redis_client:
|
|
917
|
+
try:
|
|
918
|
+
# Store checkpoint
|
|
919
|
+
await self.redis_client.hset( # type: ignore[misc]
|
|
920
|
+
f"checkpoints:{thread_id}",
|
|
921
|
+
checkpoint_id,
|
|
922
|
+
json.dumps(checkpoint, cls=DateTimeEncoder),
|
|
923
|
+
)
|
|
924
|
+
# Set TTL
|
|
925
|
+
await self.redis_client.expire(f"checkpoints:{thread_id}", self.checkpoint_ttl) # type: ignore[misc]
|
|
926
|
+
|
|
927
|
+
# Update global metrics
|
|
928
|
+
self._global_metrics["total_checkpoints"] += 1
|
|
929
|
+
return True
|
|
930
|
+
|
|
931
|
+
except Exception as e:
|
|
932
|
+
logger.error(f"Failed to store checkpoint to Redis: {e}")
|
|
933
|
+
|
|
934
|
+
# Fallback to memory
|
|
935
|
+
key = f"{thread_id}:{checkpoint_id}"
|
|
936
|
+
self._memory_checkpoints[key] = checkpoint
|
|
937
|
+
return True
|
|
938
|
+
|
|
939
|
+
async def get_checkpoint(self, thread_id: str, checkpoint_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
940
|
+
"""Get checkpoint data. If checkpoint_id is None, get the latest."""
|
|
941
|
+
if self.redis_client:
|
|
942
|
+
try:
|
|
943
|
+
if checkpoint_id:
|
|
944
|
+
# Get specific checkpoint
|
|
945
|
+
data = await self.redis_client.hget(f"checkpoints:{thread_id}", checkpoint_id) # type: ignore[misc]
|
|
946
|
+
if data:
|
|
947
|
+
return json.loads(data)
|
|
948
|
+
else:
|
|
949
|
+
# Get latest checkpoint
|
|
950
|
+
checkpoints = await self.redis_client.hgetall(f"checkpoints:{thread_id}") # type: ignore[misc]
|
|
951
|
+
if checkpoints:
|
|
952
|
+
# Sort by creation time and get latest
|
|
953
|
+
latest = max(
|
|
954
|
+
checkpoints.values(),
|
|
955
|
+
key=lambda x: json.loads(x)["created_at"],
|
|
956
|
+
)
|
|
957
|
+
return json.loads(latest)
|
|
958
|
+
except Exception as e:
|
|
959
|
+
logger.error(f"Failed to get checkpoint from Redis: {e}")
|
|
960
|
+
|
|
961
|
+
# Fallback to memory
|
|
962
|
+
if checkpoint_id:
|
|
963
|
+
key = f"{thread_id}:{checkpoint_id}"
|
|
964
|
+
return self._memory_checkpoints.get(key)
|
|
965
|
+
else:
|
|
966
|
+
# Get latest from memory
|
|
967
|
+
thread_checkpoints = {k: v for k, v in self._memory_checkpoints.items() if k.startswith(f"{thread_id}:")}
|
|
968
|
+
if thread_checkpoints:
|
|
969
|
+
latest_key = max(
|
|
970
|
+
thread_checkpoints.keys(),
|
|
971
|
+
key=lambda k: thread_checkpoints[k]["created_at"],
|
|
972
|
+
)
|
|
973
|
+
return thread_checkpoints[latest_key]
|
|
974
|
+
|
|
975
|
+
return None
|
|
976
|
+
|
|
977
|
+
async def list_checkpoints(self, thread_id: str, limit: int = 10) -> List[Dict[str, Any]]:
|
|
978
|
+
"""List checkpoints for a thread, ordered by creation time (newest first)."""
|
|
979
|
+
if self.redis_client:
|
|
980
|
+
try:
|
|
981
|
+
checkpoints_data = await self.redis_client.hgetall(f"checkpoints:{thread_id}") # type: ignore[misc]
|
|
982
|
+
checkpoints = [json.loads(data) for data in checkpoints_data.values()]
|
|
983
|
+
# Sort by creation time (newest first)
|
|
984
|
+
checkpoints.sort(key=lambda x: x["created_at"], reverse=True)
|
|
985
|
+
return checkpoints[:limit]
|
|
986
|
+
except Exception as e:
|
|
987
|
+
logger.error(f"Failed to list checkpoints from Redis: {e}")
|
|
988
|
+
|
|
989
|
+
# Fallback to memory
|
|
990
|
+
thread_checkpoints = [v for k, v in self._memory_checkpoints.items() if k.startswith(f"{thread_id}:")]
|
|
991
|
+
thread_checkpoints.sort(key=lambda x: x["created_at"], reverse=True)
|
|
992
|
+
return thread_checkpoints[:limit]
|
|
993
|
+
|
|
994
|
+
# ==================== Cleanup and Maintenance ====================
|
|
995
|
+
|
|
996
|
+
async def cleanup_expired_sessions(self, max_idle_hours: int = 24) -> int:
|
|
997
|
+
"""Clean up expired sessions and associated data."""
|
|
998
|
+
cutoff_time = datetime.utcnow() - timedelta(hours=max_idle_hours)
|
|
999
|
+
cleaned_count = 0
|
|
1000
|
+
|
|
1001
|
+
if self.redis_client:
|
|
1002
|
+
try:
|
|
1003
|
+
# Get all sessions
|
|
1004
|
+
sessions_data = await self.redis_client.hgetall("sessions") # type: ignore[misc]
|
|
1005
|
+
expired_sessions = []
|
|
1006
|
+
|
|
1007
|
+
for session_id, data in sessions_data.items():
|
|
1008
|
+
session = SessionMetrics.from_dict(json.loads(data))
|
|
1009
|
+
if session.last_activity < cutoff_time:
|
|
1010
|
+
expired_sessions.append(session_id)
|
|
1011
|
+
|
|
1012
|
+
# Clean up expired sessions
|
|
1013
|
+
for session_id in expired_sessions:
|
|
1014
|
+
await self._cleanup_session_data(session_id)
|
|
1015
|
+
cleaned_count += 1
|
|
1016
|
+
|
|
1017
|
+
except Exception as e:
|
|
1018
|
+
logger.error(f"Failed to cleanup expired sessions from Redis: {e}")
|
|
1019
|
+
else:
|
|
1020
|
+
# Memory cleanup
|
|
1021
|
+
expired_sessions = [session_id for session_id, session in self._memory_sessions.items() if session.last_activity < cutoff_time]
|
|
1022
|
+
|
|
1023
|
+
for session_id in expired_sessions:
|
|
1024
|
+
await self._cleanup_session_data(session_id)
|
|
1025
|
+
cleaned_count += 1
|
|
1026
|
+
|
|
1027
|
+
if cleaned_count > 0:
|
|
1028
|
+
logger.info(f"Cleaned up {cleaned_count} expired sessions")
|
|
1029
|
+
|
|
1030
|
+
return cleaned_count
|
|
1031
|
+
|
|
1032
|
+
async def _cleanup_session_data(self, session_id: str):
|
|
1033
|
+
"""Clean up all data associated with a session."""
|
|
1034
|
+
if self.redis_client:
|
|
1035
|
+
try:
|
|
1036
|
+
# Remove session
|
|
1037
|
+
await self.redis_client.hdel("sessions", session_id) # type: ignore[misc]
|
|
1038
|
+
# Remove conversation
|
|
1039
|
+
await self.redis_client.delete(f"conversation:{session_id}") # type: ignore[misc]
|
|
1040
|
+
# Remove task context
|
|
1041
|
+
await self.redis_client.hdel("task_contexts", session_id) # type: ignore[misc]
|
|
1042
|
+
# Remove checkpoints
|
|
1043
|
+
await self.redis_client.delete(f"checkpoints:{session_id}") # type: ignore[misc]
|
|
1044
|
+
except Exception as e:
|
|
1045
|
+
logger.error(f"Failed to cleanup session data from Redis: {e}")
|
|
1046
|
+
else:
|
|
1047
|
+
# Memory cleanup
|
|
1048
|
+
self._memory_sessions.pop(session_id, None)
|
|
1049
|
+
self._memory_conversations.pop(session_id, None)
|
|
1050
|
+
self._memory_contexts.pop(session_id, None)
|
|
1051
|
+
|
|
1052
|
+
# Remove checkpoints
|
|
1053
|
+
checkpoint_keys = [k for k in self._memory_checkpoints.keys() if k.startswith(f"{session_id}:")]
|
|
1054
|
+
for key in checkpoint_keys:
|
|
1055
|
+
self._memory_checkpoints.pop(key, None)
|
|
1056
|
+
|
|
1057
|
+
# ==================== Metrics and Health ====================
|
|
1058
|
+
|
|
1059
|
+
async def get_metrics(self) -> Dict[str, Any]:
|
|
1060
|
+
"""Get comprehensive metrics."""
|
|
1061
|
+
active_sessions_count = 0
|
|
1062
|
+
|
|
1063
|
+
if self.redis_client:
|
|
1064
|
+
try:
|
|
1065
|
+
sessions_data = await self.redis_client.hgetall("sessions") # type: ignore[misc]
|
|
1066
|
+
active_sessions_count = len([s for s in sessions_data.values() if json.loads(s)["status"] == "active"])
|
|
1067
|
+
except Exception as e:
|
|
1068
|
+
logger.error(f"Failed to get metrics from Redis: {e}")
|
|
1069
|
+
else:
|
|
1070
|
+
active_sessions_count = len([s for s in self._memory_sessions.values() if s.status == "active"])
|
|
1071
|
+
|
|
1072
|
+
return {
|
|
1073
|
+
**self._global_metrics,
|
|
1074
|
+
"active_sessions": active_sessions_count,
|
|
1075
|
+
"storage_backend": "redis" if self.redis_client else "memory",
|
|
1076
|
+
"redis_connected": self.redis_client is not None,
|
|
1077
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async def health_check(self) -> Dict[str, Any]:
|
|
1081
|
+
"""Perform health check."""
|
|
1082
|
+
health: Dict[str, Any] = {
|
|
1083
|
+
"status": "healthy",
|
|
1084
|
+
"storage_backend": "redis" if self.redis_client else "memory",
|
|
1085
|
+
"redis_connected": False,
|
|
1086
|
+
"issues": [],
|
|
1087
|
+
}
|
|
1088
|
+
issues: List[str] = health["issues"] # Type narrowing
|
|
1089
|
+
|
|
1090
|
+
# Check Redis connection
|
|
1091
|
+
if self.redis_client:
|
|
1092
|
+
try:
|
|
1093
|
+
await self.redis_client.ping()
|
|
1094
|
+
health["redis_connected"] = True
|
|
1095
|
+
except Exception as e:
|
|
1096
|
+
issues.append(f"Redis connection failed: {e}")
|
|
1097
|
+
health["status"] = "degraded"
|
|
1098
|
+
|
|
1099
|
+
# Check memory usage (basic check)
|
|
1100
|
+
if not self.redis_client:
|
|
1101
|
+
total_memory_items = len(self._memory_sessions) + len(self._memory_conversations) + len(self._memory_contexts) + len(self._memory_checkpoints)
|
|
1102
|
+
if total_memory_items > 10000: # Arbitrary threshold
|
|
1103
|
+
issues.append(f"High memory usage: {total_memory_items} items")
|
|
1104
|
+
health["status"] = "warning"
|
|
1105
|
+
|
|
1106
|
+
health["issues"] = issues # Update health dict
|
|
1107
|
+
|
|
1108
|
+
return health
|
|
1109
|
+
|
|
1110
|
+
# ==================== ICheckpointerBackend Implementation ===============
|
|
1111
|
+
|
|
1112
|
+
async def put_checkpoint(
|
|
1113
|
+
self,
|
|
1114
|
+
thread_id: str,
|
|
1115
|
+
checkpoint_id: str,
|
|
1116
|
+
checkpoint_data: Dict[str, Any],
|
|
1117
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1118
|
+
) -> bool:
|
|
1119
|
+
"""Store a checkpoint for LangGraph workflows (ICheckpointerBackend interface)."""
|
|
1120
|
+
return await self.store_checkpoint(thread_id, checkpoint_id, checkpoint_data, metadata)
|
|
1121
|
+
|
|
1122
|
+
async def put_writes(
|
|
1123
|
+
self,
|
|
1124
|
+
thread_id: str,
|
|
1125
|
+
checkpoint_id: str,
|
|
1126
|
+
task_id: str,
|
|
1127
|
+
writes_data: List[tuple],
|
|
1128
|
+
) -> bool:
|
|
1129
|
+
"""Store intermediate writes for a checkpoint (ICheckpointerBackend interface)."""
|
|
1130
|
+
writes_key = f"writes:{thread_id}:{checkpoint_id}:{task_id}"
|
|
1131
|
+
writes_payload = {
|
|
1132
|
+
"thread_id": thread_id,
|
|
1133
|
+
"checkpoint_id": checkpoint_id,
|
|
1134
|
+
"task_id": task_id,
|
|
1135
|
+
"writes": writes_data,
|
|
1136
|
+
"created_at": datetime.utcnow().isoformat(),
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if self.redis_client:
|
|
1140
|
+
try:
|
|
1141
|
+
await self.redis_client.hset( # type: ignore[misc]
|
|
1142
|
+
f"checkpoint_writes:{thread_id}",
|
|
1143
|
+
f"{checkpoint_id}:{task_id}",
|
|
1144
|
+
json.dumps(writes_payload, cls=DateTimeEncoder),
|
|
1145
|
+
)
|
|
1146
|
+
await self.redis_client.expire(f"checkpoint_writes:{thread_id}", self.checkpoint_ttl)
|
|
1147
|
+
return True
|
|
1148
|
+
except Exception as e:
|
|
1149
|
+
logger.error(f"Failed to store writes to Redis: {e}")
|
|
1150
|
+
|
|
1151
|
+
# Fallback to memory
|
|
1152
|
+
self._memory_checkpoints[writes_key] = writes_payload
|
|
1153
|
+
return True
|
|
1154
|
+
|
|
1155
|
+
async def get_writes(self, thread_id: str, checkpoint_id: str) -> List[tuple]:
|
|
1156
|
+
"""Get intermediate writes for a checkpoint (ICheckpointerBackend interface)."""
|
|
1157
|
+
if self.redis_client:
|
|
1158
|
+
try:
|
|
1159
|
+
writes_data = await self.redis_client.hgetall(f"checkpoint_writes:{thread_id}") # type: ignore[misc]
|
|
1160
|
+
writes = []
|
|
1161
|
+
for key, data in writes_data.items():
|
|
1162
|
+
if key.startswith(f"{checkpoint_id}:"):
|
|
1163
|
+
payload = json.loads(data)
|
|
1164
|
+
writes.extend(payload.get("writes", []))
|
|
1165
|
+
return writes
|
|
1166
|
+
except Exception as e:
|
|
1167
|
+
logger.error(f"Failed to get writes from Redis: {e}")
|
|
1168
|
+
|
|
1169
|
+
# Fallback to memory
|
|
1170
|
+
writes = []
|
|
1171
|
+
writes_prefix = f"writes:{thread_id}:{checkpoint_id}:"
|
|
1172
|
+
for key, payload in self._memory_checkpoints.items():
|
|
1173
|
+
if key.startswith(writes_prefix):
|
|
1174
|
+
writes.extend(payload.get("writes", []))
|
|
1175
|
+
return writes
|
|
1176
|
+
|
|
1177
|
+
# ==================== ITaskContextStorage Implementation ================
|
|
1178
|
+
|
|
1179
|
+
async def store_task_context(self, session_id: str, context: Any) -> bool:
|
|
1180
|
+
"""Store TaskContext for a session (ITaskContextStorage interface)."""
|
|
1181
|
+
return await self._store_task_context(session_id, context)
|
|
1182
|
+
|
|
1183
|
+
# ==================== Agent Communication and Conversation Isolation ====
|
|
1184
|
+
|
|
1185
|
+
async def create_conversation_session(
|
|
1186
|
+
self,
|
|
1187
|
+
session_id: str,
|
|
1188
|
+
participants: List[Dict[str, Any]],
|
|
1189
|
+
session_type: str,
|
|
1190
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1191
|
+
) -> str:
|
|
1192
|
+
"""
|
|
1193
|
+
Create an isolated conversation session between participants.
|
|
1194
|
+
|
|
1195
|
+
Args:
|
|
1196
|
+
session_id: Base session ID
|
|
1197
|
+
participants: List of participant dictionaries with id, type, role
|
|
1198
|
+
session_type: Type of conversation ('user_to_mc', 'mc_to_agent', 'agent_to_agent', 'user_to_agent')
|
|
1199
|
+
metadata: Additional session metadata
|
|
1200
|
+
|
|
1201
|
+
Returns:
|
|
1202
|
+
Generated session key for conversation isolation
|
|
1203
|
+
"""
|
|
1204
|
+
from .conversation_models import (
|
|
1205
|
+
ConversationSession,
|
|
1206
|
+
ConversationParticipant,
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
# Create participant objects
|
|
1210
|
+
participant_objects = [
|
|
1211
|
+
ConversationParticipant(
|
|
1212
|
+
participant_id=p.get("id") or "",
|
|
1213
|
+
participant_type=p.get("type") or "",
|
|
1214
|
+
participant_role=p.get("role"),
|
|
1215
|
+
metadata=p.get("metadata", {}),
|
|
1216
|
+
)
|
|
1217
|
+
for p in participants
|
|
1218
|
+
]
|
|
1219
|
+
|
|
1220
|
+
# Create conversation session
|
|
1221
|
+
conversation_session = ConversationSession(
|
|
1222
|
+
session_id=session_id,
|
|
1223
|
+
participants=participant_objects,
|
|
1224
|
+
session_type=session_type,
|
|
1225
|
+
created_at=datetime.utcnow(),
|
|
1226
|
+
last_activity=datetime.utcnow(),
|
|
1227
|
+
metadata=metadata or {},
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
# Generate unique session key
|
|
1231
|
+
session_key = conversation_session.generate_session_key()
|
|
1232
|
+
|
|
1233
|
+
# Store conversation session metadata
|
|
1234
|
+
await self._store_conversation_session(session_key, conversation_session)
|
|
1235
|
+
|
|
1236
|
+
logger.info(f"Created conversation session: {session_key} (type: {session_type})")
|
|
1237
|
+
return session_key
|
|
1238
|
+
|
|
1239
|
+
async def add_agent_communication_message(
|
|
1240
|
+
self,
|
|
1241
|
+
session_key: str,
|
|
1242
|
+
sender_id: str,
|
|
1243
|
+
sender_type: str,
|
|
1244
|
+
sender_role: Optional[str],
|
|
1245
|
+
recipient_id: str,
|
|
1246
|
+
recipient_type: str,
|
|
1247
|
+
recipient_role: Optional[str],
|
|
1248
|
+
content: str,
|
|
1249
|
+
message_type: str = "communication",
|
|
1250
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1251
|
+
) -> bool:
|
|
1252
|
+
"""
|
|
1253
|
+
Add a message to an agent communication session.
|
|
1254
|
+
|
|
1255
|
+
Args:
|
|
1256
|
+
session_key: Isolated session key
|
|
1257
|
+
sender_id: ID of the sender
|
|
1258
|
+
sender_type: Type of sender ('master_controller', 'agent', 'user')
|
|
1259
|
+
sender_role: Role of sender (for agents)
|
|
1260
|
+
recipient_id: ID of the recipient
|
|
1261
|
+
recipient_type: Type of recipient
|
|
1262
|
+
recipient_role: Role of recipient (for agents)
|
|
1263
|
+
content: Message content
|
|
1264
|
+
message_type: Type of message
|
|
1265
|
+
metadata: Additional message metadata
|
|
1266
|
+
|
|
1267
|
+
Returns:
|
|
1268
|
+
Success status
|
|
1269
|
+
"""
|
|
1270
|
+
from .conversation_models import AgentCommunicationMessage
|
|
1271
|
+
|
|
1272
|
+
# Create agent communication message
|
|
1273
|
+
message = AgentCommunicationMessage(
|
|
1274
|
+
message_id=str(uuid.uuid4()),
|
|
1275
|
+
session_key=session_key,
|
|
1276
|
+
sender_id=sender_id,
|
|
1277
|
+
sender_type=sender_type,
|
|
1278
|
+
sender_role=sender_role,
|
|
1279
|
+
recipient_id=recipient_id,
|
|
1280
|
+
recipient_type=recipient_type,
|
|
1281
|
+
recipient_role=recipient_role,
|
|
1282
|
+
content=content,
|
|
1283
|
+
message_type=message_type,
|
|
1284
|
+
timestamp=datetime.utcnow(),
|
|
1285
|
+
metadata=metadata or {},
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
# Convert to conversation message format and store
|
|
1289
|
+
conv_message_dict = message.to_conversation_message_dict()
|
|
1290
|
+
|
|
1291
|
+
# Store using existing conversation message infrastructure
|
|
1292
|
+
await self.add_conversation_message(
|
|
1293
|
+
session_id=session_key,
|
|
1294
|
+
role=conv_message_dict["role"],
|
|
1295
|
+
content=conv_message_dict["content"],
|
|
1296
|
+
metadata=conv_message_dict["metadata"],
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
# Update session activity
|
|
1300
|
+
await self._update_conversation_session_activity(session_key)
|
|
1301
|
+
|
|
1302
|
+
logger.debug(f"Added agent communication message to session {session_key}")
|
|
1303
|
+
return True
|
|
1304
|
+
|
|
1305
|
+
async def get_agent_conversation_history(
|
|
1306
|
+
self,
|
|
1307
|
+
session_key: str,
|
|
1308
|
+
limit: int = 50,
|
|
1309
|
+
message_types: Optional[List[str]] = None,
|
|
1310
|
+
) -> List[Dict[str, Any]]:
|
|
1311
|
+
"""
|
|
1312
|
+
Get conversation history for an agent communication session.
|
|
1313
|
+
|
|
1314
|
+
Args:
|
|
1315
|
+
session_key: Isolated session key
|
|
1316
|
+
limit: Maximum number of messages to retrieve
|
|
1317
|
+
message_types: Filter by message types
|
|
1318
|
+
|
|
1319
|
+
Returns:
|
|
1320
|
+
List of conversation messages
|
|
1321
|
+
"""
|
|
1322
|
+
# Get conversation history using existing infrastructure
|
|
1323
|
+
messages = await self.get_conversation_history(session_key, limit)
|
|
1324
|
+
|
|
1325
|
+
# Filter by message types if specified
|
|
1326
|
+
if message_types:
|
|
1327
|
+
filtered_messages = []
|
|
1328
|
+
for msg in messages:
|
|
1329
|
+
if hasattr(msg, "to_dict"):
|
|
1330
|
+
msg_dict = msg.to_dict()
|
|
1331
|
+
else:
|
|
1332
|
+
msg_dict = msg # type: ignore[assignment]
|
|
1333
|
+
|
|
1334
|
+
msg_metadata = msg_dict.get("metadata", {})
|
|
1335
|
+
msg_type = msg_metadata.get("message_type", "communication")
|
|
1336
|
+
|
|
1337
|
+
if msg_type in message_types:
|
|
1338
|
+
filtered_messages.append(msg_dict)
|
|
1339
|
+
|
|
1340
|
+
return filtered_messages
|
|
1341
|
+
|
|
1342
|
+
# Convert messages to dict format
|
|
1343
|
+
return [msg.to_dict() if hasattr(msg, "to_dict") else msg for msg in messages]
|
|
1344
|
+
|
|
1345
|
+
async def _store_conversation_session(self, session_key: str, conversation_session) -> None:
|
|
1346
|
+
"""Store conversation session metadata."""
|
|
1347
|
+
session_data = {
|
|
1348
|
+
"session_id": conversation_session.session_id,
|
|
1349
|
+
"participants": [
|
|
1350
|
+
{
|
|
1351
|
+
"participant_id": p.participant_id,
|
|
1352
|
+
"participant_type": p.participant_type,
|
|
1353
|
+
"participant_role": p.participant_role,
|
|
1354
|
+
"metadata": p.metadata,
|
|
1355
|
+
}
|
|
1356
|
+
for p in conversation_session.participants
|
|
1357
|
+
],
|
|
1358
|
+
"session_type": conversation_session.session_type,
|
|
1359
|
+
"created_at": conversation_session.created_at.isoformat(),
|
|
1360
|
+
"last_activity": conversation_session.last_activity.isoformat(),
|
|
1361
|
+
"metadata": conversation_session.metadata,
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
if self.redis_client:
|
|
1365
|
+
try:
|
|
1366
|
+
await self.redis_client.hset( # type: ignore[misc]
|
|
1367
|
+
"conversation_sessions",
|
|
1368
|
+
session_key,
|
|
1369
|
+
json.dumps(session_data, cls=DateTimeEncoder),
|
|
1370
|
+
)
|
|
1371
|
+
await self.redis_client.expire("conversation_sessions", self.session_ttl) # type: ignore[misc]
|
|
1372
|
+
return
|
|
1373
|
+
except Exception as e:
|
|
1374
|
+
logger.error(f"Failed to store conversation session to Redis: {e}")
|
|
1375
|
+
|
|
1376
|
+
# Fallback to memory (extend memory storage)
|
|
1377
|
+
if not hasattr(self, "_memory_conversation_sessions"):
|
|
1378
|
+
self._memory_conversation_sessions = {}
|
|
1379
|
+
self._memory_conversation_sessions[session_key] = session_data
|
|
1380
|
+
|
|
1381
|
+
async def _update_conversation_session_activity(self, session_key: str) -> None:
|
|
1382
|
+
"""Update last activity timestamp for a conversation session."""
|
|
1383
|
+
if self.redis_client:
|
|
1384
|
+
try:
|
|
1385
|
+
session_data = await self.redis_client.hget("conversation_sessions", session_key) # type: ignore[misc]
|
|
1386
|
+
if session_data:
|
|
1387
|
+
session_dict = json.loads(session_data)
|
|
1388
|
+
session_dict["last_activity"] = datetime.utcnow().isoformat()
|
|
1389
|
+
await self.redis_client.hset( # type: ignore[misc]
|
|
1390
|
+
"conversation_sessions",
|
|
1391
|
+
session_key,
|
|
1392
|
+
json.dumps(session_dict, cls=DateTimeEncoder),
|
|
1393
|
+
)
|
|
1394
|
+
return
|
|
1395
|
+
except Exception as e:
|
|
1396
|
+
logger.error(f"Failed to update conversation session activity in Redis: {e}")
|
|
1397
|
+
|
|
1398
|
+
# Fallback to memory
|
|
1399
|
+
if hasattr(self, "_memory_conversation_sessions") and session_key in self._memory_conversation_sessions:
|
|
1400
|
+
self._memory_conversation_sessions[session_key]["last_activity"] = datetime.utcnow().isoformat()
|
|
1401
|
+
|
|
1402
|
+
# ==================== Compression Methods (Phase 6) ====================
|
|
1403
|
+
|
|
1404
|
+
async def compress_conversation(
|
|
1405
|
+
self,
|
|
1406
|
+
session_id: str,
|
|
1407
|
+
strategy: Optional[str] = None,
|
|
1408
|
+
config_override: Optional[CompressionConfig] = None,
|
|
1409
|
+
) -> Dict[str, Any]:
|
|
1410
|
+
"""
|
|
1411
|
+
Compress conversation history using specified strategy.
|
|
1412
|
+
|
|
1413
|
+
Args:
|
|
1414
|
+
session_id: Session ID to compress
|
|
1415
|
+
strategy: Compression strategy (overrides config if provided)
|
|
1416
|
+
config_override: Override compression config for this operation
|
|
1417
|
+
|
|
1418
|
+
Returns:
|
|
1419
|
+
Dictionary with compression results:
|
|
1420
|
+
{
|
|
1421
|
+
"success": bool,
|
|
1422
|
+
"strategy": str,
|
|
1423
|
+
"original_count": int,
|
|
1424
|
+
"compressed_count": int,
|
|
1425
|
+
"compression_ratio": float,
|
|
1426
|
+
"tokens_saved": int (if applicable),
|
|
1427
|
+
"time_taken": float
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
Example:
|
|
1431
|
+
result = await engine.compress_conversation(
|
|
1432
|
+
session_id="session-123",
|
|
1433
|
+
strategy="summarize"
|
|
1434
|
+
)
|
|
1435
|
+
print(f"Compressed from {result['original_count']} to {result['compressed_count']} messages")
|
|
1436
|
+
"""
|
|
1437
|
+
import time
|
|
1438
|
+
|
|
1439
|
+
start_time = time.time()
|
|
1440
|
+
|
|
1441
|
+
# Use config override or default
|
|
1442
|
+
config = config_override or self.compression_config
|
|
1443
|
+
selected_strategy = strategy or config.strategy
|
|
1444
|
+
|
|
1445
|
+
logger.info(f"Compressing conversation {session_id} using strategy: {selected_strategy}")
|
|
1446
|
+
|
|
1447
|
+
try:
|
|
1448
|
+
# Get current conversation
|
|
1449
|
+
messages_dict = await self.get_conversation_history(session_id)
|
|
1450
|
+
# Convert dict list to ConversationMessage list
|
|
1451
|
+
messages = [ConversationMessage.from_dict(msg) for msg in messages_dict]
|
|
1452
|
+
original_count = len(messages)
|
|
1453
|
+
|
|
1454
|
+
if original_count == 0:
|
|
1455
|
+
return {
|
|
1456
|
+
"success": False,
|
|
1457
|
+
"error": "No messages to compress",
|
|
1458
|
+
"original_count": 0,
|
|
1459
|
+
"compressed_count": 0,
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
# Select compression strategy
|
|
1463
|
+
if selected_strategy == "truncate":
|
|
1464
|
+
compressed_messages = await self._compress_with_truncation(messages, config)
|
|
1465
|
+
elif selected_strategy == "summarize":
|
|
1466
|
+
compressed_messages = await self._compress_with_summarization(messages, config)
|
|
1467
|
+
elif selected_strategy == "semantic":
|
|
1468
|
+
compressed_messages = await self._compress_with_semantic_dedup(messages, config)
|
|
1469
|
+
elif selected_strategy == "hybrid":
|
|
1470
|
+
compressed_messages = await self._compress_with_hybrid(messages, config)
|
|
1471
|
+
else:
|
|
1472
|
+
raise ValueError(f"Unknown compression strategy: {selected_strategy}")
|
|
1473
|
+
|
|
1474
|
+
compressed_count = len(compressed_messages)
|
|
1475
|
+
compression_ratio = 1.0 - (compressed_count / original_count) if original_count > 0 else 0.0
|
|
1476
|
+
|
|
1477
|
+
# Replace conversation history
|
|
1478
|
+
await self._replace_conversation_history(session_id, compressed_messages)
|
|
1479
|
+
|
|
1480
|
+
time_taken = time.time() - start_time
|
|
1481
|
+
|
|
1482
|
+
result = {
|
|
1483
|
+
"success": True,
|
|
1484
|
+
"strategy": selected_strategy,
|
|
1485
|
+
"original_count": original_count,
|
|
1486
|
+
"compressed_count": compressed_count,
|
|
1487
|
+
"compression_ratio": compression_ratio,
|
|
1488
|
+
"time_taken": time_taken,
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
logger.info(f"Compression complete: {original_count} -> {compressed_count} messages " f"({compression_ratio:.1%} reduction) in {time_taken:.2f}s")
|
|
1492
|
+
|
|
1493
|
+
return result
|
|
1494
|
+
|
|
1495
|
+
except Exception as e:
|
|
1496
|
+
logger.error(f"Compression failed for session {session_id}: {e}")
|
|
1497
|
+
return {
|
|
1498
|
+
"success": False,
|
|
1499
|
+
"error": str(e),
|
|
1500
|
+
"strategy": selected_strategy,
|
|
1501
|
+
"time_taken": time.time() - start_time,
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
async def _compress_with_truncation(self, messages: List[ConversationMessage], config: CompressionConfig) -> List[ConversationMessage]:
|
|
1505
|
+
"""
|
|
1506
|
+
Compress by truncating old messages (fast, no LLM required).
|
|
1507
|
+
|
|
1508
|
+
Keeps the most recent N messages based on config.keep_recent.
|
|
1509
|
+
|
|
1510
|
+
Args:
|
|
1511
|
+
messages: List of conversation messages
|
|
1512
|
+
config: Compression configuration
|
|
1513
|
+
|
|
1514
|
+
Returns:
|
|
1515
|
+
Truncated list of messages
|
|
1516
|
+
"""
|
|
1517
|
+
if len(messages) <= config.keep_recent:
|
|
1518
|
+
return messages
|
|
1519
|
+
|
|
1520
|
+
# Keep most recent messages
|
|
1521
|
+
truncated = messages[-config.keep_recent :]
|
|
1522
|
+
|
|
1523
|
+
logger.debug(f"Truncation: kept {len(truncated)} most recent messages " f"(removed {len(messages) - len(truncated)})")
|
|
1524
|
+
|
|
1525
|
+
return truncated
|
|
1526
|
+
|
|
1527
|
+
async def _compress_with_summarization(self, messages: List[ConversationMessage], config: CompressionConfig) -> List[ConversationMessage]:
|
|
1528
|
+
"""
|
|
1529
|
+
Compress using LLM-based summarization.
|
|
1530
|
+
|
|
1531
|
+
Creates a summary of older messages and keeps recent messages intact.
|
|
1532
|
+
|
|
1533
|
+
Args:
|
|
1534
|
+
messages: List of conversation messages
|
|
1535
|
+
config: Compression configuration
|
|
1536
|
+
|
|
1537
|
+
Returns:
|
|
1538
|
+
List with summary message + recent messages
|
|
1539
|
+
|
|
1540
|
+
Raises:
|
|
1541
|
+
ValueError: If no LLM client configured
|
|
1542
|
+
"""
|
|
1543
|
+
if not self.llm_client:
|
|
1544
|
+
raise ValueError("LLM client required for summarization compression. " "Provide llm_client parameter to ContextEngine.")
|
|
1545
|
+
|
|
1546
|
+
if len(messages) <= config.keep_recent:
|
|
1547
|
+
return messages
|
|
1548
|
+
|
|
1549
|
+
# Split into messages to summarize and messages to keep
|
|
1550
|
+
messages_to_summarize = messages[: -config.keep_recent]
|
|
1551
|
+
messages_to_keep = messages[-config.keep_recent :]
|
|
1552
|
+
|
|
1553
|
+
# Build summary prompt
|
|
1554
|
+
summary_prompt = self._build_summary_prompt(messages_to_summarize, config)
|
|
1555
|
+
|
|
1556
|
+
# Generate summary using LLM
|
|
1557
|
+
from aiecs.llm.clients.base_client import LLMMessage
|
|
1558
|
+
|
|
1559
|
+
llm_messages = [LLMMessage(role="user", content=summary_prompt)]
|
|
1560
|
+
|
|
1561
|
+
response = await self.llm_client.generate_text(messages=llm_messages, max_tokens=config.summary_max_tokens)
|
|
1562
|
+
|
|
1563
|
+
summary_text = response.content
|
|
1564
|
+
|
|
1565
|
+
# Create summary message
|
|
1566
|
+
summary_message = ConversationMessage(
|
|
1567
|
+
role="system",
|
|
1568
|
+
content=f"[Summary of {len(messages_to_summarize)} previous messages]\n\n{summary_text}",
|
|
1569
|
+
timestamp=datetime.utcnow(),
|
|
1570
|
+
metadata={"type": "summary", "summarized_count": len(messages_to_summarize)},
|
|
1571
|
+
)
|
|
1572
|
+
|
|
1573
|
+
# Combine summary + recent messages
|
|
1574
|
+
if config.include_summary_in_history:
|
|
1575
|
+
compressed = [summary_message] + messages_to_keep
|
|
1576
|
+
else:
|
|
1577
|
+
compressed = messages_to_keep
|
|
1578
|
+
|
|
1579
|
+
logger.debug(f"Summarization: {len(messages_to_summarize)} messages -> 1 summary, " f"kept {len(messages_to_keep)} recent messages")
|
|
1580
|
+
|
|
1581
|
+
return compressed
|
|
1582
|
+
|
|
1583
|
+
def _build_summary_prompt(self, messages: List[ConversationMessage], config: CompressionConfig) -> str:
|
|
1584
|
+
"""
|
|
1585
|
+
Build prompt for summarization.
|
|
1586
|
+
|
|
1587
|
+
Args:
|
|
1588
|
+
messages: Messages to summarize
|
|
1589
|
+
config: Compression configuration
|
|
1590
|
+
|
|
1591
|
+
Returns:
|
|
1592
|
+
Prompt string for LLM
|
|
1593
|
+
"""
|
|
1594
|
+
# Use custom template if provided
|
|
1595
|
+
if config.summary_prompt_template:
|
|
1596
|
+
# Format template with messages
|
|
1597
|
+
messages_text = "\n\n".join([f"{msg.role}: {msg.content}" for msg in messages])
|
|
1598
|
+
return config.summary_prompt_template.format(messages=messages_text)
|
|
1599
|
+
|
|
1600
|
+
# Default template
|
|
1601
|
+
messages_text = "\n\n".join([f"{msg.role}: {msg.content}" for msg in messages])
|
|
1602
|
+
|
|
1603
|
+
prompt = f"""Please provide a concise summary of the following conversation.
|
|
1604
|
+
Focus on key points, decisions, and important information.
|
|
1605
|
+
Keep the summary under {config.summary_max_tokens} tokens.
|
|
1606
|
+
|
|
1607
|
+
Conversation:
|
|
1608
|
+
{messages_text}
|
|
1609
|
+
|
|
1610
|
+
Summary:"""
|
|
1611
|
+
|
|
1612
|
+
return prompt
|
|
1613
|
+
|
|
1614
|
+
async def _compress_with_semantic_dedup(self, messages: List[ConversationMessage], config: CompressionConfig) -> List[ConversationMessage]:
|
|
1615
|
+
"""
|
|
1616
|
+
Compress using semantic deduplication (embedding-based).
|
|
1617
|
+
|
|
1618
|
+
Removes messages that are semantically similar to keep diverse content.
|
|
1619
|
+
|
|
1620
|
+
Args:
|
|
1621
|
+
messages: List of conversation messages
|
|
1622
|
+
config: Compression configuration
|
|
1623
|
+
|
|
1624
|
+
Returns:
|
|
1625
|
+
List of semantically diverse messages
|
|
1626
|
+
|
|
1627
|
+
Raises:
|
|
1628
|
+
ValueError: If no LLM client configured
|
|
1629
|
+
"""
|
|
1630
|
+
if not self.llm_client:
|
|
1631
|
+
raise ValueError("LLM client required for semantic deduplication. " "Provide llm_client parameter to ContextEngine.")
|
|
1632
|
+
|
|
1633
|
+
if len(messages) <= config.keep_recent:
|
|
1634
|
+
return messages
|
|
1635
|
+
|
|
1636
|
+
# Get embeddings for all messages
|
|
1637
|
+
texts = [msg.content for msg in messages]
|
|
1638
|
+
|
|
1639
|
+
try:
|
|
1640
|
+
embeddings = await self.llm_client.get_embeddings(texts=texts, model=config.embedding_model)
|
|
1641
|
+
except NotImplementedError:
|
|
1642
|
+
logger.warning("LLM client does not support embeddings. Falling back to truncation.")
|
|
1643
|
+
return await self._compress_with_truncation(messages, config)
|
|
1644
|
+
|
|
1645
|
+
# Find diverse messages using embeddings
|
|
1646
|
+
diverse_indices = self._find_diverse_messages(embeddings, config.similarity_threshold, config.keep_recent)
|
|
1647
|
+
|
|
1648
|
+
# Keep messages at diverse indices
|
|
1649
|
+
compressed = [messages[i] for i in sorted(diverse_indices)]
|
|
1650
|
+
|
|
1651
|
+
logger.debug(f"Semantic dedup: kept {len(compressed)} diverse messages " f"(removed {len(messages) - len(compressed)} similar messages)")
|
|
1652
|
+
|
|
1653
|
+
return compressed
|
|
1654
|
+
|
|
1655
|
+
def _find_diverse_messages(self, embeddings: List[List[float]], similarity_threshold: float, target_count: int) -> List[int]:
|
|
1656
|
+
"""
|
|
1657
|
+
Find diverse messages using embeddings.
|
|
1658
|
+
|
|
1659
|
+
Uses greedy selection to find messages that are semantically diverse.
|
|
1660
|
+
|
|
1661
|
+
Args:
|
|
1662
|
+
embeddings: List of embedding vectors
|
|
1663
|
+
similarity_threshold: Similarity threshold for deduplication
|
|
1664
|
+
target_count: Target number of messages to keep
|
|
1665
|
+
|
|
1666
|
+
Returns:
|
|
1667
|
+
List of indices of diverse messages
|
|
1668
|
+
"""
|
|
1669
|
+
import numpy as np
|
|
1670
|
+
|
|
1671
|
+
if len(embeddings) <= target_count:
|
|
1672
|
+
return list(range(len(embeddings)))
|
|
1673
|
+
|
|
1674
|
+
# Convert to numpy array
|
|
1675
|
+
emb_array = np.array(embeddings)
|
|
1676
|
+
|
|
1677
|
+
# Normalize embeddings for cosine similarity
|
|
1678
|
+
norms = np.linalg.norm(emb_array, axis=1, keepdims=True)
|
|
1679
|
+
emb_normalized = emb_array / (norms + 1e-8)
|
|
1680
|
+
|
|
1681
|
+
# Greedy selection: always keep most recent messages
|
|
1682
|
+
selected_indices = list(range(len(embeddings) - target_count, len(embeddings)))
|
|
1683
|
+
|
|
1684
|
+
# For older messages, select diverse ones
|
|
1685
|
+
remaining_indices = list(range(len(embeddings) - target_count))
|
|
1686
|
+
|
|
1687
|
+
while remaining_indices and len(selected_indices) < target_count:
|
|
1688
|
+
# Find message most different from selected ones
|
|
1689
|
+
max_min_distance = -1
|
|
1690
|
+
best_idx = None
|
|
1691
|
+
|
|
1692
|
+
for idx in remaining_indices:
|
|
1693
|
+
# Calculate similarity to all selected messages
|
|
1694
|
+
similarities = np.dot(emb_normalized[idx], emb_normalized[selected_indices].T)
|
|
1695
|
+
min_similarity = np.min(similarities) if len(similarities) > 0 else 0
|
|
1696
|
+
|
|
1697
|
+
# We want maximum minimum distance (most diverse)
|
|
1698
|
+
if min_similarity > max_min_distance:
|
|
1699
|
+
max_min_distance = min_similarity
|
|
1700
|
+
best_idx = idx
|
|
1701
|
+
|
|
1702
|
+
if best_idx is not None and max_min_distance < similarity_threshold:
|
|
1703
|
+
selected_indices.append(best_idx)
|
|
1704
|
+
remaining_indices.remove(best_idx)
|
|
1705
|
+
else:
|
|
1706
|
+
break
|
|
1707
|
+
|
|
1708
|
+
return selected_indices
|
|
1709
|
+
|
|
1710
|
+
async def _replace_conversation_history(self, session_id: str, messages: List[ConversationMessage]) -> None:
|
|
1711
|
+
"""
|
|
1712
|
+
Replace conversation history with compressed messages.
|
|
1713
|
+
|
|
1714
|
+
Args:
|
|
1715
|
+
session_id: Session ID
|
|
1716
|
+
messages: New list of messages
|
|
1717
|
+
"""
|
|
1718
|
+
if self.redis_client:
|
|
1719
|
+
try:
|
|
1720
|
+
# Clear existing messages
|
|
1721
|
+
await self.redis_client.delete(f"conversation:{session_id}")
|
|
1722
|
+
|
|
1723
|
+
# Store new messages
|
|
1724
|
+
for msg in messages:
|
|
1725
|
+
await self.redis_client.rpush( # type: ignore[misc]
|
|
1726
|
+
f"conversation:{session_id}",
|
|
1727
|
+
json.dumps(msg.to_dict(), cls=DateTimeEncoder),
|
|
1728
|
+
)
|
|
1729
|
+
|
|
1730
|
+
# Set TTL
|
|
1731
|
+
await self.redis_client.expire(f"conversation:{session_id}", self.session_ttl)
|
|
1732
|
+
|
|
1733
|
+
logger.debug(f"Replaced conversation history for {session_id} with {len(messages)} messages")
|
|
1734
|
+
return
|
|
1735
|
+
except Exception as e:
|
|
1736
|
+
logger.error(f"Failed to replace conversation history in Redis: {e}")
|
|
1737
|
+
|
|
1738
|
+
# Fallback to memory
|
|
1739
|
+
self._memory_conversations[session_id] = messages
|
|
1740
|
+
logger.debug(f"Replaced conversation history (memory) for {session_id} with {len(messages)} messages")
|
|
1741
|
+
|
|
1742
|
+
async def _compress_with_hybrid(self, messages: List[ConversationMessage], config: CompressionConfig) -> List[ConversationMessage]:
|
|
1743
|
+
"""
|
|
1744
|
+
Compress using hybrid strategy (combination of multiple strategies).
|
|
1745
|
+
|
|
1746
|
+
Applies multiple compression strategies in sequence based on config.hybrid_strategies.
|
|
1747
|
+
|
|
1748
|
+
Args:
|
|
1749
|
+
messages: List of conversation messages
|
|
1750
|
+
config: Compression configuration
|
|
1751
|
+
|
|
1752
|
+
Returns:
|
|
1753
|
+
Compressed list of messages
|
|
1754
|
+
|
|
1755
|
+
Example:
|
|
1756
|
+
# Default hybrid: truncate then summarize
|
|
1757
|
+
config = CompressionConfig(
|
|
1758
|
+
strategy="hybrid",
|
|
1759
|
+
hybrid_strategies=["truncate", "summarize"]
|
|
1760
|
+
)
|
|
1761
|
+
"""
|
|
1762
|
+
compressed = messages
|
|
1763
|
+
|
|
1764
|
+
# Type narrowing: ensure hybrid_strategies is a list
|
|
1765
|
+
if config.hybrid_strategies is None:
|
|
1766
|
+
config.hybrid_strategies = ["truncate", "summarize"]
|
|
1767
|
+
|
|
1768
|
+
for strategy in config.hybrid_strategies:
|
|
1769
|
+
if strategy == "truncate":
|
|
1770
|
+
compressed = await self._compress_with_truncation(compressed, config)
|
|
1771
|
+
elif strategy == "summarize":
|
|
1772
|
+
compressed = await self._compress_with_summarization(compressed, config)
|
|
1773
|
+
elif strategy == "semantic":
|
|
1774
|
+
compressed = await self._compress_with_semantic_dedup(compressed, config)
|
|
1775
|
+
else:
|
|
1776
|
+
logger.warning(f"Unknown hybrid strategy: {strategy}, skipping")
|
|
1777
|
+
|
|
1778
|
+
logger.debug(f"Hybrid compression: {len(messages)} -> {len(compressed)} messages " f"using strategies: {', '.join(config.hybrid_strategies)}")
|
|
1779
|
+
|
|
1780
|
+
return compressed
|
|
1781
|
+
|
|
1782
|
+
async def auto_compress_on_limit(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
1783
|
+
"""
|
|
1784
|
+
Automatically compress conversation if it exceeds threshold.
|
|
1785
|
+
|
|
1786
|
+
Checks if conversation exceeds auto_compress_threshold and compresses
|
|
1787
|
+
to auto_compress_target if needed.
|
|
1788
|
+
|
|
1789
|
+
Args:
|
|
1790
|
+
session_id: Session ID to check
|
|
1791
|
+
|
|
1792
|
+
Returns:
|
|
1793
|
+
Compression result dict if compression was triggered, None otherwise
|
|
1794
|
+
|
|
1795
|
+
Example:
|
|
1796
|
+
# Configure auto-compression
|
|
1797
|
+
config = CompressionConfig(
|
|
1798
|
+
auto_compress_enabled=True,
|
|
1799
|
+
auto_compress_threshold=100,
|
|
1800
|
+
auto_compress_target=50
|
|
1801
|
+
)
|
|
1802
|
+
engine = ContextEngine(compression_config=config)
|
|
1803
|
+
|
|
1804
|
+
# Check and auto-compress if needed
|
|
1805
|
+
result = await engine.auto_compress_on_limit(session_id)
|
|
1806
|
+
if result:
|
|
1807
|
+
print(f"Auto-compressed: {result['original_count']} -> {result['compressed_count']}")
|
|
1808
|
+
"""
|
|
1809
|
+
if not self.compression_config.auto_compress_enabled:
|
|
1810
|
+
return None
|
|
1811
|
+
|
|
1812
|
+
# Get current message count
|
|
1813
|
+
messages = await self.get_conversation_history(session_id)
|
|
1814
|
+
message_count = len(messages)
|
|
1815
|
+
|
|
1816
|
+
# Check if threshold exceeded
|
|
1817
|
+
if message_count <= self.compression_config.auto_compress_threshold:
|
|
1818
|
+
return None
|
|
1819
|
+
|
|
1820
|
+
logger.info(f"Auto-compression triggered for {session_id}: " f"{message_count} messages exceeds threshold of " f"{self.compression_config.auto_compress_threshold}")
|
|
1821
|
+
|
|
1822
|
+
# Compress conversation
|
|
1823
|
+
result = await self.compress_conversation(session_id)
|
|
1824
|
+
|
|
1825
|
+
if result.get("success"):
|
|
1826
|
+
logger.info(f"Auto-compression complete for {session_id}: " f"{result['original_count']} -> {result['compressed_count']} messages")
|
|
1827
|
+
|
|
1828
|
+
return result
|
|
1829
|
+
|
|
1830
|
+
async def get_compressed_context(
|
|
1831
|
+
self,
|
|
1832
|
+
session_id: str,
|
|
1833
|
+
format: str = "messages",
|
|
1834
|
+
compress_first: bool = False,
|
|
1835
|
+
) -> Any:
|
|
1836
|
+
"""
|
|
1837
|
+
Get conversation context in compressed format.
|
|
1838
|
+
|
|
1839
|
+
Args:
|
|
1840
|
+
session_id: Session ID
|
|
1841
|
+
format: Output format - "messages", "string", or "dict"
|
|
1842
|
+
compress_first: Whether to compress before returning
|
|
1843
|
+
|
|
1844
|
+
Returns:
|
|
1845
|
+
Conversation in requested format:
|
|
1846
|
+
- "messages": List[ConversationMessage]
|
|
1847
|
+
- "string": Formatted string
|
|
1848
|
+
- "dict": List[Dict[str, Any]]
|
|
1849
|
+
|
|
1850
|
+
Example:
|
|
1851
|
+
# Get as formatted string
|
|
1852
|
+
context = await engine.get_compressed_context(
|
|
1853
|
+
session_id="session-123",
|
|
1854
|
+
format="string"
|
|
1855
|
+
)
|
|
1856
|
+
print(context)
|
|
1857
|
+
|
|
1858
|
+
# Get as messages, compress first
|
|
1859
|
+
messages = await engine.get_compressed_context(
|
|
1860
|
+
session_id="session-456",
|
|
1861
|
+
format="messages",
|
|
1862
|
+
compress_first=True
|
|
1863
|
+
)
|
|
1864
|
+
"""
|
|
1865
|
+
# Compress first if requested
|
|
1866
|
+
if compress_first:
|
|
1867
|
+
await self.compress_conversation(session_id)
|
|
1868
|
+
|
|
1869
|
+
# Get conversation history
|
|
1870
|
+
messages = await self.get_conversation_history(session_id)
|
|
1871
|
+
|
|
1872
|
+
# Return in requested format
|
|
1873
|
+
if format == "messages":
|
|
1874
|
+
return messages
|
|
1875
|
+
|
|
1876
|
+
elif format == "string":
|
|
1877
|
+
# Format as string
|
|
1878
|
+
lines = []
|
|
1879
|
+
for msg in messages:
|
|
1880
|
+
# messages is List[Dict[str, Any]] from get_conversation_history
|
|
1881
|
+
timestamp = msg.get("timestamp", "").strftime("%Y-%m-%d %H:%M:%S") if isinstance(msg.get("timestamp"), datetime) else str(msg.get("timestamp", ""))
|
|
1882
|
+
role = msg.get("role", "")
|
|
1883
|
+
content = msg.get("content", "")
|
|
1884
|
+
lines.append(f"[{timestamp}] {role}: {content}")
|
|
1885
|
+
return "\n\n".join(lines)
|
|
1886
|
+
|
|
1887
|
+
elif format == "dict":
|
|
1888
|
+
# Return as list of dicts (already dicts from get_conversation_history)
|
|
1889
|
+
return [self._sanitize_for_json(msg) for msg in messages]
|
|
1890
|
+
|
|
1891
|
+
else:
|
|
1892
|
+
raise ValueError(f"Invalid format '{format}'. Must be 'messages', 'string', or 'dict'")
|
|
1893
|
+
|
|
1894
|
+
def _sanitize_for_json(self, obj: Any) -> Any:
|
|
1895
|
+
"""
|
|
1896
|
+
Sanitize object for JSON serialization.
|
|
1897
|
+
|
|
1898
|
+
Handles common non-serializable types like datetime, dataclasses, etc.
|
|
1899
|
+
|
|
1900
|
+
Args:
|
|
1901
|
+
obj: Object to sanitize
|
|
1902
|
+
|
|
1903
|
+
Returns:
|
|
1904
|
+
JSON-serializable version of object
|
|
1905
|
+
|
|
1906
|
+
Note:
|
|
1907
|
+
This is similar to _sanitize_dataclasses but more general purpose.
|
|
1908
|
+
"""
|
|
1909
|
+
# Use existing sanitization logic
|
|
1910
|
+
return self._sanitize_dataclasses(obj)
|