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.
Files changed (69) hide show
  1. mito_ai/__init__.py +10 -1
  2. mito_ai/_version.py +1 -1
  3. mito_ai/anthropic_client.py +90 -5
  4. mito_ai/chat_history/handlers.py +63 -0
  5. mito_ai/chat_history/urls.py +32 -0
  6. mito_ai/completions/handlers.py +18 -20
  7. mito_ai/constants.py +3 -0
  8. mito_ai/streamlit_conversion/agent_utils.py +148 -30
  9. mito_ai/streamlit_conversion/prompts/prompt_constants.py +147 -24
  10. mito_ai/streamlit_conversion/prompts/streamlit_app_creation_prompt.py +2 -1
  11. mito_ai/streamlit_conversion/prompts/streamlit_error_correction_prompt.py +2 -2
  12. mito_ai/streamlit_conversion/prompts/streamlit_finish_todo_prompt.py +4 -3
  13. mito_ai/streamlit_conversion/prompts/update_existing_app_prompt.py +50 -0
  14. mito_ai/streamlit_conversion/streamlit_agent_handler.py +101 -107
  15. mito_ai/streamlit_conversion/streamlit_system_prompt.py +1 -0
  16. mito_ai/streamlit_conversion/streamlit_utils.py +13 -10
  17. mito_ai/streamlit_conversion/validate_streamlit_app.py +77 -82
  18. mito_ai/streamlit_preview/handlers.py +3 -4
  19. mito_ai/streamlit_preview/utils.py +11 -7
  20. mito_ai/tests/chat_history/test_chat_history.py +211 -0
  21. mito_ai/tests/message_history/test_message_history_utils.py +43 -19
  22. mito_ai/tests/providers/test_anthropic_client.py +178 -6
  23. mito_ai/tests/streamlit_conversion/test_apply_patch_to_text.py +368 -0
  24. mito_ai/tests/streamlit_conversion/test_fix_diff_headers.py +533 -0
  25. mito_ai/tests/streamlit_conversion/test_streamlit_agent_handler.py +71 -74
  26. mito_ai/tests/streamlit_conversion/test_streamlit_utils.py +16 -16
  27. mito_ai/tests/streamlit_conversion/test_validate_streamlit_app.py +17 -14
  28. mito_ai/tests/streamlit_preview/test_streamlit_preview_handler.py +2 -2
  29. mito_ai/tests/user/__init__.py +2 -0
  30. mito_ai/tests/user/test_user.py +120 -0
  31. mito_ai/user/handlers.py +33 -0
  32. mito_ai/user/urls.py +21 -0
  33. mito_ai/utils/anthropic_utils.py +8 -6
  34. mito_ai/utils/message_history_utils.py +4 -3
  35. mito_ai/utils/telemetry_utils.py +7 -4
  36. {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +1 -1
  37. {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/package.json +2 -2
  38. {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
  39. 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
  40. mito_ai-0.1.46.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.20f12766ecd3d430568e.js.map +1 -0
  41. 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
  42. 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
  43. {mito_ai-0.1.45.dist-info → mito_ai-0.1.46.dist-info}/METADATA +1 -1
  44. {mito_ai-0.1.45.dist-info → mito_ai-0.1.46.dist-info}/RECORD +68 -58
  45. mito_ai-0.1.45.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.0c3368195d954d2ed033.js.map +0 -1
  46. {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
  47. {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
  48. {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
  49. {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
  50. {mito_ai-0.1.45.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
  51. {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
  52. {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
  53. {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
  54. {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
  55. {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
  56. {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
  57. {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
  58. {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
  59. {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
  60. {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
  61. {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
  62. {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
  63. {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
  64. {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
  65. {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
  66. {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
  67. {mito_ai-0.1.45.dist-info → mito_ai-0.1.46.dist-info}/WHEEL +0 -0
  68. {mito_ai-0.1.45.dist-info → mito_ai-0.1.46.dist-info}/entry_points.txt +0 -0
  69. {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
@@ -1,4 +1,4 @@
1
1
  # This file is auto-generated by Hatchling. As such, do not:
2
2
  # - modify
3
3
  # - track in version control e.g. be sure to add to .gitignore
4
- __version__ = VERSION = '0.1.45'
4
+ __version__ = VERSION = '0.1.46'
@@ -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 CompletionError, ResponseFormatInfo, CompletionReply, CompletionStreamChunk, CompletionItem, MessageType
10
- from mito_ai.utils.mito_server_utils import ProviderCompletionException
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 = get_anthropic_system_prompt_and_messages(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 = get_anthropic_system_prompt_and_messages(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
+ ]
@@ -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 responsible for updating the message histories stored in the .mito/ai-chats directory.
53
- # We create one GlobalMessageHistory per backend server instance instead of one per websocket connection so that the
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 = message_history.create_new_thread()
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 = message_history.get_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 = message_history.delete_thread(thread_id_to_delete)
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 = message_history.get_display_history(thread_id)
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 message_history.append_message(
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
- message_history,
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, message_history, model)
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
- message_history,
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, message_history, model)
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
- message_history,
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, message_history, model)
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, message_history, model)
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, message_history, model)
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, message_history, model)
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
@@ -58,3 +58,6 @@ COGNITO_CONFIG_DEV = {
58
58
  }
59
59
 
60
60
  ACTIVE_COGNITO_CONFIG = COGNITO_CONFIG_DEV # Change to COGNITO_CONFIG_DEV for dev
61
+
62
+
63
+ MESSAGE_HISTORY_TRIM_THRESHOLD: int = 3
@@ -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) != 1:
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 a patch for exactly one file, got {len(patch)} files."
60
+ f"Expected patches for exactly one file, got files: {file_names}"
48
61
  )
49
62
 
50
- file_patch = patch[0]
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
- for hunk in file_patch:
58
- # Copy unchanged lines before this hunk
59
- while cursor < hunk.source_start - 1:
60
- result_lines.append(original_lines[cursor])
61
- cursor += 1
62
-
63
- # Apply hunk line-by-line
64
- for line in hunk:
65
- if line.is_context:
66
- result_lines.append(original_lines[cursor])
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
- elif line.is_removed:
69
- cursor += 1 # Skip this line from the original
70
- elif line.is_added:
71
- # Ensure added line ends with newline for consistency
72
- val = line.value
73
- if not val.endswith("\n"):
74
- val += "\n"
75
- result_lines.append(val)
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
- next_line = lines[j]
104
- if next_line.startswith('@@') or next_line.startswith('---'):
177
+ if lines[j].startswith('@@'):
178
+ hunk_end = j
105
179
  break
106
- if next_line.startswith(' ') or next_line.startswith('-'):
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 next_line.startswith(' ') or next_line.startswith('+'):
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
- return '\n'.join(lines)
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