hdsp-jupyter-extension 2.0.8__py3-none-any.whl → 2.0.11__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 (88) hide show
  1. agent_server/core/notebook_generator.py +4 -4
  2. agent_server/core/rag_manager.py +12 -3
  3. agent_server/core/retriever.py +2 -1
  4. agent_server/core/vllm_embedding_service.py +8 -5
  5. agent_server/langchain/ARCHITECTURE.md +7 -51
  6. agent_server/langchain/agent.py +31 -20
  7. agent_server/langchain/custom_middleware.py +234 -31
  8. agent_server/langchain/hitl_config.py +5 -8
  9. agent_server/langchain/logging_utils.py +7 -7
  10. agent_server/langchain/prompts.py +106 -120
  11. agent_server/langchain/tools/__init__.py +1 -10
  12. agent_server/langchain/tools/file_tools.py +9 -61
  13. agent_server/langchain/tools/jupyter_tools.py +0 -1
  14. agent_server/langchain/tools/lsp_tools.py +8 -8
  15. agent_server/langchain/tools/resource_tools.py +12 -12
  16. agent_server/langchain/tools/search_tools.py +3 -158
  17. agent_server/prompts/file_action_prompts.py +8 -8
  18. agent_server/routers/langchain_agent.py +200 -125
  19. hdsp_agent_core/__init__.py +46 -47
  20. hdsp_agent_core/factory.py +6 -10
  21. hdsp_agent_core/interfaces.py +4 -2
  22. hdsp_agent_core/knowledge/__init__.py +5 -5
  23. hdsp_agent_core/knowledge/chunking.py +87 -61
  24. hdsp_agent_core/knowledge/loader.py +103 -101
  25. hdsp_agent_core/llm/service.py +192 -107
  26. hdsp_agent_core/managers/config_manager.py +16 -22
  27. hdsp_agent_core/managers/session_manager.py +5 -4
  28. hdsp_agent_core/models/__init__.py +12 -12
  29. hdsp_agent_core/models/agent.py +15 -8
  30. hdsp_agent_core/models/common.py +1 -2
  31. hdsp_agent_core/models/rag.py +48 -111
  32. hdsp_agent_core/prompts/__init__.py +12 -12
  33. hdsp_agent_core/prompts/cell_action_prompts.py +9 -7
  34. hdsp_agent_core/services/agent_service.py +10 -8
  35. hdsp_agent_core/services/chat_service.py +10 -6
  36. hdsp_agent_core/services/rag_service.py +3 -6
  37. hdsp_agent_core/tests/conftest.py +4 -1
  38. hdsp_agent_core/tests/test_factory.py +2 -2
  39. hdsp_agent_core/tests/test_services.py +12 -19
  40. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/build_log.json +1 -1
  41. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/package.json +2 -2
  42. hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.8740a527757068814573.js → hdsp_jupyter_extension-2.0.11.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.2d9fb488c82498c45c2d.js +93 -4
  43. hdsp_jupyter_extension-2.0.11.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.2d9fb488c82498c45c2d.js.map +1 -0
  44. hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.e4ff4b5779b5e049f84c.js → hdsp_jupyter_extension-2.0.11.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.58c1e128ba0b76f41f04.js +153 -130
  45. hdsp_jupyter_extension-2.0.11.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.58c1e128ba0b76f41f04.js.map +1 -0
  46. hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.020cdb0b864cfaa4e41e.js → hdsp_jupyter_extension-2.0.11.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.9da31d1134a53b0c4af5.js +6 -6
  47. hdsp_jupyter_extension-2.0.11.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.9da31d1134a53b0c4af5.js.map +1 -0
  48. {hdsp_jupyter_extension-2.0.8.dist-info → hdsp_jupyter_extension-2.0.11.dist-info}/METADATA +1 -3
  49. hdsp_jupyter_extension-2.0.11.dist-info/RECORD +144 -0
  50. jupyter_ext/__init__.py +21 -11
  51. jupyter_ext/_version.py +1 -1
  52. jupyter_ext/handlers.py +69 -50
  53. jupyter_ext/labextension/build_log.json +1 -1
  54. jupyter_ext/labextension/package.json +2 -2
  55. jupyter_ext/labextension/static/{frontend_styles_index_js.8740a527757068814573.js → frontend_styles_index_js.2d9fb488c82498c45c2d.js} +93 -4
  56. jupyter_ext/labextension/static/frontend_styles_index_js.2d9fb488c82498c45c2d.js.map +1 -0
  57. jupyter_ext/labextension/static/{lib_index_js.e4ff4b5779b5e049f84c.js → lib_index_js.58c1e128ba0b76f41f04.js} +153 -130
  58. jupyter_ext/labextension/static/lib_index_js.58c1e128ba0b76f41f04.js.map +1 -0
  59. jupyter_ext/labextension/static/{remoteEntry.020cdb0b864cfaa4e41e.js → remoteEntry.9da31d1134a53b0c4af5.js} +6 -6
  60. jupyter_ext/labextension/static/remoteEntry.9da31d1134a53b0c4af5.js.map +1 -0
  61. hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.8740a527757068814573.js.map +0 -1
  62. hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.e4ff4b5779b5e049f84c.js.map +0 -1
  63. hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.020cdb0b864cfaa4e41e.js.map +0 -1
  64. hdsp_jupyter_extension-2.0.8.dist-info/RECORD +0 -144
  65. jupyter_ext/labextension/static/frontend_styles_index_js.8740a527757068814573.js.map +0 -1
  66. jupyter_ext/labextension/static/lib_index_js.e4ff4b5779b5e049f84c.js.map +0 -1
  67. jupyter_ext/labextension/static/remoteEntry.020cdb0b864cfaa4e41e.js.map +0 -1
  68. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +0 -0
  69. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/install.json +0 -0
  70. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.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
  71. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.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
  72. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.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
  73. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.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
  74. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/static/style.js +0 -0
  75. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.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
  76. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.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
  77. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js +0 -0
  78. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js.map +0 -0
  79. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js +0 -0
  80. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js.map +0 -0
  81. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +0 -0
  82. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.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
  83. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +0 -0
  84. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +0 -0
  85. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js +0 -0
  86. {hdsp_jupyter_extension-2.0.8.data → hdsp_jupyter_extension-2.0.11.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +0 -0
  87. {hdsp_jupyter_extension-2.0.8.dist-info → hdsp_jupyter_extension-2.0.11.dist-info}/WHEEL +0 -0
  88. {hdsp_jupyter_extension-2.0.8.dist-info → hdsp_jupyter_extension-2.0.11.dist-info}/licenses/LICENSE +0 -0
@@ -118,9 +118,9 @@ JSON만 응답하세요:"""
118
118
  요청: {prompt}
119
119
 
120
120
  분석 결과:
121
- - 목표: {analysis['objective']}
122
- - 라이브러리: {', '.join(analysis['libraries'])}
123
- - 분석 유형: {analysis['analysis_type']}
121
+ - 목표: {analysis["objective"]}
122
+ - 라이브러리: {", ".join(analysis["libraries"])}
123
+ - 분석 유형: {analysis["analysis_type"]}
124
124
 
125
125
  노트북의 각 셀에 대한 계획을 JSON 형식으로 작성해주세요:
126
126
  {{
@@ -171,7 +171,7 @@ JSON만 응답하세요:"""
171
171
  # Update progress (30% -> 80%)
172
172
  progress = 30 + int((idx / total_cells) * 50)
173
173
  self.task_manager.update_progress(
174
- task_id, progress, f"셀 생성 중... ({idx+1}/{total_cells})"
174
+ task_id, progress, f"셀 생성 중... ({idx + 1}/{total_cells})"
175
175
  )
176
176
 
177
177
  cell_type = cell_plan.get("type", "code")
@@ -90,16 +90,25 @@ class RAGManager:
90
90
 
91
91
  # 2. Initialize embedding service (local or vLLM backend)
92
92
  import os
93
- embedding_backend = os.environ.get("HDSP_EMBEDDING_BACKEND", "local").lower()
93
+
94
+ embedding_backend = os.environ.get(
95
+ "HDSP_EMBEDDING_BACKEND", "local"
96
+ ).lower()
94
97
 
95
98
  if embedding_backend == "vllm":
96
- from agent_server.core.vllm_embedding_service import get_vllm_embedding_service
97
- self._embedding_service = get_vllm_embedding_service(self._config.embedding)
99
+ from agent_server.core.vllm_embedding_service import (
100
+ get_vllm_embedding_service,
101
+ )
102
+
103
+ self._embedding_service = get_vllm_embedding_service(
104
+ self._config.embedding
105
+ )
98
106
  logger.info(
99
107
  f"vLLM Embedding service initialized (dim={self._embedding_service.dimension})"
100
108
  )
101
109
  else:
102
110
  from agent_server.core.embedding_service import get_embedding_service
111
+
103
112
  self._embedding_service = get_embedding_service(self._config.embedding)
104
113
  # Load model to get dimension
105
114
  await self._embedding_service._ensure_model_loaded()
@@ -96,7 +96,8 @@ class Retriever:
96
96
  query=query_embedding,
97
97
  query_filter=qdrant_filter,
98
98
  limit=effective_top_k,
99
- score_threshold=effective_threshold * 0.5, # Lower for initial retrieval
99
+ score_threshold=effective_threshold
100
+ * 0.5, # Lower for initial retrieval
100
101
  with_payload=True,
101
102
  with_vectors=False,
102
103
  )
