atlas-chat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,789 @@
1
+ """
2
+ Tool execution utilities - pure functions for tool operations.
3
+
4
+ This module provides stateless utility functions for handling tool execution,
5
+ argument processing, and synthesis decisions without maintaining any state.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
12
+
13
+ from atlas.core.capabilities import create_download_url
14
+ from atlas.domain.messages.models import ToolCall, ToolResult
15
+ from atlas.interfaces.llm import LLMResponse
16
+ from atlas.modules.mcp_tools.token_storage import AuthenticationRequiredException
17
+
18
+ from ..approval_manager import get_approval_manager
19
+ from .event_notifier import _sanitize_filename_value # reuse same filename sanitizer for UI args
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def _try_repair_json(raw: str) -> Optional[Dict[str, Any]]:
25
+ """Attempt to repair truncated JSON from LLM tool arguments.
26
+
27
+ Common cases: missing opening/closing braces, trailing quote.
28
+ Returns parsed dict on success, None on failure.
29
+ """
30
+ s = raw.strip()
31
+ # Add missing braces
32
+ if not s.startswith("{"):
33
+ s = "{" + s
34
+ if not s.endswith("}"):
35
+ s = s + "}"
36
+ try:
37
+ result = json.loads(s)
38
+ if isinstance(result, dict):
39
+ return result
40
+ except Exception:
41
+ pass
42
+ # Try closing an open string value: e.g. {"expression": "355/113
43
+ if s.count('"') % 2 != 0:
44
+ s = s.rstrip("}") + '"}'
45
+ try:
46
+ result = json.loads(s)
47
+ if isinstance(result, dict):
48
+ return result
49
+ except Exception:
50
+ pass
51
+ return None
52
+
53
+
54
+ # Type hint for update callback
55
+ UpdateCallback = Callable[[Dict[str, Any]], Awaitable[None]]
56
+
57
+
58
+ async def execute_tools_workflow(
59
+ llm_response: LLMResponse,
60
+ messages: List[Dict],
61
+ model: str,
62
+ session_context: Dict[str, Any],
63
+ tool_manager,
64
+ llm_caller,
65
+ prompt_provider,
66
+ update_callback: Optional[UpdateCallback] = None,
67
+ config_manager=None,
68
+ skip_approval: bool = False,
69
+ user_email: Optional[str] = None,
70
+ ) -> tuple[str, List[ToolResult]]:
71
+ """
72
+ Execute the complete tools workflow: calls -> results -> synthesis.
73
+
74
+ Pure function that coordinates tool execution without maintaining state.
75
+ """
76
+ logger.debug("Entering execute_tools_workflow")
77
+ # Add assistant message with tool calls
78
+ messages.append({
79
+ "role": "assistant",
80
+ "content": llm_response.content,
81
+ "tool_calls": llm_response.tool_calls
82
+ })
83
+
84
+ # Execute all tool calls
85
+ tool_results: List[ToolResult] = []
86
+ for tool_call in llm_response.tool_calls:
87
+ result = await execute_single_tool(
88
+ tool_call=tool_call,
89
+ session_context=session_context,
90
+ tool_manager=tool_manager,
91
+ update_callback=update_callback,
92
+ config_manager=config_manager,
93
+ skip_approval=skip_approval,
94
+ )
95
+ tool_results.append(result)
96
+
97
+ # Add tool results to messages
98
+ for result in tool_results:
99
+ messages.append({
100
+ "role": "tool",
101
+ "content": result.content,
102
+ "tool_call_id": result.tool_call_id
103
+ })
104
+
105
+ # Determine if synthesis is needed
106
+ final_response = await handle_synthesis_decision(
107
+ llm_response=llm_response,
108
+ messages=messages,
109
+ model=model,
110
+ session_context=session_context,
111
+ llm_caller=llm_caller,
112
+ prompt_provider=prompt_provider,
113
+ update_callback=update_callback,
114
+ user_email=user_email,
115
+ )
116
+
117
+ return final_response, tool_results
118
+
119
+
120
+ def requires_approval(tool_name: str, config_manager) -> tuple[bool, bool, bool]:
121
+ """
122
+ Check if a tool requires approval before execution.
123
+
124
+ Args:
125
+ tool_name: Name of the tool to check
126
+ config_manager: ConfigManager instance (can be None)
127
+
128
+ Returns:
129
+ Tuple of (requires_approval, allow_edit, admin_required)
130
+ - requires_approval: Whether approval is needed (always True)
131
+ - allow_edit: Whether arguments can be edited (always True)
132
+ - admin_required: Whether this is admin-mandated (True) or user-level (False)
133
+
134
+ Admin-required (True) means user CANNOT toggle auto-approve:
135
+ - FORCE_TOOL_APPROVAL_GLOBALLY=true
136
+ - Per-tool require_approval=true in mcp.json
137
+
138
+ User-level (False) means user CAN toggle auto-approve via inline UI:
139
+ - All other cases (including REQUIRE_TOOL_APPROVAL_BY_DEFAULT)
140
+ """
141
+ if config_manager is None:
142
+ return (True, True, False) # Default to requiring user-level approval
143
+
144
+ try:
145
+ # Global override: force approval for all tools (admin-enforced)
146
+ app_settings = getattr(config_manager, "app_settings", None)
147
+ force_flag = False
148
+ if app_settings is not None:
149
+ raw_force = getattr(app_settings, "force_tool_approval_globally", False)
150
+ force_flag = (isinstance(raw_force, bool) and raw_force is True)
151
+ if force_flag:
152
+ return (True, True, True)
153
+
154
+ approvals_config = config_manager.tool_approvals_config
155
+
156
+ # Per-tool explicit requirement (admin-enforced)
157
+ if tool_name in approvals_config.tools:
158
+ tool_config = approvals_config.tools[tool_name]
159
+ # Only treat as admin-required if explicitly required
160
+ if getattr(tool_config, "require_approval", False):
161
+ return (True, True, True)
162
+ # Explicit false falls through to default behavior
163
+
164
+ # Default requirement: user-level regardless of default setting
165
+ # Users can always toggle auto-approve via inline UI unless admin explicitly requires it
166
+ return (True, True, False)
167
+
168
+ except Exception as e:
169
+ logger.warning(f"Error checking approval requirements for {tool_name}: {e}")
170
+ return (True, True, False) # Default to user-level approval on error
171
+
172
+
173
+ def tool_accepts_mcp_data(tool_name: str, tool_manager) -> bool:
174
+ """
175
+ Check if a tool accepts an _mcp_data parameter by examining its schema.
176
+
177
+ Returns True if the tool schema defines an '_mcp_data' parameter, False otherwise.
178
+ """
179
+ if not tool_name or not tool_manager:
180
+ return False
181
+
182
+ try:
183
+ tools_schema = tool_manager.get_tools_schema([tool_name])
184
+ if not tools_schema:
185
+ return False
186
+
187
+ for tool_schema in tools_schema:
188
+ if tool_schema.get("function", {}).get("name") == tool_name:
189
+ parameters = tool_schema.get("function", {}).get("parameters", {})
190
+ properties = parameters.get("properties", {})
191
+ return "_mcp_data" in properties
192
+
193
+ return False
194
+ except Exception as e:
195
+ logger.warning(f"Could not determine if tool {tool_name} accepts _mcp_data: {e}")
196
+ return False
197
+
198
+
199
+ def build_mcp_data(tool_manager) -> Dict[str, Any]:
200
+ """
201
+ Build structured metadata about all available MCP tools for injection.
202
+
203
+ Returns a dict with server and tool information that planning tools
204
+ can use to reason about available capabilities.
205
+ """
206
+ available_servers = []
207
+
208
+ if not tool_manager or not hasattr(tool_manager, "available_tools"):
209
+ return {"available_servers": available_servers}
210
+
211
+ for server_name, server_data in tool_manager.available_tools.items():
212
+ if server_name == "canvas":
213
+ continue
214
+
215
+ tools_list = server_data.get("tools", []) or []
216
+ config = server_data.get("config", {}) or {}
217
+
218
+ tools_info = []
219
+ for tool in tools_list:
220
+ tool_entry = {
221
+ "name": f"{server_name}_{tool.name}",
222
+ "description": getattr(tool, "description", "") or "",
223
+ "parameters": getattr(tool, "inputSchema", {}) or {},
224
+ }
225
+ tools_info.append(tool_entry)
226
+
227
+ server_entry = {
228
+ "server_name": server_name,
229
+ "description": config.get("description", "") or config.get("short_description", "") or "",
230
+ "tools": tools_info,
231
+ }
232
+ available_servers.append(server_entry)
233
+
234
+ return {"available_servers": available_servers}
235
+
236
+
237
+ def tool_accepts_username(tool_name: str, tool_manager) -> bool:
238
+ """
239
+ Check if a tool accepts a username parameter by examining its schema.
240
+
241
+ Returns True if the tool schema defines a 'username' parameter, False otherwise.
242
+ """
243
+ if not tool_name or not tool_manager:
244
+ return False
245
+
246
+ try:
247
+ # Get the tool schema for this specific tool
248
+ tools_schema = tool_manager.get_tools_schema([tool_name])
249
+ if not tools_schema:
250
+ return False
251
+
252
+ # Find the schema for our specific tool
253
+ for tool_schema in tools_schema:
254
+ if tool_schema.get("function", {}).get("name") == tool_name:
255
+ # Check if username is in the parameters
256
+ parameters = tool_schema.get("function", {}).get("parameters", {})
257
+ properties = parameters.get("properties", {})
258
+ return "username" in properties
259
+
260
+ return False
261
+ except Exception as e:
262
+ logger.warning(f"Could not determine if tool {tool_name} accepts username: {e}")
263
+ return False # Default to not injecting if we can't determine
264
+
265
+
266
+ async def execute_single_tool(
267
+ tool_call,
268
+ session_context: Dict[str, Any],
269
+ tool_manager,
270
+ update_callback: Optional[UpdateCallback] = None,
271
+ config_manager=None,
272
+ skip_approval: bool = False,
273
+ ) -> ToolResult:
274
+ """
275
+ Execute a single tool with argument preparation and error handling.
276
+
277
+ Pure function that doesn't maintain state - all context passed as parameters.
278
+ """
279
+ logger.debug("Entering execute_single_tool")
280
+ from . import event_notifier
281
+
282
+ try:
283
+ # Prepare arguments with injections (username, filename URL mapping)
284
+ parsed_args = prepare_tool_arguments(tool_call, session_context, tool_manager)
285
+
286
+ # Filter to only schema-declared parameters so MCP tools don't receive extras
287
+ filtered_args = _filter_args_to_schema(parsed_args, tool_call.function.name, tool_manager)
288
+
289
+ # Sanitize arguments for UI (hide tokens in URLs, etc.)
290
+ display_args = _sanitize_args_for_ui(dict(filtered_args))
291
+
292
+ # Check if this tool requires approval
293
+ needs_approval = False
294
+ allow_edit = True
295
+ admin_required = False
296
+ if skip_approval:
297
+ needs_approval = False
298
+ elif config_manager:
299
+ needs_approval, allow_edit, admin_required = requires_approval(tool_call.function.name, config_manager)
300
+ else:
301
+ # No config manager means user-level approval by default
302
+ needs_approval = True
303
+ allow_edit = True
304
+ admin_required = False
305
+
306
+ # Track if arguments were edited (for LLM context)
307
+ arguments_were_edited = False
308
+ original_display_args = dict(display_args) if isinstance(display_args, dict) else display_args
309
+
310
+ # If approval is required, request it from the user
311
+ if needs_approval:
312
+ logger.info(f"Tool {tool_call.function.name} requires approval (admin_required={admin_required})")
313
+
314
+ # Send approval request to frontend
315
+ if update_callback:
316
+ await update_callback({
317
+ "type": "tool_approval_request",
318
+ "tool_call_id": tool_call.id,
319
+ "tool_name": tool_call.function.name,
320
+ "arguments": display_args,
321
+ "allow_edit": allow_edit,
322
+ "admin_required": admin_required
323
+ })
324
+
325
+ # Wait for approval response
326
+ approval_manager = get_approval_manager()
327
+ request = approval_manager.create_approval_request(
328
+ tool_call.id,
329
+ tool_call.function.name,
330
+ filtered_args,
331
+ allow_edit
332
+ )
333
+
334
+ try:
335
+ response = await request.wait_for_response(timeout=300.0)
336
+ approval_manager.cleanup_request(tool_call.id)
337
+
338
+ if not response["approved"]:
339
+ # Tool was rejected
340
+ reason = response.get("reason", "User rejected the tool call")
341
+ logger.info(f"Tool {tool_call.function.name} rejected by user: {reason}")
342
+ return ToolResult(
343
+ tool_call_id=tool_call.id,
344
+ content=f"Tool execution rejected by user: {reason}",
345
+ success=False,
346
+ error=reason
347
+ )
348
+
349
+ # Use potentially edited arguments
350
+ if allow_edit and response.get("arguments"):
351
+ edited_args = response["arguments"]
352
+ # Check if arguments actually changed by comparing with what we sent (display_args)
353
+ # Use json comparison to avoid false positives from dict ordering
354
+ if json.dumps(edited_args, sort_keys=True) != json.dumps(original_display_args, sort_keys=True):
355
+ arguments_were_edited = True
356
+ logger.info(f"User edited arguments for tool {tool_call.function.name}")
357
+
358
+ # SECURITY: Re-apply security injections after user edits
359
+ # This ensures username and other security-critical parameters cannot be tampered with
360
+ re_injected_args = inject_context_into_args(
361
+ edited_args,
362
+ session_context,
363
+ tool_call.function.name,
364
+ tool_manager
365
+ )
366
+
367
+ # Re-filter to schema to ensure only valid parameters
368
+ filtered_args = _filter_args_to_schema(
369
+ re_injected_args,
370
+ tool_call.function.name,
371
+ tool_manager
372
+ )
373
+ else:
374
+ # No actual changes, but response included arguments - keep original filtered_args
375
+ logger.debug(f"Arguments returned unchanged for tool {tool_call.function.name}")
376
+
377
+ except asyncio.TimeoutError:
378
+ approval_manager.cleanup_request(tool_call.id)
379
+ logger.warning(f"Approval timeout for tool {tool_call.function.name}")
380
+ return ToolResult(
381
+ tool_call_id=tool_call.id,
382
+ content="Tool execution timed out waiting for user approval",
383
+ success=False,
384
+ error="Approval timeout"
385
+ )
386
+
387
+ # Send tool start notification with sanitized args
388
+ await event_notifier.notify_tool_start(tool_call, display_args, update_callback)
389
+
390
+ # Create tool call object and execute with filtered args only
391
+ tool_call_obj = ToolCall(
392
+ id=tool_call.id,
393
+ name=tool_call.function.name,
394
+ arguments=filtered_args
395
+ )
396
+
397
+ result = await tool_manager.execute_tool(
398
+ tool_call_obj,
399
+ context={
400
+ "session_id": session_context.get("session_id"),
401
+ "user_email": session_context.get("user_email"),
402
+ # pass update callback so MCP client can emit progress
403
+ "update_callback": update_callback,
404
+ }
405
+ )
406
+
407
+ # If arguments were edited, prepend a note to the result for LLM context
408
+ if arguments_were_edited:
409
+ edit_note = (
410
+ f"[IMPORTANT: The user manually edited the tool arguments before execution. "
411
+ f"Security-critical parameters (like username) were re-injected by the system and cannot be modified. "
412
+ f"The ACTUAL arguments executed were: {json.dumps(filtered_args)}. "
413
+ f"Your response must reflect these arguments as the user's true intent.]\\n\\n"
414
+ )
415
+ if isinstance(result.content, str):
416
+ result.content = edit_note + result.content
417
+ else:
418
+ # If content is not a string, convert and prepend
419
+ result.content = edit_note + str(result.content)
420
+
421
+ # Send tool complete notification
422
+ await event_notifier.notify_tool_complete(tool_call, result, parsed_args, update_callback)
423
+
424
+ return result
425
+
426
+ except AuthenticationRequiredException as auth_err:
427
+ # Special handling for authentication required - send OAuth redirect info
428
+ logger.info(f"Tool {tool_call.function.name} requires authentication for server {auth_err.server_name}")
429
+
430
+ # Send authentication required notification with OAuth URL
431
+ if update_callback:
432
+ await update_callback({
433
+ "type": "auth_required",
434
+ "tool_call_id": tool_call.id,
435
+ "tool_name": tool_call.function.name,
436
+ "server_name": auth_err.server_name,
437
+ "auth_type": auth_err.auth_type,
438
+ "oauth_start_url": auth_err.oauth_start_url,
439
+ "message": auth_err.message,
440
+ })
441
+
442
+ # Return error result with auth info
443
+ return ToolResult(
444
+ tool_call_id=tool_call.id,
445
+ content=f"Authentication required: {auth_err.message}",
446
+ success=False,
447
+ error=str(auth_err),
448
+ meta_data={
449
+ "auth_required": True,
450
+ "server_name": auth_err.server_name,
451
+ "auth_type": auth_err.auth_type,
452
+ "oauth_start_url": auth_err.oauth_start_url,
453
+ }
454
+ )
455
+
456
+ except Exception as e:
457
+ logger.error(f"Error executing tool {tool_call.function.name}: {e}")
458
+
459
+ # Send tool error notification
460
+ await event_notifier.notify_tool_error(tool_call, str(e), update_callback)
461
+
462
+ # Return error result instead of raising
463
+ return ToolResult(
464
+ tool_call_id=tool_call.id,
465
+ content=f"Tool execution failed: {str(e)}",
466
+ success=False,
467
+ error=str(e)
468
+ )
469
+
470
+
471
+ def _filter_args_to_schema(parsed_args: Dict[str, Any], tool_name: str, tool_manager) -> Dict[str, Any]:
472
+ """Return only arguments that are explicitly declared in the tool schema.
473
+
474
+ If schema can't be retrieved, fall back to dropping known injected extras
475
+ like original_* and file_url(s) to avoid Pydantic validation errors.
476
+ """
477
+ try:
478
+ tools_schema = tool_manager.get_tools_schema([tool_name]) if tool_manager else []
479
+ found_schema = False
480
+ allowed: set[str] = set()
481
+ for tool_schema in tools_schema or []:
482
+ if tool_schema.get("function", {}).get("name") == tool_name:
483
+ params = tool_schema.get("function", {}).get("parameters", {})
484
+ props = params.get("properties", {}) or {}
485
+ allowed = set(props.keys())
486
+ found_schema = True
487
+ break
488
+
489
+ # If we found the tool's schema, filter to allowed keys only
490
+ # (even if allowed is empty - meaning no parameters expected)
491
+ if found_schema:
492
+ return {k: v for k, v in (parsed_args or {}).items() if k in allowed}
493
+ except Exception:
494
+ # Fall through to conservative filtering
495
+ pass
496
+
497
+ # Conservative fallback: drop common injected extras if schema unavailable
498
+ drop_prefixes = ("original_",)
499
+ drop_keys = {"file_url", "file_urls"}
500
+ return {k: v for k, v in (parsed_args or {}).items()
501
+ if not any(k.startswith(p) for p in drop_prefixes) and k not in drop_keys}
502
+
503
+
504
+ def _sanitize_args_for_ui(args: Dict[str, Any]) -> Dict[str, Any]:
505
+ """Sanitize arguments before emitting to UI.
506
+
507
+ - Reduce any filename(s) to clean basenames (no query/token, no internal prefixes)
508
+ - Avoid leaking full download URLs or tokens to regular users in the chat UI
509
+ """
510
+ cleaned = dict(args or {})
511
+
512
+ # Single filename
513
+ if isinstance(cleaned.get("filename"), str):
514
+ cleaned["filename"] = _sanitize_filename_value(cleaned["filename"]) # basename only
515
+
516
+ # Multiple filenames
517
+ if isinstance(cleaned.get("file_names"), list):
518
+ cleaned["file_names"] = [
519
+ _sanitize_filename_value(x) if isinstance(x, str) else x
520
+ for x in cleaned["file_names"]
521
+ ]
522
+
523
+ # If a tool schema (unexpectedly) exposes file_url(s), sanitize for display too
524
+ if isinstance(cleaned.get("file_url"), str):
525
+ cleaned["file_url"] = _sanitize_filename_value(cleaned["file_url"]) # show just name
526
+ if isinstance(cleaned.get("file_urls"), list):
527
+ cleaned["file_urls"] = [
528
+ _sanitize_filename_value(x) if isinstance(x, str) else x
529
+ for x in cleaned["file_urls"]
530
+ ]
531
+
532
+ return cleaned
533
+
534
+
535
+ def prepare_tool_arguments(tool_call, session_context: Dict[str, Any], tool_manager=None) -> Dict[str, Any]:
536
+ """
537
+ Process and prepare tool arguments with all injections and transformations.
538
+
539
+ Pure function that transforms arguments based on context and tool schema.
540
+ """
541
+ logger.debug("Entering prepare_tool_arguments")
542
+ # Parse raw arguments
543
+ raw_args = getattr(tool_call.function, "arguments", {})
544
+ if isinstance(raw_args, dict):
545
+ parsed_args = raw_args
546
+ else:
547
+ if raw_args is None or raw_args == "":
548
+ parsed_args = {}
549
+ else:
550
+ try:
551
+ parsed_args = json.loads(raw_args)
552
+ if not isinstance(parsed_args, dict):
553
+ parsed_args = {"_value": parsed_args}
554
+ except Exception:
555
+ # Attempt to repair truncated JSON (e.g., missing braces)
556
+ repaired = _try_repair_json(raw_args)
557
+ if repaired is not None:
558
+ logger.info(
559
+ "Repaired truncated tool arguments for %s",
560
+ getattr(tool_call.function, "name", "<unknown>"),
561
+ )
562
+ parsed_args = repaired
563
+ else:
564
+ logger.warning(
565
+ "Failed to parse tool arguments as JSON for %s, using empty dict. Raw: %r",
566
+ getattr(tool_call.function, "name", "<unknown>"), raw_args
567
+ )
568
+ parsed_args = {}
569
+
570
+ # Inject username and file URL mappings with schema awareness
571
+ return inject_context_into_args(parsed_args, session_context, tool_call.function.name, tool_manager)
572
+
573
+
574
+ def inject_context_into_args(parsed_args: Dict[str, Any], session_context: Dict[str, Any], tool_name: str = None, tool_manager=None) -> Dict[str, Any]:
575
+ """
576
+ Inject username and file URL mappings into tool arguments.
577
+
578
+ Pure function that adds context without side effects.
579
+ Only injects username if the tool schema defines a username parameter.
580
+
581
+ If BACKEND_PUBLIC_URL is configured, uses absolute URLs for file downloads.
582
+ If INCLUDE_FILE_CONTENT_BASE64 is enabled, also injects base64 content as fallback.
583
+ """
584
+ if not isinstance(parsed_args, dict):
585
+ return parsed_args
586
+
587
+ try:
588
+ # Inject username. Prefer schema-aware injection; if schema unavailable,
589
+ # include username by default to support tools that expect it.
590
+ user_email = session_context.get("user_email")
591
+ if user_email and (not tool_manager or tool_accepts_username(tool_name, tool_manager)):
592
+ parsed_args["username"] = user_email
593
+
594
+ # Inject _mcp_data if the tool schema declares it
595
+ if tool_manager and tool_accepts_mcp_data(tool_name, tool_manager):
596
+ parsed_args["_mcp_data"] = build_mcp_data(tool_manager)
597
+
598
+ # Provide URL hints for filename/file_names fields
599
+ files_ctx = session_context.get("files", {})
600
+
601
+ # Check if base64 content injection is enabled
602
+ include_base64 = False
603
+ try:
604
+ from atlas.modules.config import config_manager
605
+ settings = config_manager.app_settings
606
+ include_base64 = getattr(settings, "include_file_content_base64", False)
607
+ except Exception as e:
608
+ logger.debug(f"Could not check include_file_content_base64 setting: {e}")
609
+
610
+ def to_url(key: str) -> str:
611
+ # Use tokenized URL so tools can fetch without cookies
612
+ return create_download_url(key, user_email)
613
+
614
+ async def get_file_base64(key: str) -> Optional[str]:
615
+ """Fetch base64 content for a file key."""
616
+ try:
617
+ # Get file manager from session context or use global
618
+ file_manager = session_context.get("file_manager")
619
+ if not file_manager:
620
+ from atlas.infrastructure.app_factory import get_file_storage
621
+ file_manager = get_file_storage()
622
+
623
+ if file_manager and user_email:
624
+ file_data = await file_manager.get_file(user_email, key)
625
+ return file_data.get("content_base64") if file_data else None
626
+ except Exception as e:
627
+ logger.warning(f"Failed to fetch base64 content for file key {key}: {e}")
628
+ return None
629
+
630
+ # Handle single filename
631
+ if "filename" in parsed_args and isinstance(parsed_args["filename"], str):
632
+ fname = parsed_args["filename"]
633
+ ref = files_ctx.get(fname)
634
+ if ref and ref.get("key"):
635
+ url = to_url(ref["key"])
636
+ # SECURITY: tokenized URLs can contain secrets; do not log them.
637
+ logger.debug(
638
+ "Rewriting filename argument to tokenized URL (filename=%s)",
639
+ _sanitize_filename_value(fname),
640
+ )
641
+ parsed_args.setdefault("original_filename", fname)
642
+ parsed_args["filename"] = url
643
+ parsed_args.setdefault("file_url", url)
644
+
645
+ # Optionally inject base64 content as fallback
646
+ if include_base64:
647
+ # Note: We can't make this function async, so we mark this for future enhancement
648
+ # For now, just log that this feature requires additional integration
649
+ logger.debug(
650
+ "Base64 content injection requested but requires async context (filename=%s)",
651
+ _sanitize_filename_value(fname),
652
+ )
653
+ # TODO: Implement async context support for base64 injection
654
+ # For now, tools should use the URL-based approach
655
+
656
+ # Handle multiple filenames
657
+ if "file_names" in parsed_args and isinstance(parsed_args["file_names"], list):
658
+ urls = []
659
+ originals = []
660
+ for fname in parsed_args["file_names"]:
661
+ if not isinstance(fname, str):
662
+ continue
663
+ originals.append(fname)
664
+ ref = files_ctx.get(fname)
665
+ if ref and ref.get("key"):
666
+ urls.append(to_url(ref["key"]))
667
+ else:
668
+ urls.append(fname)
669
+ if urls:
670
+ logger.debug("Rewriting file_names arguments to tokenized URLs (count=%d)", len(urls))
671
+ parsed_args.setdefault("original_file_names", originals)
672
+ parsed_args["file_names"] = urls
673
+ parsed_args.setdefault("file_urls", urls)
674
+
675
+ except Exception as inj_err:
676
+ logger.warning(f"Non-fatal: failed to inject tool args: {inj_err}")
677
+
678
+ return parsed_args
679
+
680
+
681
+ async def handle_synthesis_decision(
682
+ llm_response: LLMResponse,
683
+ messages: List[Dict[str, Any]],
684
+ model: str,
685
+ session_context: Dict[str, Any],
686
+ llm_caller,
687
+ prompt_provider,
688
+ update_callback: Optional[UpdateCallback] = None,
689
+ user_email: Optional[str] = None,
690
+ ) -> str:
691
+ """
692
+ Decide whether synthesis is needed and execute accordingly.
693
+
694
+ Pure function that doesn't maintain state.
695
+ """
696
+ # Check if we have only canvas tools
697
+ canvas_tool_calls = [tc for tc in llm_response.tool_calls if tc.function.name == "canvas_canvas"]
698
+ has_only_canvas_tools = len(canvas_tool_calls) == len(llm_response.tool_calls)
699
+
700
+ if has_only_canvas_tools:
701
+ # Canvas tools don't need follow-up
702
+ return llm_response.content or "Content displayed in canvas."
703
+
704
+ # Add updated files manifest before synthesis
705
+ files_manifest = build_files_manifest(session_context)
706
+ if files_manifest:
707
+ updated_manifest = {
708
+ "role": "system",
709
+ "content": (
710
+ "Available session files (updated after tool runs):\n"
711
+ f"{files_manifest['content'].split('Available session files:')[1].split('(You can ask')[0].strip()}\n\n"
712
+ "(You can ask to open or analyze any of these by name.)"
713
+ )
714
+ }
715
+ messages.append(updated_manifest)
716
+
717
+ # Get final synthesis
718
+ return await synthesize_tool_results(
719
+ model=model,
720
+ messages=messages,
721
+ llm_caller=llm_caller,
722
+ prompt_provider=prompt_provider,
723
+ update_callback=update_callback,
724
+ user_email=user_email,
725
+ )
726
+
727
+
728
+ async def synthesize_tool_results(
729
+ model: str,
730
+ messages: List[Dict[str, Any]],
731
+ llm_caller,
732
+ prompt_provider,
733
+ update_callback: Optional[UpdateCallback] = None,
734
+ user_email: Optional[str] = None,
735
+ ) -> str:
736
+ """
737
+ Prepare augmented messages with synthesis prompt and obtain final answer.
738
+
739
+ Pure function that coordinates LLM call for synthesis.
740
+ """
741
+ # Extract latest user question (walk backwards)
742
+ user_question = ""
743
+ for m in reversed(messages):
744
+ if m.get("role") == "user" and m.get("content"):
745
+ user_question = m["content"]
746
+ break
747
+
748
+ prompt_text = None
749
+ if prompt_provider:
750
+ prompt_text = prompt_provider.get_tool_synthesis_prompt(user_question or "the user's last request")
751
+
752
+ synthesis_messages = list(messages)
753
+ if prompt_text:
754
+ synthesis_messages.append({
755
+ "role": "system",
756
+ "content": prompt_text
757
+ })
758
+ else:
759
+ logger.info("Proceeding without dedicated tool synthesis prompt (fallback)")
760
+
761
+ final_response = await llm_caller.call_plain(model, synthesis_messages, user_email=user_email)
762
+
763
+ # Do not emit a separate 'tool_synthesis' assistant-visible event here.
764
+ # The chat service will emit a single 'chat_response' for the final answer
765
+ # to avoid duplicate assistant messages in the UI.
766
+
767
+ return final_response
768
+
769
+
770
+ def build_files_manifest(session_context: Dict[str, Any]) -> Optional[Dict[str, str]]:
771
+ """
772
+ Build ephemeral files manifest for LLM context.
773
+
774
+ Pure function that creates manifest from session context.
775
+ """
776
+ files_ctx = session_context.get("files", {})
777
+ if not files_ctx:
778
+ return None
779
+
780
+ file_list = "\n".join(f"- {name}" for name in sorted(files_ctx.keys()))
781
+ return {
782
+ "role": "system",
783
+ "content": (
784
+ "Available session files:\n"
785
+ f"{file_list}\n\n"
786
+ "(You can ask to open or analyze any of these by name. "
787
+ "Large contents are not fully in this prompt unless user or tools provided excerpts.)"
788
+ )
789
+ }