alita-sdk 0.3.257__py3-none-any.whl → 0.3.562__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.
Files changed (278) hide show
  1. alita_sdk/cli/__init__.py +10 -0
  2. alita_sdk/cli/__main__.py +17 -0
  3. alita_sdk/cli/agent/__init__.py +5 -0
  4. alita_sdk/cli/agent/default.py +258 -0
  5. alita_sdk/cli/agent_executor.py +155 -0
  6. alita_sdk/cli/agent_loader.py +215 -0
  7. alita_sdk/cli/agent_ui.py +228 -0
  8. alita_sdk/cli/agents.py +3601 -0
  9. alita_sdk/cli/callbacks.py +647 -0
  10. alita_sdk/cli/cli.py +168 -0
  11. alita_sdk/cli/config.py +306 -0
  12. alita_sdk/cli/context/__init__.py +30 -0
  13. alita_sdk/cli/context/cleanup.py +198 -0
  14. alita_sdk/cli/context/manager.py +731 -0
  15. alita_sdk/cli/context/message.py +285 -0
  16. alita_sdk/cli/context/strategies.py +289 -0
  17. alita_sdk/cli/context/token_estimation.py +127 -0
  18. alita_sdk/cli/formatting.py +182 -0
  19. alita_sdk/cli/input_handler.py +419 -0
  20. alita_sdk/cli/inventory.py +1073 -0
  21. alita_sdk/cli/mcp_loader.py +315 -0
  22. alita_sdk/cli/toolkit.py +327 -0
  23. alita_sdk/cli/toolkit_loader.py +85 -0
  24. alita_sdk/cli/tools/__init__.py +43 -0
  25. alita_sdk/cli/tools/approval.py +224 -0
  26. alita_sdk/cli/tools/filesystem.py +1751 -0
  27. alita_sdk/cli/tools/planning.py +389 -0
  28. alita_sdk/cli/tools/terminal.py +414 -0
  29. alita_sdk/community/__init__.py +72 -12
  30. alita_sdk/community/inventory/__init__.py +236 -0
  31. alita_sdk/community/inventory/config.py +257 -0
  32. alita_sdk/community/inventory/enrichment.py +2137 -0
  33. alita_sdk/community/inventory/extractors.py +1469 -0
  34. alita_sdk/community/inventory/ingestion.py +3172 -0
  35. alita_sdk/community/inventory/knowledge_graph.py +1457 -0
  36. alita_sdk/community/inventory/parsers/__init__.py +218 -0
  37. alita_sdk/community/inventory/parsers/base.py +295 -0
  38. alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
  39. alita_sdk/community/inventory/parsers/go_parser.py +851 -0
  40. alita_sdk/community/inventory/parsers/html_parser.py +389 -0
  41. alita_sdk/community/inventory/parsers/java_parser.py +593 -0
  42. alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
  43. alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
  44. alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
  45. alita_sdk/community/inventory/parsers/python_parser.py +604 -0
  46. alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
  47. alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
  48. alita_sdk/community/inventory/parsers/text_parser.py +322 -0
  49. alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
  50. alita_sdk/community/inventory/patterns/__init__.py +61 -0
  51. alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
  52. alita_sdk/community/inventory/patterns/loader.py +348 -0
  53. alita_sdk/community/inventory/patterns/registry.py +198 -0
  54. alita_sdk/community/inventory/presets.py +535 -0
  55. alita_sdk/community/inventory/retrieval.py +1403 -0
  56. alita_sdk/community/inventory/toolkit.py +173 -0
  57. alita_sdk/community/inventory/toolkit_utils.py +176 -0
  58. alita_sdk/community/inventory/visualize.py +1370 -0
  59. alita_sdk/configurations/__init__.py +11 -0
  60. alita_sdk/configurations/ado.py +148 -2
  61. alita_sdk/configurations/azure_search.py +1 -1
  62. alita_sdk/configurations/bigquery.py +1 -1
  63. alita_sdk/configurations/bitbucket.py +94 -2
  64. alita_sdk/configurations/browser.py +18 -0
  65. alita_sdk/configurations/carrier.py +19 -0
  66. alita_sdk/configurations/confluence.py +130 -1
  67. alita_sdk/configurations/delta_lake.py +1 -1
  68. alita_sdk/configurations/figma.py +76 -5
  69. alita_sdk/configurations/github.py +65 -1
  70. alita_sdk/configurations/gitlab.py +81 -0
  71. alita_sdk/configurations/google_places.py +17 -0
  72. alita_sdk/configurations/jira.py +103 -0
  73. alita_sdk/configurations/openapi.py +111 -0
  74. alita_sdk/configurations/postman.py +1 -1
  75. alita_sdk/configurations/qtest.py +72 -3
  76. alita_sdk/configurations/report_portal.py +115 -0
  77. alita_sdk/configurations/salesforce.py +19 -0
  78. alita_sdk/configurations/service_now.py +1 -12
  79. alita_sdk/configurations/sharepoint.py +167 -0
  80. alita_sdk/configurations/sonar.py +18 -0
  81. alita_sdk/configurations/sql.py +20 -0
  82. alita_sdk/configurations/testio.py +101 -0
  83. alita_sdk/configurations/testrail.py +88 -0
  84. alita_sdk/configurations/xray.py +94 -1
  85. alita_sdk/configurations/zephyr_enterprise.py +94 -1
  86. alita_sdk/configurations/zephyr_essential.py +95 -0
  87. alita_sdk/runtime/clients/artifact.py +21 -4
  88. alita_sdk/runtime/clients/client.py +458 -67
  89. alita_sdk/runtime/clients/mcp_discovery.py +342 -0
  90. alita_sdk/runtime/clients/mcp_manager.py +262 -0
  91. alita_sdk/runtime/clients/sandbox_client.py +352 -0
  92. alita_sdk/runtime/langchain/_constants_bkup.py +1318 -0
  93. alita_sdk/runtime/langchain/assistant.py +183 -43
  94. alita_sdk/runtime/langchain/constants.py +647 -1
  95. alita_sdk/runtime/langchain/document_loaders/AlitaDocxMammothLoader.py +315 -3
  96. alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +209 -31
  97. alita_sdk/runtime/langchain/document_loaders/AlitaImageLoader.py +1 -1
  98. alita_sdk/runtime/langchain/document_loaders/AlitaJSONLinesLoader.py +77 -0
  99. alita_sdk/runtime/langchain/document_loaders/AlitaJSONLoader.py +10 -3
  100. alita_sdk/runtime/langchain/document_loaders/AlitaMarkdownLoader.py +66 -0
  101. alita_sdk/runtime/langchain/document_loaders/AlitaPDFLoader.py +79 -10
  102. alita_sdk/runtime/langchain/document_loaders/AlitaPowerPointLoader.py +52 -15
  103. alita_sdk/runtime/langchain/document_loaders/AlitaPythonLoader.py +9 -0
  104. alita_sdk/runtime/langchain/document_loaders/AlitaTableLoader.py +1 -4
  105. alita_sdk/runtime/langchain/document_loaders/AlitaTextLoader.py +15 -2
  106. alita_sdk/runtime/langchain/document_loaders/ImageParser.py +30 -0
  107. alita_sdk/runtime/langchain/document_loaders/constants.py +189 -41
  108. alita_sdk/runtime/langchain/interfaces/llm_processor.py +4 -2
  109. alita_sdk/runtime/langchain/langraph_agent.py +407 -92
  110. alita_sdk/runtime/langchain/utils.py +102 -8
  111. alita_sdk/runtime/llms/preloaded.py +2 -6
  112. alita_sdk/runtime/models/mcp_models.py +61 -0
  113. alita_sdk/runtime/skills/__init__.py +91 -0
  114. alita_sdk/runtime/skills/callbacks.py +498 -0
  115. alita_sdk/runtime/skills/discovery.py +540 -0
  116. alita_sdk/runtime/skills/executor.py +610 -0
  117. alita_sdk/runtime/skills/input_builder.py +371 -0
  118. alita_sdk/runtime/skills/models.py +330 -0
  119. alita_sdk/runtime/skills/registry.py +355 -0
  120. alita_sdk/runtime/skills/skill_runner.py +330 -0
  121. alita_sdk/runtime/toolkits/__init__.py +28 -0
  122. alita_sdk/runtime/toolkits/application.py +14 -4
  123. alita_sdk/runtime/toolkits/artifact.py +24 -9
  124. alita_sdk/runtime/toolkits/datasource.py +13 -6
  125. alita_sdk/runtime/toolkits/mcp.py +780 -0
  126. alita_sdk/runtime/toolkits/planning.py +178 -0
  127. alita_sdk/runtime/toolkits/skill_router.py +238 -0
  128. alita_sdk/runtime/toolkits/subgraph.py +11 -6
  129. alita_sdk/runtime/toolkits/tools.py +314 -70
  130. alita_sdk/runtime/toolkits/vectorstore.py +11 -5
  131. alita_sdk/runtime/tools/__init__.py +24 -0
  132. alita_sdk/runtime/tools/application.py +16 -4
  133. alita_sdk/runtime/tools/artifact.py +367 -33
  134. alita_sdk/runtime/tools/data_analysis.py +183 -0
  135. alita_sdk/runtime/tools/function.py +100 -4
  136. alita_sdk/runtime/tools/graph.py +81 -0
  137. alita_sdk/runtime/tools/image_generation.py +218 -0
  138. alita_sdk/runtime/tools/llm.py +1013 -177
  139. alita_sdk/runtime/tools/loop.py +3 -1
  140. alita_sdk/runtime/tools/loop_output.py +3 -1
  141. alita_sdk/runtime/tools/mcp_inspect_tool.py +284 -0
  142. alita_sdk/runtime/tools/mcp_remote_tool.py +181 -0
  143. alita_sdk/runtime/tools/mcp_server_tool.py +3 -1
  144. alita_sdk/runtime/tools/planning/__init__.py +36 -0
  145. alita_sdk/runtime/tools/planning/models.py +246 -0
  146. alita_sdk/runtime/tools/planning/wrapper.py +607 -0
  147. alita_sdk/runtime/tools/router.py +2 -1
  148. alita_sdk/runtime/tools/sandbox.py +375 -0
  149. alita_sdk/runtime/tools/skill_router.py +776 -0
  150. alita_sdk/runtime/tools/tool.py +3 -1
  151. alita_sdk/runtime/tools/vectorstore.py +69 -65
  152. alita_sdk/runtime/tools/vectorstore_base.py +163 -90
  153. alita_sdk/runtime/utils/AlitaCallback.py +137 -21
  154. alita_sdk/runtime/utils/mcp_client.py +492 -0
  155. alita_sdk/runtime/utils/mcp_oauth.py +361 -0
  156. alita_sdk/runtime/utils/mcp_sse_client.py +434 -0
  157. alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
  158. alita_sdk/runtime/utils/streamlit.py +41 -14
  159. alita_sdk/runtime/utils/toolkit_utils.py +28 -9
  160. alita_sdk/runtime/utils/utils.py +48 -0
  161. alita_sdk/tools/__init__.py +135 -37
  162. alita_sdk/tools/ado/__init__.py +2 -2
  163. alita_sdk/tools/ado/repos/__init__.py +15 -19
  164. alita_sdk/tools/ado/repos/repos_wrapper.py +12 -20
  165. alita_sdk/tools/ado/test_plan/__init__.py +26 -8
  166. alita_sdk/tools/ado/test_plan/test_plan_wrapper.py +56 -28
  167. alita_sdk/tools/ado/wiki/__init__.py +27 -12
  168. alita_sdk/tools/ado/wiki/ado_wrapper.py +114 -40
  169. alita_sdk/tools/ado/work_item/__init__.py +27 -12
  170. alita_sdk/tools/ado/work_item/ado_wrapper.py +95 -11
  171. alita_sdk/tools/advanced_jira_mining/__init__.py +12 -8
  172. alita_sdk/tools/aws/delta_lake/__init__.py +14 -11
  173. alita_sdk/tools/aws/delta_lake/tool.py +5 -1
  174. alita_sdk/tools/azure_ai/search/__init__.py +13 -8
  175. alita_sdk/tools/base/tool.py +5 -1
  176. alita_sdk/tools/base_indexer_toolkit.py +454 -110
  177. alita_sdk/tools/bitbucket/__init__.py +27 -19
  178. alita_sdk/tools/bitbucket/api_wrapper.py +285 -27
  179. alita_sdk/tools/bitbucket/cloud_api_wrapper.py +5 -5
  180. alita_sdk/tools/browser/__init__.py +41 -16
  181. alita_sdk/tools/browser/crawler.py +3 -1
  182. alita_sdk/tools/browser/utils.py +15 -6
  183. alita_sdk/tools/carrier/__init__.py +18 -17
  184. alita_sdk/tools/carrier/backend_reports_tool.py +8 -4
  185. alita_sdk/tools/carrier/excel_reporter.py +8 -4
  186. alita_sdk/tools/chunkers/__init__.py +3 -1
  187. alita_sdk/tools/chunkers/code/codeparser.py +1 -1
  188. alita_sdk/tools/chunkers/sematic/json_chunker.py +2 -1
  189. alita_sdk/tools/chunkers/sematic/markdown_chunker.py +97 -6
  190. alita_sdk/tools/chunkers/sematic/proposal_chunker.py +1 -1
  191. alita_sdk/tools/chunkers/universal_chunker.py +270 -0
  192. alita_sdk/tools/cloud/aws/__init__.py +11 -7
  193. alita_sdk/tools/cloud/azure/__init__.py +11 -7
  194. alita_sdk/tools/cloud/gcp/__init__.py +11 -7
  195. alita_sdk/tools/cloud/k8s/__init__.py +11 -7
  196. alita_sdk/tools/code/linter/__init__.py +9 -8
  197. alita_sdk/tools/code/loaders/codesearcher.py +3 -2
  198. alita_sdk/tools/code/sonar/__init__.py +20 -13
  199. alita_sdk/tools/code_indexer_toolkit.py +199 -0
  200. alita_sdk/tools/confluence/__init__.py +21 -14
  201. alita_sdk/tools/confluence/api_wrapper.py +197 -58
  202. alita_sdk/tools/confluence/loader.py +14 -2
  203. alita_sdk/tools/custom_open_api/__init__.py +11 -5
  204. alita_sdk/tools/elastic/__init__.py +10 -8
  205. alita_sdk/tools/elitea_base.py +546 -64
  206. alita_sdk/tools/figma/__init__.py +11 -8
  207. alita_sdk/tools/figma/api_wrapper.py +352 -153
  208. alita_sdk/tools/github/__init__.py +17 -17
  209. alita_sdk/tools/github/api_wrapper.py +9 -26
  210. alita_sdk/tools/github/github_client.py +81 -12
  211. alita_sdk/tools/github/schemas.py +2 -1
  212. alita_sdk/tools/github/tool.py +5 -1
  213. alita_sdk/tools/gitlab/__init__.py +18 -13
  214. alita_sdk/tools/gitlab/api_wrapper.py +224 -80
  215. alita_sdk/tools/gitlab_org/__init__.py +13 -10
  216. alita_sdk/tools/google/bigquery/__init__.py +13 -13
  217. alita_sdk/tools/google/bigquery/tool.py +5 -1
  218. alita_sdk/tools/google_places/__init__.py +20 -11
  219. alita_sdk/tools/jira/__init__.py +21 -11
  220. alita_sdk/tools/jira/api_wrapper.py +315 -168
  221. alita_sdk/tools/keycloak/__init__.py +10 -8
  222. alita_sdk/tools/localgit/__init__.py +8 -3
  223. alita_sdk/tools/localgit/local_git.py +62 -54
  224. alita_sdk/tools/localgit/tool.py +5 -1
  225. alita_sdk/tools/memory/__init__.py +38 -14
  226. alita_sdk/tools/non_code_indexer_toolkit.py +7 -2
  227. alita_sdk/tools/ocr/__init__.py +10 -8
  228. alita_sdk/tools/openapi/__init__.py +281 -108
  229. alita_sdk/tools/openapi/api_wrapper.py +883 -0
  230. alita_sdk/tools/openapi/tool.py +20 -0
  231. alita_sdk/tools/pandas/__init__.py +18 -11
  232. alita_sdk/tools/pandas/api_wrapper.py +40 -45
  233. alita_sdk/tools/pandas/dataframe/generator/base.py +3 -1
  234. alita_sdk/tools/postman/__init__.py +10 -11
  235. alita_sdk/tools/postman/api_wrapper.py +19 -8
  236. alita_sdk/tools/postman/postman_analysis.py +8 -1
  237. alita_sdk/tools/pptx/__init__.py +10 -10
  238. alita_sdk/tools/qtest/__init__.py +21 -14
  239. alita_sdk/tools/qtest/api_wrapper.py +1784 -88
  240. alita_sdk/tools/rally/__init__.py +12 -10
  241. alita_sdk/tools/report_portal/__init__.py +22 -16
  242. alita_sdk/tools/salesforce/__init__.py +21 -16
  243. alita_sdk/tools/servicenow/__init__.py +20 -16
  244. alita_sdk/tools/servicenow/api_wrapper.py +1 -1
  245. alita_sdk/tools/sharepoint/__init__.py +16 -14
  246. alita_sdk/tools/sharepoint/api_wrapper.py +179 -39
  247. alita_sdk/tools/sharepoint/authorization_helper.py +191 -1
  248. alita_sdk/tools/sharepoint/utils.py +8 -2
  249. alita_sdk/tools/slack/__init__.py +11 -7
  250. alita_sdk/tools/sql/__init__.py +21 -19
  251. alita_sdk/tools/sql/api_wrapper.py +71 -23
  252. alita_sdk/tools/testio/__init__.py +20 -13
  253. alita_sdk/tools/testrail/__init__.py +12 -11
  254. alita_sdk/tools/testrail/api_wrapper.py +214 -46
  255. alita_sdk/tools/utils/__init__.py +28 -4
  256. alita_sdk/tools/utils/content_parser.py +182 -62
  257. alita_sdk/tools/utils/text_operations.py +254 -0
  258. alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +83 -27
  259. alita_sdk/tools/xray/__init__.py +17 -14
  260. alita_sdk/tools/xray/api_wrapper.py +58 -113
  261. alita_sdk/tools/yagmail/__init__.py +8 -3
  262. alita_sdk/tools/zephyr/__init__.py +11 -7
  263. alita_sdk/tools/zephyr_enterprise/__init__.py +15 -9
  264. alita_sdk/tools/zephyr_enterprise/api_wrapper.py +30 -15
  265. alita_sdk/tools/zephyr_essential/__init__.py +15 -10
  266. alita_sdk/tools/zephyr_essential/api_wrapper.py +297 -54
  267. alita_sdk/tools/zephyr_essential/client.py +6 -4
  268. alita_sdk/tools/zephyr_scale/__init__.py +12 -8
  269. alita_sdk/tools/zephyr_scale/api_wrapper.py +39 -31
  270. alita_sdk/tools/zephyr_squad/__init__.py +11 -7
  271. {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.562.dist-info}/METADATA +184 -37
  272. alita_sdk-0.3.562.dist-info/RECORD +450 -0
  273. alita_sdk-0.3.562.dist-info/entry_points.txt +2 -0
  274. alita_sdk/tools/bitbucket/tools.py +0 -304
  275. alita_sdk-0.3.257.dist-info/RECORD +0 -343
  276. {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.562.dist-info}/WHEEL +0 -0
  277. {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.562.dist-info}/licenses/LICENSE +0 -0
  278. {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.562.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,3601 @@
1
+ """
2
+ Agent commands for Alita CLI.
3
+
4
+ Provides commands to work with agents interactively or in handoff mode,
5
+ supporting both platform agents and local agent definition files.
6
+ """
7
+
8
+ import asyncio
9
+ import click
10
+ import json
11
+ import logging
12
+ import sqlite3
13
+ import sys
14
+ import re
15
+ from typing import Optional, Dict, Any, List
16
+ from pathlib import Path
17
+ from datetime import datetime
18
+ import yaml
19
+
20
+ from rich.console import Console
21
+ from rich.panel import Panel
22
+ from rich.table import Table
23
+ from rich.markdown import Markdown
24
+ from rich import box
25
+ from rich.text import Text
26
+ from rich.status import Status
27
+ from rich.live import Live
28
+
29
+ from .cli import get_client
30
+ # Import from refactored modules
31
+ from .agent_ui import print_welcome, print_help, display_output, extract_output_from_result
32
+ from .agent_loader import load_agent_definition
33
+ from .agent_executor import create_llm_instance, create_agent_executor, create_agent_executor_with_mcp
34
+ from .toolkit_loader import load_toolkit_config, load_toolkit_configs
35
+ from .callbacks import create_cli_callback, CLICallbackHandler
36
+ from .input_handler import get_input_handler, styled_input, styled_selection_input
37
+ # Context management for chat history
38
+ from .context import CLIContextManager, CLIMessage, purge_old_sessions as purge_context_sessions
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # Create a rich console for beautiful output
43
+ console = Console()
44
+
45
+
46
+ def resolve_toolkit_config_path(config_path_str: str, test_file: Path, test_cases_dir: Path) -> Optional[str]:
47
+ """
48
+ Resolve toolkit configuration file path from test case.
49
+
50
+ Tries multiple locations in order:
51
+ 1. Absolute path
52
+ 2. Relative to test case file directory
53
+ 3. Relative to test cases directory
54
+ 4. Relative to workspace root
55
+
56
+ Args:
57
+ config_path_str: Config path from test case
58
+ test_file: Path to the test case file
59
+ test_cases_dir: Path to test cases directory
60
+
61
+ Returns:
62
+ Absolute path to config file if found, None otherwise
63
+ """
64
+ if not config_path_str:
65
+ return None
66
+
67
+ # Normalize path separators
68
+ config_path_str = config_path_str.replace('\\', '/')
69
+
70
+ # Try absolute path first
71
+ config_path = Path(config_path_str)
72
+ if config_path.is_absolute() and config_path.exists():
73
+ return str(config_path)
74
+
75
+ # Try relative to test case file directory
76
+ config_path = test_file.parent / config_path_str
77
+ if config_path.exists():
78
+ return str(config_path)
79
+
80
+ # Try relative to test_cases_dir
81
+ config_path = test_cases_dir / config_path_str
82
+ if config_path.exists():
83
+ return str(config_path)
84
+
85
+ # Try relative to workspace root
86
+ workspace_root = Path.cwd()
87
+ config_path = workspace_root / config_path_str
88
+ if config_path.exists():
89
+ return str(config_path)
90
+
91
+ return None
92
+
93
+
94
+ def parse_test_case(test_case_path: str) -> Dict[str, Any]:
95
+ """
96
+ Parse a test case markdown file to extract configuration, steps, and expectations.
97
+
98
+ Args:
99
+ test_case_path: Path to the test case markdown file
100
+
101
+ Returns:
102
+ Dictionary containing:
103
+ - name: Test case name
104
+ - objective: Test objective
105
+ - config_path: Path to toolkit config file
106
+ - generate_test_data: Boolean flag indicating if test data generation is needed (default: True)
107
+ - test_data_config: Dictionary of test data configuration from table
108
+ - prerequisites: Pre-requisites section text
109
+ - variables: List of variable placeholders found (e.g., {{TEST_PR_NUMBER}})
110
+ - steps: List of test steps with their descriptions
111
+ - expectations: List of expectations/assertions
112
+ """
113
+ path = Path(test_case_path)
114
+ if not path.exists():
115
+ raise FileNotFoundError(f"Test case not found: {test_case_path}")
116
+
117
+ content = path.read_text(encoding='utf-8')
118
+
119
+ # Extract test case name from the first heading
120
+ name_match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
121
+ name = name_match.group(1) if name_match else path.stem
122
+
123
+ # Extract objective
124
+ objective_match = re.search(r'##\s+Objective\s*\n\n(.+?)(?=\n\n##|\Z)', content, re.DOTALL)
125
+ objective = objective_match.group(1).strip() if objective_match else ""
126
+
127
+ # Extract config path and generateTestData flag
128
+ config_section_match = re.search(r'##\s+Config\s*\n\n(.+?)(?=\n\n##|\Z)', content, re.DOTALL)
129
+ config_path = None
130
+ generate_test_data = True # Default to True if not specified
131
+
132
+ if config_section_match:
133
+ config_section = config_section_match.group(1)
134
+ # Extract path
135
+ path_match = re.search(r'path:\s*(.+?)(?=\n|$)', config_section, re.MULTILINE)
136
+ if path_match:
137
+ config_path = path_match.group(1).strip()
138
+
139
+ # Extract generateTestData flag
140
+ gen_data_match = re.search(r'generateTestData\s*:\s*(true|false)', config_section, re.IGNORECASE)
141
+ if gen_data_match:
142
+ generate_test_data = gen_data_match.group(1).lower() == 'true'
143
+
144
+ # Extract Test Data Configuration table
145
+ test_data_config = {}
146
+ config_section_match = re.search(r'##\s+Test Data Configuration\s*\n(.+?)(?=\n##|\Z)', content, re.DOTALL)
147
+ if config_section_match:
148
+ config_section = config_section_match.group(1)
149
+ # Parse markdown table (format: | Parameter | Value | Description |)
150
+ table_rows = re.findall(r'\|\s*\*\*([^*]+)\*\*\s*\|\s*`?([^|`]+)`?\s*\|', config_section)
151
+ for param, value in table_rows:
152
+ test_data_config[param.strip()] = value.strip()
153
+
154
+ # Extract Pre-requisites section
155
+ prerequisites = ""
156
+ prereq_match = re.search(r'##\s+Pre-requisites\s*\n\n(.+?)(?=\n\n##|\Z)', content, re.DOTALL)
157
+ if prereq_match:
158
+ prerequisites = prereq_match.group(1).strip()
159
+
160
+ # Find all variable placeholders ({{VARIABLE_NAME}})
161
+ variables = list(set(re.findall(r'\{\{([A-Z_]+)\}\}', content)))
162
+
163
+ # Extract test steps and expectations
164
+ steps = []
165
+ expectations = []
166
+
167
+ # Find all Step sections
168
+ step_pattern = r'###\s+Step\s+(\d+):\s+(.+?)\n\n(.+?)(?=\n\n###|\n\n##|\Z)'
169
+ for step_match in re.finditer(step_pattern, content, re.DOTALL):
170
+ step_num = step_match.group(1)
171
+ step_title = step_match.group(2).strip()
172
+ step_content = step_match.group(3).strip()
173
+
174
+ # Extract the actual instruction (first paragraph before "Expectation:")
175
+ instruction_match = re.search(r'(.+?)(?=\n\n\*\*Expectation:\*\*|\Z)', step_content, re.DOTALL)
176
+ instruction = instruction_match.group(1).strip() if instruction_match else step_content
177
+
178
+ # Extract expectation if present
179
+ expectation_match = re.search(r'\*\*Expectation:\*\*\s+(.+)', step_content, re.DOTALL)
180
+ expectation = expectation_match.group(1).strip() if expectation_match else None
181
+
182
+ steps.append({
183
+ 'number': int(step_num),
184
+ 'title': step_title,
185
+ 'instruction': instruction,
186
+ 'expectation': expectation
187
+ })
188
+
189
+ if expectation:
190
+ expectations.append({
191
+ 'step': int(step_num),
192
+ 'description': expectation
193
+ })
194
+
195
+ return {
196
+ 'name': name,
197
+ 'objective': objective,
198
+ 'config_path': config_path,
199
+ 'generate_test_data': generate_test_data,
200
+ 'test_data_config': test_data_config,
201
+ 'prerequisites': prerequisites,
202
+ 'variables': variables,
203
+ 'steps': steps,
204
+ 'expectations': expectations
205
+ }
206
+
207
+
208
+ def validate_test_output(output: str, expectation: str) -> tuple[bool, str]:
209
+ """
210
+ Validate test output against expectations.
211
+
212
+ Args:
213
+ output: The actual output from the agent
214
+ expectation: The expected result description
215
+
216
+ Returns:
217
+ Tuple of (passed: bool, details: str)
218
+ """
219
+ # Simple keyword-based validation
220
+ # Extract key phrases from expectation
221
+
222
+ # Common patterns in expectations
223
+ if "contains" in expectation.lower():
224
+ # Extract what should be contained
225
+ contains_match = re.search(r'contains.*?["`]([^"`]+)["`]', expectation, re.IGNORECASE)
226
+ if contains_match:
227
+ expected_text = contains_match.group(1)
228
+ if expected_text in output:
229
+ return True, f"Output contains expected text: '{expected_text}'"
230
+ else:
231
+ return False, f"Output does not contain expected text: '{expected_text}'"
232
+
233
+ if "without errors" in expectation.lower() or "runs without errors" in expectation.lower():
234
+ # Check for common error indicators
235
+ error_indicators = ['error', 'exception', 'failed', 'traceback']
236
+ has_error = any(indicator in output.lower() for indicator in error_indicators)
237
+ if not has_error:
238
+ return True, "Execution completed without errors"
239
+ else:
240
+ return False, "Execution encountered errors"
241
+
242
+ # Default: assume pass if output is non-empty
243
+ if output and len(output.strip()) > 0:
244
+ return True, "Output generated successfully"
245
+
246
+ return False, "No output generated"
247
+
248
+
249
+ def _build_bulk_data_gen_prompt(parsed_test_cases: list) -> str:
250
+ """Build consolidated requirements text for bulk test data generation."""
251
+ requirements = []
252
+ for idx, tc in enumerate(parsed_test_cases, 1):
253
+ test_case = tc['data']
254
+ test_file = tc['file']
255
+
256
+ parts = [f"Test Case #{idx}: {test_case['name']}", f"File: {test_file.name}", ""]
257
+
258
+ if test_case.get('test_data_config'):
259
+ parts.append("Test Data Configuration:")
260
+ for param, value in test_case['test_data_config'].items():
261
+ parts.append(f" - {param}: {value}")
262
+
263
+ if test_case.get('prerequisites'):
264
+ parts.append(f"\nPre-requisites:\n{test_case['prerequisites']}")
265
+
266
+ if test_case.get('variables'):
267
+ parts.append(f"\nVariables to generate: {', '.join(test_case['variables'])}")
268
+
269
+ requirements.append("\n".join(parts))
270
+
271
+ return f"""{'='*60}
272
+
273
+ {chr(10).join(requirements)}
274
+
275
+ {'='*60}"""
276
+
277
+
278
+ def _build_bulk_execution_prompt(parsed_test_cases: list) -> str:
279
+ """Build consolidated prompt for bulk test execution."""
280
+ parts = []
281
+
282
+ for idx, tc_info in enumerate(parsed_test_cases, 1):
283
+ test_case = tc_info['data']
284
+ test_file = tc_info['file']
285
+
286
+ parts.append(f"\n{'='*80}\nTEST CASE #{idx}: {test_case['name']}\nFile: {test_file.name}\n{'='*80}")
287
+
288
+ if test_case['steps']:
289
+ for step in test_case['steps']:
290
+ parts.append(f"\nStep {step['number']}: {step['title']}\n{step['instruction']}")
291
+ if step['expectation']:
292
+ parts.append(f"Expected Result: {step['expectation']}")
293
+ else:
294
+ parts.append("\n(No steps defined)")
295
+
296
+ return "\n".join(parts)
297
+
298
+
299
+ def _build_validation_prompt(parsed_test_cases: list, execution_output: str) -> str:
300
+ """Build prompt for bulk validation of test results."""
301
+ parts = ["You are a test validator. Review the test execution results and validate each test case.\n\nTest Cases to Validate:\n"]
302
+
303
+ for idx, tc_info in enumerate(parsed_test_cases, 1):
304
+ test_case = tc_info['data']
305
+ parts.append(f"\nTest Case #{idx}: {test_case['name']}")
306
+ if test_case['steps']:
307
+ for step in test_case['steps']:
308
+ parts.append(f" Step {step['number']}: {step['title']}")
309
+ if step['expectation']:
310
+ parts.append(f" Expected: {step['expectation']}")
311
+
312
+ parts.append(f"\n\nActual Execution Results:\n{execution_output}\n")
313
+ parts.append(f"""\nBased on the execution results above, validate each test case.
314
+
315
+ Respond with valid JSON in this EXACT format:
316
+ {{
317
+ "test_cases": [
318
+ {{
319
+ "test_number": 1,
320
+ "test_name": "<test case name>",
321
+ "steps": [
322
+ {{"step_number": 1, "title": "<step title>", "passed": true/false, "details": "<brief explanation>"}},
323
+ {{"step_number": 2, "title": "<step title>", "passed": true/false, "details": "<brief explanation>"}}
324
+ ]
325
+ }},
326
+ {{
327
+ "test_number": 2,
328
+ "test_name": "<test case name>",
329
+ "steps": [...]
330
+ }}
331
+ ]
332
+ }}
333
+
334
+ Validate all {len(parsed_test_cases)} test cases and their steps.""")
335
+
336
+ return "\n".join(parts)
337
+
338
+
339
+ def _extract_json_from_text(text: str) -> dict:
340
+ """Extract JSON object from text using brace counting."""
341
+ start_idx = text.find('{')
342
+ if start_idx == -1:
343
+ raise ValueError("No JSON found in text")
344
+
345
+ brace_count = 0
346
+ end_idx = -1
347
+ for i, char in enumerate(text[start_idx:], start=start_idx):
348
+ if char == '{':
349
+ brace_count += 1
350
+ elif char == '}':
351
+ brace_count -= 1
352
+ if brace_count == 0:
353
+ end_idx = i + 1
354
+ break
355
+
356
+ if end_idx == -1:
357
+ raise ValueError("Could not find matching closing brace")
358
+
359
+ return json.loads(text[start_idx:end_idx])
360
+
361
+
362
+ def _create_fallback_results(parsed_test_cases: list) -> tuple[list, int, int, int]:
363
+ """Create fallback results when execution/validation fails."""
364
+ test_results = []
365
+ for tc_info in parsed_test_cases:
366
+ test_results.append({
367
+ 'title': tc_info['data']['name'],
368
+ 'passed': False,
369
+ 'file': tc_info['file'].name,
370
+ 'step_results': []
371
+ })
372
+ return test_results, len(parsed_test_cases), 0, len(parsed_test_cases)
373
+
374
+
375
+ def _get_alita_system_prompt(config) -> str:
376
+ """
377
+ Get the Alita system prompt from user config or fallback to default.
378
+
379
+ Checks for $ALITA_DIR/agents/default.agent.md first, then falls back
380
+ to the built-in DEFAULT_PROMPT.
381
+
382
+ Returns:
383
+ The system prompt string for Alita
384
+ """
385
+ from .agent.default import DEFAULT_PROMPT
386
+
387
+ # Check for user-customized prompt
388
+ custom_prompt_path = Path(config.agents_dir) / 'default.agent.md'
389
+
390
+ if custom_prompt_path.exists():
391
+ try:
392
+ content = custom_prompt_path.read_text(encoding='utf-8')
393
+ # Parse the agent.md file - extract system_prompt from frontmatter or use content
394
+ if content.startswith('---'):
395
+ # Has YAML frontmatter, try to parse
396
+ try:
397
+ parts = content.split('---', 2)
398
+ if len(parts) >= 3:
399
+ frontmatter = yaml.safe_load(parts[1])
400
+ body = parts[2].strip()
401
+ # Use system_prompt from frontmatter if present, otherwise use body
402
+ return frontmatter.get('system_prompt', body) if frontmatter else body
403
+ except Exception:
404
+ pass
405
+ # No frontmatter or parsing failed, use entire content as prompt
406
+ return content.strip()
407
+ except Exception as e:
408
+ logger.debug(f"Failed to load custom Alita prompt from {custom_prompt_path}: {e}")
409
+
410
+ return DEFAULT_PROMPT
411
+
412
+
413
+ def _get_inventory_system_prompt(config) -> str:
414
+ """
415
+ Get the Inventory agent system prompt from user config or fallback to default.
416
+
417
+ Checks for $ALITA_DIR/agents/inventory.agent.md first, then falls back
418
+ to the default prompt with inventory-specific instructions.
419
+
420
+ Returns:
421
+ The system prompt string for Inventory agent
422
+ """
423
+ from .agent.default import DEFAULT_PROMPT
424
+
425
+ # Check for user-customized prompt
426
+ custom_prompt_path = Path(config.agents_dir) / 'inventory.agent.md'
427
+
428
+ if custom_prompt_path.exists():
429
+ try:
430
+ content = custom_prompt_path.read_text(encoding='utf-8')
431
+ # Parse the agent.md file - extract system_prompt from frontmatter or use content
432
+ if content.startswith('---'):
433
+ try:
434
+ parts = content.split('---', 2)
435
+ if len(parts) >= 3:
436
+ frontmatter = yaml.safe_load(parts[1])
437
+ body = parts[2].strip()
438
+ return frontmatter.get('system_prompt', body) if frontmatter else body
439
+ except Exception:
440
+ pass
441
+ return content.strip()
442
+ except Exception as e:
443
+ logger.debug(f"Failed to load custom Inventory prompt from {custom_prompt_path}: {e}")
444
+
445
+ # Use default prompt + inventory toolkit instructions
446
+ inventory_context = """
447
+
448
+ ## Inventory Knowledge Graph
449
+
450
+ You have access to the Inventory toolkit for querying a knowledge graph of software entities and relationships.
451
+ Use these tools to help users understand their codebase:
452
+
453
+ - **search_entities**: Find entities by name, type, or path patterns
454
+ - **get_entity**: Get full details of a specific entity
455
+ - **get_relationships**: Find relationships from/to an entity
456
+ - **impact_analysis**: Analyze what depends on an entity (useful for change impact)
457
+ - **get_graph_stats**: Get statistics about the knowledge graph
458
+
459
+ When answering questions about the codebase, use these tools to provide accurate, citation-backed answers.
460
+ """
461
+ return DEFAULT_PROMPT + inventory_context
462
+
463
+
464
+ def _resolve_inventory_path(path: str, work_dir: Optional[str] = None) -> Optional[str]:
465
+ """
466
+ Resolve an inventory/knowledge graph file path.
467
+
468
+ Tries locations in order:
469
+ 1. Absolute path
470
+ 2. Relative to current working directory (or work_dir if provided)
471
+ 3. Relative to .alita/inventory/ in current directory
472
+ 4. Relative to .alita/inventory/ in work_dir (if different)
473
+
474
+ Args:
475
+ path: The path to resolve (can be relative or absolute)
476
+ work_dir: Optional workspace directory to check
477
+
478
+ Returns:
479
+ Absolute path to the file if found, None otherwise
480
+ """
481
+ # Expand user home directory
482
+ path = str(Path(path).expanduser())
483
+
484
+ # Try absolute path first
485
+ if Path(path).is_absolute() and Path(path).exists():
486
+ return str(Path(path).resolve())
487
+
488
+ # Try relative to current working directory
489
+ cwd = Path.cwd()
490
+ cwd_path = cwd / path
491
+ if cwd_path.exists():
492
+ return str(cwd_path.resolve())
493
+
494
+ # Try .alita/inventory/ in current directory
495
+ alita_inventory_path = cwd / '.alita' / 'inventory' / path
496
+ if alita_inventory_path.exists():
497
+ return str(alita_inventory_path.resolve())
498
+
499
+ # If work_dir is different from cwd, try there too
500
+ if work_dir:
501
+ work_path = Path(work_dir)
502
+ if work_path != cwd:
503
+ # Try relative to work_dir
504
+ work_rel_path = work_path / path
505
+ if work_rel_path.exists():
506
+ return str(work_rel_path.resolve())
507
+
508
+ # Try .alita/inventory/ in work_dir
509
+ work_alita_path = work_path / '.alita' / 'inventory' / path
510
+ if work_alita_path.exists():
511
+ return str(work_alita_path.resolve())
512
+
513
+ return None
514
+
515
+
516
+ def _build_inventory_config(path: str, work_dir: Optional[str] = None) -> Optional[Dict[str, Any]]:
517
+ """
518
+ Build an inventory toolkit configuration from a file path.
519
+
520
+ The toolkit name is derived from the filename (stem).
521
+ All available tools are included.
522
+
523
+ Args:
524
+ path: Path to the knowledge graph JSON file
525
+ work_dir: Optional workspace directory for path resolution
526
+
527
+ Returns:
528
+ Toolkit configuration dict if file found, None otherwise
529
+ """
530
+ # Resolve the path
531
+ resolved_path = _resolve_inventory_path(path, work_dir)
532
+ if not resolved_path:
533
+ return None
534
+
535
+ # Validate it's a JSON file
536
+ if not resolved_path.endswith('.json'):
537
+ return None
538
+
539
+ # Validate file exists and is readable
540
+ try:
541
+ with open(resolved_path, 'r') as f:
542
+ # Just check it's valid JSON
543
+ json.load(f)
544
+ except (IOError, json.JSONDecodeError):
545
+ return None
546
+
547
+ # Extract toolkit name from filename (e.g., 'alita' from 'alita.json')
548
+ toolkit_name = Path(resolved_path).stem
549
+
550
+ # Build configuration with all available tools
551
+ from .toolkit_loader import INVENTORY_TOOLS
552
+
553
+ return {
554
+ 'type': 'inventory',
555
+ 'toolkit_name': toolkit_name,
556
+ 'graph_path': resolved_path,
557
+ 'base_directory': work_dir,
558
+ 'selected_tools': INVENTORY_TOOLS,
559
+ }
560
+
561
+
562
+ def _get_inventory_json_files(work_dir: Optional[str] = None) -> List[str]:
563
+ """
564
+ Get list of .json files for inventory path completion.
565
+
566
+ Searches:
567
+ 1. Current working directory (*.json files)
568
+ 2. .alita/inventory/ directory (*.json files)
569
+ 3. work_dir and work_dir/.alita/inventory/ if different from cwd
570
+
571
+ Args:
572
+ work_dir: Optional workspace directory
573
+
574
+ Returns:
575
+ List of relative or display paths for completion
576
+ """
577
+ suggestions = []
578
+ seen = set()
579
+
580
+ cwd = Path.cwd()
581
+
582
+ # Current directory .json files
583
+ for f in cwd.glob('*.json'):
584
+ if f.name not in seen:
585
+ suggestions.append(f.name)
586
+ seen.add(f.name)
587
+
588
+ # .alita/inventory/ directory
589
+ alita_inv = cwd / '.alita' / 'inventory'
590
+ if alita_inv.exists():
591
+ for f in alita_inv.glob('*.json'):
592
+ display = f'.alita/inventory/{f.name}'
593
+ if display not in seen:
594
+ suggestions.append(display)
595
+ seen.add(display)
596
+
597
+ # work_dir if different
598
+ if work_dir:
599
+ work_path = Path(work_dir)
600
+ if work_path != cwd:
601
+ for f in work_path.glob('*.json'):
602
+ if f.name not in seen:
603
+ suggestions.append(f.name)
604
+ seen.add(f.name)
605
+
606
+ work_alita_inv = work_path / '.alita' / 'inventory'
607
+ if work_alita_inv.exists():
608
+ for f in work_alita_inv.glob('*.json'):
609
+ display = f'.alita/inventory/{f.name}'
610
+ if display not in seen:
611
+ suggestions.append(display)
612
+ seen.add(display)
613
+
614
+ return sorted(suggestions)
615
+
616
+
617
+ def _load_mcp_tools(agent_def: Dict[str, Any], mcp_config_path: str) -> List[Dict[str, Any]]:
618
+ """Load MCP tools from agent definition with tool-level filtering.
619
+
620
+ Args:
621
+ agent_def: Agent definition dictionary containing mcps list
622
+ mcp_config_path: Path to mcp.json configuration file (workspace-level)
623
+
624
+ Returns:
625
+ List of toolkit configurations for MCP servers
626
+ """
627
+ from .mcp_loader import load_mcp_tools
628
+ return load_mcp_tools(agent_def, mcp_config_path)
629
+
630
+
631
+ def _setup_local_agent_executor(client, agent_def: Dict[str, Any], toolkit_config: tuple,
632
+ config, model: Optional[str], temperature: Optional[float],
633
+ max_tokens: Optional[int], memory, allowed_directories: Optional[List[str]],
634
+ plan_state: Optional[Dict] = None):
635
+ """Setup local agent executor with all configurations.
636
+
637
+ Args:
638
+ allowed_directories: List of allowed directories for filesystem access.
639
+ First directory is the primary/base directory.
640
+
641
+ Returns:
642
+ Tuple of (agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools)
643
+ """
644
+ # Load toolkit configs
645
+ toolkit_configs = load_toolkit_configs(agent_def, toolkit_config)
646
+
647
+ # Load MCP tools
648
+ mcp_toolkit_configs = _load_mcp_tools(agent_def, config.mcp_config_path)
649
+ toolkit_configs.extend(mcp_toolkit_configs)
650
+
651
+ # Create LLM instance
652
+ llm, llm_model, llm_temperature, llm_max_tokens = create_llm_instance(
653
+ client, model, agent_def, temperature, max_tokens
654
+ )
655
+
656
+ # Add filesystem tools if directories are provided
657
+ filesystem_tools = None
658
+ terminal_tools = None
659
+ if allowed_directories:
660
+ from .tools import get_filesystem_tools, get_terminal_tools
661
+ preset = agent_def.get('filesystem_tools_preset')
662
+ include_tools = agent_def.get('filesystem_tools_include')
663
+ exclude_tools = agent_def.get('filesystem_tools_exclude')
664
+
665
+ # First directory is the primary base directory
666
+ base_dir = allowed_directories[0]
667
+ extra_dirs = allowed_directories[1:] if len(allowed_directories) > 1 else None
668
+ filesystem_tools = get_filesystem_tools(base_dir, include_tools, exclude_tools, preset, extra_dirs)
669
+
670
+ # Terminal tools use primary directory as cwd
671
+ terminal_tools = get_terminal_tools(base_dir)
672
+
673
+ tool_count = len(filesystem_tools) + len(terminal_tools)
674
+ if len(allowed_directories) == 1:
675
+ access_msg = f"✓ Granted filesystem & terminal access to: {base_dir} ({tool_count} tools)"
676
+ else:
677
+ access_msg = f"✓ Granted filesystem & terminal access to {len(allowed_directories)} directories ({tool_count} tools)"
678
+ if preset:
679
+ access_msg += f" [preset: {preset}]"
680
+ if include_tools:
681
+ access_msg += f" [include: {', '.join(include_tools)}]"
682
+ if exclude_tools:
683
+ access_msg += f" [exclude: {', '.join(exclude_tools)}]"
684
+ console.print(f"[dim]{access_msg}[/dim]")
685
+
686
+ # Add planning tools (always available)
687
+ planning_tools = None
688
+ plan_state_obj = None
689
+ if plan_state is not None:
690
+ from .tools import get_planning_tools, PlanState
691
+ # Create a plan callback to update the dict when plan changes
692
+ def plan_callback(state: PlanState):
693
+ plan_state['title'] = state.title
694
+ plan_state['steps'] = state.to_dict()['steps']
695
+ plan_state['session_id'] = state.session_id
696
+
697
+ # Get session_id from plan_state dict if provided
698
+ session_id = plan_state.get('session_id')
699
+ planning_tools, plan_state_obj = get_planning_tools(
700
+ plan_state=None,
701
+ plan_callback=plan_callback,
702
+ session_id=session_id
703
+ )
704
+ console.print(f"[dim]✓ Planning tools enabled ({len(planning_tools)} tools) [session: {plan_state_obj.session_id}][/dim]")
705
+
706
+ # Check if we have tools
707
+ has_tools = bool(agent_def.get('tools') or toolkit_configs or filesystem_tools or terminal_tools or planning_tools)
708
+ has_mcp = any(tc.get('toolkit_type') == 'mcp' for tc in toolkit_configs)
709
+
710
+ if not has_tools:
711
+ return None, None, llm, llm_model, filesystem_tools, terminal_tools, planning_tools
712
+
713
+ # Create agent executor with or without MCP
714
+ mcp_session_manager = None
715
+ if has_mcp:
716
+ # Create persistent event loop for MCP tools
717
+ from alita_sdk.runtime.tools.llm import LLMNode
718
+ if not hasattr(LLMNode, '_persistent_loop') or \
719
+ LLMNode._persistent_loop is None or \
720
+ LLMNode._persistent_loop.is_closed():
721
+ LLMNode._persistent_loop = asyncio.new_event_loop()
722
+ console.print("[dim]Created persistent event loop for MCP tools[/dim]")
723
+
724
+ # Load MCP tools using persistent loop
725
+ loop = LLMNode._persistent_loop
726
+ asyncio.set_event_loop(loop)
727
+ agent_executor, mcp_session_manager = loop.run_until_complete(
728
+ create_agent_executor_with_mcp(
729
+ client, agent_def, toolkit_configs,
730
+ llm, llm_model, llm_temperature, llm_max_tokens, memory,
731
+ filesystem_tools=filesystem_tools,
732
+ terminal_tools=terminal_tools,
733
+ planning_tools=planning_tools
734
+ )
735
+ )
736
+ else:
737
+ agent_executor = create_agent_executor(
738
+ client, agent_def, toolkit_configs,
739
+ llm, llm_model, llm_temperature, llm_max_tokens, memory,
740
+ filesystem_tools=filesystem_tools,
741
+ terminal_tools=terminal_tools,
742
+ planning_tools=planning_tools
743
+ )
744
+
745
+ return agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools
746
+
747
+
748
+ def _select_model_interactive(client) -> Optional[Dict[str, Any]]:
749
+ """
750
+ Show interactive menu to select a model from available models.
751
+
752
+ Returns:
753
+ Selected model info dict or None if cancelled
754
+ """
755
+ console.print("\n🔧 [bold cyan]Select a model:[/bold cyan]\n")
756
+
757
+ try:
758
+ # Use the new get_available_models API
759
+ models = client.get_available_models()
760
+ if not models:
761
+ console.print("[yellow]No models available from the platform.[/yellow]")
762
+ return None
763
+
764
+ # Build models list - API returns items[].name
765
+ models_list = []
766
+ for model in models:
767
+ model_name = model.get('name')
768
+ if model_name:
769
+ models_list.append({
770
+ 'name': model_name,
771
+ 'id': model.get('id'),
772
+ 'model_data': model
773
+ })
774
+
775
+ if not models_list:
776
+ console.print("[yellow]No models found.[/yellow]")
777
+ return None
778
+
779
+ # Display models with numbers
780
+ table = Table(show_header=True, header_style="bold cyan", box=box.SIMPLE)
781
+ table.add_column("#", style="dim", width=4)
782
+ table.add_column("Model", style="cyan")
783
+
784
+ for i, model in enumerate(models_list, 1):
785
+ table.add_row(str(i), model['name'])
786
+
787
+ console.print(table)
788
+ console.print(f"\n[dim]0. Cancel[/dim]")
789
+
790
+ # Get user selection using styled input
791
+ while True:
792
+ try:
793
+ choice = styled_selection_input("Select model number")
794
+
795
+ if choice == '0':
796
+ return None
797
+
798
+ idx = int(choice) - 1
799
+ if 0 <= idx < len(models_list):
800
+ selected = models_list[idx]
801
+ console.print(f"✓ [green]Selected:[/green] [bold]{selected['name']}[/bold]")
802
+ return selected
803
+ else:
804
+ console.print(f"[yellow]Invalid selection. Please enter a number between 0 and {len(models_list)}[/yellow]")
805
+ except ValueError:
806
+ console.print("[yellow]Please enter a valid number[/yellow]")
807
+ except (KeyboardInterrupt, EOFError):
808
+ return None
809
+
810
+ except Exception as e:
811
+ console.print(f"[red]Error fetching models: {e}[/red]")
812
+ return None
813
+
814
+
815
+ def _select_mcp_interactive(config) -> Optional[Dict[str, Any]]:
816
+ """
817
+ Show interactive menu to select an MCP server from mcp.json.
818
+
819
+ Returns:
820
+ Selected MCP server config dict or None if cancelled
821
+ """
822
+ from .mcp_loader import load_mcp_config
823
+
824
+ console.print("\n🔌 [bold cyan]Select an MCP server to add:[/bold cyan]\n")
825
+
826
+ mcp_config = load_mcp_config(config.mcp_config_path)
827
+ mcp_servers = mcp_config.get('mcpServers', {})
828
+
829
+ if not mcp_servers:
830
+ console.print(f"[yellow]No MCP servers found in {config.mcp_config_path}[/yellow]")
831
+ return None
832
+
833
+ servers_list = list(mcp_servers.items())
834
+
835
+ # Display servers with numbers
836
+ table = Table(show_header=True, header_style="bold cyan", box=box.SIMPLE)
837
+ table.add_column("#", style="dim", width=4)
838
+ table.add_column("Server", style="cyan")
839
+ table.add_column("Type", style="dim")
840
+ table.add_column("Command/URL", style="dim")
841
+
842
+ for i, (name, server_config) in enumerate(servers_list, 1):
843
+ server_type = server_config.get('type', 'stdio')
844
+ cmd_or_url = server_config.get('url') or server_config.get('command', '')
845
+ table.add_row(str(i), name, server_type, cmd_or_url[:40])
846
+
847
+ console.print(table)
848
+ console.print(f"\n[dim]0. Cancel[/dim]")
849
+
850
+ # Get user selection using styled input
851
+ while True:
852
+ try:
853
+ choice = styled_selection_input("Select MCP server number")
854
+
855
+ if choice == '0':
856
+ return None
857
+
858
+ idx = int(choice) - 1
859
+ if 0 <= idx < len(servers_list):
860
+ name, server_config = servers_list[idx]
861
+ console.print(f"✓ [green]Selected:[/green] [bold]{name}[/bold]")
862
+ return {'name': name, 'config': server_config}
863
+ else:
864
+ console.print(f"[yellow]Invalid selection. Please enter a number between 0 and {len(servers_list)}[/yellow]")
865
+ except ValueError:
866
+ console.print("[yellow]Please enter a valid number[/yellow]")
867
+ except (KeyboardInterrupt, EOFError):
868
+ return None
869
+
870
+
871
+ def _select_toolkit_interactive(config) -> Optional[Dict[str, Any]]:
872
+ """
873
+ Show interactive menu to select a toolkit from $ALITA_DIR/tools.
874
+
875
+ Returns:
876
+ Selected toolkit config dict or None if cancelled
877
+ """
878
+ console.print("\n🧰 [bold cyan]Select a toolkit to add:[/bold cyan]\n")
879
+
880
+ tools_dir = Path(config.tools_dir)
881
+
882
+ if not tools_dir.exists():
883
+ console.print(f"[yellow]Tools directory not found: {tools_dir}[/yellow]")
884
+ return None
885
+
886
+ # Find all toolkit config files
887
+ toolkit_files = []
888
+ for pattern in ['*.json', '*.yaml', '*.yml']:
889
+ toolkit_files.extend(tools_dir.glob(pattern))
890
+
891
+ if not toolkit_files:
892
+ console.print(f"[yellow]No toolkit configurations found in {tools_dir}[/yellow]")
893
+ return None
894
+
895
+ # Load toolkit info
896
+ toolkits_list = []
897
+ for file_path in toolkit_files:
898
+ try:
899
+ config_data = load_toolkit_config(str(file_path))
900
+ toolkits_list.append({
901
+ 'file': str(file_path),
902
+ 'name': config_data.get('toolkit_name') or config_data.get('name') or file_path.stem,
903
+ 'type': config_data.get('toolkit_type') or config_data.get('type', 'unknown'),
904
+ 'config': config_data
905
+ })
906
+ except Exception as e:
907
+ logger.debug(f"Failed to load toolkit config {file_path}: {e}")
908
+
909
+ if not toolkits_list:
910
+ console.print(f"[yellow]No valid toolkit configurations found in {tools_dir}[/yellow]")
911
+ return None
912
+
913
+ # Display toolkits with numbers
914
+ table = Table(show_header=True, header_style="bold cyan", box=box.SIMPLE)
915
+ table.add_column("#", style="dim", width=4)
916
+ table.add_column("Toolkit", style="cyan")
917
+ table.add_column("Type", style="dim")
918
+ table.add_column("File", style="dim")
919
+
920
+ for i, toolkit in enumerate(toolkits_list, 1):
921
+ table.add_row(str(i), toolkit['name'], toolkit['type'], Path(toolkit['file']).name)
922
+
923
+ console.print(table)
924
+ console.print(f"\n[dim]0. Cancel[/dim]")
925
+
926
+ # Get user selection using styled input
927
+ while True:
928
+ try:
929
+ choice = styled_selection_input("Select toolkit number")
930
+
931
+ if choice == '0':
932
+ return None
933
+
934
+ idx = int(choice) - 1
935
+ if 0 <= idx < len(toolkits_list):
936
+ selected = toolkits_list[idx]
937
+ console.print(f"✓ [green]Selected:[/green] [bold]{selected['name']}[/bold]")
938
+ return selected
939
+ else:
940
+ console.print(f"[yellow]Invalid selection. Please enter a number between 0 and {len(toolkits_list)}[/yellow]")
941
+ except ValueError:
942
+ console.print("[yellow]Please enter a valid number[/yellow]")
943
+ except (KeyboardInterrupt, EOFError):
944
+ return None
945
+
946
+
947
+ def _list_available_toolkits(config) -> List[str]:
948
+ """
949
+ List names of all available toolkits in $ALITA_DIR/tools.
950
+
951
+ Returns:
952
+ List of toolkit names
953
+ """
954
+ tools_dir = Path(config.tools_dir)
955
+
956
+ if not tools_dir.exists():
957
+ return []
958
+
959
+ toolkit_names = []
960
+ for pattern in ['*.json', '*.yaml', '*.yml']:
961
+ for file_path in tools_dir.glob(pattern):
962
+ try:
963
+ config_data = load_toolkit_config(str(file_path))
964
+ name = config_data.get('toolkit_name') or config_data.get('name') or file_path.stem
965
+ toolkit_names.append(name)
966
+ except Exception:
967
+ pass
968
+
969
+ return toolkit_names
970
+
971
+
972
+ def _find_toolkit_by_name(config, toolkit_name: str) -> Optional[Dict[str, Any]]:
973
+ """
974
+ Find a toolkit by name in $ALITA_DIR/tools.
975
+
976
+ Args:
977
+ config: CLI configuration
978
+ toolkit_name: Name of the toolkit to find (case-insensitive)
979
+
980
+ Returns:
981
+ Toolkit config dict or None if not found
982
+ """
983
+ tools_dir = Path(config.tools_dir)
984
+
985
+ if not tools_dir.exists():
986
+ return None
987
+
988
+ toolkit_name_lower = toolkit_name.lower()
989
+
990
+ for pattern in ['*.json', '*.yaml', '*.yml']:
991
+ for file_path in tools_dir.glob(pattern):
992
+ try:
993
+ config_data = load_toolkit_config(str(file_path))
994
+ name = config_data.get('toolkit_name') or config_data.get('name') or file_path.stem
995
+
996
+ # Match by name (case-insensitive) or file stem
997
+ if name.lower() == toolkit_name_lower or file_path.stem.lower() == toolkit_name_lower:
998
+ return {
999
+ 'file': str(file_path),
1000
+ 'name': name,
1001
+ 'type': config_data.get('toolkit_type') or config_data.get('type', 'unknown'),
1002
+ 'config': config_data
1003
+ }
1004
+ except Exception:
1005
+ pass
1006
+
1007
+ return None
1008
+
1009
+
1010
+ def _select_agent_interactive(client, config) -> Optional[str]:
1011
+ """
1012
+ Show interactive menu to select an agent from platform and local agents.
1013
+
1014
+ Returns:
1015
+ Agent source (name/id for platform, file path for local, '__direct__' for direct chat,
1016
+ '__inventory__' for inventory agent) or None if cancelled
1017
+ """
1018
+ from .config import CLIConfig
1019
+
1020
+ console.print("\n🤖 [bold cyan]Select an agent to chat with:[/bold cyan]\n")
1021
+
1022
+ # Built-in agents
1023
+ console.print(f"1. [[bold]💬 Alita[/bold]] [cyan]Chat directly with LLM (no agent)[/cyan]")
1024
+ console.print(f" [dim]Direct conversation with the model without agent configuration[/dim]")
1025
+ console.print(f"2. [[bold]📊 Inventory[/bold]] [cyan]Knowledge graph builder agent[/cyan]")
1026
+ console.print(f" [dim]Build inventories from connected toolkits (use --toolkit-config to add sources)[/dim]")
1027
+
1028
+ agents_list = []
1029
+
1030
+ # Load platform agents
1031
+ try:
1032
+ platform_agents = client.get_list_of_apps()
1033
+ for agent in platform_agents:
1034
+ agents_list.append({
1035
+ 'type': 'platform',
1036
+ 'name': agent['name'],
1037
+ 'source': agent['name'],
1038
+ 'description': agent.get('description', '')[:60]
1039
+ })
1040
+ except Exception as e:
1041
+ logger.debug(f"Failed to load platform agents: {e}")
1042
+
1043
+ # Load local agents
1044
+ agents_dir = config.agents_dir
1045
+ search_dir = Path(agents_dir)
1046
+
1047
+ if search_dir.exists():
1048
+ for pattern in ['*.agent.md', '*.agent.yaml', '*.agent.yml', '*.agent.json']:
1049
+ for file_path in search_dir.rglob(pattern):
1050
+ try:
1051
+ agent_def = load_agent_definition(str(file_path))
1052
+ agents_list.append({
1053
+ 'type': 'local',
1054
+ 'name': agent_def.get('name', file_path.stem),
1055
+ 'source': str(file_path),
1056
+ 'description': agent_def.get('description', '')[:60]
1057
+ })
1058
+ except Exception as e:
1059
+ logger.debug(f"Failed to load {file_path}: {e}")
1060
+
1061
+ # Display agents with numbers using rich (starting from 3 since 1-2 are built-in)
1062
+ for i, agent in enumerate(agents_list, 3):
1063
+ agent_type = "📦 Platform" if agent['type'] == 'platform' else "📁 Local"
1064
+ console.print(f"{i}. [[bold]{agent_type}[/bold]] [cyan]{agent['name']}[/cyan]")
1065
+ if agent['description']:
1066
+ console.print(f" [dim]{agent['description']}[/dim]")
1067
+
1068
+ console.print(f"\n[dim]0. Cancel[/dim]")
1069
+
1070
+ # Get user selection using styled input
1071
+ while True:
1072
+ try:
1073
+ choice = styled_selection_input("Select agent number")
1074
+
1075
+ if choice == '0':
1076
+ return None
1077
+
1078
+ if choice == '1':
1079
+ console.print(f"✓ [green]Selected:[/green] [bold]Alita[/bold]")
1080
+ return '__direct__'
1081
+
1082
+ if choice == '2':
1083
+ console.print(f"✓ [green]Selected:[/green] [bold]Inventory[/bold]")
1084
+ return '__inventory__'
1085
+
1086
+ idx = int(choice) - 3 # Offset by 3 since 1-2 are built-in agents
1087
+ if 0 <= idx < len(agents_list):
1088
+ selected = agents_list[idx]
1089
+ console.print(f"✓ [green]Selected:[/green] [bold]{selected['name']}[/bold]")
1090
+ return selected['source']
1091
+ else:
1092
+ console.print(f"[yellow]Invalid selection. Please enter a number between 0 and {len(agents_list) + 2}[/yellow]")
1093
+ except ValueError:
1094
+ console.print("[yellow]Please enter a valid number[/yellow]")
1095
+ except (KeyboardInterrupt, EOFError):
1096
+ console.print("\n[dim]Cancelled.[/dim]")
1097
+ return None
1098
+
1099
+
1100
+ @click.group()
1101
+ def agent():
1102
+ """Agent testing and interaction commands."""
1103
+ pass
1104
+
1105
+
1106
+ @agent.command('list')
1107
+ @click.option('--local', is_flag=True, help='List local agent definition files')
1108
+ @click.option('--directory', default=None, help='Directory to search for local agents (defaults to AGENTS_DIR from .env)')
1109
+ @click.pass_context
1110
+ def agent_list(ctx, local: bool, directory: Optional[str]):
1111
+ """
1112
+ List available agents.
1113
+
1114
+ By default, lists agents from the platform.
1115
+ Use --local to list agent definition files in the local directory.
1116
+ """
1117
+ formatter = ctx.obj['formatter']
1118
+ config = ctx.obj['config']
1119
+
1120
+ try:
1121
+ if local:
1122
+ # List local agent definition files
1123
+ if directory is None:
1124
+ directory = config.agents_dir
1125
+ search_dir = Path(directory)
1126
+
1127
+ if not search_dir.exists():
1128
+ console.print(f"[red]Directory not found: {directory}[/red]")
1129
+ return
1130
+
1131
+ agents = []
1132
+
1133
+ # Find agent definition files
1134
+ for pattern in ['*.agent.md', '*.agent.yaml', '*.agent.yml', '*.agent.json']:
1135
+ for file_path in search_dir.rglob(pattern):
1136
+ try:
1137
+ agent_def = load_agent_definition(str(file_path))
1138
+ # Use relative path if already relative, otherwise make it relative to cwd
1139
+ try:
1140
+ display_path = str(file_path.relative_to(Path.cwd()))
1141
+ except ValueError:
1142
+ display_path = str(file_path)
1143
+
1144
+ agents.append({
1145
+ 'name': agent_def.get('name', file_path.stem),
1146
+ 'file': display_path,
1147
+ 'description': agent_def.get('description', '')[:80]
1148
+ })
1149
+ except Exception as e:
1150
+ logger.debug(f"Failed to load {file_path}: {e}")
1151
+
1152
+ if not agents:
1153
+ console.print(f"\n[yellow]No agent definition files found in {directory}[/yellow]")
1154
+ return
1155
+
1156
+ # Display local agents in a table
1157
+ table = Table(
1158
+ title=f"Local Agent Definitions in {directory}",
1159
+ show_header=True,
1160
+ header_style="bold cyan",
1161
+ border_style="cyan",
1162
+ box=box.ROUNDED
1163
+ )
1164
+ table.add_column("Name", style="bold cyan", no_wrap=True)
1165
+ table.add_column("File", style="dim")
1166
+ table.add_column("Description", style="white")
1167
+
1168
+ for agent_info in sorted(agents, key=lambda x: x['name']):
1169
+ table.add_row(
1170
+ agent_info['name'],
1171
+ agent_info['file'],
1172
+ agent_info['description'] or "-"
1173
+ )
1174
+
1175
+ console.print("\n")
1176
+ console.print(table)
1177
+ console.print(f"\n[green]Total: {len(agents)} local agents[/green]")
1178
+
1179
+ else:
1180
+ # List platform agents
1181
+ client = get_client(ctx)
1182
+
1183
+ agents = client.get_list_of_apps()
1184
+
1185
+ if formatter.__class__.__name__ == 'JSONFormatter':
1186
+ click.echo(formatter._dump({'agents': agents, 'total': len(agents)}))
1187
+ else:
1188
+ table = Table(
1189
+ title="Available Platform Agents",
1190
+ show_header=True,
1191
+ header_style="bold cyan",
1192
+ border_style="cyan",
1193
+ box=box.ROUNDED
1194
+ )
1195
+ table.add_column("ID", style="yellow", no_wrap=True)
1196
+ table.add_column("Name", style="bold cyan")
1197
+ table.add_column("Description", style="white")
1198
+
1199
+ for agent_info in agents:
1200
+ table.add_row(
1201
+ str(agent_info['id']),
1202
+ agent_info['name'],
1203
+ agent_info.get('description', '')[:80] or "-"
1204
+ )
1205
+
1206
+ console.print("\n")
1207
+ console.print(table)
1208
+ console.print(f"\n[green]Total: {len(agents)} agents[/green]")
1209
+
1210
+ except Exception as e:
1211
+ logger.exception("Failed to list agents")
1212
+ error_panel = Panel(
1213
+ str(e),
1214
+ title="Error",
1215
+ border_style="red",
1216
+ box=box.ROUNDED
1217
+ )
1218
+ console.print(error_panel, style="red")
1219
+ raise click.Abort()
1220
+
1221
+
1222
+ @agent.command('show')
1223
+ @click.argument('agent_source')
1224
+ @click.option('--version', help='Agent version (for platform agents)')
1225
+ @click.pass_context
1226
+ def agent_show(ctx, agent_source: str, version: Optional[str]):
1227
+ """
1228
+ Show agent details.
1229
+
1230
+ AGENT_SOURCE can be:
1231
+ - Platform agent ID or name (e.g., "123" or "my-agent")
1232
+ - Path to local agent file (e.g., ".github/agents/sdk-dev.agent.md")
1233
+ """
1234
+ formatter = ctx.obj['formatter']
1235
+
1236
+ try:
1237
+ # Check if it's a file path
1238
+ if Path(agent_source).exists():
1239
+ # Local agent file
1240
+ agent_def = load_agent_definition(agent_source)
1241
+
1242
+ if formatter.__class__.__name__ == 'JSONFormatter':
1243
+ click.echo(formatter._dump(agent_def))
1244
+ else:
1245
+ # Create details panel
1246
+ details = Text()
1247
+ details.append("File: ", style="bold")
1248
+ details.append(f"{agent_source}\n", style="cyan")
1249
+
1250
+ if agent_def.get('description'):
1251
+ details.append("\nDescription: ", style="bold")
1252
+ details.append(f"{agent_def['description']}\n", style="white")
1253
+
1254
+ if agent_def.get('model'):
1255
+ details.append("Model: ", style="bold")
1256
+ details.append(f"{agent_def['model']}\n", style="cyan")
1257
+
1258
+ if agent_def.get('tools'):
1259
+ details.append("Tools: ", style="bold")
1260
+ details.append(f"{', '.join(agent_def['tools'])}\n", style="cyan")
1261
+
1262
+ if agent_def.get('temperature') is not None:
1263
+ details.append("Temperature: ", style="bold")
1264
+ details.append(f"{agent_def['temperature']}\n", style="cyan")
1265
+
1266
+ panel = Panel(
1267
+ details,
1268
+ title=f"Local Agent: {agent_def.get('name', 'Unknown')}",
1269
+ title_align="left",
1270
+ border_style="cyan",
1271
+ box=box.ROUNDED
1272
+ )
1273
+ console.print("\n")
1274
+ console.print(panel)
1275
+
1276
+ if agent_def.get('system_prompt'):
1277
+ console.print("\n[bold]System Prompt:[/bold]")
1278
+ console.print(Panel(agent_def['system_prompt'][:500] + "...", border_style="dim", box=box.ROUNDED))
1279
+
1280
+ else:
1281
+ # Platform agent
1282
+ client = get_client(ctx)
1283
+
1284
+ # Try to find agent by ID or name
1285
+ agents = client.get_list_of_apps()
1286
+
1287
+ agent = None
1288
+ try:
1289
+ agent_id = int(agent_source)
1290
+ agent = next((a for a in agents if a['id'] == agent_id), None)
1291
+ except ValueError:
1292
+ agent = next((a for a in agents if a['name'] == agent_source), None)
1293
+
1294
+ if not agent:
1295
+ raise click.ClickException(f"Agent '{agent_source}' not found")
1296
+
1297
+ # Get details
1298
+ details = client.get_app_details(agent['id'])
1299
+
1300
+ if formatter.__class__.__name__ == 'JSONFormatter':
1301
+ click.echo(formatter._dump(details))
1302
+ else:
1303
+ # Create platform agent details panel
1304
+ content = Text()
1305
+ content.append("ID: ", style="bold")
1306
+ content.append(f"{details['id']}\n", style="yellow")
1307
+
1308
+ if details.get('description'):
1309
+ content.append("\nDescription: ", style="bold")
1310
+ content.append(f"{details['description']}\n", style="white")
1311
+
1312
+ panel = Panel(
1313
+ content,
1314
+ title=f"Agent: {details['name']}",
1315
+ title_align="left",
1316
+ border_style="cyan",
1317
+ box=box.ROUNDED
1318
+ )
1319
+ console.print("\n")
1320
+ console.print(panel)
1321
+
1322
+ # Display versions in a table
1323
+ if details.get('versions'):
1324
+ console.print("\n[bold]Versions:[/bold]")
1325
+ versions_table = Table(box=box.ROUNDED, border_style="dim")
1326
+ versions_table.add_column("Name", style="cyan")
1327
+ versions_table.add_column("ID", style="yellow")
1328
+ for ver in details.get('versions', []):
1329
+ versions_table.add_row(ver['name'], str(ver['id']))
1330
+ console.print(versions_table)
1331
+
1332
+ except click.ClickException:
1333
+ raise
1334
+ except Exception as e:
1335
+ logger.exception("Failed to show agent details")
1336
+ error_panel = Panel(
1337
+ str(e),
1338
+ title="Error",
1339
+ border_style="red",
1340
+ box=box.ROUNDED
1341
+ )
1342
+ console.print(error_panel, style="red")
1343
+ raise click.Abort()
1344
+
1345
+
1346
+ @agent.command('chat')
1347
+ @click.argument('agent_source', required=False)
1348
+ @click.option('--version', help='Agent version (for platform agents)')
1349
+ @click.option('--toolkit-config', multiple=True, type=click.Path(exists=True),
1350
+ help='Toolkit configuration files (can specify multiple)')
1351
+ @click.option('--inventory', 'inventory_path', type=str,
1352
+ help='Load inventory/knowledge graph from JSON file (e.g., alita.json or .alita/inventory/alita.json)')
1353
+ @click.option('--thread-id', help='Continue existing conversation thread')
1354
+ @click.option('--model', help='Override LLM model')
1355
+ @click.option('--temperature', type=float, help='Override temperature')
1356
+ @click.option('--max-tokens', type=int, help='Override max tokens')
1357
+ @click.option('--dir', 'work_dir', type=click.Path(exists=True, file_okay=False, dir_okay=True),
1358
+ help='Grant agent filesystem access to this directory')
1359
+ @click.option('--verbose', '-v', type=click.Choice(['quiet', 'default', 'debug']), default='default',
1360
+ help='Output verbosity level: quiet (final output only), default (tool calls + outputs), debug (all including LLM calls)')
1361
+ @click.option('--recursion-limit', type=int, default=50,
1362
+ help='Maximum number of tool execution steps per turn')
1363
+ @click.pass_context
1364
+ def agent_chat(ctx, agent_source: Optional[str], version: Optional[str],
1365
+ toolkit_config: tuple, inventory_path: Optional[str], thread_id: Optional[str],
1366
+ model: Optional[str], temperature: Optional[float],
1367
+ max_tokens: Optional[int], work_dir: Optional[str],
1368
+ verbose: str, recursion_limit: Optional[int]):
1369
+ """Start interactive chat with an agent.
1370
+
1371
+ \b
1372
+ Examples:
1373
+ alita chat # Interactive agent selection
1374
+ alita chat my-agent # Chat with platform agent
1375
+ alita chat ./agent.md # Chat with local agent file
1376
+ alita chat --inventory alita.json
1377
+ alita chat my-agent --dir ./src
1378
+ alita chat my-agent --thread-id abc123
1379
+ alita chat my-agent -v quiet # Hide tool calls
1380
+ alita chat my-agent -v debug # Show all LLM calls
1381
+ alita chat __inventory__ --toolkit-config jira.json
1382
+ """
1383
+ formatter = ctx.obj['formatter']
1384
+ config = ctx.obj['config']
1385
+ client = get_client(ctx)
1386
+
1387
+ # Setup verbose level
1388
+ show_verbose = verbose != 'quiet'
1389
+ debug_mode = verbose == 'debug'
1390
+
1391
+ try:
1392
+ # If no agent specified, start with direct chat by default
1393
+ if not agent_source:
1394
+ agent_source = '__direct__'
1395
+
1396
+ # Check for built-in agent modes
1397
+ is_direct = agent_source == '__direct__'
1398
+ is_inventory = agent_source == '__inventory__'
1399
+ is_builtin = is_direct or is_inventory
1400
+ is_local = not is_builtin and Path(agent_source).exists()
1401
+
1402
+ # Get defaults from config
1403
+ default_model = config.default_model or 'gpt-4o'
1404
+ default_temperature = config.default_temperature if config.default_temperature is not None else 0.1
1405
+ default_max_tokens = config.default_max_tokens or 4096
1406
+
1407
+ # Initialize variables for dynamic updates
1408
+ current_model = model
1409
+ current_temperature = temperature
1410
+ current_max_tokens = max_tokens
1411
+ added_mcp_configs = []
1412
+ added_toolkit_configs = list(toolkit_config) if toolkit_config else []
1413
+ mcp_session_manager = None
1414
+ llm = None
1415
+ agent_executor = None
1416
+ agent_def = {}
1417
+ filesystem_tools = None
1418
+ terminal_tools = None
1419
+ planning_tools = None
1420
+ plan_state = None
1421
+
1422
+ # Handle --inventory option: add inventory toolkit config at startup
1423
+ if inventory_path:
1424
+ inventory_config = _build_inventory_config(inventory_path, work_dir)
1425
+ if inventory_config:
1426
+ added_toolkit_configs.append(inventory_config)
1427
+ console.print(f"[dim]✓ Loading inventory: {inventory_config['toolkit_name']} ({inventory_config['graph_path']})[/dim]")
1428
+ else:
1429
+ console.print(f"[yellow]Warning: Inventory file not found: {inventory_path}[/yellow]")
1430
+ console.print("[dim]Searched in current directory and .alita/inventory/[/dim]")
1431
+
1432
+ # Approval mode: 'always' (confirm each tool), 'auto' (no confirmation), 'yolo' (no safety checks)
1433
+ approval_mode = 'always'
1434
+ allowed_directories = [work_dir] if work_dir else [] # Track allowed directories for /dir command
1435
+ current_agent_file = agent_source if is_local else None # Track agent file for /reload command
1436
+
1437
+ if is_direct:
1438
+ # Direct chat mode - no agent, just LLM with Alita instructions
1439
+ agent_name = "Alita"
1440
+ agent_type = "Direct LLM"
1441
+ alita_prompt = _get_alita_system_prompt(config)
1442
+ agent_def = {
1443
+ 'model': model or default_model,
1444
+ 'temperature': temperature if temperature is not None else default_temperature,
1445
+ 'max_tokens': max_tokens or default_max_tokens,
1446
+ 'system_prompt': alita_prompt
1447
+ }
1448
+ elif is_inventory:
1449
+ # Inventory agent mode - knowledge graph builder with inventory toolkit
1450
+ agent_name = "Inventory"
1451
+ agent_type = "Built-in Agent"
1452
+ inventory_prompt = _get_inventory_system_prompt(config)
1453
+ agent_def = {
1454
+ 'name': 'inventory-agent',
1455
+ 'model': model or default_model,
1456
+ 'temperature': temperature if temperature is not None else 0.3,
1457
+ 'max_tokens': max_tokens or default_max_tokens,
1458
+ 'system_prompt': inventory_prompt,
1459
+ # Include inventory toolkit by default
1460
+ 'toolkit_configs': [
1461
+ {'type': 'inventory', 'graph_path': './knowledge_graph.json'}
1462
+ ]
1463
+ }
1464
+ elif is_local:
1465
+ agent_def = load_agent_definition(agent_source)
1466
+ agent_name = agent_def.get('name', Path(agent_source).stem)
1467
+ agent_type = "Local Agent"
1468
+ else:
1469
+ # Platform agent - find it
1470
+ agents = client.get_list_of_apps()
1471
+ agent = None
1472
+
1473
+ try:
1474
+ agent_id = int(agent_source)
1475
+ agent = next((a for a in agents if a['id'] == agent_id), None)
1476
+ except ValueError:
1477
+ agent = next((a for a in agents if a['name'] == agent_source), None)
1478
+
1479
+ if not agent:
1480
+ raise click.ClickException(f"Agent '{agent_source}' not found")
1481
+
1482
+ agent_name = agent['name']
1483
+ agent_type = "Platform Agent"
1484
+
1485
+ # Get model and temperature for welcome banner
1486
+ llm_model_display = current_model or agent_def.get('model', default_model)
1487
+ llm_temperature_display = current_temperature if current_temperature is not None else agent_def.get('temperature', default_temperature)
1488
+
1489
+ # Print nice welcome banner
1490
+ print_welcome(agent_name, llm_model_display, llm_temperature_display, approval_mode)
1491
+
1492
+ # Initialize conversation
1493
+ chat_history = []
1494
+
1495
+ # Initialize session for persistence (memory + plan)
1496
+ from .tools import generate_session_id, create_session_memory, save_session_metadata, to_portable_path
1497
+ current_session_id = generate_session_id()
1498
+ plan_state = {'session_id': current_session_id}
1499
+
1500
+ # Create persistent memory for agent (stored in session directory)
1501
+ memory = create_session_memory(current_session_id)
1502
+
1503
+ # Save session metadata with agent source for session resume
1504
+ agent_source_portable = to_portable_path(current_agent_file) if current_agent_file else None
1505
+ # Filter out transient inventory configs (dicts) - only save file paths
1506
+ serializable_toolkit_configs = [tc for tc in added_toolkit_configs if isinstance(tc, str)]
1507
+ # Extract inventory graph path if present
1508
+ inventory_graph = None
1509
+ for tc in added_toolkit_configs:
1510
+ if isinstance(tc, dict) and tc.get('type') == 'inventory':
1511
+ inventory_graph = tc.get('graph_path')
1512
+ break
1513
+ save_session_metadata(current_session_id, {
1514
+ 'agent_name': agent_name,
1515
+ 'agent_type': agent_type if 'agent_type' in dir() else 'Direct LLM',
1516
+ 'agent_source': agent_source_portable,
1517
+ 'model': llm_model_display,
1518
+ 'temperature': llm_temperature_display,
1519
+ 'work_dir': work_dir,
1520
+ 'is_direct': is_direct,
1521
+ 'is_local': is_local,
1522
+ 'is_inventory': is_inventory,
1523
+ 'added_toolkit_configs': serializable_toolkit_configs,
1524
+ 'inventory_graph': inventory_graph,
1525
+ 'added_mcps': [m if isinstance(m, str) else m.get('name') for m in agent_def.get('mcps', [])],
1526
+ })
1527
+ console.print(f"[dim]Session: {current_session_id}[/dim]")
1528
+
1529
+ # Initialize context manager for chat history management
1530
+ context_config = config.context_management
1531
+ ctx_manager = CLIContextManager(
1532
+ session_id=current_session_id,
1533
+ max_context_tokens=context_config.get('max_context_tokens', 8000),
1534
+ preserve_recent=context_config.get('preserve_recent_messages', 5),
1535
+ pruning_method=context_config.get('pruning_method', 'oldest_first'),
1536
+ enable_summarization=context_config.get('enable_summarization', True),
1537
+ summary_trigger_ratio=context_config.get('summary_trigger_ratio', 0.8),
1538
+ summaries_limit=context_config.get('summaries_limit_count', 5),
1539
+ llm=None # Will be set after LLM creation
1540
+ )
1541
+
1542
+ # Purge old sessions on startup (cleanup task)
1543
+ try:
1544
+ purge_context_sessions(
1545
+ sessions_dir=config.sessions_dir,
1546
+ max_age_days=context_config.get('session_max_age_days', 30),
1547
+ max_sessions=context_config.get('max_sessions', 100)
1548
+ )
1549
+ except Exception as e:
1550
+ logger.debug(f"Session cleanup failed: {e}")
1551
+
1552
+ # Create agent executor
1553
+ if is_direct or is_local or is_inventory:
1554
+ # Setup local agent executor (handles all config, tools, MCP, etc.)
1555
+ try:
1556
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
1557
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, work_dir, plan_state
1558
+ )
1559
+ except Exception:
1560
+ return
1561
+ else:
1562
+ # Platform agent
1563
+ details = client.get_app_details(agent['id'])
1564
+
1565
+ if version:
1566
+ version_obj = next((v for v in details['versions'] if v['name'] == version), None)
1567
+ if not version_obj:
1568
+ raise click.ClickException(f"Version '{version}' not found")
1569
+ version_id = version_obj['id']
1570
+ else:
1571
+ # Use first version
1572
+ version_id = details['versions'][0]['id']
1573
+
1574
+ # Display configuration
1575
+ console.print()
1576
+ console.print("✓ [green]Connected to platform agent[/green]")
1577
+ console.print()
1578
+
1579
+ agent_executor = client.application(
1580
+ application_id=agent['id'],
1581
+ application_version_id=version_id,
1582
+ memory=memory,
1583
+ chat_history=chat_history
1584
+ )
1585
+ llm = None # Platform agents don't use direct LLM
1586
+
1587
+ # Set LLM on context manager for summarization
1588
+ if llm is not None:
1589
+ ctx_manager.llm = llm
1590
+
1591
+ # Initialize input handler for readline support
1592
+ input_handler = get_input_handler()
1593
+
1594
+ # Set up toolkit names callback for tab completion
1595
+ from .input_handler import set_toolkit_names_callback, set_inventory_files_callback
1596
+ set_toolkit_names_callback(lambda: _list_available_toolkits(config))
1597
+
1598
+ # Set up inventory files callback for /inventory tab completion
1599
+ set_inventory_files_callback(lambda: _get_inventory_json_files(allowed_directories[0] if allowed_directories else None))
1600
+
1601
+ # Interactive chat loop
1602
+ while True:
1603
+ try:
1604
+ # Get context info for the UI indicator
1605
+ context_info = ctx_manager.get_context_info()
1606
+
1607
+ # Get input with styled prompt (prompt is part of input() for proper readline handling)
1608
+ user_input = styled_input(context_info=context_info).strip()
1609
+
1610
+ if not user_input:
1611
+ continue
1612
+
1613
+ # Handle commands
1614
+ if user_input.lower() in ['exit', 'quit']:
1615
+ # Save final session state before exiting
1616
+ try:
1617
+ from .tools import update_session_metadata, to_portable_path
1618
+ update_session_metadata(current_session_id, {
1619
+ 'agent_source': to_portable_path(current_agent_file) if current_agent_file else None,
1620
+ 'model': current_model or llm_model_display,
1621
+ 'temperature': current_temperature if current_temperature is not None else llm_temperature_display,
1622
+ 'allowed_directories': allowed_directories,
1623
+ 'added_toolkit_configs': list(added_toolkit_configs),
1624
+ 'added_mcps': [m if isinstance(m, str) else m.get('name') for m in agent_def.get('mcps', [])],
1625
+ })
1626
+ except Exception as e:
1627
+ logger.debug(f"Failed to save session state on exit: {e}")
1628
+ console.print("\n[bold cyan]👋 Goodbye![/bold cyan]\n")
1629
+ break
1630
+
1631
+ if user_input == '/clear':
1632
+ chat_history = []
1633
+ ctx_manager.clear()
1634
+ console.print("[green]✓ Conversation history cleared.[/green]")
1635
+ continue
1636
+
1637
+ if user_input == '/history':
1638
+ if not chat_history:
1639
+ console.print("[yellow]No conversation history yet.[/yellow]")
1640
+ else:
1641
+ console.print("\n[bold cyan]── Conversation History ──[/bold cyan]")
1642
+ for i, msg in enumerate(chat_history, 1):
1643
+ role = msg.get('role', 'unknown')
1644
+ content = msg.get('content', '')
1645
+ role_color = 'blue' if role == 'user' else 'green'
1646
+ included_marker = "" if ctx_manager.is_message_included(i - 1) else " [dim](pruned)[/dim]"
1647
+ console.print(f"\n[bold {role_color}]{i}. {role.upper()}:[/bold {role_color}] {content[:100]}...{included_marker}")
1648
+ continue
1649
+
1650
+ if user_input == '/save':
1651
+ console.print("[yellow]Save to file (default: conversation.json):[/yellow] ", end="")
1652
+ filename = input().strip()
1653
+ filename = filename or "conversation.json"
1654
+ with open(filename, 'w') as f:
1655
+ json.dump({'history': chat_history}, f, indent=2)
1656
+ console.print(f"[green]✓ Conversation saved to {filename}[/green]")
1657
+ continue
1658
+
1659
+ if user_input == '/help':
1660
+ print_help()
1661
+ continue
1662
+
1663
+ # /model command - switch model
1664
+ if user_input == '/model':
1665
+ if not (is_direct or is_local):
1666
+ console.print("[yellow]Model switching is only available for local agents and direct chat.[/yellow]")
1667
+ continue
1668
+
1669
+ selected_model = _select_model_interactive(client)
1670
+ if selected_model:
1671
+ current_model = selected_model['name']
1672
+ agent_def['model'] = current_model
1673
+
1674
+ # Recreate LLM and agent executor - use session memory to preserve history
1675
+ from .tools import create_session_memory, update_session_metadata
1676
+ memory = create_session_memory(current_session_id)
1677
+ try:
1678
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
1679
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
1680
+ )
1681
+ # Persist model change to session
1682
+ update_session_metadata(current_session_id, {
1683
+ 'model': current_model,
1684
+ 'temperature': current_temperature if current_temperature is not None else agent_def.get('temperature', 0.7)
1685
+ })
1686
+ console.print(Panel(
1687
+ f"[cyan]ℹ Model switched to [bold]{current_model}[/bold]. Agent state reset, chat history preserved.[/cyan]",
1688
+ border_style="cyan",
1689
+ box=box.ROUNDED
1690
+ ))
1691
+ except Exception as e:
1692
+ console.print(f"[red]Error switching model: {e}[/red]")
1693
+ continue
1694
+
1695
+ # /reload command - reload agent definition from file
1696
+ if user_input == '/reload':
1697
+ if not is_local:
1698
+ if is_direct or is_inventory:
1699
+ console.print("[yellow]Cannot reload built-in agent mode - no agent file to reload.[/yellow]")
1700
+ else:
1701
+ console.print("[yellow]Reload is only available for local agents (file-based).[/yellow]")
1702
+ continue
1703
+
1704
+ if not current_agent_file or not Path(current_agent_file).exists():
1705
+ console.print("[red]Agent file not found. Cannot reload.[/red]")
1706
+ continue
1707
+
1708
+ try:
1709
+ # Reload agent definition from file
1710
+ new_agent_def = load_agent_definition(current_agent_file)
1711
+
1712
+ # Preserve runtime additions (MCPs, tools added via commands)
1713
+ if 'mcps' in agent_def and agent_def['mcps']:
1714
+ # Merge MCPs: file MCPs + runtime added MCPs
1715
+ file_mcps = new_agent_def.get('mcps', [])
1716
+ for mcp in agent_def['mcps']:
1717
+ mcp_name = mcp if isinstance(mcp, str) else mcp.get('name')
1718
+ file_mcp_names = [m if isinstance(m, str) else m.get('name') for m in file_mcps]
1719
+ if mcp_name not in file_mcp_names:
1720
+ file_mcps.append(mcp)
1721
+ new_agent_def['mcps'] = file_mcps
1722
+
1723
+ # Update agent_def with new values (preserving model/temp overrides)
1724
+ old_system_prompt = agent_def.get('system_prompt', '')
1725
+ new_system_prompt = new_agent_def.get('system_prompt', '')
1726
+
1727
+ agent_def.update(new_agent_def)
1728
+
1729
+ # Restore runtime overrides
1730
+ if current_model:
1731
+ agent_def['model'] = current_model
1732
+ if current_temperature is not None:
1733
+ agent_def['temperature'] = current_temperature
1734
+ if current_max_tokens:
1735
+ agent_def['max_tokens'] = current_max_tokens
1736
+
1737
+ # Recreate agent executor with reloaded definition
1738
+ from .tools import create_session_memory
1739
+ memory = create_session_memory(current_session_id)
1740
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
1741
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
1742
+ )
1743
+
1744
+ # Show what changed
1745
+ prompt_changed = old_system_prompt != new_system_prompt
1746
+ agent_name = agent_def.get('name', Path(current_agent_file).stem)
1747
+
1748
+ if prompt_changed:
1749
+ console.print(Panel(
1750
+ f"[green]✓ Reloaded agent: [bold]{agent_name}[/bold][/green]\n"
1751
+ f"[dim]System prompt updated ({len(new_system_prompt)} chars)[/dim]",
1752
+ border_style="green",
1753
+ box=box.ROUNDED
1754
+ ))
1755
+ else:
1756
+ console.print(Panel(
1757
+ f"[cyan]ℹ Reloaded agent: [bold]{agent_name}[/bold][/cyan]\n"
1758
+ f"[dim]No changes detected in system prompt[/dim]",
1759
+ border_style="cyan",
1760
+ box=box.ROUNDED
1761
+ ))
1762
+ except Exception as e:
1763
+ console.print(f"[red]Error reloading agent: {e}[/red]")
1764
+ continue
1765
+
1766
+ # /add_mcp command - add MCP server
1767
+ if user_input == '/add_mcp':
1768
+ if not (is_direct or is_local or is_inventory):
1769
+ console.print("[yellow]Adding MCP is only available for local agents and built-in agents.[/yellow]")
1770
+ continue
1771
+
1772
+ selected_mcp = _select_mcp_interactive(config)
1773
+ if selected_mcp:
1774
+ mcp_name = selected_mcp['name']
1775
+ # Add MCP to agent definition
1776
+ if 'mcps' not in agent_def:
1777
+ agent_def['mcps'] = []
1778
+ if mcp_name not in [m if isinstance(m, str) else m.get('name') for m in agent_def.get('mcps', [])]:
1779
+ agent_def['mcps'].append(mcp_name)
1780
+
1781
+ # Recreate agent executor with new MCP - use session memory to preserve history
1782
+ from .tools import create_session_memory, update_session_metadata
1783
+ memory = create_session_memory(current_session_id)
1784
+ try:
1785
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
1786
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
1787
+ )
1788
+ # Persist added MCPs to session
1789
+ update_session_metadata(current_session_id, {
1790
+ 'added_mcps': [m if isinstance(m, str) else m.get('name') for m in agent_def.get('mcps', [])]
1791
+ })
1792
+ console.print(Panel(
1793
+ f"[cyan]ℹ Added MCP: [bold]{mcp_name}[/bold]. Agent state reset, chat history preserved.[/cyan]",
1794
+ border_style="cyan",
1795
+ box=box.ROUNDED
1796
+ ))
1797
+ except Exception as e:
1798
+ console.print(f"[red]Error adding MCP: {e}[/red]")
1799
+ continue
1800
+
1801
+ # /add_toolkit command - add toolkit
1802
+ if user_input == '/add_toolkit' or user_input.startswith('/add_toolkit '):
1803
+ if not (is_direct or is_local or is_inventory):
1804
+ console.print("[yellow]Adding toolkit is only available for local agents and built-in agents.[/yellow]")
1805
+ continue
1806
+
1807
+ parts = user_input.split(maxsplit=1)
1808
+ if len(parts) == 2:
1809
+ # Direct toolkit selection by name
1810
+ toolkit_name_arg = parts[1].strip()
1811
+ selected_toolkit = _find_toolkit_by_name(config, toolkit_name_arg)
1812
+ if not selected_toolkit:
1813
+ console.print(f"[yellow]Toolkit '{toolkit_name_arg}' not found.[/yellow]")
1814
+ # Show available toolkits
1815
+ available = _list_available_toolkits(config)
1816
+ if available:
1817
+ console.print(f"[dim]Available toolkits: {', '.join(available)}[/dim]")
1818
+ continue
1819
+ else:
1820
+ # Interactive selection
1821
+ selected_toolkit = _select_toolkit_interactive(config)
1822
+
1823
+ if selected_toolkit:
1824
+ toolkit_name = selected_toolkit['name']
1825
+ toolkit_file = selected_toolkit['file']
1826
+
1827
+ # Add toolkit config path
1828
+ if toolkit_file not in added_toolkit_configs:
1829
+ added_toolkit_configs.append(toolkit_file)
1830
+
1831
+ # Recreate agent executor with new toolkit - use session memory to preserve history
1832
+ from .tools import create_session_memory, update_session_metadata
1833
+ memory = create_session_memory(current_session_id)
1834
+ try:
1835
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
1836
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
1837
+ )
1838
+ # Persist added toolkits to session
1839
+ update_session_metadata(current_session_id, {
1840
+ 'added_toolkit_configs': list(added_toolkit_configs)
1841
+ })
1842
+ console.print(Panel(
1843
+ f"[cyan]ℹ Added toolkit: [bold]{toolkit_name}[/bold]. Agent state reset, chat history preserved.[/cyan]",
1844
+ border_style="cyan",
1845
+ box=box.ROUNDED
1846
+ ))
1847
+ except Exception as e:
1848
+ console.print(f"[red]Error adding toolkit: {e}[/red]")
1849
+ continue
1850
+
1851
+ # /rm_mcp command - remove MCP server
1852
+ if user_input == '/rm_mcp' or user_input.startswith('/rm_mcp '):
1853
+ if not (is_direct or is_local or is_inventory):
1854
+ console.print("[yellow]Removing MCP is only available for local agents and built-in agents.[/yellow]")
1855
+ continue
1856
+
1857
+ current_mcps = agent_def.get('mcps', [])
1858
+ if not current_mcps:
1859
+ console.print("[yellow]No MCP servers are currently loaded.[/yellow]")
1860
+ continue
1861
+
1862
+ # Get list of MCP names
1863
+ mcp_names = [m if isinstance(m, str) else m.get('name') for m in current_mcps]
1864
+
1865
+ parts = user_input.split(maxsplit=1)
1866
+ if len(parts) == 2:
1867
+ # Direct removal by name
1868
+ mcp_name_to_remove = parts[1].strip()
1869
+ if mcp_name_to_remove not in mcp_names:
1870
+ console.print(f"[yellow]MCP '{mcp_name_to_remove}' not found.[/yellow]")
1871
+ console.print(f"[dim]Loaded MCPs: {', '.join(mcp_names)}[/dim]")
1872
+ continue
1873
+ else:
1874
+ # Interactive selection
1875
+ console.print("\n🔌 [bold cyan]Remove MCP Server[/bold cyan]\n")
1876
+ for i, name in enumerate(mcp_names, 1):
1877
+ console.print(f" [bold]{i}[/bold]. {name}")
1878
+ console.print(f" [bold]0[/bold]. [dim]Cancel[/dim]")
1879
+ console.print()
1880
+
1881
+ try:
1882
+ choice = int(input("Select MCP to remove: ").strip())
1883
+ if choice == 0:
1884
+ continue
1885
+ if 1 <= choice <= len(mcp_names):
1886
+ mcp_name_to_remove = mcp_names[choice - 1]
1887
+ else:
1888
+ console.print("[yellow]Invalid selection.[/yellow]")
1889
+ continue
1890
+ except (ValueError, KeyboardInterrupt):
1891
+ continue
1892
+
1893
+ # Remove the MCP
1894
+ agent_def['mcps'] = [m for m in current_mcps if (m if isinstance(m, str) else m.get('name')) != mcp_name_to_remove]
1895
+
1896
+ # Recreate agent executor without the MCP
1897
+ from .tools import create_session_memory, update_session_metadata
1898
+ memory = create_session_memory(current_session_id)
1899
+ try:
1900
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
1901
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
1902
+ )
1903
+ # Persist updated MCPs to session
1904
+ update_session_metadata(current_session_id, {
1905
+ 'added_mcps': [m if isinstance(m, str) else m.get('name') for m in agent_def.get('mcps', [])]
1906
+ })
1907
+ console.print(Panel(
1908
+ f"[cyan]ℹ Removed MCP: [bold]{mcp_name_to_remove}[/bold]. Agent state reset, chat history preserved.[/cyan]",
1909
+ border_style="cyan",
1910
+ box=box.ROUNDED
1911
+ ))
1912
+ except Exception as e:
1913
+ console.print(f"[red]Error removing MCP: {e}[/red]")
1914
+ continue
1915
+
1916
+ # /rm_toolkit command - remove toolkit
1917
+ if user_input == '/rm_toolkit' or user_input.startswith('/rm_toolkit '):
1918
+ if not (is_direct or is_local or is_inventory):
1919
+ console.print("[yellow]Removing toolkit is only available for local agents and built-in agents.[/yellow]")
1920
+ continue
1921
+
1922
+ if not added_toolkit_configs:
1923
+ console.print("[yellow]No toolkits are currently loaded.[/yellow]")
1924
+ continue
1925
+
1926
+ # Get toolkit names from config files
1927
+ toolkit_info = [] # List of (name, file_path)
1928
+ for toolkit_file in added_toolkit_configs:
1929
+ try:
1930
+ with open(toolkit_file, 'r') as f:
1931
+ tk_config = json.load(f)
1932
+ tk_name = tk_config.get('toolkit_name', Path(toolkit_file).stem)
1933
+ toolkit_info.append((tk_name, toolkit_file))
1934
+ except Exception:
1935
+ toolkit_info.append((Path(toolkit_file).stem, toolkit_file))
1936
+
1937
+ parts = user_input.split(maxsplit=1)
1938
+ if len(parts) == 2:
1939
+ # Direct removal by name
1940
+ toolkit_name_to_remove = parts[1].strip()
1941
+ matching = [(name, path) for name, path in toolkit_info if name == toolkit_name_to_remove]
1942
+ if not matching:
1943
+ console.print(f"[yellow]Toolkit '{toolkit_name_to_remove}' not found.[/yellow]")
1944
+ console.print(f"[dim]Loaded toolkits: {', '.join(name for name, _ in toolkit_info)}[/dim]")
1945
+ continue
1946
+ toolkit_file_to_remove = matching[0][1]
1947
+ else:
1948
+ # Interactive selection
1949
+ console.print("\n🔧 [bold cyan]Remove Toolkit[/bold cyan]\n")
1950
+ for i, (name, _) in enumerate(toolkit_info, 1):
1951
+ console.print(f" [bold]{i}[/bold]. {name}")
1952
+ console.print(f" [bold]0[/bold]. [dim]Cancel[/dim]")
1953
+ console.print()
1954
+
1955
+ try:
1956
+ choice = int(input("Select toolkit to remove: ").strip())
1957
+ if choice == 0:
1958
+ continue
1959
+ if 1 <= choice <= len(toolkit_info):
1960
+ toolkit_name_to_remove, toolkit_file_to_remove = toolkit_info[choice - 1]
1961
+ else:
1962
+ console.print("[yellow]Invalid selection.[/yellow]")
1963
+ continue
1964
+ except (ValueError, KeyboardInterrupt):
1965
+ continue
1966
+
1967
+ # Remove the toolkit
1968
+ added_toolkit_configs.remove(toolkit_file_to_remove)
1969
+
1970
+ # Recreate agent executor without the toolkit
1971
+ from .tools import create_session_memory, update_session_metadata
1972
+ memory = create_session_memory(current_session_id)
1973
+ try:
1974
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
1975
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
1976
+ )
1977
+ # Persist updated toolkits to session
1978
+ update_session_metadata(current_session_id, {
1979
+ 'added_toolkit_configs': list(added_toolkit_configs)
1980
+ })
1981
+ console.print(Panel(
1982
+ f"[cyan]ℹ Removed toolkit: [bold]{toolkit_name_to_remove}[/bold]. Agent state reset, chat history preserved.[/cyan]",
1983
+ border_style="cyan",
1984
+ box=box.ROUNDED
1985
+ ))
1986
+ except Exception as e:
1987
+ console.print(f"[red]Error removing toolkit: {e}[/red]")
1988
+ continue
1989
+
1990
+ # /mode command - set approval mode
1991
+ if user_input == '/mode' or user_input.startswith('/mode '):
1992
+ parts = user_input.split(maxsplit=1)
1993
+ if len(parts) == 1:
1994
+ # Show current mode and options
1995
+ mode_info = {
1996
+ 'always': ('yellow', 'Confirm before each tool execution'),
1997
+ 'auto': ('green', 'Execute tools without confirmation'),
1998
+ 'yolo': ('red', 'No confirmations, skip safety warnings')
1999
+ }
2000
+ console.print("\n🔧 [bold cyan]Approval Mode:[/bold cyan]\n")
2001
+ for mode_name, (color, desc) in mode_info.items():
2002
+ marker = "●" if mode_name == approval_mode else "○"
2003
+ console.print(f" [{color}]{marker}[/{color}] [bold]{mode_name}[/bold] - {desc}")
2004
+ console.print(f"\n[dim]Usage: /mode <always|auto|yolo>[/dim]")
2005
+ else:
2006
+ new_mode = parts[1].lower().strip()
2007
+ if new_mode in ['always', 'auto', 'yolo']:
2008
+ approval_mode = new_mode
2009
+ mode_colors = {'always': 'yellow', 'auto': 'green', 'yolo': 'red'}
2010
+ console.print(f"✓ [green]Mode set to[/green] [{mode_colors[new_mode]}][bold]{new_mode}[/bold][/{mode_colors[new_mode]}]")
2011
+ else:
2012
+ console.print(f"[yellow]Unknown mode: {new_mode}. Use: always, auto, or yolo[/yellow]")
2013
+ continue
2014
+
2015
+ # /dir command - manage allowed directories
2016
+ if user_input == '/dir' or user_input.startswith('/dir '):
2017
+ parts = user_input.split()
2018
+
2019
+ if len(parts) == 1:
2020
+ # /dir - list all allowed directories
2021
+ if allowed_directories:
2022
+ console.print("📁 [bold cyan]Allowed directories:[/bold cyan]")
2023
+ for i, d in enumerate(allowed_directories):
2024
+ marker = "●" if i == 0 else "○"
2025
+ label = " [dim](primary)[/dim]" if i == 0 else ""
2026
+ console.print(f" {marker} {d}{label}")
2027
+ else:
2028
+ console.print("[yellow]No directories allowed.[/yellow]")
2029
+ console.print("[dim]Usage: /dir [add|rm|remove] /path/to/directory[/dim]")
2030
+ continue
2031
+
2032
+ action = parts[1].lower()
2033
+
2034
+ # Handle /dir add /path or /dir /path (add is default)
2035
+ if action in ['add', 'rm', 'remove']:
2036
+ if len(parts) < 3:
2037
+ console.print(f"[yellow]Missing path. Usage: /dir {action} /path/to/directory[/yellow]")
2038
+ continue
2039
+ dir_path = parts[2]
2040
+ else:
2041
+ # /dir /path - default to add
2042
+ action = 'add'
2043
+ dir_path = parts[1]
2044
+
2045
+ dir_path = str(Path(dir_path).expanduser().resolve())
2046
+
2047
+ if action == 'add':
2048
+ if not Path(dir_path).exists():
2049
+ console.print(f"[red]Directory not found: {dir_path}[/red]")
2050
+ continue
2051
+ if not Path(dir_path).is_dir():
2052
+ console.print(f"[red]Not a directory: {dir_path}[/red]")
2053
+ continue
2054
+
2055
+ if dir_path in allowed_directories:
2056
+ console.print(f"[yellow]Directory already allowed: {dir_path}[/yellow]")
2057
+ continue
2058
+
2059
+ allowed_directories.append(dir_path)
2060
+
2061
+ # Recreate agent executor with updated directories
2062
+ if is_direct or is_local or is_inventory:
2063
+ from .tools import create_session_memory
2064
+ memory = create_session_memory(current_session_id)
2065
+ try:
2066
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
2067
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
2068
+ )
2069
+ console.print(Panel(
2070
+ f"[cyan]✓ Added directory: [bold]{dir_path}[/bold]\n Total allowed: {len(allowed_directories)}[/cyan]",
2071
+ border_style="cyan",
2072
+ box=box.ROUNDED
2073
+ ))
2074
+ except Exception as e:
2075
+ allowed_directories.remove(dir_path) # Rollback
2076
+ console.print(f"[red]Error adding directory: {e}[/red]")
2077
+ else:
2078
+ console.print("[yellow]Directory mounting is only available for local agents and built-in agents.[/yellow]")
2079
+
2080
+ elif action in ['rm', 'remove']:
2081
+ if dir_path not in allowed_directories:
2082
+ console.print(f"[yellow]Directory not in allowed list: {dir_path}[/yellow]")
2083
+ if allowed_directories:
2084
+ console.print("[dim]Currently allowed:[/dim]")
2085
+ for d in allowed_directories:
2086
+ console.print(f"[dim] - {d}[/dim]")
2087
+ continue
2088
+
2089
+ if len(allowed_directories) == 1:
2090
+ console.print("[yellow]Cannot remove the last directory. Use /dir add first to add another.[/yellow]")
2091
+ continue
2092
+
2093
+ allowed_directories.remove(dir_path)
2094
+
2095
+ # Recreate agent executor with updated directories
2096
+ if is_direct or is_local or is_inventory:
2097
+ from .tools import create_session_memory
2098
+ memory = create_session_memory(current_session_id)
2099
+ try:
2100
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
2101
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
2102
+ )
2103
+ console.print(Panel(
2104
+ f"[cyan]✓ Removed directory: [bold]{dir_path}[/bold]\n Remaining: {len(allowed_directories)}[/cyan]",
2105
+ border_style="cyan",
2106
+ box=box.ROUNDED
2107
+ ))
2108
+ except Exception as e:
2109
+ allowed_directories.append(dir_path) # Rollback
2110
+ console.print(f"[red]Error removing directory: {e}[/red]")
2111
+ else:
2112
+ console.print("[yellow]Directory mounting is only available for local agents and built-in agents.[/yellow]")
2113
+ continue
2114
+
2115
+ # /inventory command - load inventory/knowledge graph from path
2116
+ if user_input == '/inventory' or user_input.startswith('/inventory '):
2117
+ if not (is_direct or is_local or is_inventory):
2118
+ console.print("[yellow]Loading inventory is only available for local agents and built-in agents.[/yellow]")
2119
+ continue
2120
+
2121
+ parts = user_input.split(maxsplit=1)
2122
+ if len(parts) == 1:
2123
+ # Show current inventory and available files
2124
+ current_inventory = None
2125
+ for tc in added_toolkit_configs:
2126
+ if isinstance(tc, dict) and tc.get('type') == 'inventory':
2127
+ current_inventory = tc.get('graph_path')
2128
+ break
2129
+ elif isinstance(tc, str):
2130
+ try:
2131
+ with open(tc, 'r') as f:
2132
+ cfg = json.load(f)
2133
+ if cfg.get('type') == 'inventory':
2134
+ current_inventory = cfg.get('graph_path')
2135
+ break
2136
+ except Exception:
2137
+ pass
2138
+
2139
+ if current_inventory:
2140
+ console.print(f"📊 [bold cyan]Current inventory:[/bold cyan] {current_inventory}")
2141
+ else:
2142
+ console.print("[yellow]No inventory loaded.[/yellow]")
2143
+
2144
+ # Show available .json files
2145
+ primary_dir = allowed_directories[0] if allowed_directories else None
2146
+ available = _get_inventory_json_files(primary_dir)
2147
+ if available:
2148
+ console.print(f"[dim]Available files: {', '.join(available[:10])}")
2149
+ if len(available) > 10:
2150
+ console.print(f"[dim] ... and {len(available) - 10} more[/dim]")
2151
+ console.print("[dim]Usage: /inventory <path/to/graph.json>[/dim]")
2152
+ else:
2153
+ inventory_path = parts[1].strip()
2154
+
2155
+ # Build inventory config from path
2156
+ primary_dir = allowed_directories[0] if allowed_directories else None
2157
+ inventory_config = _build_inventory_config(inventory_path, primary_dir)
2158
+ if not inventory_config:
2159
+ console.print(f"[red]Inventory file not found: {inventory_path}[/red]")
2160
+ # Show search locations
2161
+ console.print("[dim]Searched in:[/dim]")
2162
+ console.print(f"[dim] - {Path.cwd()}[/dim]")
2163
+ console.print(f"[dim] - {Path.cwd() / '.alita' / 'inventory'}[/dim]")
2164
+ if primary_dir:
2165
+ console.print(f"[dim] - {primary_dir}[/dim]")
2166
+ console.print(f"[dim] - {Path(primary_dir) / '.alita' / 'inventory'}[/dim]")
2167
+ continue
2168
+
2169
+ # Remove any existing inventory toolkit configs
2170
+ new_toolkit_configs = []
2171
+ removed_inventory = None
2172
+ for tc in added_toolkit_configs:
2173
+ if isinstance(tc, dict) and tc.get('type') == 'inventory':
2174
+ removed_inventory = tc.get('toolkit_name', 'inventory')
2175
+ continue # Skip existing inventory
2176
+ elif isinstance(tc, str):
2177
+ try:
2178
+ with open(tc, 'r') as f:
2179
+ cfg = json.load(f)
2180
+ if cfg.get('type') == 'inventory':
2181
+ removed_inventory = cfg.get('toolkit_name', Path(tc).stem)
2182
+ continue # Skip existing inventory
2183
+ except Exception:
2184
+ pass
2185
+ new_toolkit_configs.append(tc)
2186
+
2187
+ # Add new inventory config
2188
+ new_toolkit_configs.append(inventory_config)
2189
+ added_toolkit_configs = new_toolkit_configs
2190
+
2191
+ # Recreate agent executor with new inventory
2192
+ from .tools import create_session_memory, update_session_metadata
2193
+ memory = create_session_memory(current_session_id)
2194
+ try:
2195
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
2196
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
2197
+ )
2198
+ # Persist updated toolkits to session (exclude transient inventory configs)
2199
+ serializable_configs = [tc for tc in added_toolkit_configs if isinstance(tc, str)]
2200
+ update_session_metadata(current_session_id, {
2201
+ 'added_toolkit_configs': serializable_configs,
2202
+ 'inventory_graph': inventory_config.get('graph_path') # Save just the graph path
2203
+ })
2204
+
2205
+ toolkit_name = inventory_config['toolkit_name']
2206
+ graph_path = inventory_config['graph_path']
2207
+ if removed_inventory:
2208
+ console.print(Panel(
2209
+ f"[cyan]ℹ Replaced inventory [bold]{removed_inventory}[/bold] with [bold]{toolkit_name}[/bold]\n"
2210
+ f" Graph: {graph_path}[/cyan]",
2211
+ border_style="cyan",
2212
+ box=box.ROUNDED
2213
+ ))
2214
+ else:
2215
+ console.print(Panel(
2216
+ f"[cyan]✓ Loaded inventory: [bold]{toolkit_name}[/bold]\n"
2217
+ f" Graph: {graph_path}[/cyan]",
2218
+ border_style="cyan",
2219
+ box=box.ROUNDED
2220
+ ))
2221
+ except Exception as e:
2222
+ console.print(f"[red]Error loading inventory: {e}[/red]")
2223
+ continue
2224
+
2225
+ # /session command - list or resume sessions
2226
+ if user_input == '/session' or user_input.startswith('/session '):
2227
+ from .tools import list_sessions, PlanState
2228
+ parts = user_input.split(maxsplit=2)
2229
+
2230
+ if len(parts) == 1 or parts[1] == 'list':
2231
+ # List all sessions with plans
2232
+ sessions = list_sessions()
2233
+ if not sessions:
2234
+ console.print("[dim]No saved sessions found.[/dim]")
2235
+ console.print("[dim]Sessions are created when you start chatting.[/dim]")
2236
+ else:
2237
+ console.print("\n📋 [bold cyan]Saved Sessions:[/bold cyan]\n")
2238
+ from datetime import datetime
2239
+ for i, sess in enumerate(sessions[:10], 1): # Show last 10
2240
+ modified = datetime.fromtimestamp(sess['modified']).strftime('%Y-%m-%d %H:%M')
2241
+
2242
+ # Build session info line
2243
+ agent_info = sess.get('agent_name', 'unknown')
2244
+ model_info = sess.get('model', '')
2245
+ if model_info:
2246
+ agent_info = f"{agent_info} ({model_info})"
2247
+
2248
+ # Check if this is current session
2249
+ is_current = sess['session_id'] == current_session_id
2250
+ current_marker = " [green]◀ current[/green]" if is_current else ""
2251
+
2252
+ # Plan progress if available
2253
+ if sess.get('steps_total', 0) > 0:
2254
+ progress = f"[{sess['steps_completed']}/{sess['steps_total']}]"
2255
+ status = "✓" if sess['steps_completed'] == sess['steps_total'] else "○"
2256
+ plan_info = f" - {sess.get('title', 'Untitled')} {progress}"
2257
+ else:
2258
+ status = "●"
2259
+ plan_info = ""
2260
+
2261
+ console.print(f" {status} [cyan]{sess['session_id']}[/cyan]{plan_info}")
2262
+ console.print(f" [dim]{agent_info} • {modified}[/dim]{current_marker}")
2263
+ console.print(f"\n[dim]Usage: /session resume <session_id>[/dim]")
2264
+
2265
+ elif parts[1] == 'resume' and len(parts) > 2:
2266
+ session_id = parts[2].strip()
2267
+ from .tools import load_session_metadata, create_session_memory, from_portable_path
2268
+
2269
+ # Check if session exists (either plan or metadata)
2270
+ loaded_state = PlanState.load(session_id)
2271
+ session_metadata = load_session_metadata(session_id)
2272
+
2273
+ if loaded_state or session_metadata:
2274
+ # Update current session to use this session_id
2275
+ current_session_id = session_id
2276
+
2277
+ # Restore memory from session SQLite (reuses existing memory.db file)
2278
+ memory = create_session_memory(session_id)
2279
+
2280
+ # Update plan state if available
2281
+ if loaded_state:
2282
+ plan_state.update(loaded_state.to_dict())
2283
+ resume_info = f"\n\n{loaded_state.render()}"
2284
+ else:
2285
+ plan_state['session_id'] = session_id
2286
+ resume_info = ""
2287
+
2288
+ # Restore agent source and reload agent definition if available
2289
+ restored_agent = False
2290
+ if session_metadata:
2291
+ agent_source = session_metadata.get('agent_source')
2292
+ if agent_source:
2293
+ agent_file_path = from_portable_path(agent_source)
2294
+ if Path(agent_file_path).exists():
2295
+ try:
2296
+ agent_def = load_agent_definition(agent_file_path)
2297
+ current_agent_file = agent_file_path
2298
+ agent_name = agent_def.get('name', Path(agent_file_path).stem)
2299
+ is_local = True
2300
+ is_direct = False
2301
+ restored_agent = True
2302
+ except Exception as e:
2303
+ console.print(f"[yellow]Warning: Could not reload agent from {agent_source}: {e}[/yellow]")
2304
+
2305
+ # Restore added toolkit configs
2306
+ restored_toolkit_configs = session_metadata.get('added_toolkit_configs', [])
2307
+ if restored_toolkit_configs:
2308
+ added_toolkit_configs.clear()
2309
+ added_toolkit_configs.extend(restored_toolkit_configs)
2310
+
2311
+ # Restore added MCPs to agent_def
2312
+ restored_mcps = session_metadata.get('added_mcps', [])
2313
+ if restored_mcps and restored_agent:
2314
+ if 'mcps' not in agent_def:
2315
+ agent_def['mcps'] = []
2316
+ for mcp_name in restored_mcps:
2317
+ if mcp_name not in [m if isinstance(m, str) else m.get('name') for m in agent_def.get('mcps', [])]:
2318
+ agent_def['mcps'].append(mcp_name)
2319
+
2320
+ # Restore model/temperature overrides
2321
+ if session_metadata.get('model'):
2322
+ current_model = session_metadata['model']
2323
+ if restored_agent:
2324
+ agent_def['model'] = current_model
2325
+ if session_metadata.get('temperature') is not None:
2326
+ current_temperature = session_metadata['temperature']
2327
+ if restored_agent:
2328
+ agent_def['temperature'] = current_temperature
2329
+
2330
+ # Restore allowed directories
2331
+ if session_metadata.get('allowed_directories'):
2332
+ allowed_directories = session_metadata['allowed_directories']
2333
+ elif session_metadata.get('work_dir'):
2334
+ # Backward compatibility with old sessions
2335
+ allowed_directories = [session_metadata['work_dir']]
2336
+
2337
+ # Reinitialize context manager with resumed session_id to load chat history
2338
+ ctx_manager = CLIContextManager(
2339
+ session_id=session_id,
2340
+ max_context_tokens=context_config.get('max_context_tokens', 8000),
2341
+ preserve_recent=context_config.get('preserve_recent_messages', 5),
2342
+ pruning_method=context_config.get('pruning_method', 'oldest_first'),
2343
+ enable_summarization=context_config.get('enable_summarization', True),
2344
+ summary_trigger_ratio=context_config.get('summary_trigger_ratio', 0.8),
2345
+ summaries_limit=context_config.get('summaries_limit_count', 5),
2346
+ llm=llm if 'llm' in dir() else None
2347
+ )
2348
+
2349
+ # Show session info
2350
+ agent_info = session_metadata.get('agent_name', 'unknown') if session_metadata else 'unknown'
2351
+ model_info = session_metadata.get('model', '') if session_metadata else ''
2352
+
2353
+ console.print(Panel(
2354
+ f"[green]✓ Resumed session:[/green] [bold]{session_id}[/bold]\n"
2355
+ f"[dim]Agent: {agent_info}" + (f" • Model: {model_info}" if model_info else "") + f"[/dim]"
2356
+ f"{resume_info}",
2357
+ border_style="green",
2358
+ box=box.ROUNDED
2359
+ ))
2360
+
2361
+ # Display restored chat history
2362
+ chat_history_export = ctx_manager.export_chat_history(include_only=False)
2363
+ if chat_history_export:
2364
+ preserve_recent = context_config.get('preserve_recent_messages', 5)
2365
+ total_messages = len(chat_history_export)
2366
+
2367
+ if total_messages > preserve_recent:
2368
+ console.print(f"\n[dim]... {total_messages - preserve_recent} earlier messages in context[/dim]")
2369
+ messages_to_show = chat_history_export[-preserve_recent:]
2370
+ else:
2371
+ messages_to_show = chat_history_export
2372
+
2373
+ for msg in messages_to_show:
2374
+ role = msg.get('role', 'user')
2375
+ content = msg.get('content', '')[:200] # Truncate for display
2376
+ if len(msg.get('content', '')) > 200:
2377
+ content += '...'
2378
+ role_color = 'cyan' if role == 'user' else 'green'
2379
+ role_label = 'You' if role == 'user' else 'Assistant'
2380
+ console.print(f"[dim][{role_color}]{role_label}:[/{role_color}] {content}[/dim]")
2381
+ console.print()
2382
+
2383
+ # Recreate agent executor with restored tools if we have a local/built-in agent
2384
+ if (is_direct or is_local or is_inventory) and restored_agent:
2385
+ try:
2386
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
2387
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
2388
+ )
2389
+ ctx_manager.llm = llm # Update LLM for summarization
2390
+
2391
+ # Warn about MCP state loss
2392
+ if restored_mcps:
2393
+ console.print("[yellow]Note: MCP connections re-initialized (stateful server state like browser sessions are lost)[/yellow]")
2394
+ except Exception as e:
2395
+ console.print(f"[red]Error recreating agent executor: {e}[/red]")
2396
+ console.print("[yellow]Session state loaded but agent not fully restored. Some tools may not work.[/yellow]")
2397
+ elif is_direct or is_local or is_inventory:
2398
+ # Just update planning tools if we couldn't restore agent
2399
+ try:
2400
+ from .tools import get_planning_tools
2401
+ if loaded_state:
2402
+ planning_tools, _ = get_planning_tools(loaded_state)
2403
+ except Exception as e:
2404
+ console.print(f"[yellow]Warning: Could not reload planning tools: {e}[/yellow]")
2405
+ else:
2406
+ console.print(f"[red]Session not found: {session_id}[/red]")
2407
+ else:
2408
+ console.print("[dim]Usage: /session [list] or /session resume <session_id>[/dim]")
2409
+ continue
2410
+
2411
+ # /agent command - switch to a different agent
2412
+ if user_input == '/agent':
2413
+ selected_agent = _select_agent_interactive(client, config)
2414
+ if selected_agent and selected_agent != '__direct__' and selected_agent != '__inventory__':
2415
+ # Load the new agent
2416
+ new_is_local = Path(selected_agent).exists()
2417
+
2418
+ if new_is_local:
2419
+ agent_def = load_agent_definition(selected_agent)
2420
+ agent_name = agent_def.get('name', Path(selected_agent).stem)
2421
+ agent_type = "Local Agent"
2422
+ is_local = True
2423
+ is_direct = False
2424
+ is_inventory = False
2425
+ current_agent_file = selected_agent # Track for /reload
2426
+ else:
2427
+ # Platform agent
2428
+ agents = client.get_list_of_apps()
2429
+ new_agent = None
2430
+ try:
2431
+ agent_id = int(selected_agent)
2432
+ new_agent = next((a for a in agents if a['id'] == agent_id), None)
2433
+ except ValueError:
2434
+ new_agent = next((a for a in agents if a['name'] == selected_agent), None)
2435
+
2436
+ if new_agent:
2437
+ agent_name = new_agent['name']
2438
+ agent_type = "Platform Agent"
2439
+ is_local = False
2440
+ is_direct = False
2441
+ current_agent_file = None # No file for platform agents
2442
+
2443
+ # Setup platform agent
2444
+ details = client.get_app_details(new_agent['id'])
2445
+ version_id = details['versions'][0]['id']
2446
+ agent_executor = client.application(
2447
+ application_id=new_agent['id'],
2448
+ application_version_id=version_id,
2449
+ memory=memory,
2450
+ chat_history=chat_history
2451
+ )
2452
+ console.print(Panel(
2453
+ f"[cyan]ℹ Switched to agent: [bold]{agent_name}[/bold] ({agent_type}). Chat history preserved.[/cyan]",
2454
+ border_style="cyan",
2455
+ box=box.ROUNDED
2456
+ ))
2457
+ continue
2458
+
2459
+ # For local agents, recreate executor
2460
+ if new_is_local:
2461
+ from .tools import create_session_memory
2462
+ memory = create_session_memory(current_session_id)
2463
+ added_toolkit_configs = []
2464
+ try:
2465
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
2466
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
2467
+ )
2468
+ console.print(Panel(
2469
+ f"[cyan]ℹ Switched to agent: [bold]{agent_name}[/bold] ({agent_type}). Agent state reset, chat history preserved.[/cyan]",
2470
+ border_style="cyan",
2471
+ box=box.ROUNDED
2472
+ ))
2473
+ except Exception as e:
2474
+ console.print(f"[red]Error switching agent: {e}[/red]")
2475
+ elif selected_agent == '__direct__':
2476
+ # Switch back to direct mode
2477
+ is_direct = True
2478
+ is_local = False
2479
+ is_inventory = False
2480
+ current_agent_file = None # No file for direct mode
2481
+ agent_name = "Alita"
2482
+ agent_type = "Direct LLM"
2483
+ alita_prompt = _get_alita_system_prompt(config)
2484
+ agent_def = {
2485
+ 'model': current_model or default_model,
2486
+ 'temperature': current_temperature if current_temperature is not None else default_temperature,
2487
+ 'max_tokens': current_max_tokens or default_max_tokens,
2488
+ 'system_prompt': alita_prompt
2489
+ }
2490
+ from .tools import create_session_memory
2491
+ memory = create_session_memory(current_session_id)
2492
+ try:
2493
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
2494
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
2495
+ )
2496
+ console.print(Panel(
2497
+ f"[cyan]ℹ Switched to [bold]Alita[/bold]. Agent state reset, chat history preserved.[/cyan]",
2498
+ border_style="cyan",
2499
+ box=box.ROUNDED
2500
+ ))
2501
+ except Exception as e:
2502
+ console.print(f"[red]Error switching to direct mode: {e}[/red]")
2503
+ elif selected_agent == '__inventory__':
2504
+ # Switch to inventory mode
2505
+ is_direct = False
2506
+ is_local = False
2507
+ is_inventory = True
2508
+ current_agent_file = None # No file for inventory mode
2509
+ agent_name = "Inventory"
2510
+ agent_type = "Built-in Agent"
2511
+ inventory_prompt = _get_inventory_system_prompt(config)
2512
+ agent_def = {
2513
+ 'name': 'inventory-agent',
2514
+ 'model': current_model or default_model,
2515
+ 'temperature': current_temperature if current_temperature is not None else 0.3,
2516
+ 'max_tokens': current_max_tokens or default_max_tokens,
2517
+ 'system_prompt': inventory_prompt,
2518
+ 'toolkit_configs': [
2519
+ {'type': 'inventory', 'graph_path': './knowledge_graph.json'}
2520
+ ]
2521
+ }
2522
+ from .tools import create_session_memory
2523
+ memory = create_session_memory(current_session_id)
2524
+ try:
2525
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
2526
+ client, agent_def, tuple(added_toolkit_configs), config, current_model, current_temperature, current_max_tokens, memory, allowed_directories, plan_state
2527
+ )
2528
+ console.print(Panel(
2529
+ f"[cyan]ℹ Switched to [bold]Inventory[/bold] agent. Use /add_toolkit to add source toolkits.[/cyan]",
2530
+ border_style="cyan",
2531
+ box=box.ROUNDED
2532
+ ))
2533
+ except Exception as e:
2534
+ console.print(f"[red]Error switching to inventory mode: {e}[/red]")
2535
+ continue
2536
+
2537
+ # Execute agent
2538
+ # Track if history was already added during continuation handling
2539
+ history_already_added = False
2540
+ original_user_input = user_input # Preserve for history tracking
2541
+
2542
+ if (is_direct or is_local or is_inventory) and agent_executor is None:
2543
+ # Local agent without tools: use direct LLM call with streaming
2544
+ system_prompt = agent_def.get('system_prompt', '')
2545
+ messages = []
2546
+ if system_prompt:
2547
+ messages.append({"role": "system", "content": system_prompt})
2548
+
2549
+ # Build pruned context from context manager
2550
+ context_messages = ctx_manager.build_context()
2551
+ for msg in context_messages:
2552
+ messages.append(msg)
2553
+
2554
+ # Add user message
2555
+ messages.append({"role": "user", "content": user_input})
2556
+
2557
+ try:
2558
+ # Try streaming if available
2559
+ if hasattr(llm, 'stream'):
2560
+ output_chunks = []
2561
+ first_chunk = True
2562
+
2563
+ # Show spinner until first token arrives
2564
+ status = console.status("[yellow]Thinking...[/yellow]", spinner="dots")
2565
+ status.start()
2566
+
2567
+ # Stream the response token by token
2568
+ for chunk in llm.stream(messages):
2569
+ if hasattr(chunk, 'content'):
2570
+ token = chunk.content
2571
+ else:
2572
+ token = str(chunk)
2573
+
2574
+ if token:
2575
+ # Stop spinner and show agent name on first token
2576
+ if first_chunk:
2577
+ status.stop()
2578
+ console.print(f"\n[bold bright_cyan]{agent_name}:[/bold bright_cyan]\n", end="")
2579
+ first_chunk = False
2580
+
2581
+ console.print(token, end="", markup=False)
2582
+ output_chunks.append(token)
2583
+
2584
+ # Stop status if still running (no tokens received)
2585
+ if first_chunk:
2586
+ status.stop()
2587
+ console.print(f"\n[bold bright_cyan]{agent_name}:[/bold bright_cyan]\n", end="")
2588
+
2589
+ output = ''.join(output_chunks)
2590
+ console.print() # New line after streaming
2591
+ else:
2592
+ # Fallback to non-streaming with spinner
2593
+ with console.status("[yellow]Thinking...[/yellow]", spinner="dots"):
2594
+ response = llm.invoke(messages)
2595
+ if hasattr(response, 'content'):
2596
+ output = response.content
2597
+ else:
2598
+ output = str(response)
2599
+
2600
+ # Display response after spinner stops
2601
+ console.print(f"\n[bold bright_cyan]{agent_name}:[/bold bright_cyan]")
2602
+ if any(marker in output for marker in ['```', '**', '##', '- ', '* ']):
2603
+ console.print(Markdown(output))
2604
+ else:
2605
+ console.print(output)
2606
+ except Exception as e:
2607
+ console.print(f"\n[red]✗ Error: {e}[/red]\n")
2608
+ continue
2609
+ else:
2610
+ # Agent with tools or platform agent: use agent executor
2611
+ # Setup callback for verbose output
2612
+ from langchain_core.runnables import RunnableConfig
2613
+ from langgraph.errors import GraphRecursionError
2614
+
2615
+ # Initialize invoke_config with thread_id for checkpointing
2616
+ # This ensures the same thread is used across continuations
2617
+ invoke_config = RunnableConfig(
2618
+ configurable={"thread_id": current_session_id}
2619
+ )
2620
+ # always proceed with continuation enabled
2621
+ invoke_config["should_continue"] = True
2622
+ # Set recursion limit for tool executions
2623
+ logger.debug(f"Setting tool steps limit to {recursion_limit}")
2624
+ invoke_config["recursion_limit"] = recursion_limit
2625
+ cli_callback = None
2626
+ if show_verbose:
2627
+ cli_callback = create_cli_callback(verbose=True, debug=debug_mode)
2628
+ invoke_config["callbacks"] = [cli_callback]
2629
+
2630
+ # Track recursion continuation state
2631
+ continue_from_recursion = False
2632
+ recursion_attempts = 0
2633
+ tool_limit_attempts = 0 # Track tool limit continuation attempts
2634
+ max_recursion_continues = 5 # Prevent infinite continuation loops
2635
+ output = None # Initialize output before loop
2636
+ result = None # Initialize result before loop
2637
+
2638
+ while True:
2639
+ try:
2640
+ # Always start with a thinking spinner
2641
+ status = console.status("[yellow]Thinking...[/yellow]", spinner="dots")
2642
+ status.start()
2643
+
2644
+ # Pass status to callback so it can stop it when tool calls start
2645
+ if cli_callback:
2646
+ cli_callback.status = status
2647
+
2648
+ try:
2649
+ result = agent_executor.invoke(
2650
+ {
2651
+ "input": [user_input] if not is_local else user_input,
2652
+ "chat_history": ctx_manager.build_context()
2653
+ },
2654
+ config=invoke_config
2655
+ )
2656
+ finally:
2657
+ # Make sure spinner is stopped
2658
+ try:
2659
+ status.stop()
2660
+ except Exception:
2661
+ pass
2662
+
2663
+ # Extract output from result
2664
+ if result is not None:
2665
+ output = extract_output_from_result(result)
2666
+
2667
+ # Check if max tool iterations were reached and prompt user
2668
+ if output and "Maximum tool execution iterations" in output and "reached" in output:
2669
+ tool_limit_attempts += 1
2670
+
2671
+ console.print()
2672
+ console.print(Panel(
2673
+ f"[yellow]⚠ Tool execution limit reached[/yellow]\n\n"
2674
+ f"The agent has executed the maximum number of tool calls in a single turn.\n"
2675
+ f"This usually happens with complex tasks that require many sequential operations.\n\n"
2676
+ f"[dim]Attempt {tool_limit_attempts}/{max_recursion_continues}[/dim]",
2677
+ title="Tool Limit Reached",
2678
+ border_style="yellow",
2679
+ box=box.ROUNDED
2680
+ ))
2681
+
2682
+ if tool_limit_attempts >= max_recursion_continues:
2683
+ console.print("[red]Maximum continuation attempts reached. Please break down your request into smaller tasks.[/red]")
2684
+ break
2685
+
2686
+ console.print("\nWhat would you like to do?")
2687
+ console.print(" [bold cyan]c[/bold cyan] - Continue execution (tell agent to resume)")
2688
+ console.print(" [bold cyan]s[/bold cyan] - Stop and keep partial results")
2689
+ console.print(" [bold cyan]n[/bold cyan] - Start a new request")
2690
+ console.print()
2691
+
2692
+ try:
2693
+ choice = input_handler.get_input("Choice [c/s/n]: ").strip().lower()
2694
+ except (KeyboardInterrupt, EOFError):
2695
+ choice = 's'
2696
+
2697
+ if choice == 'c':
2698
+ # Continue - send a follow-up message to resume
2699
+ console.print("\n[cyan]Continuing execution...[/cyan]\n")
2700
+
2701
+ # Clean up the output - remove the tool limit warning message
2702
+ clean_output = output
2703
+ if "Maximum tool execution iterations" in output:
2704
+ # Strip the warning from the end of the output
2705
+ lines = output.split('\n')
2706
+ clean_lines = [l for l in lines if "Maximum tool execution iterations" not in l and "Stopping tool execution" not in l]
2707
+ clean_output = '\n'.join(clean_lines).strip()
2708
+
2709
+ # Add current output to history first (without the warning)
2710
+ # Use original user input for first continuation, current for subsequent
2711
+ history_input = original_user_input if not history_already_added else user_input
2712
+ if clean_output:
2713
+ chat_history.append({"role": "user", "content": history_input})
2714
+ chat_history.append({"role": "assistant", "content": clean_output})
2715
+ ctx_manager.add_message("user", history_input)
2716
+ ctx_manager.add_message("assistant", clean_output)
2717
+ history_already_added = True
2718
+
2719
+ # CRITICAL: Use a new thread_id when continuing to avoid corrupted
2720
+ # checkpoint state. The tool limit may have left the checkpoint with
2721
+ # an AIMessage containing tool_calls without corresponding ToolMessages.
2722
+ # Using a new thread_id starts fresh with our clean context manager state.
2723
+ import uuid
2724
+ continuation_thread_id = f"{current_session_id}-cont-{uuid.uuid4().hex[:8]}"
2725
+ invoke_config = RunnableConfig(
2726
+ configurable={"thread_id": continuation_thread_id}
2727
+ )
2728
+ invoke_config["should_continue"] = True
2729
+ invoke_config["recursion_limit"] = recursion_limit
2730
+ if cli_callback:
2731
+ invoke_config["callbacks"] = [cli_callback]
2732
+
2733
+ # Set new input to continue with a more explicit continuation message
2734
+ # Include context about the task limit to help the agent understand
2735
+ user_input = (
2736
+ "The previous response was interrupted due to reaching the tool execution limit. "
2737
+ "Continue from where you left off and complete the remaining steps of the original task. "
2738
+ "Focus on what still needs to be done - do not repeat completed work."
2739
+ )
2740
+ continue # Retry the invoke in this inner loop
2741
+
2742
+ elif choice == 's':
2743
+ console.print("\n[yellow]Stopped. Partial work has been completed.[/yellow]")
2744
+ break # Exit retry loop and show output
2745
+
2746
+ else: # 'n' or anything else
2747
+ console.print("\n[dim]Skipped. Enter a new request.[/dim]")
2748
+ output = None
2749
+ break # Exit retry loop
2750
+
2751
+ # Success - exit the retry loop
2752
+ break
2753
+
2754
+ except GraphRecursionError as e:
2755
+ recursion_attempts += 1
2756
+ step_limit = getattr(e, 'recursion_limit', 25)
2757
+
2758
+ console.print()
2759
+ console.print(Panel(
2760
+ f"[yellow]⚠ Step limit reached ({step_limit} steps)[/yellow]\n\n"
2761
+ f"The agent has executed the maximum number of steps allowed.\n"
2762
+ f"This usually happens with complex tasks that require many tool calls.\n\n"
2763
+ f"[dim]Attempt {recursion_attempts}/{max_recursion_continues}[/dim]",
2764
+ title="Step Limit Reached",
2765
+ border_style="yellow",
2766
+ box=box.ROUNDED
2767
+ ))
2768
+
2769
+ if recursion_attempts >= max_recursion_continues:
2770
+ console.print("[red]Maximum continuation attempts reached. Please break down your request into smaller tasks.[/red]")
2771
+ output = f"[Step limit reached after {recursion_attempts} continuation attempts. The task may be too complex - please break it into smaller steps.]"
2772
+ break
2773
+
2774
+ # Prompt user for action
2775
+ console.print("\nWhat would you like to do?")
2776
+ console.print(" [bold cyan]c[/bold cyan] - Continue execution (agent will resume from checkpoint)")
2777
+ console.print(" [bold cyan]s[/bold cyan] - Stop and get partial results")
2778
+ console.print(" [bold cyan]n[/bold cyan] - Start a new request")
2779
+ console.print()
2780
+
2781
+ try:
2782
+ choice = input_handler.get_input("Choice [c/s/n]: ").strip().lower()
2783
+ except (KeyboardInterrupt, EOFError):
2784
+ choice = 's'
2785
+
2786
+ if choice == 'c':
2787
+ # Continue - Use a new thread_id to avoid corrupted checkpoint state.
2788
+ # GraphRecursionError may have left the checkpoint with an AIMessage
2789
+ # containing tool_calls without corresponding ToolMessages.
2790
+ # Using a new thread_id starts fresh with our clean context manager state.
2791
+ continue_from_recursion = True
2792
+ console.print("\n[cyan]Continuing with fresh context...[/cyan]\n")
2793
+
2794
+ # Add current progress to history if we have it
2795
+ # (GraphRecursionError doesn't give us partial output, but context may have been updated)
2796
+ history_input = original_user_input if not history_already_added else user_input
2797
+ ctx_manager.add_message("user", history_input)
2798
+ ctx_manager.add_message("assistant", "[Previous task interrupted - continuing...]")
2799
+ history_already_added = True
2800
+
2801
+ # Create new thread_id to avoid corrupted checkpoint
2802
+ import uuid
2803
+ continuation_thread_id = f"{current_session_id}-cont-{uuid.uuid4().hex[:8]}"
2804
+ invoke_config = RunnableConfig(
2805
+ configurable={"thread_id": continuation_thread_id}
2806
+ )
2807
+ if cli_callback:
2808
+ invoke_config["callbacks"] = [cli_callback]
2809
+
2810
+ # More explicit continuation message
2811
+ user_input = (
2812
+ "The previous response was interrupted due to reaching the step limit. "
2813
+ "Continue from where you left off and complete the remaining steps of the original task. "
2814
+ "Focus on what still needs to be done - do not repeat completed work."
2815
+ )
2816
+ continue # Retry the invoke
2817
+
2818
+ elif choice == 's':
2819
+ # Stop and try to extract partial results
2820
+ console.print("\n[yellow]Stopped. Attempting to extract partial results...[/yellow]")
2821
+ output = "[Task stopped due to step limit. Partial work may have been completed - check any files or state that were modified.]"
2822
+ break
2823
+
2824
+ else: # 'n' or anything else
2825
+ console.print("\n[dim]Skipped. Enter a new request.[/dim]")
2826
+ output = None
2827
+ break
2828
+
2829
+ # Skip chat history update if we bailed out (no result)
2830
+ if output is None:
2831
+ continue
2832
+
2833
+ # Display response in a clear format
2834
+ console.print() # Add spacing
2835
+ console.print(f"[bold bright_cyan]{agent_name}:[/bold bright_cyan]")
2836
+ console.print() # Add spacing before response
2837
+ if any(marker in output for marker in ['```', '**', '##', '- ', '* ']):
2838
+ console.print(Markdown(output))
2839
+ else:
2840
+ console.print(output)
2841
+ console.print() # Add spacing after response
2842
+
2843
+ # Update chat history and context manager (skip if already added during continuation)
2844
+ if not history_already_added:
2845
+ chat_history.append({"role": "user", "content": original_user_input})
2846
+ chat_history.append({"role": "assistant", "content": output})
2847
+
2848
+ # Add messages to context manager for token tracking and pruning
2849
+ ctx_manager.add_message("user", original_user_input)
2850
+ ctx_manager.add_message("assistant", output)
2851
+ else:
2852
+ # During continuation, add the final response with continuation message
2853
+ chat_history.append({"role": "user", "content": user_input})
2854
+ chat_history.append({"role": "assistant", "content": output})
2855
+ ctx_manager.add_message("user", user_input)
2856
+ ctx_manager.add_message("assistant", output)
2857
+
2858
+ except KeyboardInterrupt:
2859
+ console.print("\n\n[yellow]Interrupted. Type 'exit' to quit or continue chatting.[/yellow]")
2860
+ continue
2861
+ except EOFError:
2862
+ # Save final session state before exiting
2863
+ try:
2864
+ from .tools import update_session_metadata, to_portable_path
2865
+ update_session_metadata(current_session_id, {
2866
+ 'agent_source': to_portable_path(current_agent_file) if current_agent_file else None,
2867
+ 'model': current_model or llm_model_display,
2868
+ 'temperature': current_temperature if current_temperature is not None else llm_temperature_display,
2869
+ 'allowed_directories': allowed_directories,
2870
+ 'added_toolkit_configs': list(added_toolkit_configs),
2871
+ 'added_mcps': [m if isinstance(m, str) else m.get('name') for m in agent_def.get('mcps', [])],
2872
+ })
2873
+ except Exception as e:
2874
+ logger.debug(f"Failed to save session state on exit: {e}")
2875
+ console.print("\n\n[bold cyan]Goodbye! 👋[/bold cyan]")
2876
+ break
2877
+
2878
+ except click.ClickException:
2879
+ raise
2880
+ except Exception as e:
2881
+ logger.exception("Failed to start chat")
2882
+ error_panel = Panel(
2883
+ str(e),
2884
+ title="Error",
2885
+ border_style="red",
2886
+ box=box.ROUNDED
2887
+ )
2888
+ console.print(error_panel, style="red")
2889
+ raise click.Abort()
2890
+
2891
+
2892
+ @agent.command('run')
2893
+ @click.argument('agent_source')
2894
+ @click.argument('message')
2895
+ @click.option('--version', help='Agent version (for platform agents)')
2896
+ @click.option('--toolkit-config', multiple=True, type=click.Path(exists=True),
2897
+ help='Toolkit configuration files')
2898
+ @click.option('--model', help='Override LLM model')
2899
+ @click.option('--temperature', type=float, help='Override temperature')
2900
+ @click.option('--max-tokens', type=int, help='Override max tokens')
2901
+ @click.option('--save-thread', help='Save thread ID to file for continuation')
2902
+ @click.option('--dir', 'work_dir', type=click.Path(exists=True, file_okay=False, dir_okay=True),
2903
+ help='Grant agent filesystem access to this directory')
2904
+ @click.option('--verbose', '-v', type=click.Choice(['quiet', 'default', 'debug']), default='default',
2905
+ help='Output verbosity level: quiet (final output only), default (tool calls + outputs), debug (all including LLM calls)')
2906
+ @click.pass_context
2907
+ def agent_run(ctx, agent_source: str, message: str, version: Optional[str],
2908
+ toolkit_config: tuple, model: Optional[str],
2909
+ temperature: Optional[float], max_tokens: Optional[int],
2910
+ save_thread: Optional[str], work_dir: Optional[str],
2911
+ verbose: str):
2912
+ """Run agent with a single message (handoff mode).
2913
+
2914
+ \b
2915
+ AGENT_SOURCE can be:
2916
+ - Platform agent ID or name
2917
+ - Path to local agent file
2918
+
2919
+ MESSAGE is the input message to send to the agent.
2920
+
2921
+ \b
2922
+ Examples:
2923
+ alita run my-agent "What is the status of JIRA-123?"
2924
+ alita run ./agent.md "Create a new toolkit for Stripe API"
2925
+ alita -o json run my-agent "Search for bugs" --toolkit-config jira.json
2926
+ alita run my-agent "Analyze code" --dir ./myproject
2927
+ alita run my-agent "Start task" --save-thread thread.txt
2928
+ alita run my-agent "Query" -v quiet
2929
+ alita run my-agent "Query" -v debug
2930
+ """
2931
+ formatter = ctx.obj['formatter']
2932
+ client = get_client(ctx)
2933
+
2934
+ # Setup verbose level
2935
+ show_verbose = verbose != 'quiet'
2936
+ debug_mode = verbose == 'debug'
2937
+
2938
+ try:
2939
+ # Load agent
2940
+ is_local = Path(agent_source).exists()
2941
+
2942
+ if is_local:
2943
+ agent_def = load_agent_definition(agent_source)
2944
+ agent_name = agent_def.get('name', Path(agent_source).stem)
2945
+
2946
+ # Create memory for agent
2947
+ from langgraph.checkpoint.sqlite import SqliteSaver
2948
+ memory = SqliteSaver(sqlite3.connect(":memory:", check_same_thread=False))
2949
+
2950
+ # Setup local agent executor (reuses same logic as agent_chat)
2951
+ try:
2952
+ agent_executor, mcp_session_manager, llm, llm_model, filesystem_tools, terminal_tools, planning_tools = _setup_local_agent_executor(
2953
+ client, agent_def, toolkit_config, ctx.obj['config'], model, temperature, max_tokens, memory, work_dir, {}
2954
+ )
2955
+ except Exception as e:
2956
+ error_panel = Panel(
2957
+ f"Failed to setup agent: {e}",
2958
+ title="Error",
2959
+ border_style="red",
2960
+ box=box.ROUNDED
2961
+ )
2962
+ console.print(error_panel, style="red")
2963
+ raise click.Abort()
2964
+
2965
+ # Execute agent
2966
+ if agent_executor:
2967
+ # Setup callback for verbose output
2968
+ from langchain_core.runnables import RunnableConfig
2969
+ from langgraph.errors import GraphRecursionError
2970
+
2971
+ invoke_config = None
2972
+ if show_verbose:
2973
+ cli_callback = create_cli_callback(verbose=True, debug=debug_mode)
2974
+ invoke_config = RunnableConfig(callbacks=[cli_callback])
2975
+
2976
+ try:
2977
+ # Execute with spinner for non-JSON output
2978
+ if formatter.__class__.__name__ == 'JSONFormatter':
2979
+ # JSON output: always quiet, no callbacks
2980
+ with console.status("[yellow]Processing...[/yellow]", spinner="dots"):
2981
+ result = agent_executor.invoke({
2982
+ "input": message,
2983
+ "chat_history": []
2984
+ })
2985
+
2986
+ click.echo(formatter._dump({
2987
+ 'agent': agent_name,
2988
+ 'message': message,
2989
+ 'response': extract_output_from_result(result),
2990
+ 'full_result': result
2991
+ }))
2992
+ else:
2993
+ # Show status only when not verbose (verbose shows its own progress)
2994
+ if not show_verbose:
2995
+ with console.status("[yellow]Processing...[/yellow]", spinner="dots"):
2996
+ result = agent_executor.invoke(
2997
+ {
2998
+ "input": message,
2999
+ "chat_history": []
3000
+ },
3001
+ config=invoke_config
3002
+ )
3003
+ else:
3004
+ console.print() # Add spacing before tool calls
3005
+ result = agent_executor.invoke(
3006
+ {
3007
+ "input": message,
3008
+ "chat_history": []
3009
+ },
3010
+ config=invoke_config
3011
+ )
3012
+
3013
+ # Extract and display output
3014
+ output = extract_output_from_result(result)
3015
+ display_output(agent_name, message, output)
3016
+
3017
+ except GraphRecursionError as e:
3018
+ step_limit = getattr(e, 'recursion_limit', 25)
3019
+ console.print()
3020
+ console.print(Panel(
3021
+ f"[yellow]⚠ Step limit reached ({step_limit} steps)[/yellow]\n\n"
3022
+ f"The agent exceeded the maximum number of steps.\n"
3023
+ f"This task may be too complex for a single run.\n\n"
3024
+ f"[bold]Suggestions:[/bold]\n"
3025
+ f"• Use [cyan]alita agent chat[/cyan] for interactive continuation\n"
3026
+ f"• Break the task into smaller, focused requests\n"
3027
+ f"• Check if partial work was completed (files created, etc.)",
3028
+ title="Step Limit Reached",
3029
+ border_style="yellow",
3030
+ box=box.ROUNDED
3031
+ ))
3032
+ if formatter.__class__.__name__ == 'JSONFormatter':
3033
+ click.echo(formatter._dump({
3034
+ 'agent': agent_name,
3035
+ 'message': message,
3036
+ 'error': 'step_limit_reached',
3037
+ 'step_limit': step_limit,
3038
+ 'response': f'Step limit of {step_limit} reached. Task may be too complex.'
3039
+ }))
3040
+ else:
3041
+ # Simple LLM mode without tools
3042
+ system_prompt = agent_def.get('system_prompt', '')
3043
+ messages = []
3044
+ if system_prompt:
3045
+ messages.append({"role": "system", "content": system_prompt})
3046
+ messages.append({"role": "user", "content": message})
3047
+
3048
+ # Execute with spinner for non-JSON output
3049
+ if formatter.__class__.__name__ == 'JSONFormatter':
3050
+ response = llm.invoke(messages)
3051
+ if hasattr(response, 'content'):
3052
+ output = response.content
3053
+ else:
3054
+ output = str(response)
3055
+
3056
+ click.echo(formatter._dump({
3057
+ 'agent': agent_name,
3058
+ 'message': message,
3059
+ 'response': output
3060
+ }))
3061
+ else:
3062
+ # Show spinner while executing
3063
+ with console.status("[yellow]Processing...[/yellow]", spinner="dots"):
3064
+ response = llm.invoke(messages)
3065
+ if hasattr(response, 'content'):
3066
+ output = response.content
3067
+ else:
3068
+ output = str(response)
3069
+
3070
+ # Display output
3071
+ display_output(agent_name, message, output)
3072
+
3073
+ else:
3074
+ # Platform agent
3075
+ agents = client.get_list_of_apps()
3076
+ agent = None
3077
+
3078
+ try:
3079
+ agent_id = int(agent_source)
3080
+ agent = next((a for a in agents if a['id'] == agent_id), None)
3081
+ except ValueError:
3082
+ agent = next((a for a in agents if a['name'] == agent_source), None)
3083
+
3084
+ if not agent:
3085
+ raise click.ClickException(f"Agent '{agent_source}' not found")
3086
+
3087
+ # Get version
3088
+ details = client.get_app_details(agent['id'])
3089
+
3090
+ if version:
3091
+ version_obj = next((v for v in details['versions'] if v['name'] == version), None)
3092
+ if not version_obj:
3093
+ raise click.ClickException(f"Version '{version}' not found")
3094
+ version_id = version_obj['id']
3095
+ else:
3096
+ version_id = details['versions'][0]['id']
3097
+
3098
+ # Load toolkit configs from CLI options
3099
+ toolkit_configs = []
3100
+ if toolkit_config:
3101
+ for config_path in toolkit_config:
3102
+ toolkit_configs.append(load_toolkit_config(config_path))
3103
+
3104
+ # Create memory
3105
+ from langgraph.checkpoint.sqlite import SqliteSaver
3106
+ memory = SqliteSaver(sqlite3.connect(":memory:", check_same_thread=False))
3107
+
3108
+ # Create agent executor
3109
+ agent_executor = client.application(
3110
+ application_id=agent['id'],
3111
+ application_version_id=version_id,
3112
+ memory=memory
3113
+ )
3114
+
3115
+ # Setup callback for verbose output
3116
+ from langchain_core.runnables import RunnableConfig
3117
+ from langgraph.errors import GraphRecursionError
3118
+
3119
+ invoke_config = None
3120
+ if show_verbose:
3121
+ cli_callback = create_cli_callback(verbose=True, debug=debug_mode)
3122
+ invoke_config = RunnableConfig(callbacks=[cli_callback])
3123
+
3124
+ try:
3125
+ # Execute with spinner for non-JSON output
3126
+ if formatter.__class__.__name__ == 'JSONFormatter':
3127
+ result = agent_executor.invoke({
3128
+ "input": [message],
3129
+ "chat_history": []
3130
+ })
3131
+
3132
+ click.echo(formatter._dump({
3133
+ 'agent': agent['name'],
3134
+ 'message': message,
3135
+ 'response': result.get('output', ''),
3136
+ 'full_result': result
3137
+ }))
3138
+ else:
3139
+ # Show status only when not verbose
3140
+ if not show_verbose:
3141
+ with console.status("[yellow]Processing...[/yellow]", spinner="dots"):
3142
+ result = agent_executor.invoke(
3143
+ {
3144
+ "input": [message],
3145
+ "chat_history": []
3146
+ },
3147
+ config=invoke_config
3148
+ )
3149
+ else:
3150
+ console.print() # Add spacing before tool calls
3151
+ result = agent_executor.invoke(
3152
+ {
3153
+ "input": [message],
3154
+ "chat_history": []
3155
+ },
3156
+ config=invoke_config
3157
+ )
3158
+
3159
+ # Display output
3160
+ response = result.get('output', 'No response')
3161
+ display_output(agent['name'], message, response)
3162
+
3163
+ # Save thread if requested
3164
+ if save_thread:
3165
+ thread_data = {
3166
+ 'agent_id': agent['id'],
3167
+ 'agent_name': agent['name'],
3168
+ 'version_id': version_id,
3169
+ 'thread_id': result.get('thread_id'),
3170
+ 'last_message': message
3171
+ }
3172
+ with open(save_thread, 'w') as f:
3173
+ json.dump(thread_data, f, indent=2)
3174
+ logger.info(f"Thread saved to {save_thread}")
3175
+
3176
+ except GraphRecursionError as e:
3177
+ step_limit = getattr(e, 'recursion_limit', 25)
3178
+ console.print()
3179
+ console.print(Panel(
3180
+ f"[yellow]⚠ Step limit reached ({step_limit} steps)[/yellow]\n\n"
3181
+ f"The agent exceeded the maximum number of steps.\n"
3182
+ f"This task may be too complex for a single run.\n\n"
3183
+ f"[bold]Suggestions:[/bold]\n"
3184
+ f"• Use [cyan]alita agent chat[/cyan] for interactive continuation\n"
3185
+ f"• Break the task into smaller, focused requests\n"
3186
+ f"• Check if partial work was completed (files created, etc.)",
3187
+ title="Step Limit Reached",
3188
+ border_style="yellow",
3189
+ box=box.ROUNDED
3190
+ ))
3191
+ if formatter.__class__.__name__ == 'JSONFormatter':
3192
+ click.echo(formatter._dump({
3193
+ 'agent': agent['name'],
3194
+ 'message': message,
3195
+ 'error': 'step_limit_reached',
3196
+ 'step_limit': step_limit,
3197
+ 'response': f'Step limit of {step_limit} reached. Task may be too complex.'
3198
+ }))
3199
+
3200
+ except click.ClickException:
3201
+ raise
3202
+ except Exception as e:
3203
+ logger.exception("Failed to run agent")
3204
+ error_panel = Panel(
3205
+ str(e),
3206
+ title="Error",
3207
+ border_style="red",
3208
+ box=box.ROUNDED
3209
+ )
3210
+ console.print(error_panel, style="red")
3211
+ raise click.Abort()
3212
+
3213
+
3214
+ @agent.command('execute-test-cases')
3215
+ @click.argument('agent_source')
3216
+ @click.option('--test-cases-dir', required=True, type=click.Path(exists=True, file_okay=False, dir_okay=True),
3217
+ help='Directory containing test case files')
3218
+ @click.option('--results-dir', required=True, type=click.Path(file_okay=False, dir_okay=True),
3219
+ help='Directory where test results will be saved')
3220
+ @click.option('--test-case', 'test_case_files', multiple=True,
3221
+ help='Specific test case file(s) to execute (e.g., TC-001.md). Can specify multiple times. If not specified, executes all test cases.')
3222
+ @click.option('--model', help='Override LLM model')
3223
+ @click.option('--temperature', type=float, help='Override temperature')
3224
+ @click.option('--max-tokens', type=int, help='Override max tokens')
3225
+ @click.option('--dir', 'work_dir', type=click.Path(exists=True, file_okay=False, dir_okay=True),
3226
+ help='Grant agent filesystem access to this directory')
3227
+ @click.option('--data-generator', type=click.Path(exists=True),
3228
+ help='Path to test data generator agent definition file')
3229
+ @click.option('--skip-data-generation', is_flag=True,
3230
+ help='Skip test data generation step')
3231
+ @click.pass_context
3232
+ def execute_test_cases(ctx, agent_source: str, test_cases_dir: str, results_dir: str,
3233
+ test_case_files: tuple, model: Optional[str], temperature: Optional[float],
3234
+ max_tokens: Optional[int], work_dir: Optional[str],
3235
+ data_generator: Optional[str], skip_data_generation: bool):
3236
+ """
3237
+ Execute test cases from a directory and save results.
3238
+
3239
+ This command:
3240
+ 1. (Optional) Executes test data generator agent to provision test data
3241
+ 2. Scans TEST_CASES_DIR for test case markdown files (TC-*.md)
3242
+ 3. For each test case:
3243
+ - Parses the test case to extract config, steps, and expectations
3244
+ - Loads the agent with the toolkit config specified in the test case
3245
+ - Executes each test step
3246
+ - Validates output against expectations
3247
+ - Generates a test result file
3248
+ 4. Saves all results to RESULTS_DIR
3249
+
3250
+ AGENT_SOURCE: Path to agent definition file (e.g., .github/agents/test-runner.agent.md)
3251
+
3252
+ \b
3253
+ Examples:
3254
+ alita execute-test-cases ./agent.json --test-cases-dir ./tests --results-dir ./results
3255
+ alita execute-test-cases ./agent.json --test-cases-dir ./tests --results-dir ./results \
3256
+ --data-generator ./data-gen.json
3257
+ alita execute-test-cases ./agent.json --test-cases-dir ./tests --results-dir ./results \
3258
+ --test-case TC-001.md --test-case TC-002.md
3259
+ alita execute-test-cases ./agent.json --test-cases-dir ./tests --results-dir ./results \
3260
+ --skip-data-generation --model gpt-4o
3261
+ """
3262
+ config = ctx.obj['config']
3263
+ client = get_client(ctx)
3264
+
3265
+ try:
3266
+ # Load agent definition
3267
+ if not Path(agent_source).exists():
3268
+ raise click.ClickException(f"Agent definition not found: {agent_source}")
3269
+
3270
+ agent_def = load_agent_definition(agent_source)
3271
+ agent_name = agent_def.get('name', Path(agent_source).stem)
3272
+
3273
+ # Find all test case files (recursively search subdirectories)
3274
+ test_cases_path = Path(test_cases_dir)
3275
+
3276
+ # Filter test cases based on --test-case options
3277
+ if test_case_files:
3278
+ # User specified specific test case files
3279
+ test_case_files_set = set(test_case_files)
3280
+ all_test_cases = sorted(test_cases_path.rglob('TC-*.md'))
3281
+ test_case_files_list = [
3282
+ tc for tc in all_test_cases
3283
+ if tc.name in test_case_files_set
3284
+ ]
3285
+
3286
+ # Check if all specified files were found
3287
+ found_names = {tc.name for tc in test_case_files_list}
3288
+ not_found = test_case_files_set - found_names
3289
+ if not_found:
3290
+ console.print(f"[yellow]⚠ Warning: Test case files not found: {', '.join(not_found)}[/yellow]")
3291
+ else:
3292
+ # Execute all test cases
3293
+ test_case_files_list = sorted(test_cases_path.rglob('TC-*.md'))
3294
+
3295
+ if not test_case_files_list:
3296
+ if test_case_files:
3297
+ console.print(f"[yellow]No matching test case files found in {test_cases_dir}[/yellow]")
3298
+ else:
3299
+ console.print(f"[yellow]No test case files found in {test_cases_dir}[/yellow]")
3300
+ return
3301
+
3302
+ console.print(f"\n[bold cyan]🧪 Test Execution Started[/bold cyan]")
3303
+ console.print(f"Agent: [bold]{agent_name}[/bold]")
3304
+ console.print(f"Test Cases: {len(test_case_files_list)}")
3305
+ if test_case_files:
3306
+ console.print(f"Selected: [cyan]{', '.join(test_case_files)}[/cyan]")
3307
+ console.print(f"Results Directory: {results_dir}\n")
3308
+
3309
+ data_gen_def = None
3310
+ if data_generator and not skip_data_generation:
3311
+ try:
3312
+ data_gen_def = load_agent_definition(data_generator)
3313
+ data_gen_name = data_gen_def.get('name', Path(data_generator).stem)
3314
+ console.print(f"Data Generator Agent: [bold]{data_gen_name}[/bold]\n")
3315
+ except Exception as e:
3316
+ console.print(f"[yellow]⚠ Warning: Failed to setup data generator: {e}[/yellow]")
3317
+ console.print("[yellow]Continuing with test execution...[/yellow]\n")
3318
+ logger.debug(f"Data generator setup error: {e}", exc_info=True)
3319
+
3320
+ # Track overall results
3321
+ total_tests = 0
3322
+ passed_tests = 0
3323
+ failed_tests = 0
3324
+ test_results = [] # Store structured results for final report
3325
+
3326
+ # Store bulk data generation chat history to pass to test executors
3327
+ bulk_gen_chat_history = []
3328
+
3329
+ # Parse all test cases upfront for bulk data generation
3330
+ parsed_test_cases = []
3331
+ for test_file in test_case_files_list:
3332
+ try:
3333
+ test_case = parse_test_case(str(test_file))
3334
+ parsed_test_cases.append({
3335
+ 'file': test_file,
3336
+ 'data': test_case
3337
+ })
3338
+ except Exception as e:
3339
+ console.print(f"[yellow]⚠ Warning: Failed to parse {test_file.name}: {e}[/yellow]")
3340
+ logger.debug(f"Parse error for {test_file.name}: {e}", exc_info=True)
3341
+
3342
+ # Filter test cases that need data generation
3343
+ test_cases_needing_data_gen = [
3344
+ tc for tc in parsed_test_cases
3345
+ if tc['data'].get('generate_test_data', True)
3346
+ ]
3347
+
3348
+ # Bulk test data generation (if enabled)
3349
+ if data_gen_def and not skip_data_generation and test_cases_needing_data_gen:
3350
+ console.print(f"\n[bold yellow]🔧 Bulk Test Data Generation[/bold yellow]")
3351
+ console.print(f"Generating test data for {len(test_cases_needing_data_gen)} test cases...\n")
3352
+ console.print(f"[dim]Skipping {len(parsed_test_cases) - len(test_cases_needing_data_gen)} test cases with generateTestData: false[/dim]\n")
3353
+
3354
+ bulk_data_gen_prompt = _build_bulk_data_gen_prompt(test_cases_needing_data_gen)
3355
+
3356
+ console.print(f"Executing test data generation prompt {bulk_data_gen_prompt}\n")
3357
+
3358
+ try:
3359
+ # Setup data generator agent
3360
+ from langgraph.checkpoint.sqlite import SqliteSaver
3361
+ bulk_memory = SqliteSaver(sqlite3.connect(":memory:", check_same_thread=False))
3362
+
3363
+ # Use first test case's config or empty tuple
3364
+ first_config_path = None
3365
+ if parsed_test_cases:
3366
+ first_tc = parsed_test_cases[0]
3367
+ first_config_path = resolve_toolkit_config_path(
3368
+ first_tc['data'].get('config_path', ''),
3369
+ first_tc['file'],
3370
+ test_cases_path
3371
+ )
3372
+
3373
+ data_gen_config_tuple = (first_config_path,) if first_config_path else ()
3374
+ data_gen_executor, _, _, _, _, _, _ = _setup_local_agent_executor(
3375
+ client, data_gen_def, data_gen_config_tuple, config,
3376
+ model, temperature, max_tokens, bulk_memory, work_dir
3377
+ )
3378
+
3379
+ if data_gen_executor:
3380
+ with console.status("[yellow]Generating test data for all test cases...[/yellow]", spinner="dots"):
3381
+ bulk_gen_result = data_gen_executor.invoke({
3382
+ "input": bulk_data_gen_prompt,
3383
+ "chat_history": []
3384
+ })
3385
+ bulk_gen_output = extract_output_from_result(bulk_gen_result)
3386
+ console.print(f"[green]✓ Bulk test data generation completed[/green]")
3387
+ console.print(f"[dim]{bulk_gen_output}...[/dim]\n")
3388
+
3389
+ # Store chat history from data generation to pass to test executors
3390
+ bulk_gen_chat_history = [
3391
+ {"role": "user", "content": bulk_data_gen_prompt},
3392
+ {"role": "assistant", "content": bulk_gen_output}
3393
+ ]
3394
+ else:
3395
+ console.print(f"[yellow]⚠ Warning: Data generator has no executor[/yellow]\n")
3396
+ except Exception as e:
3397
+ console.print(f"[yellow]⚠ Warning: Bulk data generation failed: {e}[/yellow]")
3398
+ console.print("[yellow]Continuing with test execution...[/yellow]\n")
3399
+ logger.debug(f"Bulk data generation error: {e}", exc_info=True)
3400
+
3401
+ # Execute ALL test cases in one bulk operation
3402
+ if not parsed_test_cases:
3403
+ console.print("[yellow]No test cases to execute[/yellow]")
3404
+ return
3405
+
3406
+ console.print(f"\n[bold yellow]📋 Executing ALL test cases in bulk...[/bold yellow]\n")
3407
+
3408
+ # Use first test case's config for agent setup
3409
+ first_tc = parsed_test_cases[0]
3410
+ first_test_file = first_tc['file']
3411
+ toolkit_config_path = resolve_toolkit_config_path(
3412
+ first_tc['data'].get('config_path', ''),
3413
+ first_test_file,
3414
+ test_cases_path
3415
+ )
3416
+ toolkit_config_tuple = (toolkit_config_path,) if toolkit_config_path else ()
3417
+
3418
+ # Create memory for bulk execution
3419
+ from langgraph.checkpoint.sqlite import SqliteSaver
3420
+ memory = SqliteSaver(sqlite3.connect(":memory:", check_same_thread=False))
3421
+
3422
+ # Initialize chat history with bulk data generation context
3423
+ chat_history = bulk_gen_chat_history.copy()
3424
+
3425
+ # Setup agent executor
3426
+ agent_executor, _, _, _, _, _, _ = _setup_local_agent_executor(
3427
+ client, agent_def, toolkit_config_tuple, config, model, temperature, max_tokens, memory, work_dir
3428
+ )
3429
+
3430
+ # Build bulk execution prompt
3431
+ bulk_all_prompt = _build_bulk_execution_prompt(parsed_test_cases)
3432
+
3433
+ console.print(f"Executing the prompt: {bulk_all_prompt}\n")
3434
+
3435
+ # Execute all test cases in bulk
3436
+ test_results = []
3437
+ all_execution_output = ""
3438
+
3439
+ try:
3440
+ if agent_executor:
3441
+ with console.status(f"[yellow]Executing {len(parsed_test_cases)} test cases in bulk...[/yellow]", spinner="dots"):
3442
+ bulk_result = agent_executor.invoke({
3443
+ "input": bulk_all_prompt,
3444
+ "chat_history": chat_history
3445
+ })
3446
+ all_execution_output = extract_output_from_result(bulk_result)
3447
+
3448
+ console.print(f"[green]✓ All test cases executed[/green]")
3449
+ console.print(f"[dim]{all_execution_output}...[/dim]\n")
3450
+
3451
+ # Update chat history
3452
+ chat_history.append({"role": "user", "content": bulk_all_prompt})
3453
+ chat_history.append({"role": "assistant", "content": all_execution_output})
3454
+
3455
+ # Now validate ALL test cases in bulk
3456
+ console.print(f"[bold yellow]✅ Validating all test cases...[/bold yellow]\n")
3457
+
3458
+ validation_prompt = _build_validation_prompt(parsed_test_cases, all_execution_output)
3459
+
3460
+ console.print(f"[dim]{validation_prompt}[/dim]\n")
3461
+
3462
+ with console.status("[yellow]Validating all results...[/yellow]", spinner="dots"):
3463
+ validation_result = agent_executor.invoke({
3464
+ "input": validation_prompt,
3465
+ "chat_history": chat_history
3466
+ })
3467
+
3468
+ validation_output = extract_output_from_result(validation_result)
3469
+
3470
+ console.print(f"[dim]Validation Response: {validation_output}...[/dim]\n")
3471
+
3472
+ # Parse validation JSON
3473
+ try:
3474
+ validation_json = _extract_json_from_text(validation_output)
3475
+ test_cases_results = validation_json.get('test_cases', [])
3476
+
3477
+ # Process results for each test case
3478
+ total_tests = 0
3479
+ passed_tests = 0
3480
+ failed_tests = 0
3481
+
3482
+ for tc_result in test_cases_results:
3483
+ test_name = tc_result.get('test_name', f"Test #{tc_result.get('test_number', '?')}")
3484
+ step_results = tc_result.get('steps', [])
3485
+
3486
+ # Determine if test passed (all steps must pass)
3487
+ test_passed = all(step.get('passed', False) for step in step_results) if step_results else False
3488
+
3489
+ total_tests += 1
3490
+ if test_passed:
3491
+ passed_tests += 1
3492
+ console.print(f"[bold green]✅ Test PASSED: {test_name}[/bold green]")
3493
+ else:
3494
+ failed_tests += 1
3495
+ console.print(f"[bold red]❌ Test FAILED: {test_name}[/bold red]")
3496
+
3497
+ # Display individual step results
3498
+ for step_result in step_results:
3499
+ step_num = step_result.get('step_number')
3500
+ step_title = step_result.get('title', '')
3501
+ passed = step_result.get('passed', False)
3502
+ details = step_result.get('details', '')
3503
+
3504
+ if passed:
3505
+ console.print(f" [green]✓ Step {step_num}: {step_title}[/green]")
3506
+ console.print(f" [dim]{details}[/dim]")
3507
+ else:
3508
+ console.print(f" [red]✗ Step {step_num}: {step_title}[/red]")
3509
+ console.print(f" [dim]{details}[/dim]")
3510
+
3511
+ console.print()
3512
+
3513
+ # Store result
3514
+ test_results.append({
3515
+ 'title': test_name,
3516
+ 'passed': test_passed,
3517
+ 'file': parsed_test_cases[tc_result.get('test_number', 1) - 1]['file'].name if tc_result.get('test_number', 1) - 1 < len(parsed_test_cases) else 'unknown',
3518
+ 'step_results': step_results
3519
+ })
3520
+
3521
+ except Exception as e:
3522
+ logger.debug(f"Validation parsing failed: {e}")
3523
+ console.print(f"[yellow]⚠ Warning: Could not parse validation results: {e}[/yellow]\n")
3524
+ test_results, total_tests, passed_tests, failed_tests = _create_fallback_results(parsed_test_cases)
3525
+ else:
3526
+ console.print(f"[red]✗ No agent executor available[/red]\n")
3527
+ test_results, total_tests, passed_tests, failed_tests = _create_fallback_results(parsed_test_cases)
3528
+
3529
+ except Exception as e:
3530
+ console.print(f"[red]✗ Bulk execution failed: {e}[/red]\n")
3531
+ logger.debug(f"Bulk execution error: {e}", exc_info=True)
3532
+ test_results, total_tests, passed_tests, failed_tests = _create_fallback_results(parsed_test_cases)
3533
+
3534
+ # Generate summary report
3535
+ console.print(f"\n[bold]{'='*60}[/bold]")
3536
+ console.print(f"[bold cyan]📊 Test Execution Summary[/bold cyan]")
3537
+ console.print(f"[bold]{'='*60}[/bold]\n")
3538
+
3539
+ summary_table = Table(box=box.ROUNDED, border_style="cyan")
3540
+ summary_table.add_column("Metric", style="bold")
3541
+ summary_table.add_column("Value", justify="right")
3542
+
3543
+ summary_table.add_row("Total Tests", str(total_tests))
3544
+ summary_table.add_row("Passed", f"[green]{passed_tests}[/green]")
3545
+ summary_table.add_row("Failed", f"[red]{failed_tests}[/red]")
3546
+
3547
+ if total_tests > 0:
3548
+ pass_rate = (passed_tests / total_tests) * 100
3549
+ summary_table.add_row("Pass Rate", f"{pass_rate:.1f}%")
3550
+
3551
+ console.print(summary_table)
3552
+
3553
+ # Generate structured JSON report
3554
+ overall_result = "pass" if failed_tests == 0 else "fail"
3555
+
3556
+ structured_report = {
3557
+ "test_cases": [
3558
+ {
3559
+ "title": r['title'],
3560
+ "passed": r['passed'],
3561
+ "steps": r.get('step_results', [])
3562
+ }
3563
+ for r in test_results
3564
+ ],
3565
+ "overall_result": overall_result,
3566
+ "summary": {
3567
+ "total_tests": total_tests,
3568
+ "passed": passed_tests,
3569
+ "failed": failed_tests,
3570
+ "pass_rate": f"{pass_rate:.1f}%" if total_tests > 0 else "0%"
3571
+ },
3572
+ "timestamp": datetime.now().isoformat()
3573
+ }
3574
+
3575
+ # Save structured report
3576
+ results_path = Path(results_dir)
3577
+ results_path.mkdir(parents=True, exist_ok=True)
3578
+ summary_file = results_path / "test_execution_summary.json"
3579
+
3580
+ console.print(f"\n[bold yellow]💾 Saving test execution summary...[/bold yellow]")
3581
+ with open(summary_file, 'w') as f:
3582
+ json.dump(structured_report, f, indent=2)
3583
+ console.print(f"[green]✓ Summary saved to {summary_file}[/green]\n")
3584
+
3585
+ # Exit with error code if any tests failed
3586
+ if failed_tests > 0:
3587
+ sys.exit(1)
3588
+
3589
+ except click.ClickException:
3590
+ raise
3591
+ except Exception as e:
3592
+ logger.exception("Failed to execute test cases")
3593
+ error_panel = Panel(
3594
+ str(e),
3595
+ title="Error",
3596
+ border_style="red",
3597
+ box=box.ROUNDED
3598
+ )
3599
+ console.print(error_panel, style="red")
3600
+ raise click.Abort()
3601
+