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.
- atlas/__init__.py +40 -0
- atlas/application/__init__.py +7 -0
- atlas/application/chat/__init__.py +7 -0
- atlas/application/chat/agent/__init__.py +10 -0
- atlas/application/chat/agent/act_loop.py +179 -0
- atlas/application/chat/agent/factory.py +142 -0
- atlas/application/chat/agent/protocols.py +46 -0
- atlas/application/chat/agent/react_loop.py +338 -0
- atlas/application/chat/agent/think_act_loop.py +171 -0
- atlas/application/chat/approval_manager.py +151 -0
- atlas/application/chat/elicitation_manager.py +191 -0
- atlas/application/chat/events/__init__.py +1 -0
- atlas/application/chat/events/agent_event_relay.py +112 -0
- atlas/application/chat/modes/__init__.py +1 -0
- atlas/application/chat/modes/agent.py +125 -0
- atlas/application/chat/modes/plain.py +74 -0
- atlas/application/chat/modes/rag.py +81 -0
- atlas/application/chat/modes/tools.py +179 -0
- atlas/application/chat/orchestrator.py +213 -0
- atlas/application/chat/policies/__init__.py +1 -0
- atlas/application/chat/policies/tool_authorization.py +99 -0
- atlas/application/chat/preprocessors/__init__.py +1 -0
- atlas/application/chat/preprocessors/message_builder.py +92 -0
- atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
- atlas/application/chat/service.py +454 -0
- atlas/application/chat/utilities/__init__.py +6 -0
- atlas/application/chat/utilities/error_handler.py +367 -0
- atlas/application/chat/utilities/event_notifier.py +546 -0
- atlas/application/chat/utilities/file_processor.py +613 -0
- atlas/application/chat/utilities/tool_executor.py +789 -0
- atlas/atlas_chat_cli.py +347 -0
- atlas/atlas_client.py +238 -0
- atlas/core/__init__.py +0 -0
- atlas/core/auth.py +205 -0
- atlas/core/authorization_manager.py +27 -0
- atlas/core/capabilities.py +123 -0
- atlas/core/compliance.py +215 -0
- atlas/core/domain_whitelist.py +147 -0
- atlas/core/domain_whitelist_middleware.py +82 -0
- atlas/core/http_client.py +28 -0
- atlas/core/log_sanitizer.py +102 -0
- atlas/core/metrics_logger.py +59 -0
- atlas/core/middleware.py +131 -0
- atlas/core/otel_config.py +242 -0
- atlas/core/prompt_risk.py +200 -0
- atlas/core/rate_limit.py +0 -0
- atlas/core/rate_limit_middleware.py +64 -0
- atlas/core/security_headers_middleware.py +51 -0
- atlas/domain/__init__.py +37 -0
- atlas/domain/chat/__init__.py +1 -0
- atlas/domain/chat/dtos.py +85 -0
- atlas/domain/errors.py +96 -0
- atlas/domain/messages/__init__.py +12 -0
- atlas/domain/messages/models.py +160 -0
- atlas/domain/rag_mcp_service.py +664 -0
- atlas/domain/sessions/__init__.py +7 -0
- atlas/domain/sessions/models.py +36 -0
- atlas/domain/unified_rag_service.py +371 -0
- atlas/infrastructure/__init__.py +10 -0
- atlas/infrastructure/app_factory.py +135 -0
- atlas/infrastructure/events/__init__.py +1 -0
- atlas/infrastructure/events/cli_event_publisher.py +140 -0
- atlas/infrastructure/events/websocket_publisher.py +140 -0
- atlas/infrastructure/sessions/in_memory_repository.py +56 -0
- atlas/infrastructure/transport/__init__.py +7 -0
- atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
- atlas/init_cli.py +226 -0
- atlas/interfaces/__init__.py +15 -0
- atlas/interfaces/events.py +134 -0
- atlas/interfaces/llm.py +54 -0
- atlas/interfaces/rag.py +40 -0
- atlas/interfaces/sessions.py +75 -0
- atlas/interfaces/tools.py +57 -0
- atlas/interfaces/transport.py +24 -0
- atlas/main.py +564 -0
- atlas/mcp/api_key_demo/README.md +76 -0
- atlas/mcp/api_key_demo/main.py +172 -0
- atlas/mcp/api_key_demo/run.sh +56 -0
- atlas/mcp/basictable/main.py +147 -0
- atlas/mcp/calculator/main.py +149 -0
- atlas/mcp/code-executor/execution_engine.py +98 -0
- atlas/mcp/code-executor/execution_environment.py +95 -0
- atlas/mcp/code-executor/main.py +528 -0
- atlas/mcp/code-executor/result_processing.py +276 -0
- atlas/mcp/code-executor/script_generation.py +195 -0
- atlas/mcp/code-executor/security_checker.py +140 -0
- atlas/mcp/corporate_cars/main.py +437 -0
- atlas/mcp/csv_reporter/main.py +545 -0
- atlas/mcp/duckduckgo/main.py +182 -0
- atlas/mcp/elicitation_demo/README.md +171 -0
- atlas/mcp/elicitation_demo/main.py +262 -0
- atlas/mcp/env-demo/README.md +158 -0
- atlas/mcp/env-demo/main.py +199 -0
- atlas/mcp/file_size_test/main.py +284 -0
- atlas/mcp/filesystem/main.py +348 -0
- atlas/mcp/image_demo/main.py +113 -0
- atlas/mcp/image_demo/requirements.txt +4 -0
- atlas/mcp/logging_demo/README.md +72 -0
- atlas/mcp/logging_demo/main.py +103 -0
- atlas/mcp/many_tools_demo/main.py +50 -0
- atlas/mcp/order_database/__init__.py +0 -0
- atlas/mcp/order_database/main.py +369 -0
- atlas/mcp/order_database/signal_data.csv +1001 -0
- atlas/mcp/pdfbasic/main.py +394 -0
- atlas/mcp/pptx_generator/main.py +760 -0
- atlas/mcp/pptx_generator/requirements.txt +13 -0
- atlas/mcp/pptx_generator/run_test.sh +1 -0
- atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
- atlas/mcp/progress_demo/main.py +167 -0
- atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
- atlas/mcp/progress_updates_demo/README.md +120 -0
- atlas/mcp/progress_updates_demo/main.py +497 -0
- atlas/mcp/prompts/main.py +222 -0
- atlas/mcp/public_demo/main.py +189 -0
- atlas/mcp/sampling_demo/README.md +169 -0
- atlas/mcp/sampling_demo/main.py +234 -0
- atlas/mcp/thinking/main.py +77 -0
- atlas/mcp/tool_planner/main.py +240 -0
- atlas/mcp/ui-demo/badmesh.png +0 -0
- atlas/mcp/ui-demo/main.py +383 -0
- atlas/mcp/ui-demo/templates/button_demo.html +32 -0
- atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
- atlas/mcp/ui-demo/templates/form_demo.html +28 -0
- atlas/mcp/username-override-demo/README.md +320 -0
- atlas/mcp/username-override-demo/main.py +308 -0
- atlas/modules/__init__.py +0 -0
- atlas/modules/config/__init__.py +34 -0
- atlas/modules/config/cli.py +231 -0
- atlas/modules/config/config_manager.py +1096 -0
- atlas/modules/file_storage/__init__.py +22 -0
- atlas/modules/file_storage/cli.py +330 -0
- atlas/modules/file_storage/content_extractor.py +290 -0
- atlas/modules/file_storage/manager.py +295 -0
- atlas/modules/file_storage/mock_s3_client.py +402 -0
- atlas/modules/file_storage/s3_client.py +417 -0
- atlas/modules/llm/__init__.py +19 -0
- atlas/modules/llm/caller.py +287 -0
- atlas/modules/llm/litellm_caller.py +675 -0
- atlas/modules/llm/models.py +19 -0
- atlas/modules/mcp_tools/__init__.py +17 -0
- atlas/modules/mcp_tools/client.py +2123 -0
- atlas/modules/mcp_tools/token_storage.py +556 -0
- atlas/modules/prompts/prompt_provider.py +130 -0
- atlas/modules/rag/__init__.py +24 -0
- atlas/modules/rag/atlas_rag_client.py +336 -0
- atlas/modules/rag/client.py +129 -0
- atlas/routes/admin_routes.py +865 -0
- atlas/routes/config_routes.py +484 -0
- atlas/routes/feedback_routes.py +361 -0
- atlas/routes/files_routes.py +274 -0
- atlas/routes/health_routes.py +40 -0
- atlas/routes/mcp_auth_routes.py +223 -0
- atlas/server_cli.py +164 -0
- atlas/tests/conftest.py +20 -0
- atlas/tests/integration/test_mcp_auth_integration.py +152 -0
- atlas/tests/manual_test_sampling.py +87 -0
- atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
- atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
- atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
- atlas/tests/test_agent_roa.py +135 -0
- atlas/tests/test_app_factory_smoke.py +47 -0
- atlas/tests/test_approval_manager.py +439 -0
- atlas/tests/test_atlas_client.py +188 -0
- atlas/tests/test_atlas_rag_client.py +447 -0
- atlas/tests/test_atlas_rag_integration.py +224 -0
- atlas/tests/test_attach_file_flow.py +287 -0
- atlas/tests/test_auth_utils.py +165 -0
- atlas/tests/test_backend_public_url.py +185 -0
- atlas/tests/test_banner_logging.py +287 -0
- atlas/tests/test_capability_tokens_and_injection.py +203 -0
- atlas/tests/test_compliance_level.py +54 -0
- atlas/tests/test_compliance_manager.py +253 -0
- atlas/tests/test_config_manager.py +617 -0
- atlas/tests/test_config_manager_paths.py +12 -0
- atlas/tests/test_core_auth.py +18 -0
- atlas/tests/test_core_utils.py +190 -0
- atlas/tests/test_docker_env_sync.py +202 -0
- atlas/tests/test_domain_errors.py +329 -0
- atlas/tests/test_domain_whitelist.py +359 -0
- atlas/tests/test_elicitation_manager.py +408 -0
- atlas/tests/test_elicitation_routing.py +296 -0
- atlas/tests/test_env_demo_server.py +88 -0
- atlas/tests/test_error_classification.py +113 -0
- atlas/tests/test_error_flow_integration.py +116 -0
- atlas/tests/test_feedback_routes.py +333 -0
- atlas/tests/test_file_content_extraction.py +1134 -0
- atlas/tests/test_file_extraction_routes.py +158 -0
- atlas/tests/test_file_library.py +107 -0
- atlas/tests/test_file_manager_unit.py +18 -0
- atlas/tests/test_health_route.py +49 -0
- atlas/tests/test_http_client_stub.py +8 -0
- atlas/tests/test_imports_smoke.py +30 -0
- atlas/tests/test_interfaces_llm_response.py +9 -0
- atlas/tests/test_issue_access_denied_fix.py +136 -0
- atlas/tests/test_llm_env_expansion.py +836 -0
- atlas/tests/test_log_level_sensitive_data.py +285 -0
- atlas/tests/test_mcp_auth_routes.py +341 -0
- atlas/tests/test_mcp_client_auth.py +331 -0
- atlas/tests/test_mcp_data_injection.py +270 -0
- atlas/tests/test_mcp_get_authorized_servers.py +95 -0
- atlas/tests/test_mcp_hot_reload.py +512 -0
- atlas/tests/test_mcp_image_content.py +424 -0
- atlas/tests/test_mcp_logging.py +172 -0
- atlas/tests/test_mcp_progress_updates.py +313 -0
- atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
- atlas/tests/test_mcp_prompts_server.py +39 -0
- atlas/tests/test_mcp_tool_result_parsing.py +296 -0
- atlas/tests/test_metrics_logger.py +56 -0
- atlas/tests/test_middleware_auth.py +379 -0
- atlas/tests/test_prompt_risk_and_acl.py +141 -0
- atlas/tests/test_rag_mcp_aggregator.py +204 -0
- atlas/tests/test_rag_mcp_service.py +224 -0
- atlas/tests/test_rate_limit_middleware.py +45 -0
- atlas/tests/test_routes_config_smoke.py +60 -0
- atlas/tests/test_routes_files_download_token.py +41 -0
- atlas/tests/test_routes_files_health.py +18 -0
- atlas/tests/test_runtime_imports.py +53 -0
- atlas/tests/test_sampling_integration.py +482 -0
- atlas/tests/test_security_admin_routes.py +61 -0
- atlas/tests/test_security_capability_tokens.py +65 -0
- atlas/tests/test_security_file_stats_scope.py +21 -0
- atlas/tests/test_security_header_injection.py +191 -0
- atlas/tests/test_security_headers_and_filename.py +63 -0
- atlas/tests/test_shared_session_repository.py +101 -0
- atlas/tests/test_system_prompt_loading.py +181 -0
- atlas/tests/test_token_storage.py +505 -0
- atlas/tests/test_tool_approval_config.py +93 -0
- atlas/tests/test_tool_approval_utils.py +356 -0
- atlas/tests/test_tool_authorization_group_filtering.py +223 -0
- atlas/tests/test_tool_details_in_config.py +108 -0
- atlas/tests/test_tool_planner.py +300 -0
- atlas/tests/test_unified_rag_service.py +398 -0
- atlas/tests/test_username_override_in_approval.py +258 -0
- atlas/tests/test_websocket_auth_header.py +168 -0
- atlas/version.py +6 -0
- atlas_chat-0.1.0.data/data/.env.example +253 -0
- atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
- atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
- atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
- atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
- atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
- atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
- atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
- atlas_chat-0.1.0.dist-info/METADATA +236 -0
- atlas_chat-0.1.0.dist-info/RECORD +250 -0
- atlas_chat-0.1.0.dist-info/WHEEL +5 -0
- atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
- 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
|
+
}
|