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,613 @@
1
+ """
2
+ File management utilities - pure functions for session file operations.
3
+
4
+ This module provides stateless utility functions for handling files within
5
+ chat sessions, including user uploads and tool-generated artifacts.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
10
+
11
+ from atlas.modules.file_storage.content_extractor import get_content_extractor
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Type hint for update callback
16
+ UpdateCallback = Callable[[Dict[str, Any]], Awaitable[None]]
17
+
18
+
19
+ async def handle_session_files(
20
+ session_context: Dict[str, Any],
21
+ user_email: Optional[str],
22
+ files_map: Optional[Dict[str, Any]],
23
+ file_manager,
24
+ update_callback: Optional[UpdateCallback] = None
25
+ ) -> Dict[str, Any]:
26
+ """
27
+ Handle user file ingestion and return updated session context.
28
+
29
+ Pure function that processes files and returns new context without mutations.
30
+
31
+ Args:
32
+ session_context: Current session context
33
+ user_email: User email for file storage
34
+ files_map: Map of filename to file data. Can be:
35
+ - str: base64 content (legacy format)
36
+ - dict: {"content": base64, "extract": bool} (new format with extraction flag)
37
+ file_manager: File manager instance
38
+ update_callback: Optional callback for emitting updates
39
+
40
+ Returns:
41
+ Updated session context with file references
42
+ """
43
+ if not files_map or not file_manager or not user_email:
44
+ return session_context
45
+
46
+ # Work with a copy to avoid mutations
47
+ updated_context = dict(session_context)
48
+ session_files_ctx = updated_context.setdefault("files", {})
49
+
50
+ # Get content extractor
51
+ extractor = get_content_extractor()
52
+ default_extract_mode = extractor.get_default_behavior() if extractor.is_enabled() else "none"
53
+
54
+ try:
55
+ uploaded_refs: Dict[str, Dict[str, Any]] = {}
56
+ for filename, file_data in files_map.items():
57
+ try:
58
+ # Handle both legacy (string) and new (dict) formats
59
+ if isinstance(file_data, str):
60
+ b64 = file_data
61
+ extract_mode = default_extract_mode
62
+ else:
63
+ b64 = file_data.get("content", "")
64
+ # New extractMode field takes priority, then legacy extract bool
65
+ if "extractMode" in file_data:
66
+ extract_mode = file_data["extractMode"]
67
+ elif "extract" in file_data:
68
+ extract_mode = "full" if file_data["extract"] else "none"
69
+ else:
70
+ extract_mode = default_extract_mode
71
+
72
+ meta = await file_manager.upload_file(
73
+ user_email=user_email,
74
+ filename=filename,
75
+ content_base64=b64,
76
+ source_type="user",
77
+ tags={"source": "user"}
78
+ )
79
+
80
+ # Store minimal reference in session context
81
+ file_ref = {
82
+ "key": meta.get("key"),
83
+ "content_type": meta.get("content_type"),
84
+ "size": meta.get("size"),
85
+ "source": "user",
86
+ "last_modified": meta.get("last_modified"),
87
+ }
88
+
89
+ # Store the extraction mode for build_files_manifest
90
+ file_ref["extract_mode"] = extract_mode
91
+
92
+ # Attempt content extraction if enabled and mode requests it
93
+ if extract_mode in ("full", "preview") and extractor.is_enabled():
94
+ extraction_result = await extractor.extract_content(
95
+ filename=filename,
96
+ content_base64=b64,
97
+ mime_type=meta.get("content_type"),
98
+ )
99
+ if extraction_result.success:
100
+ file_ref["extracted_content"] = extraction_result.content
101
+ file_ref["extracted_preview"] = extraction_result.preview
102
+ if extraction_result.metadata:
103
+ file_ref["extraction_metadata"] = extraction_result.metadata
104
+ logger.info(f"Extracted content from {filename}: {len(extraction_result.preview or '')} chars preview")
105
+ else:
106
+ logger.debug(f"Content extraction skipped for {filename}: {extraction_result.error}")
107
+
108
+ session_files_ctx[filename] = file_ref
109
+ uploaded_refs[filename] = meta
110
+ except Exception as e:
111
+ logger.error(f"Failed uploading user file {filename}: {e}")
112
+
113
+ # Emit files update if successful uploads
114
+ if uploaded_refs and update_callback:
115
+ organized = file_manager.organize_files_metadata(uploaded_refs)
116
+ logger.info(
117
+ "Emitting files_update for user uploads: total=%d",
118
+ len(organized.get('files', [])),
119
+ )
120
+ logger.debug("files_update details (user uploads): names=%s", list(uploaded_refs.keys()))
121
+ await update_callback({
122
+ "type": "intermediate_update",
123
+ "update_type": "files_update",
124
+ "data": organized
125
+ })
126
+
127
+ except Exception as e:
128
+ logger.error(f"Error ingesting user files: {e}", exc_info=True)
129
+
130
+ return updated_context
131
+
132
+
133
+ async def process_tool_artifacts(
134
+ session_context: Dict[str, Any],
135
+ tool_result,
136
+ file_manager,
137
+ update_callback: Optional[UpdateCallback] = None
138
+ ) -> Dict[str, Any]:
139
+ """
140
+ Process v2 MCP artifacts produced by a tool and return updated session context.
141
+
142
+ Pure function that handles tool files without side effects on input context.
143
+ """
144
+ # Check if there's an iframe display configuration (no artifacts needed)
145
+ has_iframe_display = (
146
+ tool_result.display_config and
147
+ isinstance(tool_result.display_config, dict) and
148
+ tool_result.display_config.get("type") == "iframe" and
149
+ tool_result.display_config.get("url")
150
+ )
151
+
152
+ # Early return only if no artifacts AND no iframe display, or no file_manager
153
+ if (not tool_result.artifacts and not has_iframe_display) or not file_manager:
154
+ return session_context
155
+
156
+ # Work with a copy to avoid mutations
157
+ updated_context = dict(session_context)
158
+
159
+ # Process v2 artifacts (only if we have artifacts)
160
+ if tool_result.artifacts:
161
+ user_email = session_context.get("user_email")
162
+ if not user_email:
163
+ return session_context
164
+
165
+ updated_context = await ingest_v2_artifacts(
166
+ session_context=updated_context,
167
+ tool_result=tool_result,
168
+ user_email=user_email,
169
+ file_manager=file_manager,
170
+ update_callback=update_callback
171
+ )
172
+
173
+ # Handle canvas file notifications with v2 display config
174
+ # This handles both artifact-based displays and iframe-only displays
175
+ await notify_canvas_files_v2(
176
+ session_context=updated_context,
177
+ tool_result=tool_result,
178
+ file_manager=file_manager,
179
+ update_callback=update_callback
180
+ )
181
+
182
+ return updated_context
183
+
184
+
185
+ async def ingest_tool_files(
186
+ session_context: Dict[str, Any],
187
+ tool_result,
188
+ user_email: str,
189
+ file_manager,
190
+ update_callback: Optional[UpdateCallback] = None
191
+ ) -> Dict[str, Any]:
192
+ """
193
+ Persist tool-produced files into storage and update session context.
194
+
195
+ Pure function that returns updated context without mutations.
196
+ """
197
+ if not tool_result.returned_file_names:
198
+ return session_context
199
+
200
+ # Work with a copy
201
+ updated_context = dict(session_context)
202
+
203
+ # Safety: avoid huge ingestions
204
+ MAX_FILES = 10
205
+ names = tool_result.returned_file_names[:MAX_FILES]
206
+ contents = tool_result.returned_file_contents[:MAX_FILES] if tool_result.returned_file_contents else []
207
+
208
+ if contents and len(contents) != len(names):
209
+ logger.warning(
210
+ "ToolResult file arrays length mismatch (names=%d, contents=%d) for tool_call_id=%s",
211
+ len(names), len(contents), tool_result.tool_call_id
212
+ )
213
+
214
+ pair_count = min(len(names), len(contents)) if contents else 0
215
+ session_files_ctx = updated_context.setdefault("files", {})
216
+ uploaded_refs: Dict[str, Dict[str, Any]] = {}
217
+
218
+ for idx, fname in enumerate(names):
219
+ try:
220
+ if idx < pair_count:
221
+ b64 = contents[idx]
222
+ meta = await file_manager.upload_file(
223
+ user_email=user_email,
224
+ filename=fname,
225
+ content_base64=b64,
226
+ source_type="tool",
227
+ tags={"source": "tool"}
228
+ )
229
+ session_files_ctx[fname] = {
230
+ "key": meta.get("key"),
231
+ "content_type": meta.get("content_type"),
232
+ "size": meta.get("size"),
233
+ "source": "tool",
234
+ "last_modified": meta.get("last_modified"),
235
+ "tool_call_id": tool_result.tool_call_id
236
+ }
237
+ uploaded_refs[fname] = meta
238
+ else:
239
+ # Name without content – record reference placeholder only if not existing
240
+ if fname not in session_files_ctx:
241
+ session_files_ctx[fname] = {"source": "tool", "incomplete": True}
242
+ except Exception as e:
243
+ logger.error(f"Failed uploading tool-produced file {fname}: {e}")
244
+
245
+ # Emit files update if successful uploads
246
+ if uploaded_refs and update_callback:
247
+ try:
248
+ organized = file_manager.organize_files_metadata(uploaded_refs)
249
+ logger.info(
250
+ "Emitting files_update for tool uploads: total=%d",
251
+ len(organized.get('files', [])),
252
+ )
253
+ logger.debug("files_update details (tool uploads): names=%s", list(uploaded_refs.keys()))
254
+ await update_callback({
255
+ "type": "intermediate_update",
256
+ "update_type": "files_update",
257
+ "data": organized
258
+ })
259
+ except Exception as e:
260
+ logger.error(f"Failed emitting tool files update: {e}")
261
+
262
+ return updated_context
263
+
264
+
265
+ async def notify_canvas_files(
266
+ session_context: Dict[str, Any],
267
+ file_names: List[str],
268
+ file_manager,
269
+ update_callback: Optional[UpdateCallback] = None
270
+ ) -> None:
271
+ """
272
+ Send canvas files notification for tool-produced files.
273
+
274
+ Pure function with no side effects on session context.
275
+ """
276
+ if not update_callback or not file_names or not file_manager:
277
+ return
278
+
279
+ try:
280
+ uploaded_refs = {}
281
+ files_ctx = session_context.get("files", {})
282
+
283
+ for fname in file_names:
284
+ ref = files_ctx.get(fname)
285
+ if ref and ref.get("key"):
286
+ uploaded_refs[fname] = {
287
+ "key": ref.get("key"),
288
+ "size": ref.get("size", 0),
289
+ "content_type": ref.get("content_type", "application/octet-stream"),
290
+ "last_modified": ref.get("last_modified"),
291
+ "tags": {"source": ref.get("source", "tool")}
292
+ }
293
+
294
+ if uploaded_refs:
295
+ canvas_files = []
296
+ for fname, meta in uploaded_refs.items():
297
+ if file_manager.should_display_in_canvas(fname):
298
+ file_ext = file_manager.get_file_extension(fname).lower()
299
+ canvas_files.append({
300
+ "filename": fname,
301
+ "type": file_manager.get_canvas_file_type(file_ext),
302
+ "s3_key": meta.get("key"),
303
+ "size": meta.get("size", 0),
304
+ })
305
+
306
+ if canvas_files:
307
+ await update_callback({
308
+ "type": "intermediate_update",
309
+ "update_type": "canvas_files",
310
+ "data": {"files": canvas_files}
311
+ })
312
+ except Exception as emit_err:
313
+ logger.warning(f"Non-fatal: failed to emit canvas_files update: {emit_err}")
314
+
315
+
316
+ async def emit_files_update_from_context(
317
+ session_context: Dict[str, Any],
318
+ file_manager,
319
+ update_callback: Optional[UpdateCallback] = None
320
+ ) -> None:
321
+ """
322
+ Emit a files_update event based on session context files.
323
+
324
+ Pure function with no side effects.
325
+ """
326
+ if not file_manager or not update_callback:
327
+ return
328
+
329
+ try:
330
+ # Build temp structure expected by organizer
331
+ file_refs: Dict[str, Dict[str, Any]] = {}
332
+ for fname, ref in session_context.get("files", {}).items():
333
+ # Expand to shape similar to S3 metadata for organizer
334
+ file_refs[fname] = {
335
+ "key": ref.get("key"),
336
+ "size": ref.get("size", 0),
337
+ "content_type": ref.get("content_type", "application/octet-stream"),
338
+ "last_modified": ref.get("last_modified"),
339
+ "tags": {"source": ref.get("source", "user")}
340
+ }
341
+
342
+ organized = file_manager.organize_files_metadata(file_refs)
343
+ logger.info(
344
+ "Emitting files_update from context: total=%d",
345
+ len(organized.get('files', [])),
346
+ )
347
+ await update_callback({
348
+ "type": "intermediate_update",
349
+ "update_type": "files_update",
350
+ "data": organized
351
+ })
352
+ except Exception as e:
353
+ logger.error(f"Failed emitting files update: {e}")
354
+
355
+
356
+ async def ingest_v2_artifacts(
357
+ session_context: Dict[str, Any],
358
+ tool_result,
359
+ user_email: str,
360
+ file_manager,
361
+ update_callback: Optional[UpdateCallback] = None
362
+ ) -> Dict[str, Any]:
363
+ """
364
+ Persist v2 MCP artifacts into storage and update session context.
365
+
366
+ Pure function that returns updated context without mutations.
367
+ """
368
+ if not tool_result.artifacts:
369
+ return session_context
370
+
371
+ # Work with a copy
372
+ updated_context = dict(session_context)
373
+
374
+ # Safety: avoid huge ingestions
375
+ MAX_ARTIFACTS = 10
376
+ artifacts = tool_result.artifacts[:MAX_ARTIFACTS]
377
+
378
+ try:
379
+ # Prepare files for upload
380
+ files_to_upload = []
381
+ for artifact in artifacts:
382
+ name = artifact.get("name")
383
+ b64_content = artifact.get("b64")
384
+ mime_type = artifact.get("mime")
385
+
386
+ if not name or not b64_content:
387
+ logger.warning("Skipping artifact with missing name or content")
388
+ continue
389
+
390
+ files_to_upload.append({
391
+ "filename": name,
392
+ "content": b64_content,
393
+ "mime_type": mime_type
394
+ })
395
+
396
+ if not files_to_upload:
397
+ return updated_context
398
+
399
+ # Upload files to storage
400
+ uploaded_refs = await file_manager.upload_files_from_base64(
401
+ files_to_upload, user_email
402
+ )
403
+
404
+ # Add file references to session context
405
+ current_files = updated_context.setdefault("files", {})
406
+ current_files.update(uploaded_refs)
407
+
408
+ # Emit files update if successful uploads
409
+ if uploaded_refs and update_callback:
410
+ organized = file_manager.organize_files_metadata(uploaded_refs)
411
+ logger.info(
412
+ "Emitting files_update for v2 artifacts: total=%d",
413
+ len(organized.get('files', [])),
414
+ )
415
+ logger.debug(
416
+ "files_update details (v2 artifacts): names=%s",
417
+ list(uploaded_refs.keys()),
418
+ )
419
+ await update_callback({
420
+ "type": "intermediate_update",
421
+ "update_type": "files_update",
422
+ "data": organized
423
+ })
424
+
425
+ except Exception as e:
426
+ logger.error(f"Error ingesting v2 artifacts: {e}", exc_info=True)
427
+
428
+ return updated_context
429
+
430
+
431
+ async def notify_canvas_files_v2(
432
+ session_context: Dict[str, Any],
433
+ tool_result,
434
+ file_manager,
435
+ update_callback: Optional[UpdateCallback] = None
436
+ ) -> None:
437
+ """
438
+ Send v2 canvas files notification with display configuration.
439
+
440
+ Pure function with no side effects on session context.
441
+ """
442
+ if not update_callback:
443
+ return
444
+
445
+ # Check if there's an iframe display configuration (no artifacts needed)
446
+ has_iframe_display = (
447
+ tool_result.display_config and
448
+ isinstance(tool_result.display_config, dict) and
449
+ tool_result.display_config.get("type") == "iframe" and
450
+ tool_result.display_config.get("url")
451
+ )
452
+
453
+ # If no artifacts and no iframe display, nothing to show
454
+ if not tool_result.artifacts and not has_iframe_display:
455
+ return
456
+
457
+ try:
458
+ # Get uploaded file references from session context
459
+ uploaded_refs = session_context.get("files", {})
460
+ artifact_names = [artifact.get("name") for artifact in tool_result.artifacts if artifact.get("name")]
461
+
462
+ # Handle iframe-only display (no artifacts)
463
+ if has_iframe_display and not artifact_names:
464
+ canvas_update = {
465
+ "type": "intermediate_update",
466
+ "update_type": "canvas_files",
467
+ "data": {
468
+ "files": [],
469
+ "display": tool_result.display_config
470
+ }
471
+ }
472
+ logger.info("Emitting canvas_files event for iframe display")
473
+ logger.debug(
474
+ "canvas_files iframe display details: url=%s, title=%s",
475
+ tool_result.display_config.get("url"),
476
+ tool_result.display_config.get("title", "Embedded Content"),
477
+ )
478
+ await update_callback(canvas_update)
479
+ return
480
+
481
+ if uploaded_refs and artifact_names:
482
+ canvas_files = []
483
+ for fname in artifact_names:
484
+ meta = uploaded_refs.get(fname)
485
+ if meta and file_manager.should_display_in_canvas(fname):
486
+ # Get MIME type from artifact if available
487
+ artifact = next((a for a in tool_result.artifacts if a.get("name") == fname), {})
488
+ mime_type = artifact.get("mime")
489
+
490
+ file_ext = file_manager.get_file_extension(fname).lower()
491
+ canvas_files.append({
492
+ "filename": fname,
493
+ "type": file_manager.get_canvas_file_type(file_ext),
494
+ "s3_key": meta.get("key"),
495
+ "size": meta.get("size", 0),
496
+ "mime_type": mime_type
497
+ })
498
+
499
+ if canvas_files:
500
+ # Reorder files to put primary_file first if provided
501
+ primary = None
502
+ if tool_result.display_config and isinstance(tool_result.display_config, dict):
503
+ primary = tool_result.display_config.get("primary_file")
504
+ if primary:
505
+ # stable reorder
506
+ canvas_files = sorted(
507
+ canvas_files,
508
+ key=lambda f: 0 if f.get("filename") == primary else 1
509
+ )
510
+
511
+ # Build canvas update with v2 display configuration
512
+ logger.info("Emitting canvas_files event: count=%d", len(canvas_files))
513
+ logger.debug(
514
+ "canvas_files details: files=%s, display=%s",
515
+ [f.get("filename") for f in canvas_files],
516
+ tool_result.display_config,
517
+ )
518
+ canvas_update = {
519
+ "type": "intermediate_update",
520
+ "update_type": "canvas_files",
521
+ "data": {"files": canvas_files}
522
+ }
523
+
524
+ # Add v2 display configuration if present
525
+ if tool_result.display_config:
526
+ canvas_update["data"]["display"] = tool_result.display_config
527
+
528
+ await update_callback(canvas_update)
529
+ else:
530
+ logger.debug("No canvas-displayable artifacts found. artifact_names=%s", artifact_names)
531
+
532
+ except Exception as emit_err:
533
+ logger.warning(f"Non-fatal: failed to emit v2 canvas_files update: {emit_err}")
534
+
535
+
536
+ def build_files_manifest(session_context: Dict[str, Any]) -> Optional[Dict[str, str]]:
537
+ """
538
+ Build ephemeral files manifest for LLM context.
539
+
540
+ Pure function that creates manifest from session context.
541
+ Includes extracted content previews when available.
542
+ """
543
+ files_ctx = session_context.get("files", {})
544
+ if not files_ctx:
545
+ return None
546
+
547
+ # Build file list with extracted content based on extract_mode
548
+ file_entries = []
549
+ has_full = False
550
+ has_preview = False
551
+ has_none = False
552
+ for name in sorted(files_ctx.keys()):
553
+ file_info = files_ctx[name]
554
+ entry = f"- {name}"
555
+ mode = file_info.get("extract_mode", "preview")
556
+
557
+ # Include extraction metadata if available
558
+ if file_info.get("extraction_metadata"):
559
+ meta = file_info["extraction_metadata"]
560
+ if meta.get("pages"):
561
+ entry += f" ({meta['pages']} pages)"
562
+
563
+ if mode == "full" and file_info.get("extracted_content"):
564
+ has_full = True
565
+ content = file_info["extracted_content"]
566
+ entry += (
567
+ f"\n << content of file {name} >>\n"
568
+ f" {content}\n"
569
+ f" << end content of file {name} >>"
570
+ )
571
+ elif mode == "preview" and file_info.get("extracted_preview"):
572
+ has_preview = True
573
+ preview = file_info["extracted_preview"]
574
+ # Limit to 10 lines and 2000 characters to prevent excessive token usage
575
+ lines = preview.split("\n")[:10]
576
+ indented_preview = "\n ".join(lines)
577
+ if len(indented_preview) > 2000:
578
+ indented_preview = indented_preview[:1997] + "..."
579
+ entry += f"\n Content preview:\n {indented_preview}"
580
+ else:
581
+ has_none = True
582
+
583
+ file_entries.append(entry)
584
+
585
+ file_list = "\n".join(file_entries)
586
+
587
+ # Build context note based on which modes were used
588
+ notes = []
589
+ if has_full:
590
+ notes.append(
591
+ "Files with full content shown above have been fully extracted. "
592
+ "You can reference this content directly."
593
+ )
594
+ if has_preview:
595
+ notes.append(
596
+ "Files with content previews shown above have been partially analyzed. "
597
+ "You can reference preview content directly."
598
+ )
599
+ if has_none:
600
+ notes.append(
601
+ "Files listed by name only can be opened or analyzed on request."
602
+ )
603
+ context_note = f"({' '.join(notes)})" if notes else ""
604
+
605
+ return {
606
+ "role": "system",
607
+ "content": (
608
+ "Available session files:\n"
609
+ f"{file_list}\n\n"
610
+ f"{context_note} "
611
+ "The user may refer to these files in their requests as session files or attachments."
612
+ )
613
+ }