hdsp-jupyter-extension 2.0.7__py3-none-any.whl → 2.0.10__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 (84) hide show
  1. agent_server/core/embedding_service.py +67 -46
  2. agent_server/core/rag_manager.py +40 -17
  3. agent_server/core/retriever.py +12 -6
  4. agent_server/core/vllm_embedding_service.py +246 -0
  5. agent_server/langchain/ARCHITECTURE.md +7 -51
  6. agent_server/langchain/agent.py +39 -20
  7. agent_server/langchain/custom_middleware.py +206 -62
  8. agent_server/langchain/hitl_config.py +6 -9
  9. agent_server/langchain/llm_factory.py +85 -1
  10. agent_server/langchain/logging_utils.py +52 -13
  11. agent_server/langchain/prompts.py +85 -45
  12. agent_server/langchain/tools/__init__.py +14 -10
  13. agent_server/langchain/tools/file_tools.py +266 -40
  14. agent_server/langchain/tools/file_utils.py +334 -0
  15. agent_server/langchain/tools/jupyter_tools.py +0 -1
  16. agent_server/langchain/tools/lsp_tools.py +264 -0
  17. agent_server/langchain/tools/resource_tools.py +12 -12
  18. agent_server/langchain/tools/search_tools.py +3 -158
  19. agent_server/main.py +7 -0
  20. agent_server/routers/langchain_agent.py +207 -102
  21. agent_server/routers/rag.py +8 -3
  22. hdsp_agent_core/models/rag.py +15 -1
  23. hdsp_agent_core/services/rag_service.py +6 -1
  24. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/build_log.json +1 -1
  25. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/package.json +3 -2
  26. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.4770ec0fb2d173b6deb4.js → hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.2d9fb488c82498c45c2d.js +251 -5
  27. hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.2d9fb488c82498c45c2d.js.map +1 -0
  28. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.29cf4312af19e86f82af.js → hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.dc6434bee96ab03a0539.js +1831 -274
  29. hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.dc6434bee96ab03a0539.js.map +1 -0
  30. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.61343eb4cf0577e74b50.js → hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.4a252df3ade74efee8d6.js +11 -9
  31. hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.4a252df3ade74efee8d6.js.map +1 -0
  32. jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js → hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js +2 -209
  33. hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js.map +1 -0
  34. jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js → hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js +209 -2
  35. hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js.map +1 -0
  36. jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js → hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js +212 -3
  37. hdsp_jupyter_extension-2.0.10.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +1 -0
  38. {hdsp_jupyter_extension-2.0.7.dist-info → hdsp_jupyter_extension-2.0.10.dist-info}/METADATA +1 -3
  39. hdsp_jupyter_extension-2.0.10.dist-info/RECORD +144 -0
  40. jupyter_ext/__init__.py +18 -0
  41. jupyter_ext/_version.py +1 -1
  42. jupyter_ext/handlers.py +176 -1
  43. jupyter_ext/labextension/build_log.json +1 -1
  44. jupyter_ext/labextension/package.json +3 -2
  45. jupyter_ext/labextension/static/{frontend_styles_index_js.4770ec0fb2d173b6deb4.js → frontend_styles_index_js.2d9fb488c82498c45c2d.js} +251 -5
  46. jupyter_ext/labextension/static/frontend_styles_index_js.2d9fb488c82498c45c2d.js.map +1 -0
  47. jupyter_ext/labextension/static/{lib_index_js.29cf4312af19e86f82af.js → lib_index_js.dc6434bee96ab03a0539.js} +1831 -274
  48. jupyter_ext/labextension/static/lib_index_js.dc6434bee96ab03a0539.js.map +1 -0
  49. jupyter_ext/labextension/static/{remoteEntry.61343eb4cf0577e74b50.js → remoteEntry.4a252df3ade74efee8d6.js} +11 -9
  50. jupyter_ext/labextension/static/remoteEntry.4a252df3ade74efee8d6.js.map +1 -0
  51. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js → jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js +2 -209
  52. jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js.map +1 -0
  53. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js → jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js +209 -2
  54. jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js.map +1 -0
  55. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js → jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js +212 -3
  56. jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +1 -0
  57. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.4770ec0fb2d173b6deb4.js.map +0 -1
  58. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.29cf4312af19e86f82af.js.map +0 -1
  59. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.61343eb4cf0577e74b50.js.map +0 -1
  60. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js.map +0 -1
  61. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js.map +0 -1
  62. hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js.map +0 -1
  63. hdsp_jupyter_extension-2.0.7.dist-info/RECORD +0 -141
  64. jupyter_ext/labextension/static/frontend_styles_index_js.4770ec0fb2d173b6deb4.js.map +0 -1
  65. jupyter_ext/labextension/static/lib_index_js.29cf4312af19e86f82af.js.map +0 -1
  66. jupyter_ext/labextension/static/remoteEntry.61343eb4cf0577e74b50.js.map +0 -1
  67. jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js.map +0 -1
  68. jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js.map +0 -1
  69. jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js.map +0 -1
  70. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +0 -0
  71. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/install.json +0 -0
  72. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js +0 -0
  73. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js.map +0 -0
  74. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js +0 -0
  75. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js.map +0 -0
  76. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/static/style.js +0 -0
  77. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js +0 -0
  78. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js.map +0 -0
  79. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +0 -0
  80. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js.map +0 -0
  81. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +0 -0
  82. {hdsp_jupyter_extension-2.0.7.data → hdsp_jupyter_extension-2.0.10.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +0 -0
  83. {hdsp_jupyter_extension-2.0.7.dist-info → hdsp_jupyter_extension-2.0.10.dist-info}/WHEEL +0 -0
  84. {hdsp_jupyter_extension-2.0.7.dist-info → hdsp_jupyter_extension-2.0.10.dist-info}/licenses/LICENSE +0 -0
@@ -1,19 +1,16 @@
1
1
  """
2
2
  Search Tools for LangChain Agent
3
3
 
4
- Provides tools for searching code in workspace and notebooks.
5
- These tools return pending_execution status and are executed on the client (Jupyter) side
6
- using subprocess (find/grep/ripgrep).
4
+ Provides tools for searching notebook cells.
5
+ For file searching, use execute_command_tool with find/grep commands.
7
6
 
8
7
  Key features:
9
8
  - Returns command info for client-side execution via subprocess
10
- - Supports ripgrep (rg) if available, falls back to grep
11
9
  - Executes immediately without user approval
12
10
  - Shows the command being executed in status messages
13
11
  """
14
12
 
15
13
  import logging
16
- import shutil
17
14
  from typing import Any, Dict, List, Optional
18
15
 
19
16
  from langchain_core.tools import tool
@@ -22,23 +19,6 @@ from pydantic import BaseModel, Field
22
19
  logger = logging.getLogger(__name__)
23
20
 
24
21
 
25
- class SearchWorkspaceInput(BaseModel):
26
- """Input schema for search_workspace tool"""
27
-
28
- pattern: str = Field(description="Search pattern (regex or text)")
29
- file_types: List[str] = Field(
30
- default=["*.py", "*.ipynb"],
31
- description="File patterns to search (e.g., ['*.py', '*.ipynb'])",
32
- )
33
- path: str = Field(default=".", description="Directory to search in")
34
- max_results: int = Field(default=50, description="Maximum number of results")
35
- case_sensitive: bool = Field(default=False, description="Case-sensitive search")
36
- execution_result: Optional[Dict[str, Any]] = Field(
37
- default=None,
38
- description="Execution result payload from the client",
39
- )
40
-
41
-
42
22
  class SearchNotebookCellsInput(BaseModel):
43
23
  """Input schema for search_notebook_cells tool"""
44
24
 
@@ -58,72 +38,6 @@ class SearchNotebookCellsInput(BaseModel):
58
38
  )
