hdsp-jupyter-extension 2.0.1__py3-none-any.whl → 2.0.2__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 (78) hide show
  1. agent_server/langchain/__init__.py +18 -0
  2. agent_server/langchain/agent.py +694 -0
  3. agent_server/langchain/executors/__init__.py +15 -0
  4. agent_server/langchain/executors/jupyter_executor.py +429 -0
  5. agent_server/langchain/executors/notebook_searcher.py +477 -0
  6. agent_server/langchain/middleware/__init__.py +36 -0
  7. agent_server/langchain/middleware/code_search_middleware.py +278 -0
  8. agent_server/langchain/middleware/error_handling_middleware.py +338 -0
  9. agent_server/langchain/middleware/jupyter_execution_middleware.py +301 -0
  10. agent_server/langchain/middleware/rag_middleware.py +227 -0
  11. agent_server/langchain/middleware/validation_middleware.py +240 -0
  12. agent_server/langchain/state.py +159 -0
  13. agent_server/langchain/tools/__init__.py +39 -0
  14. agent_server/langchain/tools/file_tools.py +279 -0
  15. agent_server/langchain/tools/jupyter_tools.py +143 -0
  16. agent_server/langchain/tools/search_tools.py +309 -0
  17. agent_server/main.py +13 -0
  18. agent_server/routers/health.py +14 -0
  19. agent_server/routers/langchain_agent.py +1368 -0
  20. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.data}/data/share/jupyter/labextensions/hdsp-agent/build_log.json +1 -1
  21. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.data}/data/share/jupyter/labextensions/hdsp-agent/package.json +2 -2
  22. hdsp_jupyter_extension-2.0.1.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.2607ff74c74acfa83158.js → hdsp_jupyter_extension-2.0.2.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.634cf0ae0f3592d0882f.js +408 -4
  23. hdsp_jupyter_extension-2.0.2.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.634cf0ae0f3592d0882f.js.map +1 -0
  24. hdsp_jupyter_extension-2.0.1.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.622c1a5918b3aafb2315.js → hdsp_jupyter_extension-2.0.2.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.1366019c413f1d68467f.js +753 -65
  25. hdsp_jupyter_extension-2.0.2.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.1366019c413f1d68467f.js.map +1 -0
  26. hdsp_jupyter_extension-2.0.1.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.729f933de01ad5620730.js → hdsp_jupyter_extension-2.0.2.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.3379c4b222c042de2b01.js +8 -8
  27. hdsp_jupyter_extension-2.0.2.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.3379c4b222c042de2b01.js.map +1 -0
  28. 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.2.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js +2 -209
  29. hdsp_jupyter_extension-2.0.2.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
  30. jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js → hdsp_jupyter_extension-2.0.2.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js +209 -2
  31. hdsp_jupyter_extension-2.0.2.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
  32. hdsp_jupyter_extension-2.0.1.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js → hdsp_jupyter_extension-2.0.2.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js +212 -3
  33. hdsp_jupyter_extension-2.0.2.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +1 -0
  34. {hdsp_jupyter_extension-2.0.1.dist-info → hdsp_jupyter_extension-2.0.2.dist-info}/METADATA +1 -1
  35. {hdsp_jupyter_extension-2.0.1.dist-info → hdsp_jupyter_extension-2.0.2.dist-info}/RECORD +66 -49
  36. jupyter_ext/_version.py +1 -1
  37. jupyter_ext/handlers.py +126 -1
  38. jupyter_ext/labextension/build_log.json +1 -1
  39. jupyter_ext/labextension/package.json +2 -2
  40. jupyter_ext/labextension/static/{frontend_styles_index_js.2607ff74c74acfa83158.js → frontend_styles_index_js.634cf0ae0f3592d0882f.js} +408 -4
  41. jupyter_ext/labextension/static/frontend_styles_index_js.634cf0ae0f3592d0882f.js.map +1 -0
  42. jupyter_ext/labextension/static/{lib_index_js.622c1a5918b3aafb2315.js → lib_index_js.1366019c413f1d68467f.js} +753 -65
  43. jupyter_ext/labextension/static/lib_index_js.1366019c413f1d68467f.js.map +1 -0
  44. jupyter_ext/labextension/static/{remoteEntry.729f933de01ad5620730.js → remoteEntry.3379c4b222c042de2b01.js} +8 -8
  45. jupyter_ext/labextension/static/remoteEntry.3379c4b222c042de2b01.js.map +1 -0
  46. hdsp_jupyter_extension-2.0.1.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
  47. jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js.map +1 -0
  48. hdsp_jupyter_extension-2.0.1.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
  49. jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js.map +1 -0
  50. jupyter_ext/labextension/static/{vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js → vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js} +212 -3
  51. jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +1 -0
  52. hdsp_jupyter_extension-2.0.1.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.2607ff74c74acfa83158.js.map +0 -1
  53. hdsp_jupyter_extension-2.0.1.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.622c1a5918b3aafb2315.js.map +0 -1
  54. hdsp_jupyter_extension-2.0.1.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.729f933de01ad5620730.js.map +0 -1
  55. hdsp_jupyter_extension-2.0.1.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
  56. hdsp_jupyter_extension-2.0.1.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
  57. hdsp_jupyter_extension-2.0.1.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js.map +0 -1
  58. jupyter_ext/labextension/static/frontend_styles_index_js.2607ff74c74acfa83158.js.map +0 -1
  59. jupyter_ext/labextension/static/lib_index_js.622c1a5918b3aafb2315.js.map +0 -1
  60. jupyter_ext/labextension/static/remoteEntry.729f933de01ad5620730.js.map +0 -1
  61. jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js.map +0 -1
  62. jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js.map +0 -1
  63. jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js.map +0 -1
  64. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.data}/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +0 -0
  65. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.data}/data/share/jupyter/labextensions/hdsp-agent/install.json +0 -0
  66. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.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
  67. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.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
  68. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.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
  69. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.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
  70. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.data}/data/share/jupyter/labextensions/hdsp-agent/static/style.js +0 -0
  71. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.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
  72. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.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
  73. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +0 -0
  74. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.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
  75. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +0 -0
  76. {hdsp_jupyter_extension-2.0.1.data → hdsp_jupyter_extension-2.0.2.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +0 -0
  77. {hdsp_jupyter_extension-2.0.1.dist-info → hdsp_jupyter_extension-2.0.2.dist-info}/WHEEL +0 -0
  78. {hdsp_jupyter_extension-2.0.1.dist-info → hdsp_jupyter_extension-2.0.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,694 @@
1
+ """
2
+ LangChain Agent
3
+
4
+ Main agent creation module for tool-driven chat execution.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Dict, Optional
9
+
10
+ from agent_server.langchain.tools import (
11
+ final_answer_tool,
12
+ jupyter_cell_tool,
13
+ list_files_tool,
14
+ markdown_tool,
15
+ read_file_tool,
16
+ search_notebook_cells_tool,
17
+ search_workspace_tool,
18
+ write_file_tool,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def _create_llm(llm_config: Dict[str, Any]):
25
+ """Create LangChain LLM from config"""
26
+ provider = llm_config.get("provider", "gemini")
27
+
28
+ if provider == "gemini":
29
+ from langchain_google_genai import ChatGoogleGenerativeAI
30
+
31
+ gemini_config = llm_config.get("gemini", {})
32
+ api_key = gemini_config.get("apiKey")
33
+ model = gemini_config.get("model", "gemini-2.5-pro")
34
+
35
+ if not api_key:
36
+ raise ValueError("Gemini API key not configured")
37
+
38
+ logger.info(f"Creating Gemini LLM with model: {model}")
39
+
40
+ # Gemini 2.5 Flash has issues with tool calling in LangChain
41
+ # Use convert_system_message_to_human for better compatibility
42
+ llm = ChatGoogleGenerativeAI(
43
+ model=model,
44
+ google_api_key=api_key,
45
+ temperature=0.0,
46
+ max_output_tokens=8192,
47
+ convert_system_message_to_human=True, # Better tool calling support
48
+ )
49
+ return llm
50
+
51
+ elif provider == "openai":
52
+ from langchain_openai import ChatOpenAI
53
+
54
+ openai_config = llm_config.get("openai", {})
55
+ api_key = openai_config.get("apiKey")
56
+ model = openai_config.get("model", "gpt-4")
57
+
58
+ if not api_key:
59
+ raise ValueError("OpenAI API key not configured")
60
+
61
+ llm = ChatOpenAI(
62
+ model=model,
63
+ api_key=api_key,
64
+ temperature=0.0,
65
+ max_tokens=4096,
66
+ )
67
+ return llm
68
+
69
+ elif provider == "vllm":
70
+ from langchain_openai import ChatOpenAI
71
+
72
+ vllm_config = llm_config.get("vllm", {})
73
+ endpoint = vllm_config.get("endpoint", "http://localhost:8000")
74
+ model = vllm_config.get("model", "default")
75
+ api_key = vllm_config.get("apiKey", "dummy")
76
+
77
+ llm = ChatOpenAI(
78
+ model=model,
79
+ api_key=api_key,
80
+ base_url=f"{endpoint}/v1",
81
+ temperature=0.0,
82
+ max_tokens=4096,
83
+ )
84
+ return llm
85
+
86
+ else:
87
+ raise ValueError(f"Unsupported LLM provider: {provider}")
88
+
89
+
90
+ def _get_all_tools():
91
+ """Get all available tools for the agent"""
92
+ return [
93
+ jupyter_cell_tool,
94
+ markdown_tool,
95
+ final_answer_tool,
96
+ read_file_tool,
97
+ write_file_tool,
98
+ list_files_tool,
99
+ search_workspace_tool,
100
+ search_notebook_cells_tool,
101
+ ]
102
+
103
+
104
+ def create_simple_chat_agent(
105
+ llm_config: Dict[str, Any],
106
+ workspace_root: str = ".",
107
+ enable_hitl: bool = True,
108
+ enable_todo_list: bool = True,
109
+ checkpointer: Optional[object] = None,
110
+ ):
111
+ """
112
+ Create a simple chat agent using LangChain's create_agent with Human-in-the-Loop.
113
+
114
+ This is a simplified version for chat mode that uses LangChain's built-in
115
+ HumanInTheLoopMiddleware and TodoListMiddleware.
116
+
117
+ Args:
118
+ llm_config: LLM configuration
119
+ workspace_root: Root directory
120
+ enable_hitl: Enable Human-in-the-Loop for code execution
121
+ enable_todo_list: Enable TodoListMiddleware for task planning
122
+
123
+ Returns:
124
+ Configured agent with HITL and TodoList middleware
125
+ """
126
+ try:
127
+ from langchain.agents import create_agent
128
+ from langchain.agents.middleware import (
129
+ AgentMiddleware,
130
+ HumanInTheLoopMiddleware,
131
+ ModelCallLimitMiddleware,
132
+ ModelRequest,
133
+ ModelResponse,
134
+ TodoListMiddleware,
135
+ ToolCallLimitMiddleware,
136
+ wrap_model_call,
137
+ )
138
+ from langchain_core.messages import AIMessage, ToolMessage as LCToolMessage
139
+ from langgraph.checkpoint.memory import InMemorySaver
140
+ from langgraph.types import Overwrite
141
+ except ImportError as e:
142
+ logger.error(f"Failed to import LangChain agent components: {e}")
143
+ raise ImportError(
144
+ "LangChain agent components not available. "
145
+ "Install with: pip install langchain langgraph"
146
+ ) from e
147
+
148
+ # Create LLM
149
+ llm = _create_llm(llm_config)
150
+
151
+ # Get tools
152
+ tools = _get_all_tools()
153
+
154
+ # Configure middleware
155
+ middleware = []
156
+
157
+ # JSON Schema for fallback tool calling
158
+ JSON_TOOL_SCHEMA = """You MUST respond with ONLY valid JSON matching this schema:
159
+ {
160
+ "tool": "<tool_name>",
161
+ "arguments": {"arg1": "value1", ...}
162
+ }
163
+
164
+ Available tools:
165
+ - jupyter_cell_tool: Execute Python code. Arguments: {"code": "<python_code>"}
166
+ - markdown_tool: Add markdown cell. Arguments: {"content": "<markdown>"}
167
+ - final_answer_tool: Complete task. Arguments: {"answer": "<summary>"}
168
+ - write_todos: Update task list. Arguments: {"todos": [{"content": "...", "status": "pending|in_progress|completed"}]}
169
+ - read_file_tool: Read file. Arguments: {"path": "<file_path>"}
170
+ - list_files_tool: List directory. Arguments: {"path": "."}
171
+
172
+ Output ONLY the JSON object, no markdown, no explanation."""
173
+
174
+ def _parse_json_tool_call(text: str) -> Optional[Dict[str, Any]]:
175
+ """Parse JSON tool call from text response."""
176
+ import json
177
+ import re
178
+
179
+ if not text:
180
+ return None
181
+
182
+ # Clean up response
183
+ text = text.strip()
184
+ if text.startswith("```json"):
185
+ text = text[7:]
186
+ elif text.startswith("```"):
187
+ text = text[3:]
188
+ if text.endswith("```"):
189
+ text = text[:-3]
190
+ text = text.strip()
191
+
192
+ # Try direct JSON parse
193
+ try:
194
+ data = json.loads(text)
195
+ if "tool" in data:
196
+ return data
197
+ except json.JSONDecodeError:
198
+ pass
199
+
200
+ # Try to find JSON object in response
201
+ json_match = re.search(r'\{[\s\S]*\}', text)
202
+ if json_match:
203
+ try:
204
+ data = json.loads(json_match.group())
205
+ if "tool" in data:
206
+ return data
207
+ except json.JSONDecodeError:
208
+ pass
209
+
210
+ return None
211
+
212
+ def _create_tool_call_message(tool_name: str, arguments: Dict[str, Any]) -> AIMessage:
213
+ """Create AIMessage with tool_calls from parsed JSON."""
214
+ import uuid
215
+
216
+ # Normalize tool name
217
+ if not tool_name.endswith("_tool"):
218
+ tool_name = f"{tool_name}_tool"
219
+
220
+ return AIMessage(
221
+ content="",
222
+ tool_calls=[
223
+ {
224
+ "name": tool_name,
225
+ "args": arguments,
226
+ "id": str(uuid.uuid4()),
227
+ "type": "tool_call",
228
+ }
229
+ ],
230
+ )
231
+
232
+ # Middleware to detect and handle empty LLM responses with JSON fallback
233
+ @wrap_model_call
234
+ def handle_empty_response(
235
+ request: ModelRequest,
236
+ handler,
237
+ ) -> ModelResponse:
238
+ """
239
+ Detect empty/invalid AIMessage responses and retry with JSON schema fallback.
240
+
241
+ For models that don't support native tool calling well (e.g., Gemini 2.5 Flash),
242
+ this middleware:
243
+ 1. Detects empty or text-only responses (no tool_calls)
244
+ 2. Retries with JSON schema prompt to force structured output
245
+ 3. Parses JSON response and injects tool_calls into AIMessage
246
+ 4. Falls back to synthetic final_answer if all else fails
247
+ """
248
+ import json
249
+ import uuid
250
+ from langchain_core.messages import HumanMessage
251
+
252
+ max_retries = 2 # Allow more retries for JSON fallback
253
+
254
+ for attempt in range(max_retries + 1):
255
+ response = handler(request)
256
+
257
+ # Extract AIMessage from response
258
+ response_message = None
259
+ if hasattr(response, 'result'):
260
+ result = response.result
261
+ if isinstance(result, list):
262
+ for msg in reversed(result):
263
+ if isinstance(msg, AIMessage):
264
+ response_message = msg
265
+ break
266
+ elif isinstance(result, AIMessage):
267
+ response_message = result
268
+ elif hasattr(response, 'message'):
269
+ response_message = response.message
270
+ elif hasattr(response, 'messages') and response.messages:
271
+ response_message = response.messages[-1]
272
+ elif isinstance(response, AIMessage):
273
+ response_message = response
274
+
275
+ has_content = bool(getattr(response_message, 'content', None)) if response_message else False
276
+ has_tool_calls = bool(getattr(response_message, 'tool_calls', None)) if response_message else False
277
+
278
+ logger.info(
279
+ "handle_empty_response: attempt=%d, type=%s, content=%s, tool_calls=%s",
280
+ attempt + 1,
281
+ type(response_message).__name__ if response_message else None,
282
+ has_content,
283
+ has_tool_calls,
284
+ )
285
+
286
+ # Valid response with tool_calls
287
+ if has_tool_calls:
288
+ return response
289
+
290
+ # Try to parse JSON from content (model might have output JSON without tool_calls)
291
+ if has_content and response_message:
292
+ parsed = _parse_json_tool_call(response_message.content)
293
+ if parsed:
294
+ tool_name = parsed.get("tool", "")
295
+ arguments = parsed.get("arguments", {})
296
+ logger.info(
297
+ "Parsed JSON tool call from content: tool=%s",
298
+ tool_name,
299
+ )
300
+
301
+ # Create new AIMessage with tool_calls
302
+ new_message = _create_tool_call_message(tool_name, arguments)
303
+
304
+ # Replace in response
305
+ if hasattr(response, 'result'):
306
+ if isinstance(response.result, list):
307
+ new_result = [
308
+ new_message if isinstance(m, AIMessage) else m
309
+ for m in response.result
310
+ ]
311
+ response.result = new_result
312
+ else:
313
+ response.result = new_message
314
+ return response
315
+
316
+ # Invalid response - retry with JSON schema prompt
317
+ if response_message and attempt < max_retries:
318
+ reason = "text-only" if has_content else "empty"
319
+ logger.warning(
320
+ "Invalid AIMessage (%s) detected (attempt %d/%d). "
321
+ "Retrying with JSON schema prompt...",
322
+ reason,
323
+ attempt + 1,
324
+ max_retries + 1,
325
+ )
326
+
327
+ # Get context for prompt
328
+ todos = request.state.get("todos", [])
329
+ pending_todos = [
330
+ t for t in todos
331
+ if t.get("status") in ("pending", "in_progress")
332
+ ]
333
+
334
+ # Build JSON-forcing prompt
335
+ if has_content:
336
+ # LLM wrote text - ask to wrap in final_answer
337
+ content_preview = response_message.content[:300]
338
+ json_prompt = (
339
+ f"{JSON_TOOL_SCHEMA}\n\n"
340
+ f"Your previous response was text, not JSON. "
341
+ f"Wrap your answer in final_answer_tool:\n"
342
+ f'{{"tool": "final_answer_tool", "arguments": {{"answer": "{content_preview}..."}}}}'
343
+ )
344
+ elif pending_todos:
345
+ todo_list = ", ".join(t.get("content", "")[:20] for t in pending_todos[:3])
346
+ example_json = '{"tool": "jupyter_cell_tool", "arguments": {"code": "import pandas as pd\\ndf = pd.read_csv(\'titanic.csv\')\\nprint(df.head())"}}'
347
+ json_prompt = (
348
+ f"{JSON_TOOL_SCHEMA}\n\n"
349
+ f"Pending tasks: {todo_list}\n"
350
+ f"Call jupyter_cell_tool with Python code to complete the next task.\n"
351
+ f"Example: {example_json}"
352
+ )
353
+ else:
354
+ json_prompt = (
355
+ f"{JSON_TOOL_SCHEMA}\n\n"
356
+ f"All tasks completed. Call final_answer_tool:\n"
357
+ f'{{"tool": "final_answer_tool", "arguments": {{"answer": "작업이 완료되었습니다."}}}}'
358
+ )
359
+
360
+ # Add JSON prompt and retry
361
+ request = request.override(
362
+ messages=request.messages + [
363
+ HumanMessage(content=json_prompt)
364
+ ]
365
+ )
366
+ continue
367
+
368
+ # Max retries exhausted - synthesize final_answer
369
+ if response_message:
370
+ logger.warning(
371
+ "Max retries exhausted. Synthesizing final_answer response."
372
+ )
373
+
374
+ # Use LLM's text content if available
375
+ if has_content and response_message.content:
376
+ summary = response_message.content
377
+ logger.info(
378
+ "Using LLM's text content as final answer (length=%d)",
379
+ len(summary),
380
+ )
381
+ else:
382
+ todos = request.state.get("todos", [])
383
+ completed_todos = [
384
+ t.get("content", "") for t in todos
385
+ if t.get("status") == "completed"
386
+ ]
387
+ summary = (
388
+ f"작업이 완료되었습니다. 완료된 항목: {', '.join(completed_todos[:5])}"
389
+ if completed_todos
390
+ else "작업이 완료되었습니다."
391
+ )
392
+
393
+ # Create synthetic final_answer
394
+ synthetic_message = AIMessage(
395
+ content="",
396
+ tool_calls=[
397
+ {
398
+ "name": "final_answer_tool",
399
+ "args": {"answer": summary},
400
+ "id": str(uuid.uuid4()),
401
+ "type": "tool_call",
402
+ }
403
+ ],
404
+ )
405
+
406
+ # Replace in response
407
+ if hasattr(response, 'result'):
408
+ if isinstance(response.result, list):
409
+ new_result = []
410
+ replaced = False
411
+ for msg in response.result:
412
+ if isinstance(msg, AIMessage) and not replaced:
413
+ new_result.append(synthetic_message)
414
+ replaced = True
415
+ else:
416
+ new_result.append(msg)
417
+ if not replaced:
418
+ new_result.append(synthetic_message)
419
+ response.result = new_result
420
+ else:
421
+ response.result = synthetic_message
422
+
423
+ return response
424
+
425
+ # Return response (either valid or after max retries)
426
+ return response
427
+
428
+ return response
429
+
430
+ middleware.append(handle_empty_response)
431
+
432
+ # Non-HITL tools that execute immediately without user approval
433
+ NON_HITL_TOOLS = {
434
+ "markdown_tool", "markdown",
435
+ "read_file_tool", "read_file",
436
+ "list_files_tool", "list_files",
437
+ "search_workspace_tool", "search_workspace",
438
+ "search_notebook_cells_tool", "search_notebook_cells",
439
+ "write_todos",
440
+ }
441
+
442
+ # Middleware to inject continuation prompt after non-HITL tool execution
443
+ @wrap_model_call
444
+ def inject_continuation_after_non_hitl_tool(
445
+ request: ModelRequest,
446
+ handler,
447
+ ) -> ModelResponse:
448
+ """
449
+ Inject a continuation prompt when the last message is from a non-HITL tool.
450
+
451
+ Non-HITL tools execute immediately without user approval, which can cause
452
+ Gemini to produce empty responses. This middleware injects a system message
453
+ to remind the LLM to continue with the next action.
454
+ """
455
+ messages = request.messages
456
+ if not messages:
457
+ return handler(request)
458
+
459
+ # Check if the last message is a ToolMessage from a non-HITL tool
460
+ last_msg = messages[-1]
461
+ if getattr(last_msg, "type", "") == "tool":
462
+ tool_name = getattr(last_msg, "name", "") or ""
463
+
464
+ # Also try to extract tool name from content
465
+ if not tool_name:
466
+ try:
467
+ import json
468
+ content_json = json.loads(last_msg.content)
469
+ tool_name = content_json.get("tool", "")
470
+ except (json.JSONDecodeError, TypeError, AttributeError):
471
+ pass
472
+
473
+ if tool_name in NON_HITL_TOOLS:
474
+ logger.info(
475
+ "Injecting continuation prompt after non-HITL tool: %s",
476
+ tool_name,
477
+ )
478
+
479
+ # Get todos context
480
+ todos = request.state.get("todos", [])
481
+ pending_todos = [
482
+ t for t in todos
483
+ if t.get("status") in ("pending", "in_progress")
484
+ ]
485
+
486
+ if pending_todos:
487
+ pending_list = ", ".join(
488
+ t.get("content", "")[:30] for t in pending_todos[:3]
489
+ )
490
+ continuation = (
491
+ f"Tool '{tool_name}' completed. "
492
+ f"Continue with pending tasks: {pending_list}. "
493
+ f"Call jupyter_cell_tool or the next appropriate tool."
494
+ )
495
+ else:
496
+ continuation = (
497
+ f"Tool '{tool_name}' completed. All tasks done. "
498
+ f"Call final_answer_tool with a summary NOW."
499
+ )
500
+
501
+ # Inject as a system-like user message
502
+ from langchain_core.messages import HumanMessage
503
+ new_messages = list(messages) + [
504
+ HumanMessage(content=f"[SYSTEM] {continuation}")
505
+ ]
506
+ request = request.override(messages=new_messages)
507
+
508
+ return handler(request)
509
+
510
+ middleware.append(inject_continuation_after_non_hitl_tool)
511
+
512
+ class PatchToolCallsMiddleware(AgentMiddleware):
513
+ """Patch dangling tool calls so the agent can continue."""
514
+
515
+ def before_agent(self, state, runtime):
516
+ messages = state.get("messages", [])
517
+ if not messages:
518
+ return None
519
+
520
+ patched = []
521
+ for i, msg in enumerate(messages):
522
+ patched.append(msg)
523
+ if getattr(msg, "type", "") == "ai" and getattr(
524
+ msg, "tool_calls", None
525
+ ):
526
+ for tool_call in msg.tool_calls:
527
+ tool_call_id = tool_call.get("id")
528
+ if not tool_call_id:
529
+ continue
530
+ has_tool_msg = any(
531
+ (
532
+ getattr(m, "type", "") == "tool"
533
+ and getattr(m, "tool_call_id", None) == tool_call_id
534
+ )
535
+ for m in messages[i:]
536
+ )
537
+ if not has_tool_msg:
538
+ tool_msg = (
539
+ f"Tool call {tool_call.get('name', 'unknown')} with id {tool_call_id} "
540
+ "was cancelled - another message came in before it could be completed."
541
+ )
542
+ patched.append(
543
+ LCToolMessage(
544
+ content=tool_msg,
545
+ name=tool_call.get("name", "unknown"),
546
+ tool_call_id=tool_call_id,
547
+ )
548
+ )
549
+
550
+ if patched == messages:
551
+ return None
552
+ return {"messages": Overwrite(patched)}
553
+
554
+ middleware.append(PatchToolCallsMiddleware())
555
+
556
+ # Add TodoListMiddleware for task planning
557
+ if enable_todo_list:
558
+ todo_middleware = TodoListMiddleware(
559
+ system_prompt="""
560
+ ## CRITICAL WORKFLOW RULES - MUST FOLLOW:
561
+ 1. NEVER stop after calling write_todos - ALWAYS make another tool call immediately
562
+ 2. write_todos is ONLY for tracking progress - it does NOT complete any work
563
+ 3. After EVERY write_todos call, you MUST call another tool (jupyter_cell_tool, markdown_tool, or final_answer_tool)
564
+
565
+ ## Todo List Management:
566
+ - Before complex tasks, use write_todos to create a task list
567
+ - Update todos as you complete each step (mark 'in_progress' → 'completed')
568
+ - Each todo item should be specific and descriptive (10-50 characters)
569
+ - All todo items must be written in Korean
570
+ - ALWAYS include "다음 단계 제시" as the LAST item
571
+
572
+ ## Task Completion Flow:
573
+ 1. When current task is done → mark it 'completed' with write_todos
574
+ 2. IMMEDIATELY call the next tool (jupyter_cell_tool for code, markdown_tool for text)
575
+ 3. For "다음 단계 제시" → mark completed, then call final_answer_tool with suggestions
576
+ 4. NEVER end your turn after write_todos - you MUST continue with actual work
577
+
578
+ ## FORBIDDEN PATTERNS:
579
+ ❌ Calling write_todos and then stopping
580
+ ❌ Updating todo status without doing the actual work
581
+ ❌ Ending turn without calling final_answer_tool when all tasks are done
582
+ """,
583
+ tool_description="""Update the task list for tracking progress.
584
+ ⚠️ CRITICAL: This tool is ONLY for tracking - it does NOT do any actual work.
585
+ After calling this tool, you MUST IMMEDIATELY call another tool (jupyter_cell_tool, markdown_tool, or final_answer_tool).
586
+ NEVER end your response after calling write_todos - always continue with the next action tool.""",
587
+ )
588
+ middleware.append(todo_middleware)
589
+
590
+ if enable_hitl:
591
+ # Add Human-in-the-Loop middleware for code execution
592
+ hitl_middleware = HumanInTheLoopMiddleware(
593
+ interrupt_on={
594
+ # Require approval before executing code
595
+ "jupyter_cell_tool": {
596
+ "allowed_decisions": ["approve", "edit", "reject"],
597
+ "description": "🔍 Code execution requires approval",
598
+ },
599
+ # Safe operations - no approval needed
600
+ "markdown_tool": False,
601
+ "read_file_tool": False,
602
+ "list_files_tool": False,
603
+ "search_workspace_tool": False,
604
+ "search_notebook_cells_tool": False,
605
+ "write_todos": False, # Todo updates don't need approval
606
+ # File write requires approval
607
+ "write_file_tool": {
608
+ "allowed_decisions": ["approve", "edit", "reject"],
609
+ "description": "⚠️ File write requires approval",
610
+ },
611
+ # Final answer doesn't need approval
612
+ "final_answer_tool": False,
613
+ },
614
+ description_prefix="Tool execution pending approval",
615
+ )
616
+ middleware.append(hitl_middleware)
617
+
618
+ # Add loop prevention middleware
619
+ # ModelCallLimitMiddleware: Prevent infinite LLM calls
620
+ model_limit_middleware = ModelCallLimitMiddleware(
621
+ run_limit=30, # Max 30 LLM calls per user message
622
+ exit_behavior="end", # Gracefully end when limit reached
623
+ )
624
+ middleware.append(model_limit_middleware)
625
+ logger.info("Added ModelCallLimitMiddleware with run_limit=30")
626
+
627
+ # ToolCallLimitMiddleware: Prevent specific tools from being called too many times
628
+ # Limit write_todos to prevent the loop we observed
629
+ write_todos_limit = ToolCallLimitMiddleware(
630
+ tool_name="write_todos",
631
+ run_limit=5, # Max 5 write_todos calls per user message
632
+ exit_behavior="continue", # Let agent continue with other tools
633
+ )
634
+ middleware.append(write_todos_limit)
635
+
636
+ # Limit list_files_tool to prevent excessive directory listing
637
+ list_files_limit = ToolCallLimitMiddleware(
638
+ tool_name="list_files_tool",
639
+ run_limit=5, # Max 5 list_files calls per user message
640
+ exit_behavior="continue",
641
+ )
642
+ middleware.append(list_files_limit)
643
+ logger.info("Added ToolCallLimitMiddleware for write_todos and list_files_tool")
644
+
645
+ # System prompt for the agent
646
+ system_prompt = """You are an expert Python data scientist and Jupyter notebook assistant.
647
+ Your role is to help users with data analysis, visualization, and Python coding tasks in Jupyter notebooks.
648
+
649
+ ## ⚠️ CRITICAL RULE: NEVER produce an empty response
650
+
651
+ You MUST ALWAYS call a tool in every response. After any tool result, you MUST:
652
+ 1. Check your todo list - are there pending or in_progress items?
653
+ 2. If YES → call the next appropriate tool (jupyter_cell_tool, markdown_tool, etc.)
654
+ 3. If ALL todos are completed → call final_answer_tool with a summary
655
+
656
+ NEVER end your turn without calling a tool. NEVER produce an empty response.
657
+
658
+ ## Available Tools
659
+ 1. **jupyter_cell_tool**: Execute Python code in a new notebook cell
660
+ 2. **markdown_tool**: Add a markdown explanation cell
661
+ 3. **final_answer_tool**: Complete the task with a summary - REQUIRED when done
662
+ 4. **read_file_tool**: Read file contents
663
+ 5. **write_file_tool**: Write file contents
664
+ 6. **list_files_tool**: List directory contents
665
+ 7. **search_workspace_tool**: Search for patterns in workspace files
666
+ 8. **search_notebook_cells_tool**: Search for patterns in notebook cells
667
+ 9. **write_todos**: Create and update task list for complex multi-step tasks
668
+
669
+ ## Mandatory Workflow
670
+ 1. After EVERY tool result, immediately call the next tool
671
+ 2. Continue until ALL todos show status: "completed"
672
+ 3. ONLY THEN call final_answer_tool to summarize
673
+ 4. If `!pip install` fails, use `!pip3 install` instead
674
+ 5. For plots and charts, use English text only
675
+
676
+ ## ❌ FORBIDDEN (will break the workflow)
677
+ - Producing an empty response (no tool call, no content)
678
+ - Stopping after any tool without calling the next tool
679
+ - Ending without calling final_answer_tool
680
+ - Leaving todos in "in_progress" or "pending" state without continuing
681
+ """
682
+
683
+ logger.info("SimpleChatAgent system_prompt: %s", system_prompt)
684
+
685
+ # Create agent with checkpointer (required for HITL)
686
+ agent = create_agent(
687
+ model=llm,
688
+ tools=tools,
689
+ middleware=middleware,
690
+ checkpointer=checkpointer or InMemorySaver(), # Required for interrupt/resume
691
+ system_prompt=system_prompt, # Tell the agent to use tools
692
+ )
693
+
694
+ return agent