mito-ai 0.1.45__py3-none-any.whl → 0.1.46__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.
- mito_ai/__init__.py +10 -1
- mito_ai/_version.py +1 -1
- mito_ai/anthropic_client.py +90 -5
- mito_ai/chat_history/handlers.py +63 -0
- mito_ai/chat_history/urls.py +32 -0
- mito_ai/completions/handlers.py +18 -20
- mito_ai/constants.py +3 -0
- mito_ai/streamlit_conversion/agent_utils.py +148 -30
- mito_ai/streamlit_conversion/prompts/prompt_constants.py +147 -24
- mito_ai/streamlit_conversion/prompts/streamlit_app_creation_prompt.py +2 -1
- mito_ai/streamlit_conversion/prompts/streamlit_error_correction_prompt.py +2 -2
- mito_ai/streamlit_conversion/prompts/streamlit_finish_todo_prompt.py +4 -3
- mito_ai/streamlit_conversion/prompts/update_existing_app_prompt.py +50 -0
- mito_ai/streamlit_conversion/streamlit_agent_handler.py +101 -107
- mito_ai/streamlit_conversion/streamlit_system_prompt.py +1 -0
- mito_ai/streamlit_conversion/streamlit_utils.py +13 -10
- mito_ai/streamlit_conversion/validate_streamlit_app.py +77 -82
- mito_ai/streamlit_preview/handlers.py +3 -4
- mito_ai/streamlit_preview/utils.py +11 -7
- mito_ai/tests/chat_history/test_chat_history.py +211 -0
- mito_ai/tests/message_history/test_message_history_utils.py +43 -19
- mito_ai/tests/providers/test_anthropic_client.py +178 -6
- mito_ai/tests/streamlit_conversion/test_apply_patch_to_text.py +368 -0
- mito_ai/tests/streamlit_conversion/test_fix_diff_headers.py +533 -0
- mito_ai/tests/streamlit_conversion/test_streamlit_agent_handler.py +71 -74
- mito_ai/tests/streamlit_conversion/test_streamlit_utils.py +16 -16
- mito_ai/tests/streamlit_conversion/test_validate_streamlit_app.py +17 -14
- mito_ai/tests/streamlit_preview/test_streamlit_preview_handler.py +2 -2
- mito_ai/tests/user/__init__.py +2 -0
- mito_ai/tests/user/test_user.py +120 -0
- mito_ai/user/handlers.py +33 -0
- mito_ai/user/urls.py +21 -0
- mito_ai/utils/anthropic_utils.py +8 -6
- mito_ai/utils/message_history_utils.py +4 -3
- mito_ai/utils/telemetry_utils.py +7 -4
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +1 -1
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/package.json +2 -2
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/package.json.orig +1 -1
- mito_ai-0.1.45.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.0c3368195d954d2ed033.js → mito_ai-0.1.46.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.20f12766ecd3d430568e.js +955 -173
- mito_ai-0.1.46.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.20f12766ecd3d430568e.js.map +1 -0
- mito_ai-0.1.45.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.684f82575fcc2e3b350c.js → mito_ai-0.1.46.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.54126ab6511271265443.js +5 -5
- mito_ai-0.1.45.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.684f82575fcc2e3b350c.js.map → mito_ai-0.1.46.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.54126ab6511271265443.js.map +1 -1
- {mito_ai-0.1.45.dist-info → mito_ai-0.1.46.dist-info}/METADATA +1 -1
- {mito_ai-0.1.45.dist-info → mito_ai-0.1.46.dist-info}/RECORD +68 -58
- mito_ai-0.1.45.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.0c3368195d954d2ed033.js.map +0 -1
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/toolbar-buttons.json +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js.map +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js.map +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_apis_signOut_mjs-node_module-75790d.688c25857e7b81b1740f.js +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_apis_signOut_mjs-node_module-75790d.688c25857e7b81b1740f.js.map +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js.map +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js.map +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js.map +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_react-dom_client_js-node_modules_aws-amplify_ui-react_dist_styles_css.b43d4249e4d3dac9ad7b.js +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_react-dom_client_js-node_modules_aws-amplify_ui-react_dist_styles_css.b43d4249e4d3dac9ad7b.js.map +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js.map +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js +0 -0
- {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js.map +0 -0
- {mito_ai-0.1.45.dist-info → mito_ai-0.1.46.dist-info}/WHEEL +0 -0
- {mito_ai-0.1.45.dist-info → mito_ai-0.1.46.dist-info}/entry_points.txt +0 -0
- {mito_ai-0.1.45.dist-info → mito_ai-0.1.46.dist-info}/licenses/LICENSE +0 -0
mito_ai/__init__.py
CHANGED
|
@@ -5,6 +5,7 @@ from typing import List, Dict
|
|
|
5
5
|
from jupyter_server.utils import url_path_join
|
|
6
6
|
from mito_ai.completions.handlers import CompletionHandler
|
|
7
7
|
from mito_ai.completions.providers import OpenAIProvider
|
|
8
|
+
from mito_ai.completions.message_history import GlobalMessageHistory
|
|
8
9
|
from mito_ai.app_deploy.handlers import AppDeployHandler
|
|
9
10
|
from mito_ai.streamlit_preview.handlers import StreamlitPreviewHandler
|
|
10
11
|
from mito_ai.log.urls import get_log_urls
|
|
@@ -16,6 +17,8 @@ from mito_ai.auth.urls import get_auth_urls
|
|
|
16
17
|
from mito_ai.streamlit_preview.urls import get_streamlit_preview_urls
|
|
17
18
|
from mito_ai.app_manager.handlers import AppManagerHandler
|
|
18
19
|
from mito_ai.file_uploads.urls import get_file_uploads_urls
|
|
20
|
+
from mito_ai.user.urls import get_user_urls
|
|
21
|
+
from mito_ai.chat_history.urls import get_chat_history_urls
|
|
19
22
|
|
|
20
23
|
# Force Matplotlib to use the Jupyter inline backend.
|
|
21
24
|
# Background: importing Streamlit sets os.environ["MPLBACKEND"] = "Agg" very early.
|
|
@@ -62,13 +65,17 @@ def _load_jupyter_server_extension(server_app) -> None: # type: ignore
|
|
|
62
65
|
base_url = web_app.settings["base_url"]
|
|
63
66
|
|
|
64
67
|
open_ai_provider = OpenAIProvider(config=server_app.config)
|
|
68
|
+
|
|
69
|
+
# Create a single GlobalMessageHistory instance for the entire server
|
|
70
|
+
# This ensures thread-safe access to the .mito/ai-chats directory
|
|
71
|
+
global_message_history = GlobalMessageHistory()
|
|
65
72
|
|
|
66
73
|
# WebSocket handlers
|
|
67
74
|
handlers = [
|
|
68
75
|
(
|
|
69
76
|
url_path_join(base_url, "mito-ai", "completions"),
|
|
70
77
|
CompletionHandler,
|
|
71
|
-
{"llm": open_ai_provider},
|
|
78
|
+
{"llm": open_ai_provider, "message_history": global_message_history},
|
|
72
79
|
),
|
|
73
80
|
(
|
|
74
81
|
url_path_join(base_url, "mito-ai", "app-deploy"),
|
|
@@ -100,6 +107,8 @@ def _load_jupyter_server_extension(server_app) -> None: # type: ignore
|
|
|
100
107
|
handlers.extend(get_auth_urls(base_url)) # type: ignore
|
|
101
108
|
handlers.extend(get_streamlit_preview_urls(base_url)) # type: ignore
|
|
102
109
|
handlers.extend(get_file_uploads_urls(base_url)) # type: ignore
|
|
110
|
+
handlers.extend(get_user_urls(base_url)) # type: ignore
|
|
111
|
+
handlers.extend(get_chat_history_urls(base_url, global_message_history)) # type: ignore
|
|
103
112
|
|
|
104
113
|
web_app.add_handlers(host_pattern, handlers)
|
|
105
114
|
server_app.log.info("Loaded the mito_ai server extension")
|
mito_ai/_version.py
CHANGED
mito_ai/anthropic_client.py
CHANGED
|
@@ -5,9 +5,9 @@ import json
|
|
|
5
5
|
import anthropic
|
|
6
6
|
from typing import Dict, Any, Optional, Tuple, Union, Callable, List, cast
|
|
7
7
|
|
|
8
|
-
from anthropic.types import Message, MessageParam
|
|
9
|
-
from mito_ai.completions.models import
|
|
10
|
-
from mito_ai.
|
|
8
|
+
from anthropic.types import Message, MessageParam, TextBlockParam
|
|
9
|
+
from mito_ai.completions.models import ResponseFormatInfo, CompletionReply, CompletionStreamChunk, CompletionItem, MessageType
|
|
10
|
+
from mito_ai.constants import MESSAGE_HISTORY_TRIM_THRESHOLD
|
|
11
11
|
from openai.types.chat import ChatCompletionMessageParam
|
|
12
12
|
from mito_ai.utils.anthropic_utils import get_anthropic_completion_from_mito_server, stream_anthropic_completion_from_mito_server, get_anthropic_completion_function_params
|
|
13
13
|
|
|
@@ -125,6 +125,90 @@ def get_anthropic_system_prompt_and_messages(messages: List[ChatCompletionMessag
|
|
|
125
125
|
return system_prompt, anthropic_messages
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def add_cache_control_to_message(message: MessageParam) -> MessageParam:
|
|
129
|
+
"""
|
|
130
|
+
Adds cache_control to a message's content.
|
|
131
|
+
Handles both string content and list of content blocks.
|
|
132
|
+
"""
|
|
133
|
+
content = message.get("content")
|
|
134
|
+
|
|
135
|
+
if isinstance(content, str):
|
|
136
|
+
# Simple string content - convert to list format with cache_control
|
|
137
|
+
return {
|
|
138
|
+
"role": message["role"],
|
|
139
|
+
"content": [
|
|
140
|
+
{
|
|
141
|
+
"type": "text",
|
|
142
|
+
"text": content,
|
|
143
|
+
"cache_control": {"type": "ephemeral"}
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
elif isinstance(content, list) and len(content) > 0:
|
|
149
|
+
# List of content blocks - add cache_control to last block
|
|
150
|
+
content_blocks = content.copy()
|
|
151
|
+
last_block = content_blocks[-1].copy()
|
|
152
|
+
last_block["cache_control"] = {"type": "ephemeral"}
|
|
153
|
+
content_blocks[-1] = last_block
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"role": message["role"],
|
|
157
|
+
"content": content_blocks
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
else:
|
|
161
|
+
# Edge case: empty or malformed content
|
|
162
|
+
return message
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_anthropic_system_prompt_and_messages_with_caching(messages: List[ChatCompletionMessageParam]) -> Tuple[
|
|
166
|
+
Union[str, List[TextBlockParam], anthropic.Omit], List[MessageParam]]:
|
|
167
|
+
"""
|
|
168
|
+
Convert a list of OpenAI messages to a list of Anthropic messages with caching applied.
|
|
169
|
+
|
|
170
|
+
Caching Strategy:
|
|
171
|
+
1. System prompt (static) → Always cached
|
|
172
|
+
2. Stable conversation history → Cache at keep_recent boundary
|
|
173
|
+
3. Recent messages → Never cached (always fresh)
|
|
174
|
+
|
|
175
|
+
The keep_recent parameter determines which messages are stable and won't be trimmed.
|
|
176
|
+
We cache at the keep_recent boundary because those messages are guaranteed to be stable.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
# Get the base system prompt and messages
|
|
180
|
+
system_prompt, anthropic_messages = get_anthropic_system_prompt_and_messages(messages)
|
|
181
|
+
|
|
182
|
+
# 1. Cache the system prompt always
|
|
183
|
+
# If the system prompt is something like anthropic.Omit, we don't need to cache it
|
|
184
|
+
cached_system_prompt: Union[str, List[TextBlockParam], anthropic.Omit] = system_prompt
|
|
185
|
+
if isinstance(system_prompt, str):
|
|
186
|
+
cached_system_prompt = [{
|
|
187
|
+
"type": "text",
|
|
188
|
+
"text": system_prompt,
|
|
189
|
+
"cache_control": {"type": "ephemeral"}
|
|
190
|
+
}]
|
|
191
|
+
|
|
192
|
+
# 2. Cache conversation history at the boundary where the messages are stable.
|
|
193
|
+
# Messages are stable after they are more than MESSAGE_HISTORY_TRIM_THRESHOLD old.
|
|
194
|
+
# At this point, the messages are not edited anymore, so they will not invalidate the cache.
|
|
195
|
+
# If we included the messages before the boundary in the cache, then every time we send a new
|
|
196
|
+
# message, we would invalidate the cache and we would never get a cache hit except for the system prompt.
|
|
197
|
+
messages_with_cache = []
|
|
198
|
+
|
|
199
|
+
if len(anthropic_messages) > 0:
|
|
200
|
+
cache_boundary = len(anthropic_messages) - MESSAGE_HISTORY_TRIM_THRESHOLD - 1
|
|
201
|
+
|
|
202
|
+
# Add all messages, but only add cache_control to the message at the boundary
|
|
203
|
+
for i, msg in enumerate(anthropic_messages):
|
|
204
|
+
if i == cache_boundary:
|
|
205
|
+
messages_with_cache.append(add_cache_control_to_message(msg))
|
|
206
|
+
else:
|
|
207
|
+
messages_with_cache.append(msg)
|
|
208
|
+
|
|
209
|
+
return cached_system_prompt, messages_with_cache
|
|
210
|
+
|
|
211
|
+
|
|
128
212
|
class AnthropicClient:
|
|
129
213
|
"""
|
|
130
214
|
A client for interacting with the Anthropic API or the Mito server fallback.
|
|
@@ -149,7 +233,7 @@ class AnthropicClient:
|
|
|
149
233
|
"""
|
|
150
234
|
Get a response from Claude or the Mito server that adheres to the AgentResponse format.
|
|
151
235
|
"""
|
|
152
|
-
anthropic_system_prompt, anthropic_messages =
|
|
236
|
+
anthropic_system_prompt, anthropic_messages = get_anthropic_system_prompt_and_messages_with_caching(messages)
|
|
153
237
|
|
|
154
238
|
provider_data = get_anthropic_completion_function_params(
|
|
155
239
|
message_type=message_type,
|
|
@@ -166,6 +250,7 @@ class AnthropicClient:
|
|
|
166
250
|
# Unpack provider_data for direct API call
|
|
167
251
|
assert self.client is not None
|
|
168
252
|
response = self.client.messages.create(**provider_data)
|
|
253
|
+
|
|
169
254
|
if provider_data.get("tool_choice") is not None:
|
|
170
255
|
result = extract_and_parse_anthropic_json_response(response)
|
|
171
256
|
return json.dumps(result) if not isinstance(result, str) else result
|
|
@@ -192,7 +277,7 @@ class AnthropicClient:
|
|
|
192
277
|
async def stream_completions(self, messages: List[ChatCompletionMessageParam], model: str, message_id: str, message_type: MessageType,
|
|
193
278
|
reply_fn: Callable[[Union[CompletionReply, CompletionStreamChunk]], None]) -> str:
|
|
194
279
|
try:
|
|
195
|
-
anthropic_system_prompt, anthropic_messages =
|
|
280
|
+
anthropic_system_prompt, anthropic_messages = get_anthropic_system_prompt_and_messages_with_caching(messages)
|
|
196
281
|
accumulated_response = ""
|
|
197
282
|
|
|
198
283
|
if self.api_key:
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import tornado
|
|
5
|
+
from typing import List, Any
|
|
6
|
+
from jupyter_server.base.handlers import APIHandler
|
|
7
|
+
from mito_ai.completions.message_history import GlobalMessageHistory
|
|
8
|
+
from mito_ai.completions.models import ChatThreadMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ChatHistoryHandler(APIHandler):
|
|
12
|
+
"""
|
|
13
|
+
Endpoints for working with chat history threads.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def initialize(self, message_history: GlobalMessageHistory) -> None:
|
|
17
|
+
"""Initialize the handler with the global message history instance."""
|
|
18
|
+
super().initialize()
|
|
19
|
+
self._message_history = message_history
|
|
20
|
+
|
|
21
|
+
@tornado.web.authenticated
|
|
22
|
+
def get(self, *args: Any, **kwargs: Any) -> None:
|
|
23
|
+
"""Get all chat history threads or a specific thread by ID."""
|
|
24
|
+
try:
|
|
25
|
+
# Check if a specific thread ID is provided in the URL
|
|
26
|
+
thread_id = kwargs.get("thread_id")
|
|
27
|
+
|
|
28
|
+
if thread_id:
|
|
29
|
+
# Get specific thread
|
|
30
|
+
if thread_id in self._message_history._chat_threads:
|
|
31
|
+
thread = self._message_history._chat_threads[thread_id]
|
|
32
|
+
thread_data = {
|
|
33
|
+
"thread_id": thread.thread_id,
|
|
34
|
+
"name": thread.name,
|
|
35
|
+
"creation_ts": thread.creation_ts,
|
|
36
|
+
"last_interaction_ts": thread.last_interaction_ts,
|
|
37
|
+
"display_history": thread.display_history,
|
|
38
|
+
"ai_optimized_history": thread.ai_optimized_history,
|
|
39
|
+
}
|
|
40
|
+
self.finish(thread_data)
|
|
41
|
+
else:
|
|
42
|
+
self.set_status(404)
|
|
43
|
+
self.finish({"error": f"Thread with ID {thread_id} not found"})
|
|
44
|
+
else:
|
|
45
|
+
# Get all threads
|
|
46
|
+
threads: List[ChatThreadMetadata] = self._message_history.get_threads()
|
|
47
|
+
|
|
48
|
+
# Convert to dict format for JSON serialization
|
|
49
|
+
threads_data = [
|
|
50
|
+
{
|
|
51
|
+
"thread_id": thread.thread_id,
|
|
52
|
+
"name": thread.name,
|
|
53
|
+
"creation_ts": thread.creation_ts,
|
|
54
|
+
"last_interaction_ts": thread.last_interaction_ts,
|
|
55
|
+
}
|
|
56
|
+
for thread in threads
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
self.finish({"threads": threads_data})
|
|
60
|
+
|
|
61
|
+
except Exception as e:
|
|
62
|
+
self.set_status(500)
|
|
63
|
+
self.finish({"error": str(e)})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
from typing import List, Tuple, Any
|
|
5
|
+
from jupyter_server.utils import url_path_join
|
|
6
|
+
from mito_ai.chat_history.handlers import ChatHistoryHandler
|
|
7
|
+
from mito_ai.completions.message_history import GlobalMessageHistory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_chat_history_urls(base_url: str, message_history: GlobalMessageHistory) -> List[Tuple[str, Any, dict]]:
|
|
11
|
+
"""Get all chat history related URL patterns.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
base_url: The base URL for the Jupyter server
|
|
15
|
+
message_history: The global message history instance
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
List of (url_pattern, handler_class, handler_kwargs) tuples
|
|
19
|
+
"""
|
|
20
|
+
BASE_URL = base_url + "/mito-ai/chat-history"
|
|
21
|
+
return [
|
|
22
|
+
(
|
|
23
|
+
url_path_join(BASE_URL, "threads"),
|
|
24
|
+
ChatHistoryHandler,
|
|
25
|
+
{"message_history": message_history},
|
|
26
|
+
),
|
|
27
|
+
(
|
|
28
|
+
url_path_join(BASE_URL, "threads", "(?P<thread_id>[^/]+)"),
|
|
29
|
+
ChatHistoryHandler,
|
|
30
|
+
{"message_history": message_history},
|
|
31
|
+
),
|
|
32
|
+
]
|
mito_ai/completions/handlers.py
CHANGED
|
@@ -49,11 +49,8 @@ from mito_ai.utils.telemetry_utils import identify
|
|
|
49
49
|
|
|
50
50
|
FALLBACK_MODEL = "gpt-4.1" # Default model to use for safety
|
|
51
51
|
|
|
52
|
-
# The GlobalMessageHistory is
|
|
53
|
-
#
|
|
54
|
-
# there is one manager of the locks for the .mito/ai-chats directory. This is my current understanding and it
|
|
55
|
-
# might be incorrect!
|
|
56
|
-
message_history = GlobalMessageHistory()
|
|
52
|
+
# The GlobalMessageHistory is now created in __init__.py and passed to handlers
|
|
53
|
+
# to ensure there's only one instance managing the .mito/ai-chats directory locks
|
|
57
54
|
|
|
58
55
|
# This handler is responsible for the mito_ai/completions endpoint.
|
|
59
56
|
# It takes a message from the user, sends it to the OpenAI API, and returns the response.
|
|
@@ -62,10 +59,11 @@ message_history = GlobalMessageHistory()
|
|
|
62
59
|
class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
63
60
|
"""Completion websocket handler."""
|
|
64
61
|
|
|
65
|
-
def initialize(self, llm: OpenAIProvider) -> None:
|
|
62
|
+
def initialize(self, llm: OpenAIProvider, message_history: GlobalMessageHistory) -> None:
|
|
66
63
|
super().initialize()
|
|
67
64
|
self.log.debug("Initializing websocket connection %s", self.request.path)
|
|
68
65
|
self._llm = llm
|
|
66
|
+
self._message_history = message_history
|
|
69
67
|
self.is_pro = is_pro()
|
|
70
68
|
self._selected_model = FALLBACK_MODEL
|
|
71
69
|
self.is_electron = False
|
|
@@ -150,7 +148,7 @@ class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
|
150
148
|
|
|
151
149
|
# Clear history if the type is "start_new_chat"
|
|
152
150
|
if type == MessageType.START_NEW_CHAT:
|
|
153
|
-
thread_id =
|
|
151
|
+
thread_id = self._message_history.create_new_thread()
|
|
154
152
|
|
|
155
153
|
reply = StartNewChatReply(
|
|
156
154
|
parent_id=parsed_message.get("message_id"),
|
|
@@ -161,7 +159,7 @@ class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
|
161
159
|
|
|
162
160
|
# Handle get_threads: return list of chat threads
|
|
163
161
|
if type == MessageType.GET_THREADS:
|
|
164
|
-
threads =
|
|
162
|
+
threads = self._message_history.get_threads()
|
|
165
163
|
reply = FetchThreadsReply(
|
|
166
164
|
parent_id=parsed_message.get("message_id"),
|
|
167
165
|
threads=threads
|
|
@@ -173,7 +171,7 @@ class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
|
173
171
|
if type == MessageType.DELETE_THREAD:
|
|
174
172
|
thread_id_to_delete = metadata_dict.get('thread_id')
|
|
175
173
|
if thread_id_to_delete:
|
|
176
|
-
is_thread_deleted =
|
|
174
|
+
is_thread_deleted = self._message_history.delete_thread(thread_id_to_delete)
|
|
177
175
|
reply = DeleteThreadReply(
|
|
178
176
|
parent_id=parsed_message.get("message_id"),
|
|
179
177
|
success=is_thread_deleted
|
|
@@ -189,7 +187,7 @@ class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
|
189
187
|
|
|
190
188
|
# If a thread_id is provided, use that thread's history; otherwise, use newest.
|
|
191
189
|
thread_id = metadata_dict.get('thread_id')
|
|
192
|
-
display_history =
|
|
190
|
+
display_history = self._message_history.get_display_history(thread_id)
|
|
193
191
|
|
|
194
192
|
reply = FetchHistoryReply(
|
|
195
193
|
parent_id=parsed_message.get('message_id'),
|
|
@@ -238,7 +236,7 @@ class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
|
238
236
|
"content": "Agent interupted by user "
|
|
239
237
|
}
|
|
240
238
|
|
|
241
|
-
await
|
|
239
|
+
await self._message_history.append_message(
|
|
242
240
|
ai_optimized_message=ai_optimized_message,
|
|
243
241
|
display_message=display_optimized_message,
|
|
244
242
|
model=self._selected_model,
|
|
@@ -266,7 +264,7 @@ class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
|
266
264
|
await stream_chat_completion(
|
|
267
265
|
chat_metadata,
|
|
268
266
|
self._llm,
|
|
269
|
-
|
|
267
|
+
self._message_history,
|
|
270
268
|
message_id,
|
|
271
269
|
self.reply,
|
|
272
270
|
model
|
|
@@ -274,7 +272,7 @@ class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
|
274
272
|
return
|
|
275
273
|
else:
|
|
276
274
|
# Regular non-streaming completion
|
|
277
|
-
completion = await get_chat_completion(chat_metadata, self._llm,
|
|
275
|
+
completion = await get_chat_completion(chat_metadata, self._llm, self._message_history, model)
|
|
278
276
|
elif type == MessageType.SMART_DEBUG:
|
|
279
277
|
smart_debug_metadata = SmartDebugMetadata(**metadata_dict)
|
|
280
278
|
# Handle streaming if requested and available
|
|
@@ -283,7 +281,7 @@ class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
|
283
281
|
await stream_smart_debug_completion(
|
|
284
282
|
smart_debug_metadata,
|
|
285
283
|
self._llm,
|
|
286
|
-
|
|
284
|
+
self._message_history,
|
|
287
285
|
message_id,
|
|
288
286
|
self.reply,
|
|
289
287
|
model
|
|
@@ -291,7 +289,7 @@ class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
|
291
289
|
return
|
|
292
290
|
else:
|
|
293
291
|
# Regular non-streaming completion
|
|
294
|
-
completion = await get_smart_debug_completion(smart_debug_metadata, self._llm,
|
|
292
|
+
completion = await get_smart_debug_completion(smart_debug_metadata, self._llm, self._message_history, model)
|
|
295
293
|
elif type == MessageType.CODE_EXPLAIN:
|
|
296
294
|
code_explain_metadata = CodeExplainMetadata(**metadata_dict)
|
|
297
295
|
|
|
@@ -301,7 +299,7 @@ class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
|
301
299
|
await stream_code_explain_completion(
|
|
302
300
|
code_explain_metadata,
|
|
303
301
|
self._llm,
|
|
304
|
-
|
|
302
|
+
self._message_history,
|
|
305
303
|
message_id,
|
|
306
304
|
self.reply,
|
|
307
305
|
model
|
|
@@ -309,16 +307,16 @@ class CompletionHandler(JupyterHandler, WebSocketHandler):
|
|
|
309
307
|
return
|
|
310
308
|
else:
|
|
311
309
|
# Regular non-streaming completion
|
|
312
|
-
completion = await get_code_explain_completion(code_explain_metadata, self._llm,
|
|
310
|
+
completion = await get_code_explain_completion(code_explain_metadata, self._llm, self._message_history, model)
|
|
313
311
|
elif type == MessageType.AGENT_EXECUTION:
|
|
314
312
|
agent_execution_metadata = AgentExecutionMetadata(**metadata_dict)
|
|
315
|
-
completion = await get_agent_execution_completion(agent_execution_metadata, self._llm,
|
|
313
|
+
completion = await get_agent_execution_completion(agent_execution_metadata, self._llm, self._message_history, model)
|
|
316
314
|
elif type == MessageType.AGENT_AUTO_ERROR_FIXUP:
|
|
317
315
|
agent_auto_error_fixup_metadata = AgentSmartDebugMetadata(**metadata_dict)
|
|
318
|
-
completion = await get_agent_auto_error_fixup_completion(agent_auto_error_fixup_metadata, self._llm,
|
|
316
|
+
completion = await get_agent_auto_error_fixup_completion(agent_auto_error_fixup_metadata, self._llm, self._message_history, model)
|
|
319
317
|
elif type == MessageType.INLINE_COMPLETION:
|
|
320
318
|
inline_completer_metadata = InlineCompleterMetadata(**metadata_dict)
|
|
321
|
-
completion = await get_inline_completion(inline_completer_metadata, self._llm,
|
|
319
|
+
completion = await get_inline_completion(inline_completer_metadata, self._llm, self._message_history, model)
|
|
322
320
|
else:
|
|
323
321
|
raise ValueError(f"Invalid message type: {type}")
|
|
324
322
|
|
mito_ai/constants.py
CHANGED
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
from typing import List
|
|
5
5
|
import re
|
|
6
|
+
from anthropic.types import MessageParam
|
|
7
|
+
from mito_ai.streamlit_conversion.streamlit_system_prompt import streamlit_system_prompt
|
|
8
|
+
from mito_ai.utils.anthropic_utils import stream_anthropic_completion_from_mito_server
|
|
6
9
|
from unidiff import PatchSet
|
|
7
10
|
from mito_ai.streamlit_conversion.prompts.prompt_constants import MITO_TODO_PLACEHOLDER
|
|
11
|
+
from mito_ai.completions.models import MessageType
|
|
12
|
+
|
|
13
|
+
STREAMLIT_AI_MODEL = "claude-3-5-haiku-latest"
|
|
8
14
|
|
|
9
15
|
def extract_todo_placeholders(agent_response: str) -> List[str]:
|
|
10
16
|
"""Extract TODO placeholders from the agent's response"""
|
|
@@ -23,6 +29,8 @@ def apply_patch_to_text(text: str, diff: str) -> str:
|
|
|
23
29
|
diff : str
|
|
24
30
|
A unified diff that transforms *text* into the desired output.
|
|
25
31
|
The diff must reference exactly one file (the Streamlit app).
|
|
32
|
+
NOTE: This assumes a custom format where BOTH -X,Y and +X,Y
|
|
33
|
+
reference the original file line numbers.
|
|
26
34
|
|
|
27
35
|
Returns
|
|
28
36
|
-------
|
|
@@ -42,37 +50,46 @@ def apply_patch_to_text(text: str, diff: str) -> str:
|
|
|
42
50
|
patch = PatchSet(diff.splitlines(keepends=True))
|
|
43
51
|
|
|
44
52
|
# We expect a single-file patch (what the prompt asks the model to emit)
|
|
45
|
-
if len(patch)
|
|
53
|
+
if len(patch) == 0:
|
|
54
|
+
raise ValueError("No patches found in diff")
|
|
55
|
+
|
|
56
|
+
# Check that all patches are for the same file
|
|
57
|
+
file_names = set(p.source_file for p in patch)
|
|
58
|
+
if len(file_names) > 1:
|
|
46
59
|
raise ValueError(
|
|
47
|
-
f"Expected
|
|
60
|
+
f"Expected patches for exactly one file, got files: {file_names}"
|
|
48
61
|
)
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
# Apply all hunks from all patches (they should all be for the same file)
|
|
52
64
|
original_lines = text.splitlines(keepends=True)
|
|
53
65
|
result_lines: List[str] = []
|
|
54
|
-
|
|
55
66
|
cursor = 0 # index in original_lines (0-based)
|
|
56
67
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
# Process all hunks from all patches
|
|
69
|
+
# We only expect one patch file, but it always returns as a list
|
|
70
|
+
# so we just iterate over it
|
|
71
|
+
for file_patch in patch:
|
|
72
|
+
for hunk in file_patch:
|
|
73
|
+
# Since hunks reference the original file, just convert to 0-based
|
|
74
|
+
hunk_start = hunk.source_start - 1
|
|
75
|
+
|
|
76
|
+
# Copy unchanged lines before this hunk
|
|
77
|
+
while cursor < hunk_start:
|
|
78
|
+
if cursor < len(original_lines):
|
|
79
|
+
result_lines.append(original_lines[cursor])
|
|
67
80
|
cursor += 1
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
|
|
82
|
+
# Apply hunk line-by-line
|
|
83
|
+
for line in hunk:
|
|
84
|
+
if line.is_context:
|
|
85
|
+
# Use the line from the diff to preserve exact formatting
|
|
86
|
+
result_lines.append(line.value)
|
|
87
|
+
cursor += 1
|
|
88
|
+
elif line.is_removed:
|
|
89
|
+
cursor += 1 # Skip this line from the original
|
|
90
|
+
elif line.is_added:
|
|
91
|
+
# Use the line from the diff to preserve exact formatting
|
|
92
|
+
result_lines.append(line.value)
|
|
76
93
|
|
|
77
94
|
# Copy any remaining lines after the last hunk
|
|
78
95
|
result_lines.extend(original_lines[cursor:])
|
|
@@ -80,11 +97,66 @@ def apply_patch_to_text(text: str, diff: str) -> str:
|
|
|
80
97
|
return "".join(result_lines)
|
|
81
98
|
|
|
82
99
|
|
|
100
|
+
def fix_context_lines(diff: str) -> str:
|
|
101
|
+
"""
|
|
102
|
+
Fix context lines in unified diff to ensure they all start with a space character.
|
|
103
|
+
|
|
104
|
+
In unified diffs, context lines (unchanged lines) must start with a single space ' ',
|
|
105
|
+
even if the line itself is empty. The AI sometimes generates diffs where empty
|
|
106
|
+
context lines are just blank lines without the leading space, which causes the
|
|
107
|
+
unidiff parser to fail.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
diff (str): The unified diff string
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
str: The corrected diff with proper context line formatting
|
|
114
|
+
"""
|
|
115
|
+
lines = diff.split('\n')
|
|
116
|
+
corrected_lines = []
|
|
117
|
+
in_hunk = False
|
|
118
|
+
|
|
119
|
+
for i, line in enumerate(lines):
|
|
120
|
+
# Check if we're entering a hunk
|
|
121
|
+
if line.startswith('@@'):
|
|
122
|
+
in_hunk = True
|
|
123
|
+
corrected_lines.append(line)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Check if we're leaving a hunk (new file header)
|
|
127
|
+
if line.startswith('---') or line.startswith('+++'):
|
|
128
|
+
in_hunk = False
|
|
129
|
+
corrected_lines.append(line)
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if in_hunk:
|
|
133
|
+
# We're inside a hunk
|
|
134
|
+
if line.startswith(' ') or line.startswith('-') or line.startswith('+'):
|
|
135
|
+
# Already has proper diff marker
|
|
136
|
+
corrected_lines.append(line)
|
|
137
|
+
elif line.strip() == '':
|
|
138
|
+
# Empty line should be a context line with leading space
|
|
139
|
+
corrected_lines.append(' ')
|
|
140
|
+
else:
|
|
141
|
+
# Line without diff marker - treat as context line
|
|
142
|
+
corrected_lines.append(' ' + line)
|
|
143
|
+
else:
|
|
144
|
+
# Outside hunk - keep as is
|
|
145
|
+
corrected_lines.append(line)
|
|
146
|
+
|
|
147
|
+
return '\n'.join(corrected_lines)
|
|
148
|
+
|
|
149
|
+
|
|
83
150
|
def fix_diff_headers(diff: str) -> str:
|
|
84
151
|
"""
|
|
85
152
|
The AI is generally not very good at counting the number of lines in the diff. If the hunk header has
|
|
86
153
|
an incorrect count, then the patch will fail. So instead we just calculate the counts ourselves, its deterministic.
|
|
154
|
+
|
|
155
|
+
If no header is provided at all, then there is nothing to fix.
|
|
87
156
|
"""
|
|
157
|
+
# First fix context lines to ensure they have proper leading spaces
|
|
158
|
+
diff = fix_context_lines(diff)
|
|
159
|
+
|
|
88
160
|
lines = diff.split('\n')
|
|
89
161
|
|
|
90
162
|
for i, line in enumerate(lines):
|
|
@@ -99,18 +171,64 @@ def fix_diff_headers(diff: str) -> str:
|
|
|
99
171
|
old_count = 0
|
|
100
172
|
new_count = 0
|
|
101
173
|
|
|
174
|
+
# Find the end of this hunk (next @@ line or end of file)
|
|
175
|
+
hunk_end = len(lines)
|
|
102
176
|
for j in range(i + 1, len(lines)):
|
|
103
|
-
|
|
104
|
-
|
|
177
|
+
if lines[j].startswith('@@'):
|
|
178
|
+
hunk_end = j
|
|
105
179
|
break
|
|
106
|
-
|
|
180
|
+
|
|
181
|
+
# Count lines in this hunk
|
|
182
|
+
for j in range(i + 1, hunk_end):
|
|
183
|
+
hunk_line = lines[j]
|
|
184
|
+
# Empty lines are treated as context lines
|
|
185
|
+
if hunk_line == '' or hunk_line.startswith(' ') or hunk_line.startswith('-'):
|
|
107
186
|
old_count += 1
|
|
108
|
-
if
|
|
187
|
+
if hunk_line == '' or hunk_line.startswith(' ') or hunk_line.startswith('+'):
|
|
109
188
|
new_count += 1
|
|
110
189
|
|
|
111
190
|
# Replace the header with correct counts
|
|
112
191
|
lines[i] = f"@@ -{old_start},{old_count} +{new_start},{new_count} @@"
|
|
113
192
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
193
|
+
corrected_diff = '\n'.join(lines)
|
|
194
|
+
corrected_diff = corrected_diff.lstrip()
|
|
195
|
+
|
|
196
|
+
# If there is no diff, just return it without fixing file headers
|
|
197
|
+
if len(corrected_diff) == 0:
|
|
198
|
+
return corrected_diff
|
|
199
|
+
|
|
200
|
+
# Remove known problametic file component headers that the AI sometimes returns
|
|
201
|
+
problamatic_file_header_components = ['--- a/app.py +++ b/app.py']
|
|
202
|
+
for problamatic_file_header_component in problamatic_file_header_components:
|
|
203
|
+
corrected_diff = corrected_diff.removeprefix(problamatic_file_header_component).lstrip()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# If the diff is missing the file component of the header, add it
|
|
207
|
+
valid_header_component = """--- a/app.py
|
|
208
|
+
+++ b/app.py"""
|
|
209
|
+
if not corrected_diff.startswith(valid_header_component):
|
|
210
|
+
corrected_diff = valid_header_component + '\n' + corrected_diff
|
|
211
|
+
|
|
212
|
+
return corrected_diff
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def get_response_from_agent(message_to_agent: List[MessageParam]) -> str:
|
|
216
|
+
"""Gets the streaming response from the agent using the mito server"""
|
|
217
|
+
model = STREAMLIT_AI_MODEL
|
|
218
|
+
max_tokens = 8192 # 64_000
|
|
219
|
+
temperature = 0.2
|
|
220
|
+
|
|
221
|
+
accumulated_response = ""
|
|
222
|
+
async for stream_chunk in stream_anthropic_completion_from_mito_server(
|
|
223
|
+
model = model,
|
|
224
|
+
max_tokens = max_tokens,
|
|
225
|
+
temperature = temperature,
|
|
226
|
+
system = streamlit_system_prompt,
|
|
227
|
+
messages = message_to_agent,
|
|
228
|
+
stream=True,
|
|
229
|
+
message_type=MessageType.STREAMLIT_CONVERSION,
|
|
230
|
+
reply_fn=None,
|
|
231
|
+
message_id=""
|
|
232
|
+
):
|
|
233
|
+
accumulated_response += stream_chunk
|
|
234
|
+
return accumulated_response
|