59
39
 
60
40
 
61
- def _is_ripgrep_available() -> bool:
62
- """Check if ripgrep (rg) is installed and available."""
63
- return shutil.which("rg") is not None
64
-
65
-
66
- def _build_grep_command(
67
- pattern: str,
68
- file_types: List[str],
69
- path: str,
70
- case_sensitive: bool,
71
- max_results: int,
72
- ) -> tuple[str, str]:
73
- """
74
- Build a grep/ripgrep command for searching files.
75
-
76
- Returns:
77
- Tuple of (command_string, tool_name) where tool_name is 'rg' or 'grep'
78
- """
79
- # Check ripgrep availability (this check will also be done on client)
80
- use_ripgrep = _is_ripgrep_available()
81
-
82
- if use_ripgrep:
83
- # Build ripgrep command
84
- cmd_parts = ["rg", "--line-number", "--with-filename"]
85
-
86
- if not case_sensitive:
87
- cmd_parts.append("--ignore-case")
88
-
89
- # Add file type filters using glob patterns
90
- for ft in file_types:
91
- cmd_parts.extend(["--glob", ft])
92
-
93
- # Limit results
94
- cmd_parts.extend(["--max-count", str(max_results)])
95
-
96
- # Escape pattern for shell
97
- escaped_pattern = pattern.replace("'", "'\\''")
98
- cmd_parts.append(f"'{escaped_pattern}'")
99
- cmd_parts.append(path)
100
-
101
- return " ".join(cmd_parts), "rg"
102
- else:
103
- # Build find + grep command for cross-platform compatibility
104
- find_parts = ["find", path, "-type", "f", "("]
105
-
106
- for i, ft in enumerate(file_types):
107
- if i > 0:
108
- find_parts.append("-o")
109
- find_parts.extend(["-name", f"'{ft}'"])
110
-
111
- find_parts.append(")")
112
-
113
- # Add grep with proper flags
114
- grep_flags = "-n" # Line numbers
115
- if not case_sensitive:
116
- grep_flags += "i"
117
-
118
- # Escape pattern for shell
119
- escaped_pattern = pattern.replace("'", "'\\''")
120
-
121
- # Combine with xargs for efficiency
122
- cmd = f"{' '.join(find_parts)} 2>/dev/null | xargs grep -{grep_flags} '{escaped_pattern}' 2>/dev/null | head -n {max_results}"
123
-
124
- return cmd, "grep"
125
-
126
-
127
41
  def _build_notebook_search_command(
128
42
  pattern: str,
129
43
  notebook_path: Optional[str],
@@ -139,74 +53,6 @@ def _build_notebook_search_command(
139
53
  )
140
54
 
141
55
 
142
- @tool(args_schema=SearchWorkspaceInput)
143
- def search_workspace_tool(
144
- pattern: str,
145
- file_types: List[str] = None,
146
- path: str = ".",
147
- max_results: int = 50,
148
- case_sensitive: bool = False,
149
- execution_result: Optional[Dict[str, Any]] = None,
150
- workspace_root: str = ".",
151
- ) -> Dict[str, Any]:
152
- """
153
- Search for a pattern across files in the workspace.
154
-
155
- This tool is executed on the client side using subprocess (grep/ripgrep).
156
- Searches both regular files and Jupyter notebooks.
157
-
158
- Args:
159
- pattern: Search pattern (regex or text)
160
- file_types: File patterns to search (default: ['*.py', '*.ipynb'])
161
- path: Directory to search in (relative to workspace)
162
- max_results: Maximum number of results to return
163
- case_sensitive: Whether search is case-sensitive
164
-
165
- Returns:
166
- Dict with search results or pending_execution status
167
- """
168
- if file_types is None:
169
- file_types = ["*.py", "*.ipynb"]
170
-
171
- # Build the search command
172
- command, tool_used = _build_grep_command(
173
- pattern=pattern,
174
- file_types=file_types,
175
- path=path,
176
- case_sensitive=case_sensitive,
177
- max_results=max_results,
178
- )
179
-
180
- response: Dict[str, Any] = {
181
- "tool": "search_workspace_tool",
182
- "parameters": {
183
- "pattern": pattern,
184
- "file_types": file_types,
185
- "path": path,
186
- "max_results": max_results,
187
- "case_sensitive": case_sensitive,
188
- },
189
- "command": command,
190
- "tool_used": tool_used,
191
- "status": "pending_execution",
192
- "message": "Search queued for execution by client",
193
- }
194
-
195
- if execution_result is not None:
196
- response["execution_result"] = execution_result
197
- response["status"] = "complete"
198
- response["message"] = "Search executed with client-reported results"
199
- # Parse the execution result to extract search results
200
- if isinstance(execution_result, dict):
201
- response["success"] = execution_result.get("success", False)
202
- response["results"] = execution_result.get("results", [])
203
- response["total_results"] = execution_result.get("total_results", 0)
204
- if "error" in execution_result:
205
- response["error"] = execution_result["error"]
206
-
207
- return response
208
-
209
-
210
56
  @tool(args_schema=SearchNotebookCellsInput)
211
57
  def search_notebook_cells_tool(
212
58
  pattern: str,
@@ -281,11 +127,10 @@ def create_search_tools(workspace_root: str = ".") -> List:
281
127
  Note: workspace_root is not used since tools return pending_execution
282
128
  and actual execution happens on the client side.
283
129
  """
284
- return [search_workspace_tool, search_notebook_cells_tool]
130
+ return [search_notebook_cells_tool]
285
131
 
286
132
 
287
133
  # Export all tools
288
134
  SEARCH_TOOLS = [
289
- search_workspace_tool,
290
135
  search_notebook_cells_tool,
291
136
  ]
agent_server/main.py CHANGED
@@ -32,6 +32,13 @@ logging.basicConfig(
32
32
  )
33
33
  logger = logging.getLogger(__name__)
34
34
 
35
+ # Reduce verbose logging from LangChain/LangGraph
36
+ # These libraries log entire message histories which creates excessive duplicate logs
37
+ logging.getLogger("langchain").setLevel(logging.WARNING)
38
+ logging.getLogger("langchain_core").setLevel(logging.WARNING)
39
+ logging.getLogger("langgraph").setLevel(logging.WARNING)
40
+ logging.getLogger("langsmith").setLevel(logging.WARNING)
41
+
35
42
 
36
43
  @asynccontextmanager
37
44
  async def lifespan(app: FastAPI):
@@ -561,23 +561,50 @@ async def stream_agent(request: AgentRequest):
561
561
  # Prepare config with thread_id
562
562
  config = {"configurable": {"thread_id": thread_id}}
563
563
 
564
- # Debug: Check if there's existing state for this thread
564
+ # Check existing state and reset todos if all completed
565
+ should_reset_todos = False
565
566
  try:
566
567
  existing_state = checkpointer.get(config)
567
568
  if existing_state:
568
- existing_messages = existing_state.get("channel_values", {}).get(
569
- "messages", []
570
- )
569
+ channel_values = existing_state.get("channel_values", {})
570
+ existing_messages = channel_values.get("messages", [])
571
+ existing_todos = channel_values.get("todos", [])
571
572
  logger.info(
572
- "Existing state for thread %s: %d messages found",
573
+ "Existing state for thread %s: %d messages, %d todos found",
573
574
  thread_id,
574
575
  len(existing_messages),
576
+ len(existing_todos),
575
577
  )
578
+ # Check if all todos are completed - if so, reset them
579
+ if existing_todos:
580
+ all_completed = all(
581
+ t.get("status") == "completed" for t in existing_todos
582
+ )
583
+ if all_completed:
584
+ should_reset_todos = True
585
+ logger.info(
586
+ "All %d todos are completed, will reset for new task",
587
+ len(existing_todos),
588
+ )
576
589
  else:
577
590
  logger.info("No existing state for thread %s", thread_id)
578
591
  except Exception as e:
579
592
  logger.warning("Could not check existing state: %s", e)
580
593
 
594
+ # Reset todos in agent state if all were completed
595
+ todos_reset_event = None
596
+ if should_reset_todos:
597
+ try:
598
+ agent.update_state(config, {"todos": []})
599
+ logger.info("Reset todos in agent state for thread %s", thread_id)
600
+ # Prepare event to notify frontend (will be yielded after function setup)
601
+ todos_reset_event = {
602
+ "event": "todos",
603
+ "data": json.dumps({"todos": [], "reset": True}),
604
+ }
605
+ except Exception as e:
606
+ logger.warning("Could not reset todos in agent state: %s", e)
607
+
581
608
  # Prepare input
582
609
  agent_input = {"messages": [{"role": "user", "content": request.request}]}
583
610
 
@@ -593,6 +620,11 @@ async def stream_agent(request: AgentRequest):
593
620
  emitted_contents: set = set()
594
621
  _simple_agent_emitted_contents[thread_id] = emitted_contents
595
622
 
623
+ # Emit todos reset event if needed (before starting the stream)
624
+ if todos_reset_event:
625
+ logger.info("SSE: Emitting todos reset event")
626
+ yield todos_reset_event
627
+
596
628
  # Initial status: waiting for LLM
597
629
  logger.info("SSE: Sending initial debug status '🤔 LLM 응답 대기 중'")
598
630
  yield {
@@ -670,6 +702,27 @@ async def stream_agent(request: AgentRequest):
670
702
  "event": "todos",
671
703
  "data": json.dumps({"todos": todos}),
672
704
  }
705
+ # Check if all todos are completed - auto terminate
706
+ all_completed = all(
707
+ t.get("status") == "completed" for t in todos
708
+ )
709
+ if all_completed and len(todos) > 0:
710
+ logger.info(
711
+ "All %d todos completed, auto-terminating agent",
712
+ len(todos),
713
+ )
714
+ yield {
715
+ "event": "debug_clear",
716
+ "data": json.dumps({}),
717
+ }
718
+ yield {
719
+ "event": "done",
720
+ "data": json.dumps(
721
+ {"reason": "all_todos_completed"}
722
+ ),
723
+ }
724
+ return # Exit the generator
725
+
673
726
  tool_name = getattr(last_message, "name", "") or ""
674
727
  logger.info(
675
728
  "SimpleAgent ToolMessage name attribute: %s", tool_name
@@ -695,17 +748,49 @@ async def stream_agent(request: AgentRequest):
695
748
  final_answer = tool_result.get(
696
749
  "answer"
697
750
  ) or tool_result.get("parameters", {}).get("answer")
751
+
752
+ # Check for next_items in answer field (LLM may put JSON here)
753
+ if final_answer:
754
+ try:
755
+ answer_json = json.loads(final_answer)
756
+ if "next_items" in answer_json:
757
+ next_items_block = f"\n\n```json\n{json.dumps(answer_json, ensure_ascii=False, indent=2)}\n```"
758
+ # Get summary for the main text
759
+ summary_text = (
760
+ tool_result.get("summary")
761
+ or tool_result.get(
762
+ "parameters", {}
763
+ ).get("summary")
764
+ or ""
765
+ )
766
+ final_answer = (
767
+ summary_text + next_items_block
768
+ )
769
+ logger.info(
770
+ "Extracted next_items from answer field"
771
+ )
772
+ except (json.JSONDecodeError, TypeError):
773
+ pass
774
+
698
775
  # Check for next_items in summary field (Gemini puts JSON here)
699
776
  summary = tool_result.get(
700
777
  "summary"
701
- ) or tool_result.get("parameters", {}).get("summary")
702
- if summary:
778
+ ) or tool_result.get("parameters", {}).get(
779
+ "summary"
780
+ )
781
+ if summary and "next_items" not in (
782
+ final_answer or ""
783
+ ):
703
784
  try:
704
785
  summary_json = json.loads(summary)
705
786
  if "next_items" in summary_json:
706
787
  next_items_block = f"\n\n```json\n{json.dumps(summary_json, ensure_ascii=False, indent=2)}\n```"
707
- final_answer = (final_answer or "") + next_items_block
708
- logger.info("Extracted next_items from summary field")
788
+ final_answer = (
789
+ final_answer or ""
790
+ ) + next_items_block
791
+ logger.info(
792
+ "Extracted next_items from summary field"
793
+ )
709
794
  except (json.JSONDecodeError, TypeError):
710
795
  pass
711
796
  if final_answer:
@@ -830,13 +915,6 @@ async def stream_agent(request: AgentRequest):
830
915
 
831
916
  # Create detailed status message for search tools
832
917
  if tool_name in (
833
- "search_workspace_tool",
834
- "search_workspace",
835
- ):
836
- pattern = tool_args.get("pattern", "")
837
- path = tool_args.get("path", ".")
838
- status_msg = f"🔍 검색 실행: grep/rg '{pattern}' in {path}"
839
- elif tool_name in (
840
918
  "search_notebook_cells_tool",
841
919
  "search_notebook_cells",
842
920
  ):
@@ -902,34 +980,6 @@ async def stream_agent(request: AgentRequest):
902
980
  }
903
981
  ),
904
982
  }
905
- elif tool_name in (
906
- "search_workspace_tool",
907
- "search_workspace",
908
- ):
909
- # Search workspace - emit tool_call for client-side execution
910
- produced_output = True
911
- yield {
912
- "event": "tool_call",
913
- "data": json.dumps(
914
- {
915
- "tool": "search_workspace",
916
- "pattern": tool_args.get(
917
- "pattern", ""
918
- ),
919
- "file_types": tool_args.get(
920
- "file_types",
921
- ["*.py", "*.ipynb"],
922
- ),
923
- "path": tool_args.get("path", "."),
924
- "max_results": tool_args.get(
925
- "max_results", 50
926
- ),
927
- "case_sensitive": tool_args.get(
928
- "case_sensitive", False
929
- ),
930
- }
931
- ),
932
- }
933
983
  elif tool_name in (
934
984
  "search_notebook_cells_tool",
935
985
  "search_notebook_cells",
@@ -1113,7 +1163,7 @@ async def stream_agent(request: AgentRequest):
1113
1163
  content=(
1114
1164
  "You MUST respond with a valid tool call. "
1115
1165
  "Available tools: jupyter_cell_tool (for Python code), markdown_tool (for text), "
1116
- "list_files_tool (to list files), read_file_tool (to read files). "
1166
+ "execute_command_tool (to search files with find/grep), read_file_tool (to read files). "
1117
1167
  "Choose the most appropriate tool and provide valid JSON arguments."
1118
1168
  )
1119
1169
  ),
@@ -1232,11 +1282,7 @@ async def stream_agent(request: AgentRequest):
1232
1282
  }
1233
1283
  ),
1234
1284
  }
1235
- elif tool_name in (
1236
- "read_file_tool",
1237
- "list_files_tool",
1238
- "search_workspace_tool",
1239
- ):
1285
+ elif tool_name == "read_file_tool":
1240
1286
  # For file operations, generate code with the LLM
1241
1287
  logger.info(
1242
1288
  "Fallback: Generating code for %s via LLM",
@@ -1388,9 +1434,28 @@ async def resume_agent(request: ResumeRequest):
1388
1434
  )
1389
1435
  # Get or create cached agent
1390
1436
  resolved_workspace_root = _resolve_workspace_root(request.workspaceRoot)
1391
- checkpointer = _simple_agent_checkpointers.setdefault(
1392
- request.threadId, InMemorySaver()
1393
- )
1437
+
1438
+ # CRITICAL: Validate checkpoint exists before resume
1439
+ # InMemorySaver is volatile - server restart loses all checkpoints
1440
+ if request.threadId not in _simple_agent_checkpointers:
1441
+ logger.warning(
1442
+ "Resume failed: No checkpoint found for thread %s. "
1443
+ "Server may have restarted or session expired.",
1444
+ request.threadId,
1445
+ )
1446
+ yield {
1447
+ "event": "error",
1448
+ "data": json.dumps(
1449
+ {
1450
+ "error": "Session expired or not found",
1451
+ "code": "CHECKPOINT_NOT_FOUND",
1452
+ "message": "이전 세션을 찾을 수 없습니다. 서버가 재시작되었거나 세션이 만료되었습니다. 새로운 대화를 시작해주세요.",
1453
+ }
1454
+ ),
1455
+ }
1456
+ return
1457
+
1458
+ checkpointer = _simple_agent_checkpointers.get(request.threadId)
1394
1459
 
1395
1460
  agent_cache_key = _get_agent_cache_key(
1396
1461
  llm_config=config_dict,
@@ -1406,7 +1471,9 @@ async def resume_agent(request: ResumeRequest):
1406
1471
  len(_simple_agent_instances),
1407
1472
  )
1408
1473
  else:
1409
- logger.info("Resume: Creating new agent for key %s", agent_cache_key[:8])
1474
+ logger.info(
1475
+ "Resume: Creating new agent for key %s", agent_cache_key[:8]
1476
+ )
1410
1477
  agent = create_simple_chat_agent(
1411
1478
  llm_config=config_dict,
1412
1479
  workspace_root=resolved_workspace_root,
@@ -1589,6 +1656,27 @@ async def resume_agent(request: ResumeRequest):
1589
1656
  "event": "todos",
1590
1657
  "data": json.dumps({"todos": todos}),
1591
1658
  }
1659
+ # Check if all todos are completed - auto terminate
1660
+ all_completed = all(
1661
+ t.get("status") == "completed" for t in todos
1662
+ )
1663
+ if all_completed and len(todos) > 0:
1664
+ logger.info(
1665
+ "Resume: All %d todos completed, auto-terminating agent",
1666
+ len(todos),
1667
+ )
1668
+ yield {
1669
+ "event": "debug_clear",
1670
+ "data": json.dumps({}),
1671
+ }
1672
+ yield {
1673
+ "event": "done",
1674
+ "data": json.dumps(
1675
+ {"reason": "all_todos_completed"}
1676
+ ),
1677
+ }
1678
+ return # Exit the generator
1679
+
1592
1680
  tool_name = getattr(last_message, "name", "") or ""
1593
1681
  logger.info(
1594
1682
  "Resume ToolMessage name attribute: %s", tool_name
@@ -1612,17 +1700,49 @@ async def resume_agent(request: ResumeRequest):
1612
1700
  final_answer = tool_result.get(
1613
1701
  "answer"
1614
1702
  ) or tool_result.get("parameters", {}).get("answer")
1703
+
1704
+ # Check for next_items in answer field (LLM may put JSON here)
1705
+ if final_answer:
1706
+ try:
1707
+ answer_json = json.loads(final_answer)
1708
+ if "next_items" in answer_json:
1709
+ next_items_block = f"\n\n```json\n{json.dumps(answer_json, ensure_ascii=False, indent=2)}\n```"
1710
+ # Get summary for the main text
1711
+ summary_text = (
1712
+ tool_result.get("summary")
1713
+ or tool_result.get(
1714
+ "parameters", {}
1715
+ ).get("summary")
1716
+ or ""
1717
+ )
1718
+ final_answer = (
1719
+ summary_text + next_items_block
1720
+ )
1721
+ logger.info(
1722
+ "Resume: Extracted next_items from answer field"
1723
+ )
1724
+ except (json.JSONDecodeError, TypeError):
1725
+ pass
1726
+
1615
1727
  # Check for next_items in summary field (Gemini puts JSON here)
1616
1728
  summary = tool_result.get(
1617
1729
  "summary"
1618
- ) or tool_result.get("parameters", {}).get("summary")
1619
- if summary:
1730
+ ) or tool_result.get("parameters", {}).get(
1731
+ "summary"
1732
+ )
1733
+ if summary and "next_items" not in (
1734
+ final_answer or ""
1735
+ ):
1620
1736
  try:
1621
1737
  summary_json = json.loads(summary)
1622
1738
  if "next_items" in summary_json:
1623
1739
  next_items_block = f"\n\n```json\n{json.dumps(summary_json, ensure_ascii=False, indent=2)}\n```"
1624
- final_answer = (final_answer or "") + next_items_block
1625
- logger.info("Resume: Extracted next_items from summary field")
1740
+ final_answer = (
1741
+ final_answer or ""
1742
+ ) + next_items_block
1743
+ logger.info(
1744
+ "Resume: Extracted next_items from summary field"
1745
+ )
1626
1746
  except (json.JSONDecodeError, TypeError):
1627
1747
  pass
1628
1748
  if final_answer:
@@ -1769,13 +1889,6 @@ async def resume_agent(request: ResumeRequest):
1769
1889
 
1770
1890
  # Create detailed status message for search tools
1771
1891
  if tool_name in (
1772
- "search_workspace_tool",
1773
- "search_workspace",
1774
- ):
1775
- pattern = tool_args.get("pattern", "")
1776
- path = tool_args.get("path", ".")
1777
- status_msg = f"🔍 검색 실행: grep/rg '{pattern}' in {path}"
1778
- elif tool_name in (
1779
1892
  "search_notebook_cells_tool",
1780
1893
  "search_notebook_cells",
1781
1894
  ):
@@ -1833,33 +1946,6 @@ async def resume_agent(request: ResumeRequest):
1833
1946
  }
1834
1947
  ),
1835
1948
  }
1836
- elif tool_name in (
1837
- "search_workspace_tool",
1838
- "search_workspace",
1839
- ):
1840
- # Search workspace - emit tool_call for client-side execution
1841
- yield {
1842
- "event": "tool_call",
1843
- "data": json.dumps(
1844
- {
1845
- "tool": "search_workspace",
1846
- "pattern": tool_args.get(
1847
- "pattern", ""
1848
- ),
1849
- "file_types": tool_args.get(
1850
- "file_types",
1851
- ["*.py", "*.ipynb"],
1852
- ),
1853
- "path": tool_args.get("path", "."),
1854
- "max_results": tool_args.get(
1855
- "max_results", 50
1856
- ),
1857
- "case_sensitive": tool_args.get(
1858
- "case_sensitive", False
1859
- ),
1860
- }
1861
- ),
1862
- }
1863
1949
  elif tool_name in (
1864
1950
  "search_notebook_cells_tool",
1865
1951
  "search_notebook_cells",
@@ -1966,16 +2052,35 @@ async def resume_agent(request: ResumeRequest):
1966
2052
  }
1967
2053
 
1968
2054
  except Exception as e:
1969
- logger.error(f"Resume error: {e}", exc_info=True)
1970
- yield {
1971
- "event": "error",
1972
- "data": json.dumps(
1973
- {
1974
- "error": str(e),
1975
- "error_type": type(e).__name__,
1976
- }
1977
- ),
1978
- }
2055
+ error_msg = str(e)
2056
+ logger.error(f"Resume error: {error_msg}", exc_info=True)
2057
+
2058
+ # Detect specific Gemini error for empty contents
2059
+ if "contents is not specified" in error_msg.lower():
2060
+ logger.warning(
2061
+ "Detected 'contents is not specified' error - likely session state loss"
2062
+ )
2063
+ yield {
2064
+ "event": "error",
2065
+ "data": json.dumps(
2066
+ {
2067
+ "error": "Session state lost",
2068
+ "code": "CONTENTS_NOT_SPECIFIED",
2069
+ "error_type": type(e).__name__,
2070
+ "message": "세션 상태가 손실되었습니다. 서버가 재시작되었거나 세션이 만료되었습니다. 새로운 대화를 시작해주세요.",
2071
+ }
2072
+ ),
2073
+ }
2074
+ else:
2075
+ yield {
2076
+ "event": "error",
2077
+ "data": json.dumps(
2078
+ {
2079
+ "error": error_msg,
2080
+ "error_type": type(e).__name__,
2081
+ }
2082
+ ),
2083
+ }
1979
2084
 
1980
2085
  return EventSourceResponse(event_generator())
1981
2086