@@ -17,7 +17,6 @@ import os
17
17
  from typing import TYPE_CHECKING, List, Optional
18
18
 
19
19
  import httpx
20
- import time
21
20
 
22
21
  if TYPE_CHECKING:
23
22
  from hdsp_agent_core.models.rag import EmbeddingConfig
@@ -66,7 +65,7 @@ class VLLMEmbeddingService:
66
65
  self._client = httpx.AsyncClient(
67
66
  base_url=self._endpoint,
68
67
  timeout=httpx.Timeout(30.0),
69
- limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
68
+ limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
70
69
  )
71
70
 
72
71
  logger.info(
@@ -79,7 +78,9 @@ class VLLMEmbeddingService:
79
78
  """Get embedding dimension"""
80
79
  return self._dimension
81
80
 
82
- async def _call_vllm_api(self, texts: List[str], max_retries: int = 3) -> List[List[float]]:
81
+ async def _call_vllm_api(
82
+ self, texts: List[str], max_retries: int = 3
83
+ ) -> List[List[float]]:
83
84
  """
84
85
  Call vLLM embedding API with retry logic.
85
86
 
@@ -126,7 +127,9 @@ class VLLMEmbeddingService:
126
127
  logger.error(f"Unexpected error calling vLLM API: {e}")
127
128
  break
128
129
 
129
- raise Exception(f"Failed to connect to vLLM after {max_retries} attempts: {last_error}")
130
+ raise Exception(
131
+ f"Failed to connect to vLLM after {max_retries} attempts: {last_error}"
132
+ )
130
133
 
131
134
  async def embed_texts(self, texts: List[str]) -> List[List[float]]:
132
135
  """
@@ -240,4 +243,4 @@ def reset_vllm_embedding_service() -> None:
240
243
  _vllm_embedding_service._initialized = False
241
244
  _vllm_embedding_service = None
242
245
  VLLMEmbeddingService._instance = None
243
- VLLMEmbeddingService._initialized = False
246
+ VLLMEmbeddingService._initialized = False
@@ -151,13 +151,10 @@ jupyter_ext/
151
151
  ```python
152
152
  - jupyter_cell_tool # Python 코드 실행
153
153
  - markdown_tool # 마크다운 셀 추가
154
- - final_answer_tool # 작업 완료 및 요약
155
154
  - read_file_tool # 파일 읽기
156
155
  - write_file_tool # 파일 쓰기
157
- - list_files_tool # 디렉토리 목록
158
- - search_workspace_tool # 워크스페이스 검색 (grep/rg)
159
156
  - search_notebook_cells_tool # 노트북 셀 검색
160
- - execute_command_tool # 쉘 명령 실행
157
+ - execute_command_tool # 쉘 명령 실행 (파일 검색은 find/grep 사용)
161
158
  - check_resource_tool # 리소스 확인
162
159
  ```
163
160
 
@@ -441,8 +438,6 @@ non-HITL 도구 실행 후 continuation 프롬프트를 주입합니다.
441
438
  NON_HITL_TOOLS = {
442
439
  "markdown_tool",
443
440
  "read_file_tool",
444
- "list_files_tool",
445
- "search_workspace_tool",
446
441
  "search_notebook_cells_tool",
447
442
  "write_todos",
448
443
  }
@@ -508,8 +503,7 @@ LLM 호출 횟수를 제한합니다.
508
503
 
509
504
  **설정**:
510
505
  ```python
511
- - write_todos: run_limit=5, exit_behavior="continue"
512
- - list_files_tool: run_limit=5, exit_behavior="continue"
506
+ - write_todos: run_limit=20, exit_behavior="continue"
513
507
  ```
514
508
 
515
509
  ### 9. `SummarizationMiddleware` (LangChain 내장)
@@ -634,52 +628,14 @@ Python 코드를 Jupyter 셀에서 실행합니다.
634
628
  **특징**:
635
629
  - HITL 대상 (사용자 승인 필요)
636
630
 
637
- #### `list_files_tool`
638
- 디렉토리 목록을 가져옵니다.
639
-
640
- **파라미터**:
641
- - `path`: 디렉토리 경로 (기본 ".")
642
- - `recursive`: 재귀 탐색 여부 (기본 False)
643
-
644
- **반환**:
645
- ```python
646
- {
647
- "tool": "list_files",
648
- "parameters": {"path": ".", "recursive": False},
649
- "status": "completed",
650
- "files": ["file1.py", "file2.csv", ...]
651
- }
652
- ```
653
-
654
631
  ---
655
632
 
656
633
  ### Search Tools (`search_tools.py`)
657
634
 
658
- #### `search_workspace_tool`
659
- 워크스페이스에서 패턴을 검색합니다 (grep/ripgrep).
660
-
661
- **파라미터**:
662
- - `pattern`: 정규식 패턴
663
- - `file_types`: 파일 타입 필터 (예: ["py", "md"])
664
- - `path`: 검색 경로 (기본 ".")
665
-
666
- **반환**:
667
- ```python
668
- {
669
- "tool": "search_workspace",
670
- "parameters": {"pattern": "...", "file_types": ["py"], "path": "."},
671
- "status": "completed",
672
- "results": [
673
- {"file": "file1.py", "line_number": 10, "line": "..."},
674
- ...
675
- ],
676
- "command": "rg ... (또는 grep ...)"
677
- }
678
- ```
679
-
680
- **특징**:
681
- - ripgrep 우선 사용 (속도)
682
- - 없으면 grep 사용
635
+ > **Note**: 파일 검색 기능은 `execute_command_tool`을 통해 `find`/`grep` 명령을 직접 사용합니다.
636
+ >
637
+ > - 파일명 검색: `execute_command_tool(command="find . -iname '*pattern*' 2>/dev/null")`
638
+ > - 파일 내용 검색: `execute_command_tool(command="grep -rn 'pattern' --include='*.py' .")`
683
639
 
684
640
  #### `search_notebook_cells_tool`
685
641
  Jupyter 노트북 셀에서 패턴을 검색합니다.
@@ -961,7 +917,7 @@ return
961
917
  - **HITL**: 사용자 승인 필요
962
918
  - `jupyter_cell_tool`, `execute_command_tool`, `write_file_tool`
963
919
  - **non-HITL**: 즉시 실행
964
- - `markdown_tool`, `read_file_tool`, `list_files_tool`, `search_*_tool`
920
+ - `markdown_tool`, `read_file_tool`, `search_*_tool`
965
921
  - **클라이언트 실행**: 서버에서 실행하지 않음
966
922
  - `check_resource_tool`: CheckResourceHandler에서 처리
967
923
 
@@ -26,15 +26,12 @@ from agent_server.langchain.tools import (
26
26
  diagnostics_tool,
27
27
  edit_file_tool,
28
28
  execute_command_tool,
29
- final_answer_tool,
30
29
  jupyter_cell_tool,
31
- list_files_tool,
32
30
  markdown_tool,
33
31
  multiedit_file_tool,
34
32
  read_file_tool,
35
33
  references_tool,
36
34
  search_notebook_cells_tool,
37
- search_workspace_tool,
38
35
  write_file_tool,
39
36
  )
40
37
 
@@ -46,13 +43,10 @@ def _get_all_tools():
46
43
  return [
47
44
  jupyter_cell_tool,
48
45
  markdown_tool,
49
- final_answer_tool,
50
46
  read_file_tool,
51
47
  write_file_tool,
52
48
  edit_file_tool,
53
49
  multiedit_file_tool,
54
- list_files_tool,
55
- search_workspace_tool,
56
50
  search_notebook_cells_tool,
57
51
  execute_command_tool,
58
52
  check_resource_tool,
@@ -115,7 +109,6 @@ def create_simple_chat_agent(
115
109
 
116
110
  # Configure middleware
117
111
  middleware = []
118
-
119
112
  # Add empty response handler middleware
120
113
  handle_empty_response = create_handle_empty_response_middleware(wrap_model_call)
121
114
  middleware.append(handle_empty_response)
@@ -125,7 +118,9 @@ def create_simple_chat_agent(
125
118
  middleware.append(limit_tool_calls)
126
119
 
127
120
  # Add tool args normalization middleware (convert list args to strings based on schema)
128
- normalize_tool_args = create_normalize_tool_args_middleware(wrap_model_call, tools=tools)
121
+ normalize_tool_args = create_normalize_tool_args_middleware(
122
+ wrap_model_call, tools=tools
123
+ )
129
124
  middleware.append(normalize_tool_args)
130
125
 
131
126
  # Add continuation prompt middleware
@@ -164,22 +159,14 @@ def create_simple_chat_agent(
164
159
  logger.info("Added ModelCallLimitMiddleware with run_limit=30")
165
160
 
166
161
  # ToolCallLimitMiddleware: Prevent specific tools from being called too many times
167
- # Limit write_todos to prevent loops
162
+ # run_limit resets automatically per user message
168
163
  write_todos_limit = ToolCallLimitMiddleware(
169
164
  tool_name="write_todos",
170
- run_limit=5, # Max 5 write_todos calls per user message
171
- exit_behavior="continue", # Let agent continue with other tools
172
- )
173
- middleware.append(write_todos_limit)
174
-
175
- # Limit list_files_tool to prevent excessive directory listing
176
- list_files_limit = ToolCallLimitMiddleware(
177
- tool_name="list_files_tool",
178
- run_limit=5, # Max 5 list_files calls per user message
165
+ run_limit=20, # Max 20 write_todos calls per user message
179
166
  exit_behavior="continue",
180
167
  )
181
- middleware.append(list_files_limit)
182
- logger.info("Added ToolCallLimitMiddleware for write_todos and list_files_tool")
168
+ middleware.append(write_todos_limit)
169
+ logger.info("Added ToolCallLimitMiddleware for write_todos (20/msg)")
183
170
 
184
171
  # Add SummarizationMiddleware to maintain context across cycles
185
172
  summary_llm = create_summarization_llm(llm_config)
@@ -218,6 +205,30 @@ Example: "데이터를 로드하겠습니다." then call jupyter_cell_tool.
218
205
  system_prompt = system_prompt + "\n" + gemini_content_prompt
219
206
  logger.info("Added Gemini 2.5 Flash specific prompt for content inclusion")
220
207
 
208
+ # Add vLLM/gpt-oss specific prompt for Korean responses and proper todo structure
209
+ provider = llm_config.get("provider", "")
210
+ if provider == "vllm":
211
+ vllm_prompt = """
212
+ ## 🔴 중요: 한국어로 응답하세요
213
+ - 모든 응답, 설명, todo 항목은 반드시 한국어로 작성하세요.
214
+ - 코드 주석과 출력 설명도 한국어로 작성하세요.
215
+ - 영어로 응답하지 마세요.
216
+
217
+ ## 🔴 MANDATORY: Todo List Structure
218
+ When creating todos with write_todos, you MUST:
219
+ 1. Write all todo items in Korean
220
+ 2. ALWAYS include "작업 요약 및 다음단계 제시" as the LAST todo item
221
+ 3. Example structure:
222
+ - 데이터 로드 및 확인
223
+ - 데이터 분석 수행
224
+ - 작업 요약 및 다음단계 제시 ← 반드시 마지막에 포함!
225
+
226
+ ## 🔴 IMPORTANT: Never return empty responses
227
+ If you have nothing to say, call a tool instead. NEVER return an empty response.
228
+ """
229
+ system_prompt = system_prompt + "\n" + vllm_prompt
230
+ logger.info("Added vLLM/gpt-oss specific prompt for Korean responses")
231
+
221
232
  logger.info("SimpleChatAgent system_prompt: %s", system_prompt)
222
233
 
223
234
  # Create agent with checkpointer (required for HITL)
@@ -139,6 +139,45 @@ def create_handle_empty_response_middleware(wrap_model_call):
139
139
  def handle_empty_response(request, handler):
140
140
  max_retries = 2
141
141
 
142
+ # Check if all todos are completed - if so, return empty response to stop agent
143
+ # Method 1: Check state.todos
144
+ todos = request.state.get("todos", [])
145
+ if todos:
146
+ pending_todos = [
147
+ t for t in todos if t.get("status") in ("pending", "in_progress")
148
+ ]
149
+ if not pending_todos:
150
+ logger.info(
151
+ "All %d todos completed (from state) - stopping agent (no LLM call)",
152
+ len(todos),
153
+ )
154
+ return AIMessage(content="", tool_calls=[])
155
+
156
+ # Method 2: Check last message if it's a write_todos ToolMessage with all completed
157
+ messages = request.messages
158
+ if messages:
159
+ last_msg = messages[-1]
160
+ if getattr(last_msg, "type", "") == "tool":
161
+ tool_name = getattr(last_msg, "name", "") or ""
162
+ content = getattr(last_msg, "content", "") or ""
163
+ if tool_name == "write_todos" or "Updated todo list to" in content:
164
+ # Extract todos from ToolMessage content
165
+ try:
166
+ import ast
167
+ if "Updated todo list to" in content:
168
+ list_text = content.split("Updated todo list to", 1)[1].strip()
169
+ todos_from_msg = ast.literal_eval(list_text)
170
+ if isinstance(todos_from_msg, list) and len(todos_from_msg) > 0:
171
+ pending = [t for t in todos_from_msg if t.get("status") in ("pending", "in_progress")]
172
+ if not pending:
173
+ logger.info(
174
+ "All %d todos completed (from ToolMessage) - stopping agent (no LLM call)",
175
+ len(todos_from_msg),
176
+ )
177
+ return AIMessage(content="", tool_calls=[])
178
+ except Exception as e:
179
+ logger.debug("Failed to parse todos from ToolMessage: %s", e)
180
+
142
181
  # Check if last message is final_answer_tool result - if so, don't retry/synthesize
143
182
  # This allows agent to naturally terminate after final_answer_tool
144
183
  messages = request.messages
@@ -206,6 +245,25 @@ def create_handle_empty_response_middleware(wrap_model_call):
206
245
  # Invalid response - retry with JSON schema prompt
207
246
  if response_message and attempt < max_retries:
208
247
  reason = "text-only" if has_content else "empty"
248
+
249
+ json_prompt = _build_json_prompt(request, response_message, has_content)
250
+
251
+ # If _build_json_prompt returns None, skip retry and synthesize write_todos
252
+ # This happens when: all todos completed OR current todo is summary/next_steps
253
+ if json_prompt is None:
254
+ logger.info(
255
+ "Skipping retry for %s response, synthesizing write_todos with content",
256
+ reason,
257
+ )
258
+ # Synthesize write_todos while preserving the content (summary)
259
+ synthetic_message = _create_synthetic_final_answer(
260
+ request, response_message, has_content
261
+ )
262
+ response = _replace_ai_message_in_response(
263
+ response, synthetic_message
264
+ )
265
+ return response
266
+
209
267
  logger.warning(
210
268
  "Invalid AIMessage (%s) detected (attempt %d/%d). "
211
269
  "Retrying with JSON schema prompt...",
@@ -214,16 +272,26 @@ def create_handle_empty_response_middleware(wrap_model_call):
214
272
  max_retries + 1,
215
273
  )
216
274
 
217
- json_prompt = _build_json_prompt(request, response_message, has_content)
218
275
  request = request.override(
219
276
  messages=request.messages + [HumanMessage(content=json_prompt)]
220
277
  )
221
278
  continue
222
279
 
223
- # Max retries exhausted - synthesize final_answer
280
+ # Max retries exhausted - synthesize write_todos to complete
224
281
  if response_message:
282
+ # Check if todos are already all completed - if so, just return
283
+ todos = request.state.get("todos", [])
284
+ pending_todos = [
285
+ t for t in todos if t.get("status") in ("pending", "in_progress")
286
+ ]
287
+ if todos and not pending_todos:
288
+ logger.info(
289
+ "Max retries exhausted but all todos completed - returning response as-is"
290
+ )
291
+ return response
292
+
225
293
  logger.warning(
226
- "Max retries exhausted. Synthesizing final_answer response."
294
+ "Max retries exhausted. Synthesizing write_todos to complete."
227
295
  )
228
296
  synthetic_message = _create_synthetic_final_answer(
229
297
  request, response_message, has_content
@@ -274,14 +342,33 @@ def _build_json_prompt(request, response_message, has_content):
274
342
  """Build JSON-forcing prompt based on context."""
275
343
  todos = request.state.get("todos", [])
276
344
  pending_todos = [t for t in todos if t.get("status") in ("pending", "in_progress")]
345
+ in_progress_todos = [t for t in todos if t.get("status") == "in_progress"]
277
346
 
278
347
  if has_content:
279
- content_preview = response_message.content[:300]
348
+ # If all todos completed, don't force another tool call
349
+ if todos and not pending_todos:
350
+ return None # Signal to skip retry
351
+
352
+ # If current in_progress todo is "작업 요약 및 다음단계 제시", accept text-only response
353
+ # The LLM is outputting the summary, we'll synthesize write_todos
354
+ if in_progress_todos:
355
+ current_todo = in_progress_todos[0].get("content", "")
356
+ if (
357
+ "작업 요약" in current_todo
358
+ or "다음단계" in current_todo
359
+ or "다음 단계" in current_todo
360
+ ):
361
+ logger.info(
362
+ "Current todo is summary/next steps ('%s'), accepting text-only response",
363
+ current_todo[:30],
364
+ )
365
+ return None # Signal to skip retry - will synthesize write_todos with content
366
+
280
367
  return (
281
368
  f"{JSON_TOOL_SCHEMA}\n\n"
282
369
  f"Your previous response was text, not JSON. "
283
- f"Wrap your answer in final_answer_tool:\n"
284
- f'{{"tool": "final_answer_tool", "arguments": {{"answer": "{content_preview}..."}}}}'
370
+ f"Call the next appropriate tool to continue.\n"
371
+ f'Example: {{"tool": "jupyter_cell_tool", "arguments": {{"code": "print(\'hello\')"}}}}'
285
372
  )
286
373
  elif pending_todos:
287
374
  todo_list = ", ".join(t.get("content", "")[:20] for t in pending_todos[:3])
@@ -292,39 +379,62 @@ def _build_json_prompt(request, response_message, has_content):
292
379
  f"Call jupyter_cell_tool with Python code to complete the next task.\n"
293
380
  f"Example: {example_json}"
294
381
  )
382
+ elif not todos:
383
+ # No todos yet = new task starting, LLM must create todos or call a tool
384
+ # This happens when LLM returns empty response at the start of a new task
385
+ logger.info("No todos exist yet - forcing retry to create todos or call tool")
386
+ return (
387
+ f"{JSON_TOOL_SCHEMA}\n\n"
388
+ f"Your response was empty. You MUST call a tool to proceed.\n"
389
+ f"한국어로 응답하고, write_todos로 작업 목록을 만들거나 jupyter_cell_tool/read_file_tool을 호출하세요.\n"
390
+ f'Example: {{"tool": "write_todos", "arguments": {{"todos": [{{"content": "데이터 분석", "status": "in_progress"}}]}}}}'
391
+ )
295
392
  else:
393
+ # Todos exist but all completed - ask for summary
394
+ logger.info("All todos completed but response empty - asking for summary")
296
395
  return (
297
396
  f"{JSON_TOOL_SCHEMA}\n\n"
298
- f"All tasks completed. Call final_answer_tool:\n"
299
- f'{{"tool": "final_answer_tool", "arguments": {{"answer": "작업이 완료되었습니다."}}}}'
397
+ f"All tasks completed. Call markdown_tool to provide a summary in Korean.\n"
398
+ f"한국어로 작업 요약을 작성하세요.\n"
399
+ f'Example: {{"tool": "markdown_tool", "arguments": {{"content": "작업이 완료되었습니다."}}}}'
300
400
  )
301
401
 
302
402
 
303
403
  def _create_synthetic_final_answer(request, response_message, has_content):
304
- """Create synthetic final_answer message."""
305
- if has_content and response_message.content:
306
- summary = response_message.content
404
+ """Create synthetic write_todos call to mark all todos as completed.
405
+
406
+ This triggers automatic session termination via router's all_todos_completed check.
407
+ Preserves the LLM's text content (summary) if present.
408
+ """
409
+ todos = request.state.get("todos", [])
410
+
411
+ # Mark all todos as completed
412
+ completed_todos = (
413
+ [{**todo, "status": "completed"} for todo in todos]
414
+ if todos
415
+ else [{"content": "작업 완료", "status": "completed"}]
416
+ )
417
+
418
+ # Preserve original content (summary JSON) if present
419
+ original_content = ""
420
+ if has_content and response_message and response_message.content:
421
+ original_content = response_message.content
307
422
  logger.info(
308
- "Using LLM's text content as final answer (length=%d)",
309
- len(summary),
423
+ "Creating synthetic write_todos with preserved content (length=%d)",
424
+ len(original_content),
310
425
  )
311
426
  else:
312
- todos = request.state.get("todos", [])
313
- completed_todos = [
314
- t.get("content", "") for t in todos if t.get("status") == "completed"
315
- ]
316
- summary = (
317
- f"작업이 완료되었습니다. 완료된 항목: {', '.join(completed_todos[:5])}"
318
- if completed_todos
319
- else "작업이 완료되었습니다."
427
+ logger.info(
428
+ "Creating synthetic write_todos to mark %d todos as completed",
429
+ len(completed_todos),
320
430
  )
321
431
 
322
432
  return AIMessage(
323
- content="",
433
+ content=original_content, # Preserve the summary content for UI
324
434
  tool_calls=[
325
435
  {
326
- "name": "final_answer_tool",
327
- "args": {"answer": summary},
436
+ "name": "write_todos",
437
+ "args": {"todos": completed_todos},
328
438
  "id": str(uuid.uuid4()),
329
439
  "type": "tool_call",
330
440
  }
@@ -504,6 +614,69 @@ def create_normalize_tool_args_middleware(wrap_model_call, tools=None):
504
614
  )
505
615
  args[key] = normalized_value
506
616
 
617
+ # Ensure write_todos includes summary todo as last item
618
+ if tool_name == "write_todos" and "todos" in args:
619
+ todos = args["todos"]
620
+ if isinstance(todos, list) and len(todos) > 0:
621
+ # Check if any todo contains summary keywords
622
+ summary_keywords = [
623
+ "작업 요약",
624
+ "다음단계",
625
+ "다음 단계",
626
+ "요약 및",
627
+ ]
628
+ has_summary = any(
629
+ any(
630
+ kw in todo.get("content", "")
631
+ for kw in summary_keywords
632
+ )
633
+ for todo in todos
634
+ if isinstance(todo, dict)
635
+ )
636
+
637
+ if not has_summary:
638
+ # Add summary todo as last item
639
+ summary_todo = {
640
+ "content": "작업 요약 및 다음단계 제시",
641
+ "status": "pending",
642
+ }
643
+ todos.append(summary_todo)
644
+ logger.info(
645
+ "Auto-added '작업 요약 및 다음단계 제시' to write_todos (total: %d todos)",
646
+ len(todos),
647
+ )
648
+
649
+ # Log warning if summary todo is completed without JSON (but don't block)
650
+ for todo in todos:
651
+ if not isinstance(todo, dict):
652
+ continue
653
+ content = todo.get("content", "")
654
+ status = todo.get("status", "")
655
+ is_summary_todo = any(
656
+ kw in content for kw in summary_keywords
657
+ )
658
+ if is_summary_todo and status == "completed":
659
+ # Check if AIMessage content has summary JSON
660
+ msg_content = getattr(msg, "content", "") or ""
661
+ if isinstance(msg_content, list):
662
+ msg_content = " ".join(
663
+ str(p) for p in msg_content
664
+ )
665
+ has_summary_json = (
666
+ '"summary"' in msg_content
667
+ and '"next_items"' in msg_content
668
+ ) or (
669
+ "'summary'" in msg_content
670
+ and "'next_items'" in msg_content
671
+ )
672
+ if not has_summary_json:
673
+ # Just log warning - don't block completion to avoid infinite loop
674
+ logger.warning(
675
+ "Summary todo marked completed but no summary JSON in content. "
676
+ "Allowing completion to proceed. Content: %s",
677
+ msg_content[:200],
678
+ )
679
+
507
680
  return response
508
681
 
509
682
  return normalize_tool_args
@@ -543,16 +716,45 @@ def create_inject_continuation_middleware(wrap_model_call):
543
716
  pass
544
717
 
545
718
  if tool_name in NON_HITL_TOOLS:
546
- logger.info(
547
- "Injecting continuation prompt after non-HITL tool: %s",
548
- tool_name,
549
- )
550
-
719
+ # Method 1: Check state.todos
551
720
  todos = request.state.get("todos", [])
552
721
  pending_todos = [
553
722
  t for t in todos if t.get("status") in ("pending", "in_progress")
554
723
  ]
555
724
 
725
+ # If all todos are completed, DON'T call LLM - return empty response to stop agent
726
+ if not pending_todos and todos:
727
+ logger.info(
728
+ "All todos completed (from state) after tool: %s - stopping agent (no LLM call)",
729
+ tool_name,
730
+ )
731
+ return AIMessage(content="", tool_calls=[])
732
+
733
+ # Method 2: Check ToolMessage content for write_todos
734
+ if tool_name == "write_todos" or "Updated todo list to" in (last_msg.content or ""):
735
+ try:
736
+ import ast
737
+ content = last_msg.content or ""
738
+ if "Updated todo list to" in content:
739
+ list_text = content.split("Updated todo list to", 1)[1].strip()
740
+ todos_from_msg = ast.literal_eval(list_text)
741
+ if isinstance(todos_from_msg, list) and len(todos_from_msg) > 0:
742
+ pending = [t for t in todos_from_msg if t.get("status") in ("pending", "in_progress")]
743
+ if not pending:
744
+ logger.info(
745
+ "All %d todos completed (from ToolMessage) after tool: %s - stopping agent",
746
+ len(todos_from_msg),
747
+ tool_name,
748
+ )
749
+ return AIMessage(content="", tool_calls=[])
750
+ except Exception as e:
751
+ logger.debug("Failed to parse todos from ToolMessage: %s", e)
752
+
753
+ logger.info(
754
+ "Injecting continuation prompt after non-HITL tool: %s",
755
+ tool_name,
756
+ )
757
+
556
758
  if pending_todos:
557
759
  pending_list = ", ".join(
558
760
  t.get("content", "")[:30] for t in pending_todos[:3]
@@ -563,9 +765,10 @@ def create_inject_continuation_middleware(wrap_model_call):
563
765
  f"Call jupyter_cell_tool or the next appropriate tool."
564
766
  )
565
767
  else:
768
+ # No todos yet - let agent create them
566
769
  continuation = (
567
- f"Tool '{tool_name}' completed. All tasks done. "
568
- f"Call final_answer_tool with a summary NOW."
770
+ f"Tool '{tool_name}' completed. "
771
+ f"Create a todo list with write_todos if needed."
569
772
  )
570
773
 
571
774
  new_messages = list(messages) + [