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,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
|
+
}
|