alita-sdk 0.3.379__py3-none-any.whl → 0.3.627__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.
- alita_sdk/cli/__init__.py +10 -0
- alita_sdk/cli/__main__.py +17 -0
- alita_sdk/cli/agent/__init__.py +5 -0
- alita_sdk/cli/agent/default.py +258 -0
- alita_sdk/cli/agent_executor.py +156 -0
- alita_sdk/cli/agent_loader.py +245 -0
- alita_sdk/cli/agent_ui.py +228 -0
- alita_sdk/cli/agents.py +3113 -0
- alita_sdk/cli/callbacks.py +647 -0
- alita_sdk/cli/cli.py +168 -0
- alita_sdk/cli/config.py +306 -0
- alita_sdk/cli/context/__init__.py +30 -0
- alita_sdk/cli/context/cleanup.py +198 -0
- alita_sdk/cli/context/manager.py +731 -0
- alita_sdk/cli/context/message.py +285 -0
- alita_sdk/cli/context/strategies.py +289 -0
- alita_sdk/cli/context/token_estimation.py +127 -0
- alita_sdk/cli/formatting.py +182 -0
- alita_sdk/cli/input_handler.py +419 -0
- alita_sdk/cli/inventory.py +1073 -0
- alita_sdk/cli/mcp_loader.py +315 -0
- alita_sdk/cli/testcases/__init__.py +94 -0
- alita_sdk/cli/testcases/data_generation.py +119 -0
- alita_sdk/cli/testcases/discovery.py +96 -0
- alita_sdk/cli/testcases/executor.py +84 -0
- alita_sdk/cli/testcases/logger.py +85 -0
- alita_sdk/cli/testcases/parser.py +172 -0
- alita_sdk/cli/testcases/prompts.py +91 -0
- alita_sdk/cli/testcases/reporting.py +125 -0
- alita_sdk/cli/testcases/setup.py +108 -0
- alita_sdk/cli/testcases/test_runner.py +282 -0
- alita_sdk/cli/testcases/utils.py +39 -0
- alita_sdk/cli/testcases/validation.py +90 -0
- alita_sdk/cli/testcases/workflow.py +196 -0
- alita_sdk/cli/toolkit.py +327 -0
- alita_sdk/cli/toolkit_loader.py +85 -0
- alita_sdk/cli/tools/__init__.py +43 -0
- alita_sdk/cli/tools/approval.py +224 -0
- alita_sdk/cli/tools/filesystem.py +1751 -0
- alita_sdk/cli/tools/planning.py +389 -0
- alita_sdk/cli/tools/terminal.py +414 -0
- alita_sdk/community/__init__.py +72 -12
- alita_sdk/community/inventory/__init__.py +236 -0
- alita_sdk/community/inventory/config.py +257 -0
- alita_sdk/community/inventory/enrichment.py +2137 -0
- alita_sdk/community/inventory/extractors.py +1469 -0
- alita_sdk/community/inventory/ingestion.py +3172 -0
- alita_sdk/community/inventory/knowledge_graph.py +1457 -0
- alita_sdk/community/inventory/parsers/__init__.py +218 -0
- alita_sdk/community/inventory/parsers/base.py +295 -0
- alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
- alita_sdk/community/inventory/parsers/go_parser.py +851 -0
- alita_sdk/community/inventory/parsers/html_parser.py +389 -0
- alita_sdk/community/inventory/parsers/java_parser.py +593 -0
- alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
- alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
- alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
- alita_sdk/community/inventory/parsers/python_parser.py +604 -0
- alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
- alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
- alita_sdk/community/inventory/parsers/text_parser.py +322 -0
- alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
- alita_sdk/community/inventory/patterns/__init__.py +61 -0
- alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
- alita_sdk/community/inventory/patterns/loader.py +348 -0
- alita_sdk/community/inventory/patterns/registry.py +198 -0
- alita_sdk/community/inventory/presets.py +535 -0
- alita_sdk/community/inventory/retrieval.py +1403 -0
- alita_sdk/community/inventory/toolkit.py +173 -0
- alita_sdk/community/inventory/toolkit_utils.py +176 -0
- alita_sdk/community/inventory/visualize.py +1370 -0
- alita_sdk/configurations/__init__.py +1 -1
- alita_sdk/configurations/ado.py +141 -20
- alita_sdk/configurations/bitbucket.py +94 -2
- alita_sdk/configurations/confluence.py +130 -1
- alita_sdk/configurations/figma.py +76 -0
- alita_sdk/configurations/gitlab.py +91 -0
- alita_sdk/configurations/jira.py +103 -0
- alita_sdk/configurations/openapi.py +329 -0
- alita_sdk/configurations/qtest.py +72 -1
- alita_sdk/configurations/report_portal.py +96 -0
- alita_sdk/configurations/sharepoint.py +148 -0
- alita_sdk/configurations/testio.py +83 -0
- alita_sdk/configurations/testrail.py +88 -0
- alita_sdk/configurations/xray.py +93 -0
- alita_sdk/configurations/zephyr_enterprise.py +93 -0
- alita_sdk/configurations/zephyr_essential.py +75 -0
- alita_sdk/runtime/clients/artifact.py +3 -3
- alita_sdk/runtime/clients/client.py +388 -46
- alita_sdk/runtime/clients/mcp_discovery.py +342 -0
- alita_sdk/runtime/clients/mcp_manager.py +262 -0
- alita_sdk/runtime/clients/sandbox_client.py +8 -21
- alita_sdk/runtime/langchain/_constants_bkup.py +1318 -0
- alita_sdk/runtime/langchain/assistant.py +157 -39
- alita_sdk/runtime/langchain/constants.py +647 -1
- alita_sdk/runtime/langchain/document_loaders/AlitaDocxMammothLoader.py +315 -3
- alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +103 -60
- alita_sdk/runtime/langchain/document_loaders/AlitaJSONLinesLoader.py +77 -0
- alita_sdk/runtime/langchain/document_loaders/AlitaJSONLoader.py +10 -4
- alita_sdk/runtime/langchain/document_loaders/AlitaPowerPointLoader.py +226 -7
- alita_sdk/runtime/langchain/document_loaders/AlitaTextLoader.py +5 -2
- alita_sdk/runtime/langchain/document_loaders/constants.py +40 -19
- alita_sdk/runtime/langchain/langraph_agent.py +405 -84
- alita_sdk/runtime/langchain/utils.py +106 -7
- alita_sdk/runtime/llms/preloaded.py +2 -6
- alita_sdk/runtime/models/mcp_models.py +61 -0
- alita_sdk/runtime/skills/__init__.py +91 -0
- alita_sdk/runtime/skills/callbacks.py +498 -0
- alita_sdk/runtime/skills/discovery.py +540 -0
- alita_sdk/runtime/skills/executor.py +610 -0
- alita_sdk/runtime/skills/input_builder.py +371 -0
- alita_sdk/runtime/skills/models.py +330 -0
- alita_sdk/runtime/skills/registry.py +355 -0
- alita_sdk/runtime/skills/skill_runner.py +330 -0
- alita_sdk/runtime/toolkits/__init__.py +31 -0
- alita_sdk/runtime/toolkits/application.py +29 -10
- alita_sdk/runtime/toolkits/artifact.py +20 -11
- alita_sdk/runtime/toolkits/datasource.py +13 -6
- alita_sdk/runtime/toolkits/mcp.py +783 -0
- alita_sdk/runtime/toolkits/mcp_config.py +1048 -0
- alita_sdk/runtime/toolkits/planning.py +178 -0
- alita_sdk/runtime/toolkits/skill_router.py +238 -0
- alita_sdk/runtime/toolkits/subgraph.py +251 -6
- alita_sdk/runtime/toolkits/tools.py +356 -69
- alita_sdk/runtime/toolkits/vectorstore.py +11 -5
- alita_sdk/runtime/tools/__init__.py +10 -3
- alita_sdk/runtime/tools/application.py +27 -6
- alita_sdk/runtime/tools/artifact.py +511 -28
- alita_sdk/runtime/tools/data_analysis.py +183 -0
- alita_sdk/runtime/tools/function.py +67 -35
- alita_sdk/runtime/tools/graph.py +10 -4
- alita_sdk/runtime/tools/image_generation.py +148 -46
- alita_sdk/runtime/tools/llm.py +1003 -128
- alita_sdk/runtime/tools/loop.py +3 -1
- alita_sdk/runtime/tools/loop_output.py +3 -1
- alita_sdk/runtime/tools/mcp_inspect_tool.py +284 -0
- alita_sdk/runtime/tools/mcp_remote_tool.py +181 -0
- alita_sdk/runtime/tools/mcp_server_tool.py +8 -5
- alita_sdk/runtime/tools/planning/__init__.py +36 -0
- alita_sdk/runtime/tools/planning/models.py +246 -0
- alita_sdk/runtime/tools/planning/wrapper.py +607 -0
- alita_sdk/runtime/tools/router.py +2 -4
- alita_sdk/runtime/tools/sandbox.py +65 -48
- alita_sdk/runtime/tools/skill_router.py +776 -0
- alita_sdk/runtime/tools/tool.py +3 -1
- alita_sdk/runtime/tools/vectorstore.py +9 -3
- alita_sdk/runtime/tools/vectorstore_base.py +70 -14
- alita_sdk/runtime/utils/AlitaCallback.py +137 -21
- alita_sdk/runtime/utils/constants.py +5 -1
- alita_sdk/runtime/utils/mcp_client.py +492 -0
- alita_sdk/runtime/utils/mcp_oauth.py +361 -0
- alita_sdk/runtime/utils/mcp_sse_client.py +434 -0
- alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
- alita_sdk/runtime/utils/serialization.py +155 -0
- alita_sdk/runtime/utils/streamlit.py +40 -13
- alita_sdk/runtime/utils/toolkit_utils.py +30 -9
- alita_sdk/runtime/utils/utils.py +36 -0
- alita_sdk/tools/__init__.py +134 -35
- alita_sdk/tools/ado/repos/__init__.py +51 -32
- alita_sdk/tools/ado/repos/repos_wrapper.py +148 -89
- alita_sdk/tools/ado/test_plan/__init__.py +25 -9
- alita_sdk/tools/ado/test_plan/test_plan_wrapper.py +23 -1
- alita_sdk/tools/ado/utils.py +1 -18
- alita_sdk/tools/ado/wiki/__init__.py +25 -12
- alita_sdk/tools/ado/wiki/ado_wrapper.py +291 -22
- alita_sdk/tools/ado/work_item/__init__.py +26 -13
- alita_sdk/tools/ado/work_item/ado_wrapper.py +73 -11
- alita_sdk/tools/advanced_jira_mining/__init__.py +11 -8
- alita_sdk/tools/aws/delta_lake/__init__.py +13 -9
- alita_sdk/tools/aws/delta_lake/tool.py +5 -1
- alita_sdk/tools/azure_ai/search/__init__.py +11 -8
- alita_sdk/tools/azure_ai/search/api_wrapper.py +1 -1
- alita_sdk/tools/base/tool.py +5 -1
- alita_sdk/tools/base_indexer_toolkit.py +271 -84
- alita_sdk/tools/bitbucket/__init__.py +17 -11
- alita_sdk/tools/bitbucket/api_wrapper.py +59 -11
- alita_sdk/tools/bitbucket/cloud_api_wrapper.py +49 -35
- alita_sdk/tools/browser/__init__.py +5 -4
- alita_sdk/tools/carrier/__init__.py +5 -6
- alita_sdk/tools/carrier/backend_reports_tool.py +6 -6
- alita_sdk/tools/carrier/run_ui_test_tool.py +6 -6
- alita_sdk/tools/carrier/ui_reports_tool.py +5 -5
- alita_sdk/tools/chunkers/__init__.py +3 -1
- alita_sdk/tools/chunkers/code/treesitter/treesitter.py +37 -13
- alita_sdk/tools/chunkers/sematic/json_chunker.py +1 -0
- alita_sdk/tools/chunkers/sematic/markdown_chunker.py +97 -6
- alita_sdk/tools/chunkers/sematic/proposal_chunker.py +1 -1
- alita_sdk/tools/chunkers/universal_chunker.py +270 -0
- alita_sdk/tools/cloud/aws/__init__.py +10 -7
- alita_sdk/tools/cloud/azure/__init__.py +10 -7
- alita_sdk/tools/cloud/gcp/__init__.py +10 -7
- alita_sdk/tools/cloud/k8s/__init__.py +10 -7
- alita_sdk/tools/code/linter/__init__.py +10 -8
- alita_sdk/tools/code/loaders/codesearcher.py +3 -2
- alita_sdk/tools/code/sonar/__init__.py +11 -8
- alita_sdk/tools/code_indexer_toolkit.py +82 -22
- alita_sdk/tools/confluence/__init__.py +22 -16
- alita_sdk/tools/confluence/api_wrapper.py +107 -30
- alita_sdk/tools/confluence/loader.py +14 -2
- alita_sdk/tools/custom_open_api/__init__.py +12 -5
- alita_sdk/tools/elastic/__init__.py +11 -8
- alita_sdk/tools/elitea_base.py +493 -30
- alita_sdk/tools/figma/__init__.py +58 -11
- alita_sdk/tools/figma/api_wrapper.py +1235 -143
- alita_sdk/tools/figma/figma_client.py +73 -0
- alita_sdk/tools/figma/toon_tools.py +2748 -0
- alita_sdk/tools/github/__init__.py +14 -15
- alita_sdk/tools/github/github_client.py +224 -100
- alita_sdk/tools/github/graphql_client_wrapper.py +119 -33
- alita_sdk/tools/github/schemas.py +14 -5
- alita_sdk/tools/github/tool.py +5 -1
- alita_sdk/tools/github/tool_prompts.py +9 -22
- alita_sdk/tools/gitlab/__init__.py +16 -11
- alita_sdk/tools/gitlab/api_wrapper.py +218 -48
- alita_sdk/tools/gitlab_org/__init__.py +10 -9
- alita_sdk/tools/gitlab_org/api_wrapper.py +63 -64
- alita_sdk/tools/google/bigquery/__init__.py +13 -12
- alita_sdk/tools/google/bigquery/tool.py +5 -1
- alita_sdk/tools/google_places/__init__.py +11 -8
- alita_sdk/tools/google_places/api_wrapper.py +1 -1
- alita_sdk/tools/jira/__init__.py +17 -10
- alita_sdk/tools/jira/api_wrapper.py +92 -41
- alita_sdk/tools/keycloak/__init__.py +11 -8
- alita_sdk/tools/localgit/__init__.py +9 -3
- alita_sdk/tools/localgit/local_git.py +62 -54
- alita_sdk/tools/localgit/tool.py +5 -1
- alita_sdk/tools/memory/__init__.py +12 -4
- alita_sdk/tools/non_code_indexer_toolkit.py +1 -0
- alita_sdk/tools/ocr/__init__.py +11 -8
- alita_sdk/tools/openapi/__init__.py +491 -106
- alita_sdk/tools/openapi/api_wrapper.py +1368 -0
- alita_sdk/tools/openapi/tool.py +20 -0
- alita_sdk/tools/pandas/__init__.py +20 -12
- alita_sdk/tools/pandas/api_wrapper.py +38 -25
- alita_sdk/tools/pandas/dataframe/generator/base.py +3 -1
- alita_sdk/tools/postman/__init__.py +10 -9
- alita_sdk/tools/pptx/__init__.py +11 -10
- alita_sdk/tools/pptx/pptx_wrapper.py +1 -1
- alita_sdk/tools/qtest/__init__.py +31 -11
- alita_sdk/tools/qtest/api_wrapper.py +2135 -86
- alita_sdk/tools/rally/__init__.py +10 -9
- alita_sdk/tools/rally/api_wrapper.py +1 -1
- alita_sdk/tools/report_portal/__init__.py +12 -8
- alita_sdk/tools/salesforce/__init__.py +10 -8
- alita_sdk/tools/servicenow/__init__.py +17 -15
- alita_sdk/tools/servicenow/api_wrapper.py +1 -1
- alita_sdk/tools/sharepoint/__init__.py +10 -7
- alita_sdk/tools/sharepoint/api_wrapper.py +129 -38
- alita_sdk/tools/sharepoint/authorization_helper.py +191 -1
- alita_sdk/tools/sharepoint/utils.py +8 -2
- alita_sdk/tools/slack/__init__.py +10 -7
- alita_sdk/tools/slack/api_wrapper.py +2 -2
- alita_sdk/tools/sql/__init__.py +12 -9
- alita_sdk/tools/testio/__init__.py +10 -7
- alita_sdk/tools/testrail/__init__.py +11 -10
- alita_sdk/tools/testrail/api_wrapper.py +1 -1
- alita_sdk/tools/utils/__init__.py +9 -4
- alita_sdk/tools/utils/content_parser.py +103 -18
- alita_sdk/tools/utils/text_operations.py +410 -0
- alita_sdk/tools/utils/tool_prompts.py +79 -0
- alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +30 -13
- alita_sdk/tools/xray/__init__.py +13 -9
- alita_sdk/tools/yagmail/__init__.py +9 -3
- alita_sdk/tools/zephyr/__init__.py +10 -7
- alita_sdk/tools/zephyr_enterprise/__init__.py +11 -7
- alita_sdk/tools/zephyr_essential/__init__.py +10 -7
- alita_sdk/tools/zephyr_essential/api_wrapper.py +30 -13
- alita_sdk/tools/zephyr_essential/client.py +2 -2
- alita_sdk/tools/zephyr_scale/__init__.py +11 -8
- alita_sdk/tools/zephyr_scale/api_wrapper.py +2 -2
- alita_sdk/tools/zephyr_squad/__init__.py +10 -7
- {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/METADATA +154 -8
- alita_sdk-0.3.627.dist-info/RECORD +468 -0
- alita_sdk-0.3.627.dist-info/entry_points.txt +2 -0
- alita_sdk-0.3.379.dist-info/RECORD +0 -360
- {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/WHEEL +0 -0
- {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/licenses/LICENSE +0 -0
- {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI Context Manager for chat history management.
|
|
3
|
+
|
|
4
|
+
Provides token-aware context management with pruning, summarization,
|
|
5
|
+
and efficient tracking of message inclusion status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from .message import CLIMessage, cli_messages_to_dicts, cli_messages_to_langchain
|
|
17
|
+
from .token_estimation import estimate_tokens, calculate_total_tokens
|
|
18
|
+
from .strategies import PruningOrchestrator, PruningConfig
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ContextInfo:
|
|
25
|
+
"""
|
|
26
|
+
Information about current context state for UI display.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
used_tokens: Tokens currently in context
|
|
30
|
+
max_tokens: Maximum allowed tokens
|
|
31
|
+
fill_ratio: Percentage of context used (0.0-1.0)
|
|
32
|
+
message_count: Total messages in history
|
|
33
|
+
included_count: Messages included in context
|
|
34
|
+
pruned_count: Messages excluded from context
|
|
35
|
+
summary_count: Number of active summaries
|
|
36
|
+
"""
|
|
37
|
+
used_tokens: int = 0
|
|
38
|
+
max_tokens: int = 8000
|
|
39
|
+
fill_ratio: float = 0.0
|
|
40
|
+
message_count: int = 0
|
|
41
|
+
included_count: int = 0
|
|
42
|
+
pruned_count: int = 0
|
|
43
|
+
summary_count: int = 0
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
46
|
+
"""Convert to dictionary for serialization."""
|
|
47
|
+
return {
|
|
48
|
+
'used_tokens': self.used_tokens,
|
|
49
|
+
'max_tokens': self.max_tokens,
|
|
50
|
+
'fill_ratio': self.fill_ratio,
|
|
51
|
+
'message_count': self.message_count,
|
|
52
|
+
'included_count': self.included_count,
|
|
53
|
+
'pruned_count': self.pruned_count,
|
|
54
|
+
'summary_count': self.summary_count,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Summary:
|
|
60
|
+
"""
|
|
61
|
+
Conversation summary for context compression.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
content: Summary text
|
|
65
|
+
from_idx: Start message index (inclusive)
|
|
66
|
+
to_idx: End message index (inclusive)
|
|
67
|
+
token_count: Token count of summary
|
|
68
|
+
created_at: Creation timestamp
|
|
69
|
+
"""
|
|
70
|
+
content: str
|
|
71
|
+
from_idx: int
|
|
72
|
+
to_idx: int
|
|
73
|
+
token_count: int = 0
|
|
74
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
75
|
+
|
|
76
|
+
def __post_init__(self):
|
|
77
|
+
"""Calculate token count if not provided."""
|
|
78
|
+
if self.token_count == 0 and self.content:
|
|
79
|
+
self.token_count = estimate_tokens(self.content)
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
82
|
+
"""Convert to dictionary for serialization."""
|
|
83
|
+
return {
|
|
84
|
+
'content': self.content,
|
|
85
|
+
'from_idx': self.from_idx,
|
|
86
|
+
'to_idx': self.to_idx,
|
|
87
|
+
'token_count': self.token_count,
|
|
88
|
+
'created_at': self.created_at.isoformat(),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'Summary':
|
|
93
|
+
"""Create Summary from dictionary."""
|
|
94
|
+
created_at = data.get('created_at')
|
|
95
|
+
if isinstance(created_at, str):
|
|
96
|
+
created_at = datetime.fromisoformat(created_at)
|
|
97
|
+
elif created_at is None:
|
|
98
|
+
created_at = datetime.now(timezone.utc)
|
|
99
|
+
|
|
100
|
+
return cls(
|
|
101
|
+
content=data['content'],
|
|
102
|
+
from_idx=data['from_idx'],
|
|
103
|
+
to_idx=data['to_idx'],
|
|
104
|
+
token_count=data.get('token_count', 0),
|
|
105
|
+
created_at=created_at,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CLIContextManager:
|
|
110
|
+
"""
|
|
111
|
+
Manages chat history context with token-aware pruning and summarization.
|
|
112
|
+
|
|
113
|
+
Features:
|
|
114
|
+
- Incremental token tracking (O(1) for new messages)
|
|
115
|
+
- Lazy pruning (only when needed)
|
|
116
|
+
- In-memory message inclusion tracking
|
|
117
|
+
- Summary generation and management
|
|
118
|
+
- Session state persistence
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
config: Optional[Dict[str, Any]] = None,
|
|
124
|
+
session_id: Optional[str] = None,
|
|
125
|
+
alita_dir: str = '.alita',
|
|
126
|
+
# Convenience parameters (override config if provided)
|
|
127
|
+
max_context_tokens: Optional[int] = None,
|
|
128
|
+
preserve_recent: Optional[int] = None,
|
|
129
|
+
pruning_method: Optional[str] = None,
|
|
130
|
+
enable_summarization: Optional[bool] = None,
|
|
131
|
+
summary_trigger_ratio: Optional[float] = None,
|
|
132
|
+
summaries_limit: Optional[int] = None,
|
|
133
|
+
llm: Optional[Any] = None,
|
|
134
|
+
):
|
|
135
|
+
"""
|
|
136
|
+
Initialize context manager.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
config: Context management configuration dict
|
|
140
|
+
session_id: Session ID for persistence
|
|
141
|
+
alita_dir: Base Alita directory
|
|
142
|
+
max_context_tokens: Maximum tokens in context (overrides config)
|
|
143
|
+
preserve_recent: Number of recent messages to preserve (overrides config)
|
|
144
|
+
pruning_method: Pruning strategy name (overrides config)
|
|
145
|
+
enable_summarization: Enable automatic summarization (overrides config)
|
|
146
|
+
summary_trigger_ratio: Context fill ratio that triggers summarization (overrides config)
|
|
147
|
+
summaries_limit: Maximum number of summaries to keep (overrides config)
|
|
148
|
+
llm: LLM instance for summarization
|
|
149
|
+
"""
|
|
150
|
+
# Default configuration
|
|
151
|
+
self.config = {
|
|
152
|
+
'enabled': True,
|
|
153
|
+
'max_context_tokens': 8000,
|
|
154
|
+
'preserve_recent_messages': 5,
|
|
155
|
+
'pruning_method': 'oldest_first',
|
|
156
|
+
'enable_summarization': True,
|
|
157
|
+
'summary_trigger_ratio': 0.8,
|
|
158
|
+
'summaries_limit_count': 5,
|
|
159
|
+
'weights': {
|
|
160
|
+
'recency': 1.0,
|
|
161
|
+
'importance': 1.0,
|
|
162
|
+
'user_messages': 1.2,
|
|
163
|
+
'thread_continuity': 1.0,
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
if config:
|
|
167
|
+
self.config.update(config)
|
|
168
|
+
|
|
169
|
+
# Apply convenience parameters
|
|
170
|
+
if max_context_tokens is not None:
|
|
171
|
+
self.config['max_context_tokens'] = max_context_tokens
|
|
172
|
+
if preserve_recent is not None:
|
|
173
|
+
self.config['preserve_recent_messages'] = preserve_recent
|
|
174
|
+
if pruning_method is not None:
|
|
175
|
+
self.config['pruning_method'] = pruning_method
|
|
176
|
+
if enable_summarization is not None:
|
|
177
|
+
self.config['enable_summarization'] = enable_summarization
|
|
178
|
+
if summary_trigger_ratio is not None:
|
|
179
|
+
self.config['summary_trigger_ratio'] = summary_trigger_ratio
|
|
180
|
+
if summaries_limit is not None:
|
|
181
|
+
self.config['summaries_limit_count'] = summaries_limit
|
|
182
|
+
|
|
183
|
+
self.session_id = session_id
|
|
184
|
+
self.alita_dir = alita_dir
|
|
185
|
+
self.llm = llm # LLM for summarization
|
|
186
|
+
|
|
187
|
+
# Message storage
|
|
188
|
+
self._messages: List[CLIMessage] = []
|
|
189
|
+
self._summaries: List[Summary] = []
|
|
190
|
+
|
|
191
|
+
# Token tracking (incremental)
|
|
192
|
+
self._included_tokens: int = 0
|
|
193
|
+
self._summary_tokens: int = 0
|
|
194
|
+
self._needs_pruning: bool = False
|
|
195
|
+
|
|
196
|
+
# Pruning orchestrator
|
|
197
|
+
self._orchestrator = PruningOrchestrator(self.config)
|
|
198
|
+
|
|
199
|
+
# Load existing state if session provided
|
|
200
|
+
if session_id:
|
|
201
|
+
self._load_state()
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def max_tokens(self) -> int:
|
|
205
|
+
"""Get maximum context tokens."""
|
|
206
|
+
return self.config.get('max_context_tokens', 8000)
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def total_tokens(self) -> int:
|
|
210
|
+
"""Get total tokens in context (messages + summaries)."""
|
|
211
|
+
return self._included_tokens + self._summary_tokens
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def fill_ratio(self) -> float:
|
|
215
|
+
"""Get context fill ratio (0.0-1.0)."""
|
|
216
|
+
if self.max_tokens <= 0:
|
|
217
|
+
return 0.0
|
|
218
|
+
return min(1.0, self.total_tokens / self.max_tokens)
|
|
219
|
+
|
|
220
|
+
def add_message(self, role: str, content: str) -> CLIMessage:
|
|
221
|
+
"""
|
|
222
|
+
Add a new message to the history.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
role: Message role (user, assistant, system)
|
|
226
|
+
content: Message content
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
The created CLIMessage
|
|
230
|
+
"""
|
|
231
|
+
index = len(self._messages)
|
|
232
|
+
message = CLIMessage(
|
|
233
|
+
role=role,
|
|
234
|
+
content=content,
|
|
235
|
+
index=index,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
self._messages.append(message)
|
|
239
|
+
self._included_tokens += message.token_count
|
|
240
|
+
|
|
241
|
+
# Check if we need to prune
|
|
242
|
+
if self.total_tokens > self.max_tokens:
|
|
243
|
+
self._needs_pruning = True
|
|
244
|
+
|
|
245
|
+
return message
|
|
246
|
+
|
|
247
|
+
def build_context(
|
|
248
|
+
self,
|
|
249
|
+
system_prompt: Optional[str] = None,
|
|
250
|
+
force_prune: bool = False,
|
|
251
|
+
) -> List[Dict[str, str]]:
|
|
252
|
+
"""
|
|
253
|
+
Build optimized context for LLM invocation.
|
|
254
|
+
|
|
255
|
+
This is the main method called before each LLM call.
|
|
256
|
+
Only performs pruning if tokens exceed limit.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
system_prompt: Optional system prompt to include
|
|
260
|
+
force_prune: Force re-pruning even if not needed
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
List of message dicts with 'role' and 'content' keys
|
|
264
|
+
"""
|
|
265
|
+
if not self.config.get('enabled', True):
|
|
266
|
+
# Context management disabled, return all messages
|
|
267
|
+
messages = cli_messages_to_dicts(self._messages, include_only=False)
|
|
268
|
+
if system_prompt:
|
|
269
|
+
messages.insert(0, {'role': 'system', 'content': system_prompt})
|
|
270
|
+
|
|
271
|
+
return messages
|
|
272
|
+
|
|
273
|
+
# Prune if needed
|
|
274
|
+
if self._needs_pruning or force_prune:
|
|
275
|
+
self._apply_pruning()
|
|
276
|
+
|
|
277
|
+
# Build message list
|
|
278
|
+
messages = []
|
|
279
|
+
|
|
280
|
+
# Add system prompt first
|
|
281
|
+
if system_prompt:
|
|
282
|
+
messages.append({'role': 'system', 'content': system_prompt})
|
|
283
|
+
|
|
284
|
+
# Add summaries as system context
|
|
285
|
+
if self._summaries:
|
|
286
|
+
summary_text = self._format_summaries_for_context()
|
|
287
|
+
if summary_text:
|
|
288
|
+
messages.append({
|
|
289
|
+
'role': 'system',
|
|
290
|
+
'content': f"Previous conversation summary:\n{summary_text}"
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
# Add included messages
|
|
294
|
+
messages.extend(cli_messages_to_dicts(self._messages, include_only=True))
|
|
295
|
+
|
|
296
|
+
return messages
|
|
297
|
+
|
|
298
|
+
def build_context_langchain(
|
|
299
|
+
self,
|
|
300
|
+
system_prompt: Optional[str] = None,
|
|
301
|
+
force_prune: bool = False,
|
|
302
|
+
) -> List[Any]:
|
|
303
|
+
"""
|
|
304
|
+
Build optimized context as LangChain messages.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
system_prompt: Optional system prompt to include
|
|
308
|
+
force_prune: Force re-pruning even if not needed
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
List of LangChain message objects
|
|
312
|
+
"""
|
|
313
|
+
from langchain_core.messages import SystemMessage
|
|
314
|
+
|
|
315
|
+
if not self.config.get('enabled', True):
|
|
316
|
+
messages = cli_messages_to_langchain(self._messages, include_only=False)
|
|
317
|
+
if system_prompt:
|
|
318
|
+
messages.insert(0, SystemMessage(content=system_prompt))
|
|
319
|
+
return messages
|
|
320
|
+
|
|
321
|
+
if self._needs_pruning or force_prune:
|
|
322
|
+
self._apply_pruning()
|
|
323
|
+
|
|
324
|
+
messages = []
|
|
325
|
+
|
|
326
|
+
if system_prompt:
|
|
327
|
+
messages.append(SystemMessage(content=system_prompt))
|
|
328
|
+
|
|
329
|
+
if self._summaries:
|
|
330
|
+
summary_text = self._format_summaries_for_context()
|
|
331
|
+
if summary_text:
|
|
332
|
+
messages.append(SystemMessage(
|
|
333
|
+
content=f"Previous conversation summary:\n{summary_text}"
|
|
334
|
+
))
|
|
335
|
+
|
|
336
|
+
messages.extend(cli_messages_to_langchain(self._messages, include_only=True))
|
|
337
|
+
|
|
338
|
+
return messages
|
|
339
|
+
|
|
340
|
+
def _apply_pruning(self):
|
|
341
|
+
"""Apply pruning strategy to reduce context size."""
|
|
342
|
+
if not self._messages:
|
|
343
|
+
self._needs_pruning = False
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
# Apply pruning
|
|
347
|
+
self._orchestrator.apply_pruning(
|
|
348
|
+
self._messages,
|
|
349
|
+
summary_tokens=self._summary_tokens,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Recalculate included tokens
|
|
353
|
+
self._included_tokens = sum(
|
|
354
|
+
m.token_count for m in self._messages if m.included
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
self._needs_pruning = False
|
|
358
|
+
|
|
359
|
+
# Check if we should trigger summarization
|
|
360
|
+
if self.config.get('enable_summarization', True):
|
|
361
|
+
self._maybe_trigger_summarization()
|
|
362
|
+
|
|
363
|
+
# Save state if session exists
|
|
364
|
+
if self.session_id:
|
|
365
|
+
self._save_state()
|
|
366
|
+
|
|
367
|
+
def _maybe_trigger_summarization(self):
|
|
368
|
+
"""Check if summarization should be triggered and mark for it."""
|
|
369
|
+
trigger_ratio = self.config.get('summary_trigger_ratio', 0.8)
|
|
370
|
+
|
|
371
|
+
if self.fill_ratio < trigger_ratio:
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
# Count pruned messages that haven't been summarized
|
|
375
|
+
pruned_messages = [m for m in self._messages if not m.included]
|
|
376
|
+
|
|
377
|
+
# Check if we have enough pruned messages to summarize
|
|
378
|
+
min_for_summary = 3
|
|
379
|
+
if len(pruned_messages) >= min_for_summary:
|
|
380
|
+
# Find the range of pruned messages
|
|
381
|
+
if pruned_messages:
|
|
382
|
+
from_idx = min(m.index for m in pruned_messages)
|
|
383
|
+
to_idx = max(m.index for m in pruned_messages)
|
|
384
|
+
|
|
385
|
+
# Check if this range is already summarized
|
|
386
|
+
for summary in self._summaries:
|
|
387
|
+
if summary.from_idx <= from_idx and summary.to_idx >= to_idx:
|
|
388
|
+
return # Already summarized
|
|
389
|
+
|
|
390
|
+
logger.debug(
|
|
391
|
+
f"Summarization needed: {len(pruned_messages)} pruned messages "
|
|
392
|
+
f"(indices {from_idx}-{to_idx})"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def generate_summary(
|
|
396
|
+
self,
|
|
397
|
+
llm: Any,
|
|
398
|
+
from_idx: Optional[int] = None,
|
|
399
|
+
to_idx: Optional[int] = None,
|
|
400
|
+
) -> Optional[Summary]:
|
|
401
|
+
"""
|
|
402
|
+
Generate a summary of pruned messages using the LLM.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
llm: LLM instance with invoke() method
|
|
406
|
+
from_idx: Start message index (default: first pruned)
|
|
407
|
+
to_idx: End message index (default: last pruned)
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Generated Summary or None if failed/not needed
|
|
411
|
+
"""
|
|
412
|
+
# Find pruned messages to summarize
|
|
413
|
+
pruned_messages = [m for m in self._messages if not m.included]
|
|
414
|
+
|
|
415
|
+
if not pruned_messages:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
if from_idx is None:
|
|
419
|
+
from_idx = min(m.index for m in pruned_messages)
|
|
420
|
+
if to_idx is None:
|
|
421
|
+
to_idx = max(m.index for m in pruned_messages)
|
|
422
|
+
|
|
423
|
+
# Get messages in range
|
|
424
|
+
messages_to_summarize = [
|
|
425
|
+
m for m in self._messages
|
|
426
|
+
if from_idx <= m.index <= to_idx
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
if len(messages_to_summarize) < 3:
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
# Build summary prompt
|
|
433
|
+
conversation_text = self._format_messages_for_summary(messages_to_summarize)
|
|
434
|
+
|
|
435
|
+
summary_instructions = self.config.get('summary_instructions') or (
|
|
436
|
+
"Summarize the following conversation concisely, preserving key information, "
|
|
437
|
+
"decisions made, and important context. Focus on facts and outcomes."
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
prompt = f"{summary_instructions}\n\nConversation:\n{conversation_text}"
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
response = llm.invoke([{'role': 'user', 'content': prompt}])
|
|
444
|
+
summary_content = response.content if hasattr(response, 'content') else str(response)
|
|
445
|
+
|
|
446
|
+
summary = Summary(
|
|
447
|
+
content=summary_content,
|
|
448
|
+
from_idx=from_idx,
|
|
449
|
+
to_idx=to_idx,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# Add summary and update tokens
|
|
453
|
+
self._summaries.append(summary)
|
|
454
|
+
self._summary_tokens += summary.token_count
|
|
455
|
+
|
|
456
|
+
# Prune old summaries if limit exceeded
|
|
457
|
+
self._prune_old_summaries()
|
|
458
|
+
|
|
459
|
+
# Save state
|
|
460
|
+
if self.session_id:
|
|
461
|
+
self._save_state()
|
|
462
|
+
|
|
463
|
+
logger.info(f"Generated summary for messages {from_idx}-{to_idx}")
|
|
464
|
+
return summary
|
|
465
|
+
|
|
466
|
+
except Exception as e:
|
|
467
|
+
logger.warning(f"Failed to generate summary: {e}")
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
def _prune_old_summaries(self):
|
|
471
|
+
"""Remove oldest summaries if limit exceeded."""
|
|
472
|
+
limit = self.config.get('summaries_limit_count', 5)
|
|
473
|
+
|
|
474
|
+
while len(self._summaries) > limit:
|
|
475
|
+
removed = self._summaries.pop(0)
|
|
476
|
+
self._summary_tokens -= removed.token_count
|
|
477
|
+
logger.debug(f"Pruned old summary (indices {removed.from_idx}-{removed.to_idx})")
|
|
478
|
+
|
|
479
|
+
def _format_messages_for_summary(self, messages: List[CLIMessage]) -> str:
|
|
480
|
+
"""Format messages for summary generation prompt."""
|
|
481
|
+
lines = []
|
|
482
|
+
for msg in messages:
|
|
483
|
+
role = msg.role.capitalize()
|
|
484
|
+
lines.append(f"{role}: {msg.content}")
|
|
485
|
+
return "\n\n".join(lines)
|
|
486
|
+
|
|
487
|
+
def _format_summaries_for_context(self) -> str:
|
|
488
|
+
"""Format all summaries for inclusion in context."""
|
|
489
|
+
if not self._summaries:
|
|
490
|
+
return ""
|
|
491
|
+
|
|
492
|
+
parts = []
|
|
493
|
+
for i, summary in enumerate(self._summaries, 1):
|
|
494
|
+
if len(self._summaries) > 1:
|
|
495
|
+
parts.append(f"[Part {i}] {summary.content}")
|
|
496
|
+
else:
|
|
497
|
+
parts.append(summary.content)
|
|
498
|
+
|
|
499
|
+
return "\n\n".join(parts)
|
|
500
|
+
|
|
501
|
+
def _build_context_info(self) -> ContextInfo:
|
|
502
|
+
"""Build context info for UI display."""
|
|
503
|
+
included_count = sum(1 for m in self._messages if m.included)
|
|
504
|
+
|
|
505
|
+
return ContextInfo(
|
|
506
|
+
used_tokens=self.total_tokens,
|
|
507
|
+
max_tokens=self.max_tokens,
|
|
508
|
+
fill_ratio=self.fill_ratio,
|
|
509
|
+
message_count=len(self._messages),
|
|
510
|
+
included_count=included_count,
|
|
511
|
+
pruned_count=len(self._messages) - included_count,
|
|
512
|
+
summary_count=len(self._summaries),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def get_context_info(self) -> Dict[str, Any]:
|
|
516
|
+
"""
|
|
517
|
+
Get current context info as a dictionary for UI display.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
Dict with keys: used_tokens, max_tokens, fill_ratio, pruned_count, etc.
|
|
521
|
+
"""
|
|
522
|
+
return self._build_context_info().to_dict()
|
|
523
|
+
|
|
524
|
+
def is_message_included(self, index: int) -> bool:
|
|
525
|
+
"""
|
|
526
|
+
Check if a message at a given index is included in context.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
index: Message index (0-based)
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
True if included, False if pruned
|
|
533
|
+
"""
|
|
534
|
+
if 0 <= index < len(self._messages):
|
|
535
|
+
return self._messages[index].included
|
|
536
|
+
return False
|
|
537
|
+
|
|
538
|
+
def clear(self):
|
|
539
|
+
"""Clear all messages and summaries."""
|
|
540
|
+
self._messages.clear()
|
|
541
|
+
self._summaries.clear()
|
|
542
|
+
self._included_tokens = 0
|
|
543
|
+
self._summary_tokens = 0
|
|
544
|
+
self._needs_pruning = False
|
|
545
|
+
|
|
546
|
+
if self.session_id:
|
|
547
|
+
self._save_state()
|
|
548
|
+
|
|
549
|
+
def _get_state_path(self) -> Path:
|
|
550
|
+
"""Get path to context state file."""
|
|
551
|
+
if self.alita_dir.startswith('~'):
|
|
552
|
+
base = os.path.expanduser(self.alita_dir)
|
|
553
|
+
else:
|
|
554
|
+
base = self.alita_dir
|
|
555
|
+
|
|
556
|
+
return Path(base) / 'sessions' / self.session_id / 'context_state.json'
|
|
557
|
+
|
|
558
|
+
def _save_state(self):
|
|
559
|
+
"""Save context state to disk."""
|
|
560
|
+
if not self.session_id:
|
|
561
|
+
return
|
|
562
|
+
|
|
563
|
+
state_path = self._get_state_path()
|
|
564
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
565
|
+
|
|
566
|
+
state = {
|
|
567
|
+
'messages': [m.to_state_dict() for m in self._messages],
|
|
568
|
+
'summaries': [s.to_dict() for s in self._summaries],
|
|
569
|
+
'included_tokens': self._included_tokens,
|
|
570
|
+
'summary_tokens': self._summary_tokens,
|
|
571
|
+
'saved_at': datetime.now(timezone.utc).isoformat(),
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
with open(state_path, 'w') as f:
|
|
576
|
+
json.dump(state, f, indent=2)
|
|
577
|
+
logger.debug(f"Saved context state to {state_path}")
|
|
578
|
+
except IOError as e:
|
|
579
|
+
logger.warning(f"Failed to save context state: {e}")
|
|
580
|
+
|
|
581
|
+
def _load_state(self):
|
|
582
|
+
"""Load context state from disk."""
|
|
583
|
+
if not self.session_id:
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
state_path = self._get_state_path()
|
|
587
|
+
|
|
588
|
+
if not state_path.exists():
|
|
589
|
+
return
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
with open(state_path, 'r') as f:
|
|
593
|
+
state = json.load(f)
|
|
594
|
+
|
|
595
|
+
self._messages = [
|
|
596
|
+
CLIMessage.from_state_dict(m)
|
|
597
|
+
for m in state.get('messages', [])
|
|
598
|
+
]
|
|
599
|
+
self._summaries = [
|
|
600
|
+
Summary.from_dict(s)
|
|
601
|
+
for s in state.get('summaries', [])
|
|
602
|
+
]
|
|
603
|
+
self._included_tokens = state.get('included_tokens', 0)
|
|
604
|
+
self._summary_tokens = state.get('summary_tokens', 0)
|
|
605
|
+
|
|
606
|
+
# Recalculate if needed
|
|
607
|
+
if self._included_tokens == 0 and self._messages:
|
|
608
|
+
self._included_tokens = sum(
|
|
609
|
+
m.token_count for m in self._messages if m.included
|
|
610
|
+
)
|
|
611
|
+
if self._summary_tokens == 0 and self._summaries:
|
|
612
|
+
self._summary_tokens = sum(s.token_count for s in self._summaries)
|
|
613
|
+
|
|
614
|
+
logger.debug(f"Loaded context state from {state_path}")
|
|
615
|
+
|
|
616
|
+
except (IOError, json.JSONDecodeError) as e:
|
|
617
|
+
logger.warning(f"Failed to load context state: {e}")
|
|
618
|
+
|
|
619
|
+
def import_chat_history(self, chat_history: List[Dict[str, str]]):
|
|
620
|
+
"""
|
|
621
|
+
Import existing chat history into the context manager.
|
|
622
|
+
|
|
623
|
+
Useful for migrating existing conversations or session resume.
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
chat_history: List of message dicts with 'role' and 'content'
|
|
627
|
+
"""
|
|
628
|
+
for msg_dict in chat_history:
|
|
629
|
+
role = msg_dict.get('role', 'user')
|
|
630
|
+
content = msg_dict.get('content', '')
|
|
631
|
+
if content:
|
|
632
|
+
self.add_message(role, content)
|
|
633
|
+
|
|
634
|
+
def export_chat_history(self, include_only: bool = False) -> List[Dict[str, str]]:
|
|
635
|
+
"""
|
|
636
|
+
Export chat history as list of message dicts.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
include_only: If True, only export included messages
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
List of message dicts with 'role' and 'content'
|
|
643
|
+
"""
|
|
644
|
+
return cli_messages_to_dicts(self._messages, include_only=include_only)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def sanitize_message_history(messages: List[Any]) -> List[Any]:
|
|
648
|
+
"""
|
|
649
|
+
Sanitize message history to ensure valid tool call/response structure.
|
|
650
|
+
|
|
651
|
+
This function ensures that any AIMessage with tool_calls has corresponding
|
|
652
|
+
ToolMessages for all tool_call_ids. This prevents the LLM API error:
|
|
653
|
+
"An assistant message with 'tool_calls' must be followed by tool messages
|
|
654
|
+
responding to each 'tool_call_id'."
|
|
655
|
+
|
|
656
|
+
Use this when:
|
|
657
|
+
- Resuming from a GraphRecursionError (step limit)
|
|
658
|
+
- Resuming from a tool execution limit
|
|
659
|
+
- Loading corrupted checkpoint state
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
messages: List of LangChain message objects or dicts
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
Sanitized list of messages with placeholder ToolMessages added for any
|
|
666
|
+
missing tool call responses.
|
|
667
|
+
"""
|
|
668
|
+
from langchain_core.messages import ToolMessage, AIMessage as LCAIMessage
|
|
669
|
+
|
|
670
|
+
if not messages:
|
|
671
|
+
return messages
|
|
672
|
+
|
|
673
|
+
result = list(messages) # Copy to avoid mutating original
|
|
674
|
+
|
|
675
|
+
# Build set of existing tool_call_ids that have responses
|
|
676
|
+
existing_tool_responses = set()
|
|
677
|
+
for msg in result:
|
|
678
|
+
if hasattr(msg, 'tool_call_id') and msg.tool_call_id:
|
|
679
|
+
existing_tool_responses.add(msg.tool_call_id)
|
|
680
|
+
elif isinstance(msg, dict) and msg.get('type') == 'tool':
|
|
681
|
+
tool_call_id = msg.get('tool_call_id')
|
|
682
|
+
if tool_call_id:
|
|
683
|
+
existing_tool_responses.add(tool_call_id)
|
|
684
|
+
|
|
685
|
+
# Find AIMessages with tool_calls and check for missing responses
|
|
686
|
+
messages_to_add = []
|
|
687
|
+
for i, msg in enumerate(result):
|
|
688
|
+
tool_calls = None
|
|
689
|
+
|
|
690
|
+
# Check for tool_calls in different message formats
|
|
691
|
+
if hasattr(msg, 'tool_calls') and msg.tool_calls:
|
|
692
|
+
tool_calls = msg.tool_calls
|
|
693
|
+
elif isinstance(msg, dict) and msg.get('tool_calls'):
|
|
694
|
+
tool_calls = msg.get('tool_calls')
|
|
695
|
+
|
|
696
|
+
if tool_calls:
|
|
697
|
+
# Check each tool_call for a corresponding response
|
|
698
|
+
for tool_call in tool_calls:
|
|
699
|
+
tool_call_id = None
|
|
700
|
+
tool_name = 'unknown'
|
|
701
|
+
|
|
702
|
+
if isinstance(tool_call, dict):
|
|
703
|
+
tool_call_id = tool_call.get('id', '')
|
|
704
|
+
tool_name = tool_call.get('name', 'unknown')
|
|
705
|
+
elif hasattr(tool_call, 'id'):
|
|
706
|
+
tool_call_id = getattr(tool_call, 'id', '')
|
|
707
|
+
tool_name = getattr(tool_call, 'name', 'unknown')
|
|
708
|
+
|
|
709
|
+
if tool_call_id and tool_call_id not in existing_tool_responses:
|
|
710
|
+
# Missing tool response - create placeholder
|
|
711
|
+
logger.warning(
|
|
712
|
+
f"Found AIMessage with tool_call '{tool_name}' ({tool_call_id}) "
|
|
713
|
+
f"without corresponding ToolMessage. Adding placeholder."
|
|
714
|
+
)
|
|
715
|
+
placeholder = ToolMessage(
|
|
716
|
+
content=f"[Tool call '{tool_name}' was interrupted - no response available. "
|
|
717
|
+
f"The task may need to be retried.]",
|
|
718
|
+
tool_call_id=tool_call_id
|
|
719
|
+
)
|
|
720
|
+
messages_to_add.append((i + 1, placeholder))
|
|
721
|
+
existing_tool_responses.add(tool_call_id) # Prevent duplicates
|
|
722
|
+
|
|
723
|
+
# Insert placeholder messages (reverse order to maintain correct indices)
|
|
724
|
+
for insert_idx, placeholder_msg in reversed(messages_to_add):
|
|
725
|
+
result.insert(insert_idx, placeholder_msg)
|
|
726
|
+
|
|
727
|
+
if messages_to_add:
|
|
728
|
+
logger.info(f"Sanitized message history: added {len(messages_to_add)} placeholder ToolMessages")
|
|
729
|
+
|
|
730
|
+
return result
|
|
731
|
+
|