codebuff-agent 2.1.74__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 (217) hide show
  1. codebuff_agent-2.1.74.data/data/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
  2. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/package.json +157 -0
  3. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
  4. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/150.39fa98117cc8ca590465.js +1 -0
  5. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/166.386ff9b2c37ac865fdee.js +1 -0
  6. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/216.25cf81131bf7274dabf4.js +1 -0
  7. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/353.581bd1cb00746399b62e.js +1 -0
  8. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/367.575f9621b63c9a641aa9.js +1 -0
  9. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/421.11dac223a2c2da1b09f5.js +1 -0
  10. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js +2 -0
  11. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
  12. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js +2 -0
  13. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
  14. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/481.cc2dec9fa869d66f1407.js +1 -0
  15. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/487.0796e931d6e651482c14.js +1 -0
  16. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/49.d326f554b6f9ccde6580.js +1 -0
  17. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/541.26897ea32845659e36be.js +1 -0
  18. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/579.e569d3edd8391fd8fbb3.js +1 -0
  19. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js +2 -0
  20. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
  21. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/666.d9bfdb23f98489d0a188.js +1 -0
  22. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js +2 -0
  23. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
  24. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/68.26b6f59c8454adb17f20.js +204 -0
  25. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/694.4ac443814260057e68f7.js +1 -0
  26. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/791.61affb8d3480d3d60e0d.js +1 -0
  27. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js +2 -0
  28. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
  29. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
  30. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/97.ea559a5b8a9a09f17c6b.js +1 -0
  31. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
  32. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/style.js +4 -0
  33. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/third-party-licenses.json +724 -0
  34. codebuff_agent-2.1.74.dist-info/METADATA +142 -0
  35. codebuff_agent-2.1.74.dist-info/RECORD +217 -0
  36. codebuff_agent-2.1.74.dist-info/WHEEL +4 -0
  37. codebuff_agent-2.1.74.dist-info/licenses/LICENSE +21 -0
  38. codebuff_core/__init__.py +153 -0
  39. codebuff_core/codebuff_config_defaults.json +64 -0
  40. codebuff_core/core/__init__.py +35 -0
  41. codebuff_core/core/code_validator.py +1238 -0
  42. codebuff_core/core/error_classifier.py +579 -0
  43. codebuff_core/core/exceptions.py +49 -0
  44. codebuff_core/data/tiktoken_cache/9b5ad71b2ce5302211f9c61530b329a4922fc6a4 +100256 -0
  45. codebuff_core/directory_policy.py +32 -0
  46. codebuff_core/factory.py +155 -0
  47. codebuff_core/interfaces.py +197 -0
  48. codebuff_core/knowledge/__init__.py +31 -0
  49. codebuff_core/knowledge/chunking.py +375 -0
  50. codebuff_core/knowledge/loader.py +339 -0
  51. codebuff_core/knowledge/watchdog_service.py +354 -0
  52. codebuff_core/legacy_paths.py +69 -0
  53. codebuff_core/llm/__init__.py +13 -0
  54. codebuff_core/llm/service.py +407 -0
  55. codebuff_core/managers/__init__.py +22 -0
  56. codebuff_core/managers/config_manager.py +690 -0
  57. codebuff_core/managers/remote_config.py +673 -0
  58. codebuff_core/managers/remote_skills.py +539 -0
  59. codebuff_core/managers/session_manager.py +255 -0
  60. codebuff_core/middleware/__init__.py +3 -0
  61. codebuff_core/middleware/error_recovery.py +394 -0
  62. codebuff_core/middleware/logging_utils.py +33 -0
  63. codebuff_core/models/__init__.py +111 -0
  64. codebuff_core/models/agent.py +306 -0
  65. codebuff_core/models/chat.py +57 -0
  66. codebuff_core/models/common.py +82 -0
  67. codebuff_core/models/rag.py +300 -0
  68. codebuff_core/platform_rules.py +127 -0
  69. codebuff_core/prompts/__init__.py +44 -0
  70. codebuff_core/prompts/auto_agent_prompts.py +1278 -0
  71. codebuff_core/services/__init__.py +30 -0
  72. codebuff_core/services/agent_service.py +345 -0
  73. codebuff_core/services/analytics_aggregate.py +837 -0
  74. codebuff_core/services/chat_service.py +898 -0
  75. codebuff_core/services/context_condenser.py +306 -0
  76. codebuff_core/services/pii_guardrail.py +393 -0
  77. codebuff_core/services/rag_chat_agent.py +2993 -0
  78. codebuff_core/services/rag_collections.py +2740 -0
  79. codebuff_core/services/rag_manager.py +890 -0
  80. codebuff_core/services/rag_service.py +145 -0
  81. codebuff_core/services/retriever.py +260 -0
  82. codebuff_core/services/vllm_embedding_service.py +290 -0
  83. codebuff_core/tests/__init__.py +1 -0
  84. codebuff_core/tests/conftest.py +81 -0
  85. codebuff_core/tests/test_error_recovery.py +647 -0
  86. codebuff_core/tests/test_factory.py +135 -0
  87. codebuff_core/tests/test_services.py +150 -0
  88. jupyter_ext/__init__.py +266 -0
  89. jupyter_ext/_version.py +4 -0
  90. jupyter_ext/agent/__init__.py +49 -0
  91. jupyter_ext/agent/agent_factory.py +2270 -0
  92. jupyter_ext/agent/ast_tools.py +451 -0
  93. jupyter_ext/agent/autofix_middleware.py +481 -0
  94. jupyter_ext/agent/background_procs.py +372 -0
  95. jupyter_ext/agent/code_history_middleware.py +211 -0
  96. jupyter_ext/agent/codex_autofix.py +160 -0
  97. jupyter_ext/agent/codex_bridge.py +3339 -0
  98. jupyter_ext/agent/codex_hooks.py +320 -0
  99. jupyter_ext/agent/codex_slash.py +685 -0
  100. jupyter_ext/agent/codex_threads.py +114 -0
  101. jupyter_ext/agent/content_injection.py +377 -0
  102. jupyter_ext/agent/continuation_middleware.py +548 -0
  103. jupyter_ext/agent/custom_skills.py +335 -0
  104. jupyter_ext/agent/file_diffs.py +99 -0
  105. jupyter_ext/agent/git_history.py +188 -0
  106. jupyter_ext/agent/guardrail_middleware.py +421 -0
  107. jupyter_ext/agent/json_repair_utils.py +54 -0
  108. jupyter_ext/agent/langchain_tools.py +2673 -0
  109. jupyter_ext/agent/mermaid_fixer.py +289 -0
  110. jupyter_ext/agent/mermaid_middleware.py +135 -0
  111. jupyter_ext/agent/middleware.py +359 -0
  112. jupyter_ext/agent/middleware_prompts.py +53 -0
  113. jupyter_ext/agent/observability.py +334 -0
  114. jupyter_ext/agent/patch_tool_calls.py +57 -0
  115. jupyter_ext/agent/prompts.py +112 -0
  116. jupyter_ext/agent/skill_middleware.py +257 -0
  117. jupyter_ext/agent/skills/airflow_dag_development.md +36 -0
  118. jupyter_ext/agent/skills/anomaly_detection.md +12 -0
  119. jupyter_ext/agent/skills/athena_sql.md +156 -0
  120. jupyter_ext/agent/skills/bdp_table_search.md +33 -0
  121. jupyter_ext/agent/skills/bigquery.md +65 -0
  122. jupyter_ext/agent/skills/bigquery_oracle.md +231 -0
  123. jupyter_ext/agent/skills/bigquery_tuning.md +307 -0
  124. jupyter_ext/agent/skills/classification_evaluation.md +11 -0
  125. jupyter_ext/agent/skills/clustering_analysis.md +12 -0
  126. jupyter_ext/agent/skills/comprehensive_eda.md +11 -0
  127. jupyter_ext/agent/skills/data_analysis.md +236 -0
  128. jupyter_ext/agent/skills/data_cleaning_pipeline.md +12 -0
  129. jupyter_ext/agent/skills/data_loading.md +158 -0
  130. jupyter_ext/agent/skills/data_quality_report.md +13 -0
  131. jupyter_ext/agent/skills/dimensionality_reduction.md +12 -0
  132. jupyter_ext/agent/skills/emr_script_development.md +17 -0
  133. jupyter_ext/agent/skills/experiment_tracking.md +13 -0
  134. jupyter_ext/agent/skills/feature_importance_analysis.md +10 -0
  135. jupyter_ext/agent/skills/hyperparameter_tuning.md +12 -0
  136. jupyter_ext/agent/skills/hypothesis_testing.md +12 -0
  137. jupyter_ext/agent/skills/imbalanced_data_handling.md +12 -0
  138. jupyter_ext/agent/skills/inference.md +392 -0
  139. jupyter_ext/agent/skills/lambda_function_development.md +19 -0
  140. jupyter_ext/agent/skills/model_comparison.md +11 -0
  141. jupyter_ext/agent/skills/model_explainability.md +12 -0
  142. jupyter_ext/agent/skills/model_training.md +318 -0
  143. jupyter_ext/agent/skills/pyspark.md +352 -0
  144. jupyter_ext/agent/skills/python_code_quality.md +35 -0
  145. jupyter_ext/agent/skills/regression_evaluation.md +12 -0
  146. jupyter_ext/agent/skills/spring_java_development.md +35 -0
  147. jupyter_ext/agent/skills/text_preprocessing.md +12 -0
  148. jupyter_ext/agent/skills/time_series_analysis.md +13 -0
  149. jupyter_ext/agent/skills/unit_testing.md +203 -0
  150. jupyter_ext/agent/todo_prompts.py +57 -0
  151. jupyter_ext/agent/tool_report.py +45 -0
  152. jupyter_ext/analytics_ingest.py +599 -0
  153. jupyter_ext/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
  154. jupyter_ext/handlers/__init__.py +8 -0
  155. jupyter_ext/handlers/analytics_handler.py +170 -0
  156. jupyter_ext/handlers/benchmark_handler.py +794 -0
  157. jupyter_ext/handlers/eda_benchmark_handler.py +1328 -0
  158. jupyter_ext/handlers/rag_admin_handler.py +689 -0
  159. jupyter_ext/handlers/websocket.py +497 -0
  160. jupyter_ext/http_handlers.py +8744 -0
  161. jupyter_ext/idle_monitor.py +346 -0
  162. jupyter_ext/labextension/package.json +157 -0
  163. jupyter_ext/labextension/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
  164. jupyter_ext/labextension/static/150.39fa98117cc8ca590465.js +1 -0
  165. jupyter_ext/labextension/static/166.386ff9b2c37ac865fdee.js +1 -0
  166. jupyter_ext/labextension/static/216.25cf81131bf7274dabf4.js +1 -0
  167. jupyter_ext/labextension/static/353.581bd1cb00746399b62e.js +1 -0
  168. jupyter_ext/labextension/static/367.575f9621b63c9a641aa9.js +1 -0
  169. jupyter_ext/labextension/static/421.11dac223a2c2da1b09f5.js +1 -0
  170. jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js +2 -0
  171. jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
  172. jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js +2 -0
  173. jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
  174. jupyter_ext/labextension/static/481.cc2dec9fa869d66f1407.js +1 -0
  175. jupyter_ext/labextension/static/487.0796e931d6e651482c14.js +1 -0
  176. jupyter_ext/labextension/static/49.d326f554b6f9ccde6580.js +1 -0
  177. jupyter_ext/labextension/static/541.26897ea32845659e36be.js +1 -0
  178. jupyter_ext/labextension/static/579.e569d3edd8391fd8fbb3.js +1 -0
  179. jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js +2 -0
  180. jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
  181. jupyter_ext/labextension/static/666.d9bfdb23f98489d0a188.js +1 -0
  182. jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js +2 -0
  183. jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
  184. jupyter_ext/labextension/static/68.26b6f59c8454adb17f20.js +204 -0
  185. jupyter_ext/labextension/static/694.4ac443814260057e68f7.js +1 -0
  186. jupyter_ext/labextension/static/791.61affb8d3480d3d60e0d.js +1 -0
  187. jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js +2 -0
  188. jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
  189. jupyter_ext/labextension/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
  190. jupyter_ext/labextension/static/97.ea559a5b8a9a09f17c6b.js +1 -0
  191. jupyter_ext/labextension/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
  192. jupyter_ext/labextension/static/style.js +4 -0
  193. jupyter_ext/labextension/static/third-party-licenses.json +724 -0
  194. jupyter_ext/log_paths.py +113 -0
  195. jupyter_ext/resource_usage.py +519 -0
  196. jupyter_ext/secrets_bootstrap.py +116 -0
  197. jupyter_ext/team_access.py +105 -0
  198. jupyter_ext/tests/__init__.py +1 -0
  199. jupyter_ext/tests/test_context.py +234 -0
  200. jupyter_ext/tests/test_factory.py +563 -0
  201. jupyter_ext/tests/test_file_finder.py +139 -0
  202. jupyter_ext/tests/test_handlers.py +58 -0
  203. jupyter_ext/tests/test_mcp_selftest.py +204 -0
  204. jupyter_ext/tests/test_mermaid_fixer.py +272 -0
  205. jupyter_ext/tests/test_resource_tiers.py +298 -0
  206. jupyter_ext/tools/__init__.py +15 -0
  207. jupyter_ext/tools/base.py +90 -0
  208. jupyter_ext/tools/context.py +268 -0
  209. jupyter_ext/tools/dir_complete.py +86 -0
  210. jupyter_ext/tools/file_finder.py +303 -0
  211. jupyter_ext/tools/file_tools.py +244 -0
  212. jupyter_ext/tools/jupyter_tools.py +296 -0
  213. jupyter_ext/tools/registry.py +159 -0
  214. jupyter_ext/tools/search_tools.py +230 -0
  215. jupyter_ext/tools/security_tools.py +657 -0
  216. jupyter_ext/tools/shell_tools.py +109 -0
  217. jupyter_ext/tools/user_tools.py +132 -0
@@ -0,0 +1,2270 @@
1
+ """AgentFactory - Jupyter 환경을 위한 DeepAgents 래퍼.
2
+
3
+ Jupyter 노트북 작업을 위해 구성된 DeepAgents 인스턴스를 생성하는 팩토리 모듈.
4
+ DeepAgents 라이브러리를 래핑하고 Jupyter 전용 도구를 주입함.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import os
13
+ import time
14
+ from collections.abc import Callable, Sequence
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any, Optional
18
+
19
+ from codebuff_core.directory_policy import EXCLUDED_RELATIVE_DIRS
20
+ from codebuff_core.directory_policy import SEARCH_EXCLUDED_DIRS as _SKIP_DIRS
21
+ from codebuff_core.platform_rules import is_bdp_platform, platform_rules_block
22
+ from langchain.agents.middleware import (
23
+ InterruptOnConfig,
24
+ ModelRetryMiddleware,
25
+ )
26
+ from langchain.chat_models import init_chat_model
27
+ from langchain_core.language_models import BaseChatModel
28
+ from langchain_core.messages import HumanMessage, ToolCall
29
+ from langchain_core.tools import BaseTool
30
+ from langgraph.checkpoint.memory import MemorySaver
31
+
32
+ from codebuff_core.managers.config_manager import prompt_value
33
+
34
+ from ..log_paths import get_debug_log_path
35
+ from ..tools.context import NotebookContextManager
36
+ from . import custom_skills
37
+ from .autofix_middleware import AutoFixMiddleware
38
+ from .file_diffs import FileDiffTracker
39
+ from .middleware import DSMemoryMiddleware, MemoryStore
40
+ from .middleware_prompts import DS_HITL_DESCRIPTION_PREFIX
41
+ from .prompts import get_system_prompt
42
+ from .todo_prompts import DS_TODO_TOOL_DESCRIPTION
43
+
44
+ # Direct file-based debug logging (bypasses Python logging config)
45
+ _DEBUG_LOG = get_debug_log_path()
46
+
47
+
48
+ def _planner_system_prompt() -> str:
49
+ """multi 모드 메인 에이전트 `prompts.planner` (이슈 #218 — 코드 상수 없음)."""
50
+ return prompt_value("planner")
51
+
52
+
53
+ def _patch_todo_middleware() -> None:
54
+ """TodoListMiddleware 기본값을 DS 특화 규칙(`prompts.todo_rules`)과 도구 설명으로 바꾼다."""
55
+ from langchain.agents.middleware import TodoListMiddleware as _TodoMW
56
+
57
+ _TodoMW.__init__.__kwdefaults__["system_prompt"] = prompt_value("todo_rules")
58
+ _TodoMW.__init__.__kwdefaults__["tool_description"] = DS_TODO_TOOL_DESCRIPTION
59
+
60
+
61
+ def _dbg(msg: str) -> None:
62
+ """Write debug message directly to file, bypassing logging framework."""
63
+ try:
64
+ import datetime
65
+
66
+ ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
67
+ with open(_DEBUG_LOG, "a") as f:
68
+ f.write(f"[{ts}] {msg}\n")
69
+ except OSError:
70
+ pass
71
+
72
+
73
+ logger = logging.getLogger(__name__)
74
+ if not logger.handlers and not logger.parent.handlers:
75
+ logger.setLevel(logging.INFO)
76
+
77
+ # max_tokens 잘림 시 자동 이어쓰기 최대 횟수
78
+ _MAX_CONTINUATIONS = 3
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # rg 없는 환경에서 grep Python fallback 안전화
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ _PYTHON_SEARCH_TIMEOUT = 10 # seconds
86
+ # deepagents _DEFAULT_GLOB_TIMEOUT 과 같은 값 — private 이라 import 하지 않는다
87
+ _GLOB_TIMEOUT = 5 # seconds
88
+ _MAX_SEARCH_RESULTS = 10_000
89
+
90
+
91
+ def _iter_pruned_files(root: Path, deadline: float):
92
+ """_SKIP_DIRS 를 순회 단계에서 쳐내며 root 아래 파일을 yield 한다.
93
+
94
+ rglob("*") 뒤에 사후 필터를 거는 것은 소용없다 — 제외 대상이 워크스페이스
95
+ 항목의 98.8%(336,036 중 331,986)라 비용이 순회 자체에 있다. os.walk 의
96
+ dirnames[:] 가지치기만이 그 순회를 막는다.
97
+
98
+ os.walk 는 기본이 followlinks=False 라 rglob 의 심볼릭 처리와 같다(실측 확인).
99
+ """
100
+ for dirpath, dirnames, filenames in os.walk(root):
101
+ dirnames[:] = [
102
+ d
103
+ for d in dirnames
104
+ if d not in _SKIP_DIRS
105
+ and (Path(dirpath) / d).relative_to(root).parts
106
+ not in EXCLUDED_RELATIVE_DIRS
107
+ ]
108
+ if time.monotonic() > deadline:
109
+ return
110
+ base = Path(dirpath)
111
+ for name in filenames:
112
+ if time.monotonic() > deadline:
113
+ return
114
+ yield base / name
115
+
116
+
117
+ class _InvalidGlobPattern(ValueError):
118
+ """glob 패턴 자체가 거부된 경우. 설치된 판의 예외를 이것으로 모은다."""
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class _DeepagentsCaps:
123
+ """설치된 deepagents 판의 glob/grep 계약.
124
+
125
+ pyproject 제약이 `deepagents>=0.1.0` 이고 실제로 두 판이 쓰이고 있는데
126
+ (uv.lock 은 0.6.8, 주 저장소 .venv 는 0.7.13) **두 계약이 호환되지 않는다**:
127
+
128
+ | | 0.6.8 | 0.7.13 |
129
+ |---|---|---|
130
+ | `_python_search` 반환 | `(results, error)` | `(results, truncated, error)` |
131
+ | `_python_search` 키워드 | `timeout` | `max_count`, `timeout` |
132
+ | `GlobResult` 필드 | `error`, `matches` | `+truncated`, `+truncation_reason` |
133
+ | glob 패턴 의미 | `rglob(pattern)` | `compile_grep_include_glob` |
134
+
135
+ 한쪽에 맞춰 쓰면 다른 쪽에서 TypeError 로 죽는다. 버전 문자열이 아니라
136
+ **심볼의 존재**로 판별한다 — 그래야 중간 판에서도 맞는다.
137
+ """
138
+
139
+ search_returns_truncated: bool
140
+ glob_has_truncation: bool
141
+ _compile_include_glob: Callable[[str], Callable[[str], bool]] | None
142
+ _invalid_pattern_exc: type[Exception]
143
+
144
+ def compile_pattern(self, pattern: str, virtual_mode: bool):
145
+ """패턴을 rel_path 매처로 컴파일한다. 거부되면 _InvalidGlobPattern."""
146
+ if self._compile_include_glob is not None:
147
+ # 0.7.13+: `..` 거부와 선행 `/` 앵커까지 공용 매처가 담당한다.
148
+ try:
149
+ return self._compile_include_glob(pattern)
150
+ except self._invalid_pattern_exc as e:
151
+ raise _InvalidGlobPattern(str(e)) from e
152
+
153
+ # 0.6.8: 부모가 `search_path.rglob(pattern)` 을 쓴다. 그 의미를 그대로
154
+ # 재현한다 — rglob(p) == glob("**/" + p) 이고, wcmatch 의
155
+ # GLOBSTAR|BRACE|DOTGLOB 조합이 이와 일치하는 것을 7개 패턴으로 실측했다
156
+ # (dotfile 포함 여부까지). 부모의 선행 `/` 처리와 `..` 거부도 옮긴다.
157
+ import wcmatch.glob as wcglob
158
+
159
+ if pattern.startswith("/"):
160
+ pattern = pattern.lstrip("/")
161
+ if virtual_mode and ".." in Path(pattern).parts:
162
+ msg = "Path traversal not allowed in glob pattern"
163
+ raise _InvalidGlobPattern(msg)
164
+
165
+ flags = wcglob.GLOBSTAR | wcglob.BRACE | wcglob.DOTGLOB
166
+ full = "**/" + pattern
167
+
168
+ def _match(rel_path: str) -> bool:
169
+ return wcglob.globmatch(rel_path, full, flags=flags)
170
+
171
+ return _match
172
+
173
+
174
+ _DEEPAGENTS_CAPS: _DeepagentsCaps | None = None
175
+
176
+
177
+ def _deepagents_caps() -> _DeepagentsCaps:
178
+ """설치된 deepagents 판의 계약을 한 번만 읽어 캐시한다."""
179
+ global _DEEPAGENTS_CAPS # noqa: PLW0603
180
+ if _DEEPAGENTS_CAPS is not None:
181
+ return _DEEPAGENTS_CAPS
182
+
183
+ import dataclasses
184
+ import inspect
185
+
186
+ from deepagents.backends.filesystem import FilesystemBackend
187
+ from deepagents.backends.protocol import GlobResult
188
+
189
+ sig = inspect.signature(FilesystemBackend._python_search) # noqa: SLF001
190
+ glob_fields = {f.name for f in dataclasses.fields(GlobResult)}
191
+
192
+ try:
193
+ from deepagents.backends.utils import (
194
+ InvalidGlobPatternError,
195
+ compile_grep_include_glob,
196
+ )
197
+ except ImportError:
198
+ compile_grep_include_glob = None
199
+ InvalidGlobPatternError = ValueError
200
+
201
+ _DEEPAGENTS_CAPS = _DeepagentsCaps(
202
+ search_returns_truncated="max_count" in sig.parameters,
203
+ glob_has_truncation="truncated" in glob_fields,
204
+ _compile_include_glob=compile_grep_include_glob,
205
+ _invalid_pattern_exc=InvalidGlobPatternError,
206
+ )
207
+ return _DEEPAGENTS_CAPS
208
+
209
+
210
+ def _create_safe_backend(root_dir, virtual_mode=True):
211
+ """Create a SafeFilesystemBackend instance (lazy import of deepagents)."""
212
+ from deepagents.backends.local_shell import LocalShellBackend
213
+
214
+ caps = _deepagents_caps()
215
+
216
+ class SafeFilesystemBackend(LocalShellBackend):
217
+ """LocalShellBackend with _SKIP_DIRS pruning and a deadline on glob/grep."""
218
+
219
+ def _bounded_search( # noqa: PLR0913
220
+ self,
221
+ pattern,
222
+ base_full,
223
+ include_glob,
224
+ *,
225
+ max_count=None,
226
+ timeout=None,
227
+ ):
228
+ import re
229
+
230
+ import wcmatch.glob as wcglob
231
+
232
+ limit = (
233
+ _MAX_SEARCH_RESULTS
234
+ if max_count is None
235
+ else max(0, min(max_count, _MAX_SEARCH_RESULTS))
236
+ )
237
+ budget = (
238
+ _PYTHON_SEARCH_TIMEOUT
239
+ if timeout is None
240
+ else min(timeout, _PYTHON_SEARCH_TIMEOUT)
241
+ )
242
+ deadline = time.monotonic() + budget
243
+ results = {}
244
+ count = 0
245
+ try:
246
+ regex = re.compile(pattern)
247
+ except re.error as e:
248
+ return {}, False, f"Invalid search pattern {pattern!r}: {e}"
249
+ candidates = (
250
+ (base_full,)
251
+ if base_full.is_file()
252
+ else _iter_pruned_files(base_full, deadline)
253
+ )
254
+ for fp in candidates:
255
+ if time.monotonic() >= deadline:
256
+ return results, True, "Search truncated: time budget exhausted"
257
+ if include_glob and not wcglob.globmatch(
258
+ fp.name, include_glob, flags=wcglob.BRACE
259
+ ):
260
+ continue
261
+ try:
262
+ # Keep symlink targets within the requested scope and workspace.
263
+ fp.resolve().relative_to(base_full.resolve())
264
+ if self.virtual_mode:
265
+ fp.resolve().relative_to(self.cwd)
266
+ if not fp.is_file() or fp.stat().st_size > self.max_file_size_bytes:
267
+ continue
268
+ out_path = (
269
+ self._to_virtual_path(fp) if self.virtual_mode else str(fp)
270
+ )
271
+ with fp.open(encoding="utf-8") as source:
272
+ for line_num, line in enumerate(source, 1):
273
+ if time.monotonic() >= deadline:
274
+ return (
275
+ results,
276
+ True,
277
+ "Search truncated: time budget exhausted",
278
+ )
279
+ if regex.search(line):
280
+ if count >= limit:
281
+ return (
282
+ results,
283
+ True,
284
+ "Search truncated: result limit reached",
285
+ )
286
+ results.setdefault(out_path, []).append(
287
+ (line_num, line.rstrip("\n"))
288
+ )
289
+ count += 1
290
+ except (UnicodeDecodeError, OSError, RuntimeError, ValueError):
291
+ continue
292
+ truncated = time.monotonic() >= deadline
293
+ return (
294
+ results,
295
+ truncated,
296
+ "Search truncated: time budget exhausted" if truncated else None,
297
+ )
298
+
299
+ def _ripgrep_bounded(self, pattern, base_full, include_glob, rg):
300
+ import selectors
301
+ import subprocess
302
+
303
+ limit = _MAX_SEARCH_RESULTS
304
+ deadline = time.monotonic() + _PYTHON_SEARCH_TIMEOUT
305
+ cmd = [
306
+ rg,
307
+ "--json",
308
+ "--hidden",
309
+ "--no-ignore",
310
+ "-F",
311
+ "--max-count",
312
+ str(limit + 1),
313
+ "--max-filesize",
314
+ str(self.max_file_size_bytes),
315
+ ]
316
+ if include_glob:
317
+ cmd += ["--glob", include_glob]
318
+ is_dir = base_full.is_dir()
319
+ if is_dir:
320
+ for name in sorted(_SKIP_DIRS):
321
+ cmd += ["--glob", f"!**/{name}/**"]
322
+ for parts in sorted(EXCLUDED_RELATIVE_DIRS):
323
+ cmd += ["--glob", "!/" + "/".join(parts) + "/**"]
324
+ cmd += ["--", pattern, "." if is_dir else str(base_full)]
325
+ results = {}
326
+ count = 0
327
+ buffer = b""
328
+ try:
329
+ process = subprocess.Popen(
330
+ cmd,
331
+ stdout=subprocess.PIPE,
332
+ stderr=subprocess.DEVNULL,
333
+ cwd=base_full if is_dir else None,
334
+ )
335
+ except OSError:
336
+ return None # executable disappeared after which(); use the Python path
337
+ try:
338
+ with selectors.DefaultSelector() as selector:
339
+ selector.register(process.stdout, selectors.EVENT_READ)
340
+ while True:
341
+ remaining = deadline - time.monotonic()
342
+ if remaining <= 0 or not selector.select(remaining):
343
+ return (
344
+ results,
345
+ True,
346
+ "Search truncated: time budget exhausted",
347
+ )
348
+ chunk = os.read(process.stdout.fileno(), 65536)
349
+ if not chunk:
350
+ break
351
+ buffer += chunk
352
+ while b"\n" in buffer:
353
+ raw, buffer = buffer.split(b"\n", 1)
354
+ if time.monotonic() >= deadline:
355
+ return (
356
+ results,
357
+ True,
358
+ "Search truncated: time budget exhausted",
359
+ )
360
+ try:
361
+ frame = json.loads(raw)
362
+ if frame.get("type") != "match":
363
+ continue
364
+ data = frame["data"]
365
+ raw_path = Path(data["path"]["text"])
366
+ fp = base_full / raw_path if is_dir else raw_path
367
+ fp.resolve().relative_to(base_full.resolve())
368
+ if self.virtual_mode:
369
+ fp.resolve().relative_to(self.cwd)
370
+ out_path = self._to_virtual_path(fp)
371
+ else:
372
+ out_path = str(fp)
373
+ if count >= limit:
374
+ return (
375
+ results,
376
+ True,
377
+ "Search truncated: result limit reached",
378
+ )
379
+ results.setdefault(out_path, []).append(
380
+ (
381
+ data["line_number"],
382
+ data["lines"].get("text", "").rstrip("\n"),
383
+ )
384
+ )
385
+ count += 1
386
+ except (KeyError, OSError, RuntimeError, ValueError):
387
+ continue
388
+ try:
389
+ code = process.wait(timeout=max(0.001, deadline - time.monotonic()))
390
+ except subprocess.TimeoutExpired:
391
+ return results, True, "Search truncated: time budget exhausted"
392
+ return (
393
+ results,
394
+ False,
395
+ None if code in (0, 1) else f"ripgrep failed (exit {code})",
396
+ )
397
+ finally:
398
+ if process.poll() is None:
399
+ process.terminate()
400
+ try:
401
+ process.wait(timeout=1)
402
+ except subprocess.TimeoutExpired:
403
+ process.kill()
404
+ process.wait()
405
+ process.stdout.close()
406
+
407
+ def _python_search(
408
+ self, pattern, base_full, include_glob, *, max_count=None, timeout=None
409
+ ):
410
+ results, truncated, error = self._bounded_search(
411
+ pattern, base_full, include_glob, max_count=max_count, timeout=timeout
412
+ )
413
+ if caps.search_returns_truncated:
414
+ return results, truncated, error
415
+ return results, error
416
+
417
+ def grep(self, pattern, path=None, glob=None):
418
+ import re
419
+ import shutil
420
+
421
+ from deepagents.backends.protocol import GrepResult
422
+
423
+ try:
424
+ base = self._resolve_path(path or ".")
425
+ if not base.exists():
426
+ return GrepResult(matches=[])
427
+ except (OSError, RuntimeError, ValueError) as e:
428
+ return GrepResult(matches=[], error=str(e))
429
+ rg = shutil.which("rg")
430
+ searched = self._ripgrep_bounded(pattern, base, glob, rg) if rg else None
431
+ if searched is None:
432
+ searched = self._bounded_search(re.escape(pattern), base, glob)
433
+ results, truncated, error = searched
434
+ matches = [
435
+ {"path": name, "line": number, "text": text}
436
+ for name, lines in results.items()
437
+ for number, text in lines
438
+ ]
439
+ # The supported 0.6 API exposes partial-result information through error.
440
+ # Newer result types additionally expose a dedicated truncated field.
441
+ from dataclasses import fields
442
+
443
+ extra = (
444
+ {"truncated": truncated}
445
+ if "truncated" in {f.name for f in fields(GrepResult)}
446
+ else {}
447
+ )
448
+ if truncated:
449
+ error = f"{error}; {len(matches)} partial matches retained. Narrow the path or pattern to retrieve complete results."
450
+ return GrepResult(matches=matches, error=error, **extra)
451
+
452
+ def glob(self, pattern, path=None): # noqa: C901, PLR0912
453
+ """FilesystemBackend.glob 에 _SKIP_DIRS 가지치기를 넣은 판.
454
+
455
+ 순회 소스만 `_iter_pruned_files` 로 바꾸고 나머지 계약(패턴 의미·
456
+ virtual_mode 경로 변환·정렬·GlobResult 필드)은 설치된 판의 것을
457
+ 그대로 따른다 — 패턴 매처는 `_deepagents_caps()` 가 준다.
458
+
459
+ 탈출구: 제외 디렉토리를 명시적으로 겨냥하려면 `path` 로 그 안을
460
+ 가리킨다. os.walk 는 루트 자신을 가지치기하지 않으므로
461
+ `glob("*.py", path="/extensions/jupyter/node_modules")` 는 동작한다.
462
+ """
463
+ from datetime import datetime
464
+
465
+ from deepagents.backends.protocol import GlobResult
466
+
467
+ try:
468
+ matches_pattern = caps.compile_pattern(pattern, self.virtual_mode)
469
+ except _InvalidGlobPattern as e:
470
+ return GlobResult(error=str(e), matches=None)
471
+
472
+ display_path = path if path is not None else "<default>"
473
+ try:
474
+ search_path = (
475
+ self.cwd
476
+ if path is None or path == "/"
477
+ else self._resolve_path(path)
478
+ )
479
+ if not search_path.exists() or not search_path.is_dir():
480
+ return GlobResult(matches=[])
481
+ except (OSError, RuntimeError) as e:
482
+ return GlobResult(
483
+ error=f"Error globbing path '{display_path}': {e}",
484
+ matches=[],
485
+ )
486
+
487
+ deadline = time.monotonic() + _GLOB_TIMEOUT
488
+ results: list[dict] = []
489
+ hit_cap = False
490
+ try:
491
+ for matched_path in _iter_pruned_files(search_path, deadline):
492
+ try:
493
+ rel_path = matched_path.relative_to(search_path).as_posix()
494
+ except ValueError:
495
+ continue
496
+ if not matches_pattern(rel_path):
497
+ continue
498
+ try:
499
+ if not matched_path.is_file():
500
+ continue
501
+ except (PermissionError, OSError, RuntimeError):
502
+ continue
503
+ if len(results) >= _MAX_SEARCH_RESULTS:
504
+ hit_cap = True
505
+ break
506
+ if self.virtual_mode:
507
+ try:
508
+ matched_path.resolve().relative_to(self.cwd)
509
+ out_path = self._to_virtual_path(matched_path)
510
+ except (OSError, RuntimeError, ValueError):
511
+ continue
512
+ else:
513
+ out_path = str(matched_path)
514
+ try:
515
+ st = matched_path.stat()
516
+ results.append(
517
+ {
518
+ "path": out_path,
519
+ "is_dir": False,
520
+ "size": int(st.st_size),
521
+ "modified_at": datetime.fromtimestamp( # noqa: DTZ006
522
+ st.st_mtime,
523
+ ).isoformat(),
524
+ },
525
+ )
526
+ except OSError:
527
+ results.append({"path": out_path, "is_dir": False})
528
+ except (OSError, RuntimeError, ValueError) as e:
529
+ results.sort(key=lambda x: x.get("path", ""))
530
+ return GlobResult(
531
+ error=f"Glob of '{display_path}' aborted partway: {e}",
532
+ matches=results,
533
+ )
534
+
535
+ results.sort(key=lambda x: x.get("path", ""))
536
+ truncated = hit_cap or time.monotonic() >= deadline
537
+ reason = "result_limit" if hit_cap else "budget"
538
+ if not caps.glob_has_truncation:
539
+ return GlobResult(
540
+ matches=results,
541
+ error=f"Glob truncated: {reason}; no file list returned by the tool. Narrow the path or pattern."
542
+ if truncated
543
+ else None,
544
+ )
545
+ return GlobResult(
546
+ matches=results,
547
+ truncated=truncated,
548
+ truncation_reason=reason if truncated else None,
549
+ )
550
+
551
+ return SafeFilesystemBackend(root_dir=root_dir, virtual_mode=virtual_mode)
552
+
553
+
554
+ # ---------------------------------------------------------------------------
555
+ # HITL Helper Functions
556
+ # ---------------------------------------------------------------------------
557
+
558
+
559
+ def _inject_content_from_state(tool_call: ToolCall, state: Any) -> None:
560
+ """HITL 인터럽트 전에 서브에이전트 코드/description을 tool_call args에 주입.
561
+
562
+ ContentInjectionMiddleware.before_model이 추출한 [CODE]/[SQL] 블록은
563
+ after_model에서 주입되지만, HITL이 먼저 실행되므로 args가 비어있음.
564
+ 이 함수를 ds_tool_description 콜백에서 호출하여 HITL 인터럽트 시점에
565
+ tool_call args를 in-place로 수정함.
566
+ """
567
+ from .content_injection import _extract_code_block, _extract_description
568
+
569
+ name = tool_call["name"]
570
+ args = tool_call.get("args", {})
571
+
572
+ if name == "jupyter_cell":
573
+ messages = state.get("messages", [])
574
+ # 역순 탐색: jupyter_cell 실행 결과가 [CODE] 블록보다 먼저 나오면
575
+ # 해당 [CODE]는 이미 소비됨(stale) → description 주입 안함
576
+ for msg in reversed(messages):
577
+ if not (hasattr(msg, "type") and msg.type == "tool"):
578
+ continue
579
+ content = getattr(msg, "content", "")
580
+ if not isinstance(content, str):
581
+ continue
582
+ # jupyter_cell 실행 결과가 [CODE]보다 먼저 → stale
583
+ if '"tool": "jupyter_cell"' in content:
584
+ _dbg("SKIP_INJECT: found jupyter_cell result before [CODE] → stale")
585
+ break
586
+ if "[CODE]" not in content:
587
+ continue
588
+ # fresh [CODE] 블록 발견
589
+ code = _extract_code_block(content, lang="python")
590
+ if code:
591
+ if not args.get("code"):
592
+ tool_call["args"]["code"] = code
593
+ if not args.get("description"):
594
+ desc = _extract_description(content)
595
+ if desc:
596
+ tool_call["args"]["description"] = desc
597
+ _dbg(
598
+ f"INJECT_DESCRIPTION: len={len(desc)} "
599
+ f"preview={desc[:100]!r}",
600
+ )
601
+ else:
602
+ _dbg(
603
+ "INJECT_DESCRIPTION: _extract_description returned None. "
604
+ f"content_preview={content[:200]!r}",
605
+ )
606
+ break
607
+
608
+ elif name == "file_write" and not args.get("content"):
609
+ messages = state.get("messages", [])
610
+ for msg in reversed(messages):
611
+ if not (hasattr(msg, "type") and msg.type == "tool"):
612
+ continue
613
+ content = getattr(msg, "content", "")
614
+ if not isinstance(content, str) or "[CODE]" not in content:
615
+ continue
616
+ code = _extract_code_block(content, lang="python")
617
+ if code:
618
+ tool_call["args"]["content"] = code
619
+ break
620
+
621
+
622
+ def ds_tool_description(
623
+ tool_call: ToolCall,
624
+ state: Any,
625
+ runtime: Any,
626
+ ) -> str:
627
+ """HITL 승인 요청 시 사용자 친화적 도구 설명 생성."""
628
+ # 서브에이전트 코드/description 주입 (multi-agent 모드용)
629
+ _inject_content_from_state(tool_call, state)
630
+
631
+ name = tool_call["name"]
632
+ args = tool_call.get("args", {})
633
+
634
+ if name == "jupyter_cell":
635
+ code = args.get("code", "")
636
+ lines = code.split("\n") if code else []
637
+ if len(lines) > 5:
638
+ preview = "\n".join(lines[:5])
639
+ return (
640
+ f"{DS_HITL_DESCRIPTION_PREFIX}\n\n"
641
+ f"**Jupyter Cell Execution** ({len(lines)} lines):\n"
642
+ f"```python\n{preview}\n# ... {len(lines) - 5} more lines\n```"
643
+ )
644
+ return (
645
+ f"{DS_HITL_DESCRIPTION_PREFIX}\n\n"
646
+ f"**Jupyter Cell Execution:**\n```python\n{code}\n```"
647
+ )
648
+
649
+ if name == "file_write":
650
+ path = args.get("path", "unknown")
651
+ content = args.get("content", "")
652
+ size = len(content)
653
+ return (
654
+ f"{DS_HITL_DESCRIPTION_PREFIX}\n\n**File Write:** `{path}` ({size} bytes)"
655
+ )
656
+
657
+ return (
658
+ f"{DS_HITL_DESCRIPTION_PREFIX}\n\n"
659
+ f"**Tool:** {name}\n"
660
+ f"**Args:** {json.dumps(args, ensure_ascii=False, indent=2)}"
661
+ )
662
+
663
+
664
+ _TOOL_NAME_MAP = {
665
+ "jupyter_cell": "jupyter_cell_tool",
666
+ "jupyter_markdown": "jupyter_markdown_tool",
667
+ "file_write": "write_file_tool",
668
+ "read_file": "read_file_tool",
669
+ "notebook_info": "notebook_info_tool",
670
+ "diagnostics": "diagnostics_tool",
671
+ "references": "references_tool",
672
+ "ask_user_tool": "ask_user_tool",
673
+ "final_summary_tool": "final_summary_tool",
674
+ "security_scan": "security_scan_tool",
675
+ }
676
+
677
+
678
+ def _get_tool_status_message(tool_name: str, tool_args: dict) -> dict:
679
+ """도구별 한국어 상태 메시지 생성 (chat-mode-enhancement 호환)."""
680
+ name = (tool_name or "").strip().lower()
681
+ if name in ("jupyter_cell", "jupyter_cell_tool"):
682
+ return {"status": "코드 실행 중...", "icon": "code", "label": "코드 실행"}
683
+ if name in ("jupyter_markdown", "markdown_tool"):
684
+ return {
685
+ "status": "마크다운 추가 중...",
686
+ "icon": "markdown",
687
+ "label": "마크다운",
688
+ }
689
+ if name in ("file_write", "write_file_tool"):
690
+ return {
691
+ "status": f"파일 작성: {tool_args.get('path', '')}",
692
+ "icon": "file",
693
+ "label": "파일 작성",
694
+ }
695
+ if name in ("write_todos",):
696
+ return {"status": "작업 계획 수립 중...", "icon": "todo", "label": "작업 계획"}
697
+ if name in ("glob", "search_files", "search_files_tool"):
698
+ pattern = tool_args.get("pattern", "")
699
+ return {
700
+ "status": f"파일 검색: '{pattern}'",
701
+ "icon": "search",
702
+ "label": "파일 검색",
703
+ }
704
+ if name in ("search_notebook_cells", "search_notebook_cells_tool"):
705
+ pattern = tool_args.get("pattern", "")
706
+ nb_path = tool_args.get("notebook_path", "")
707
+ return {
708
+ "status": f"노트북 검색: '{pattern}' in {nb_path}",
709
+ "icon": "search",
710
+ "label": "노트북 검색",
711
+ }
712
+ if name in ("list_workspace", "list_workspace_tool"):
713
+ path = tool_args.get("path", ".")
714
+ return {
715
+ "status": f"Workspace 검색: {path}",
716
+ "icon": "folder",
717
+ "label": "폴더 탐색",
718
+ }
719
+ if name in ("read_file", "read_file_tool"):
720
+ path = tool_args.get("path", "")
721
+ return {"status": f"파일 읽기: {path}", "icon": "file", "label": "파일 읽기"}
722
+ if name in ("final_summary", "final_summary_tool"):
723
+ return {"status": "작업 마무리중...", "icon": "summary", "label": "마무리"}
724
+ if name in ("ask_user", "ask_user_tool"):
725
+ return {
726
+ "status": "사용자 입력 대기 중...",
727
+ "icon": "pause",
728
+ "label": "사용자 입력",
729
+ }
730
+ if name in ("task",):
731
+ agent_type = tool_args.get("subagent_type", tool_args.get("agent_name", ""))
732
+ desc = tool_args.get("description", "")[:50]
733
+ return {
734
+ "status": f"서브에이전트 호출: {agent_type} - {desc}",
735
+ "icon": "agent",
736
+ "label": "서브에이전트",
737
+ }
738
+ if name in ("security_scan", "security_scan_tool"):
739
+ scan_type = tool_args.get("scan_type", "all")
740
+ return {
741
+ "status": f"보안 스캔 실행 중: {scan_type}",
742
+ "icon": "shield",
743
+ "label": "보안 스캔",
744
+ }
745
+ return {"status": f"Tool 실행: {tool_name}", "icon": "tool", "label": tool_name}
746
+
747
+
748
+ def _repair_summary_json_content(content: str) -> str:
749
+ """gpt-oss 등에서 깨진 summary JSON 복구."""
750
+ if '"summary"' not in content or '"next_items"' not in content:
751
+ return content
752
+ try:
753
+ json.loads(content)
754
+ return content # 이미 유효
755
+ except json.JSONDecodeError:
756
+ try:
757
+ from json_repair import repair_json
758
+
759
+ return repair_json(content)
760
+ except Exception:
761
+ return content
762
+
763
+
764
+ def _transform_interrupt_to_frontend(hitl_request: dict, thread_id: str) -> dict:
765
+ """HITLRequest → 프론트엔드 포맷 변환."""
766
+ action_requests = hitl_request.get("action_requests", [])
767
+ action_count = len(action_requests)
768
+ if not action_requests:
769
+ return {
770
+ "thread_id": thread_id,
771
+ "action": "unknown",
772
+ "args": {},
773
+ "description": "",
774
+ "actionCount": 0,
775
+ }
776
+ ar = action_requests[0]
777
+ backend_name = ar.get("name", "unknown")
778
+ return {
779
+ "thread_id": thread_id,
780
+ "action": _TOOL_NAME_MAP.get(backend_name, backend_name + "_tool"),
781
+ "args": ar.get("args", {}),
782
+ "description": ar.get("description", ""),
783
+ "actionCount": action_count,
784
+ }
785
+
786
+
787
+ def ds_error_formatter(exc: Exception) -> str:
788
+ """사용자 친화적 에러 메시지 포맷."""
789
+ _dbg(f"MODEL_ERROR: type={type(exc).__name__} msg={str(exc)[:500]}")
790
+ exc_type = type(exc).__name__
791
+
792
+ if "rate" in exc_type.lower() or "rate" in str(exc).lower():
793
+ return (
794
+ f"API rate limit reached ({exc_type}). "
795
+ "The request will be retried automatically. "
796
+ "If this persists, try again in a few moments."
797
+ )
798
+
799
+ if "auth" in exc_type.lower() or "key" in str(exc).lower():
800
+ return f"Authentication error ({exc_type}). Please check your API key configuration."
801
+
802
+ if "timeout" in exc_type.lower():
803
+ return f"Request timed out ({exc_type}). The model may be overloaded."
804
+
805
+ return f"Model call failed ({exc_type}): {exc}"
806
+
807
+
808
+ # ---------------------------------------------------------------------------
809
+ # AgentConfig / AgentEvent
810
+ # ---------------------------------------------------------------------------
811
+
812
+
813
+ @dataclass
814
+ class AgentConfig:
815
+ """에이전트 생성을 위한 설정."""
816
+
817
+ provider: str
818
+ model: str
819
+ api_key: str
820
+ base_url: str = ""
821
+ temperature: float = 0.0
822
+ max_tokens: int = 8192
823
+ max_iterations: int = 20
824
+ mode: str = "default"
825
+ agent_mode: str = "multi" # "single" | "multi" | "<name>_direct"
826
+
827
+
828
+ @dataclass
829
+ class AgentEvent:
830
+ """에이전트 실행 중 발생하는 이벤트."""
831
+
832
+ type: str
833
+ data: dict[str, Any]
834
+
835
+ def to_dict(self) -> dict[str, Any]:
836
+ """딕셔너리로 변환."""
837
+ return {"type": self.type, **self.data}
838
+
839
+
840
+ def _inject_resource_context_for_codegen(prompt: str, workspace_root: str) -> str:
841
+ """코드 생성 프롬프트의 {{RESOURCE_CONTEXT}}를 실제 리소스 정보로 치환."""
842
+ try:
843
+ from ..resource_usage import (
844
+ classify_resource_tiers,
845
+ generate_tier_guidance,
846
+ get_integrated_resources,
847
+ )
848
+
849
+ data = get_integrated_resources(workspace_root=workspace_root)
850
+ tiers = classify_resource_tiers(data)
851
+ guidance = generate_tier_guidance(tiers, mode="codegen")
852
+ return prompt.replace("{{RESOURCE_CONTEXT}}", guidance)
853
+ except Exception:
854
+ return prompt.replace("{{RESOURCE_CONTEXT}}", "")
855
+
856
+
857
+ PYTHON_QUALITY_SKILL = "python_code_quality"
858
+ # 옛 python_expert 기본값의 절 제목 — 저장값에 이것이 있으면 원칙이 이미 들어 있다
859
+ _PYTHON_QUALITY_TITLE = "# 코드 생성 품질 원칙"
860
+
861
+
862
+ def python_quality_section(prompt: str, workspace_root: Optional[str]) -> str:
863
+ """python_expert 에 붙일 품질 원칙 — 정본은 스킬 `python_code_quality` 한 벌이다 (이슈 #228).
864
+
865
+ 모든 호출에 적용되던 규칙이라 모델이 스킬을 부르기를 기다리지 않고 조립 때 붙인다.
866
+ 같은 이름의 포털 스킬이 있으면 그 본문이 이긴다. `prompt` 에 원칙이 이미 있으면
867
+ (옛 기본값을 저장한 조직) 두 벌이 되지 않게 붙이지 않는다.
868
+ """
869
+ if _PYTHON_QUALITY_TITLE in prompt:
870
+ return ""
871
+ skills = custom_skills.with_builtin(
872
+ custom_skills.discover(workspace_root) if workspace_root else []
873
+ )
874
+ body = custom_skills.load_body(PYTHON_QUALITY_SKILL, skills)
875
+ if body.startswith("Error: "):
876
+ return ""
877
+ # 스킬 목록에도 같은 이름이 뜨므로 한 번 더 읽는 왕복을 막는다
878
+ return (
879
+ f"\n\n(아래는 스킬 `{PYTHON_QUALITY_SKILL}` 본문이다 — 이미 붙어 있으니"
880
+ " load_skill_tool 로 다시 읽지 않는다)\n\n" + body
881
+ )
882
+
883
+
884
+ # ---------------------------------------------------------------------------
885
+ # AgentFactory
886
+ # ---------------------------------------------------------------------------
887
+
888
+ #: 실행 전 사용자 승인을 받는 도구 — HITL interrupt 대상. centri 도구 보고의
889
+ #: `approval` 도 이것을 읽는다(이슈 #248). multi 모드의 `ask_user_tool` 은 따로 더한다
890
+ HITL_TOOL_NAMES = ("file_write", "jupyter_cell")
891
+
892
+
893
+ class AgentFactory:
894
+ """Jupyter 도구가 포함된 LangChain Agent 인스턴스를 생성하는 팩토리.
895
+
896
+ LangChain Agent 라이브러리를 래핑하여 Jupyter 노트북 작업에 맞게
897
+ 구성된 에이전트를 생성함. 다음을 처리:
898
+ - 프로바이더 설정 기반 LLM 초기화
899
+ - Jupyter 전용 도구 주입 (jupyter_cell, jupyter_markdown)
900
+ - 파일, 셸 도구 주입
901
+ - 미들웨어 스택 구성 (중복 없이 필요한 것만)
902
+ - 실시간 진행 상황 업데이트를 위한 이벤트 스트리밍
903
+ - HITL (Human-in-the-loop) 그래프 인터럽트 통합
904
+ """
905
+
906
+ def __init__(
907
+ self,
908
+ notebook_context: NotebookContextManager,
909
+ workspace_root: str | None = None,
910
+ on_event: Callable[[AgentEvent], Any] | None = None,
911
+ memory_store: MemoryStore | None = None,
912
+ ):
913
+ """에이전트 팩토리 초기화.
914
+
915
+ Args:
916
+ notebook_context: 노트북 상태 관리자
917
+ workspace_root: 파일/셸/검색 작업의 루트 디렉토리
918
+ on_event: 에이전트 이벤트 콜백 (진행 상황, 도구 호출 등)
919
+ memory_store: DS 세션 메모리 저장소 (미지정 시 새로 생성)
920
+
921
+ """
922
+ self._notebook_context = notebook_context
923
+ self._workspace_root = workspace_root or "."
924
+ self._on_event = on_event
925
+ self._memory_store = memory_store or MemoryStore()
926
+
927
+ _AGENTS_MD_MAX_CHARS = 10_000
928
+
929
+ def _read_agents_md(self) -> str | None:
930
+ """워크스페이스 루트의 AGENTS.md 를 읽어 반환 (대소문자 무시, 이슈 #139)."""
931
+ ws = Path(self._workspace_root)
932
+ agents_md_path = None
933
+ try:
934
+ for f in ws.iterdir():
935
+ if f.is_file() and f.name.lower() == "agents.md":
936
+ agents_md_path = f
937
+ break
938
+ except OSError:
939
+ return None
940
+ if agents_md_path is None:
941
+ return None
942
+ try:
943
+ content = agents_md_path.read_text(encoding="utf-8").strip()
944
+ if content:
945
+ if len(content) > self._AGENTS_MD_MAX_CHARS:
946
+ _dbg(
947
+ f"{agents_md_path.name} truncated: {len(content)}"
948
+ f" → {self._AGENTS_MD_MAX_CHARS} chars",
949
+ )
950
+ content = content[: self._AGENTS_MD_MAX_CHARS]
951
+ else:
952
+ _dbg(f"{agents_md_path.name} loaded ({len(content)} chars)")
953
+ return content
954
+ except Exception as e:
955
+ _dbg(f"Failed to read {agents_md_path.name}: {e}")
956
+ return None
957
+
958
+ def _instruction_suffix(self, include_custom_skills: bool = True) -> str:
959
+ """시스템 프롬프트 뒤에 붙일 것 — 사내 플랫폼 규칙, AGENTS.md, 커스텀 스킬 목록.
960
+
961
+ 사내가 아니고 커스텀 스킬이 없으면 규칙·목록이 빈 문자열이라 로컬 프롬프트는
962
+ 전과 같다 (이슈 #120, #134). 스킬 미들웨어 섹션을 이미 실은 프롬프트(direct 모드)는
963
+ `include_custom_skills=False` 로 목록이 두 번 실리지 않게 한다.
964
+ """
965
+ suffix = ""
966
+ platform_rules = platform_rules_block()
967
+ if platform_rules:
968
+ suffix += "\n\n---\n\n" + platform_rules
969
+ agents_md = self._read_agents_md()
970
+ if agents_md:
971
+ suffix += "\n\n---\n\n# Project Instructions (AGENTS.md)\n\n" + agents_md
972
+ if include_custom_skills:
973
+ skills_section = custom_skills.prompt_section(
974
+ custom_skills.discover(self._workspace_root), "load_skill_tool"
975
+ )
976
+ if skills_section:
977
+ suffix += "\n\n---\n\n" + skills_section
978
+ return suffix
979
+
980
+ def _builtin_skill_suffix(self) -> str:
981
+ """메인 에이전트에 싣는 내장 스킬 목록 — 커스텀이 재정의한 것은 뺀다 (이슈 #220).
982
+
983
+ 키워드로 붙던 DS 템플릿(`DSSkillsMiddleware`)이 내장 스킬이 되어, 메인 에이전트는
984
+ 이 목록을 보고 `load_skill_tool` 로 읽는다. 커스텀 목록은 `_instruction_suffix` 가 싣는다.
985
+ """
986
+ section = custom_skills.builtin_section(
987
+ custom_skills.discover(self._workspace_root), "load_skill_tool"
988
+ )
989
+ return "\n\n---\n\n" + section if section else ""
990
+
991
+ def _skill_tools(self) -> list:
992
+ """메인 에이전트의 `load_skill_tool` — 내장 스킬이 있으므로 항상 준다 (이슈 #134, #220)."""
993
+ from .skill_middleware import get_skill_middleware
994
+
995
+ return get_skill_middleware(self._workspace_root).get_tools()
996
+
997
+ def _create_subagents(self) -> list:
998
+ """멀티에이전트 모드용 서브에이전트 목록 생성.
999
+
1000
+ Returns:
1001
+ SubAgent TypedDict 목록 (python_expert, researcher, athena_query,
1002
+ airflow_expert, emr_expert, lambda_expert, spring_expert, bq_agent)
1003
+
1004
+ """
1005
+ from deepagents import SubAgent
1006
+
1007
+ from .langchain_tools import (
1008
+ create_diagnostics_tool,
1009
+ create_references_tool,
1010
+ )
1011
+ from .skill_middleware import get_skill_middleware
1012
+
1013
+ # 서브에이전트 프롬프트는 `prompts.<이름>` 이다 — 관리자 값, 없으면 스냅샷 (이슈 #218)
1014
+
1015
+ # 서브에이전트 공통 도구 (read_file, execute는 DeepAgents FilesystemMiddleware가 자동 제공)
1016
+ read_tools = [
1017
+ create_diagnostics_tool(self._workspace_root),
1018
+ create_references_tool(self._workspace_root),
1019
+ ]
1020
+
1021
+ # python_expert: 코드 생성 + skills
1022
+ skill_mw = get_skill_middleware(self._workspace_root)
1023
+ base_py_prompt = prompt_value("python_expert")
1024
+ # 코드를 쓰는 서브에이전트는 AGENTS.md 를 못 받으므로 플랫폼 규칙을 직접 붙인다 (이슈 #120)
1025
+ platform_rules = platform_rules_block()
1026
+ platform_section = "\n\n" + platform_rules if platform_rules else ""
1027
+ py_dev_prompt = (
1028
+ base_py_prompt
1029
+ + python_quality_section(base_py_prompt, self._workspace_root)
1030
+ + "\n\n"
1031
+ + skill_mw.get_prompt_section()
1032
+ + platform_section
1033
+ )
1034
+ if "{{RESOURCE_CONTEXT}}" in py_dev_prompt:
1035
+ py_dev_prompt = _inject_resource_context_for_codegen(
1036
+ py_dev_prompt,
1037
+ self._workspace_root,
1038
+ )
1039
+ py_dev_tools = read_tools + skill_mw.get_tools()
1040
+
1041
+ researcher_prompt = prompt_value("researcher")
1042
+
1043
+ # 도메인 전문가 프롬프트 (skill section 포함)
1044
+ skill_section = skill_mw.get_prompt_section()
1045
+ skill_tools = read_tools + skill_mw.get_tools()
1046
+
1047
+ athena_prompt = (
1048
+ prompt_value("athena_query") + "\n\n" + skill_section + platform_section
1049
+ )
1050
+ airflow_prompt = prompt_value("airflow_expert") + "\n\n" + skill_section
1051
+ emr_prompt = (
1052
+ prompt_value("emr_expert") + "\n\n" + skill_section + platform_section
1053
+ )
1054
+ lambda_prompt = (
1055
+ prompt_value("lambda_expert") + "\n\n" + skill_section + platform_section
1056
+ )
1057
+ spring_prompt = prompt_value("spring_expert") + "\n\n" + skill_section
1058
+ bq_prompt = prompt_value("bq_agent") + "\n\n" + skill_section
1059
+
1060
+ return [
1061
+ SubAgent(
1062
+ name="python_expert",
1063
+ description="Python 코드 생성 전문가. 데이터 분석, 시각화, 파일 처리 등 Python 코드를 생성합니다.",
1064
+ system_prompt=py_dev_prompt,
1065
+ tools=py_dev_tools,
1066
+ ),
1067
+ SubAgent(
1068
+ name="researcher",
1069
+ description="정보 검색 전문가. 파일 탐색, 코드 분석, 워크스페이스 구조 파악을 수행합니다.",
1070
+ system_prompt=researcher_prompt,
1071
+ tools=read_tools,
1072
+ ),
1073
+ SubAgent(
1074
+ name="athena_query",
1075
+ description="AWS Athena SQL 전문가. 쿼리 생성, 성능 튜닝, EXPLAIN 분석, CloudWatch 로그 분석을 수행합니다.",
1076
+ system_prompt=athena_prompt,
1077
+ tools=skill_tools,
1078
+ ),
1079
+ SubAgent(
1080
+ name="airflow_expert",
1081
+ description="Airflow 전문가 (MWAA/Composer). DAG 개발, 성능 튜닝, 에러 분석, 로그 해석, task dependency 시각화(Mermaid)를 수행합니다.",
1082
+ system_prompt=airflow_prompt,
1083
+ tools=skill_tools,
1084
+ ),
1085
+ SubAgent(
1086
+ name="emr_expert",
1087
+ description="EMR PySpark 전문가. PySpark 코드 생성, Spark 성능 튜닝, YARN/Spark UI 로그 분석을 수행합니다.",
1088
+ system_prompt=emr_prompt,
1089
+ tools=skill_tools,
1090
+ ),
1091
+ SubAgent(
1092
+ name="lambda_expert",
1093
+ description="AWS Lambda 전문가. handler 생성, Cold Start 최적화, CloudWatch 로그 분석을 수행합니다.",
1094
+ system_prompt=lambda_prompt,
1095
+ tools=skill_tools,
1096
+ ),
1097
+ SubAgent(
1098
+ name="spring_expert",
1099
+ description="Java & Spring Boot 전문가. 코드 생성, JVM/Spring 성능 튜닝, GC/Thread Dump 분석을 수행합니다.",
1100
+ system_prompt=spring_prompt,
1101
+ tools=skill_tools,
1102
+ ),
1103
+ SubAgent(
1104
+ name="bq_agent",
1105
+ description="GCP BigQuery 분석 전문가. 쿼리 요약, 실행계획 시각화(Mermaid), 900-slot 쿼리 튜닝, 비용 분석, Oracle→BQ SQL 변환을 수행합니다.",
1106
+ system_prompt=bq_prompt,
1107
+ tools=skill_tools,
1108
+ ),
1109
+ ]
1110
+
1111
+ def _create_model(self, config: AgentConfig) -> BaseChatModel:
1112
+ """설정에서 LangChain 채팅 모델 생성.
1113
+
1114
+ Args:
1115
+ config: 프로바이더와 API 키가 포함된 에이전트 설정
1116
+
1117
+ Returns:
1118
+ 구성된 BaseChatModel 인스턴스
1119
+
1120
+ """
1121
+ kwargs: dict[str, Any] = {
1122
+ "api_key": config.api_key,
1123
+ "temperature": config.temperature,
1124
+ "max_tokens": config.max_tokens,
1125
+ # tool 호출 시 병렬 tool_call 비활성화. tools 없는 호출에도 같은 model
1126
+ # 객체가 쓰이지만 parallel_tool_calls는 tools 미지정 요청에서도 에러
1127
+ # 없이 무시됨(tool_choice와 달리 안전). vllm(openai-compatible)만
1128
+ # 지원하는 factory라 전역 적용 가능.
1129
+ "model_kwargs": {"parallel_tool_calls": False},
1130
+ # 스트리밍에도 토큰 사용량을 받는다(`stream_options.include_usage`). 없으면
1131
+ # astream 경로의 AIMessage.usage_metadata 가 비어 토큰이 전부 NULL 이다 (#98).
1132
+ # 엔드포인트가 이 필드를 거부하면 채팅이 400 으로 깨진다 — 반입 체크리스트
1133
+ # (docs/로깅RDS/적재-계약.md) 에서 운영 vLLM 으로 먼저 확인한다.
1134
+ "stream_usage": True,
1135
+ }
1136
+ if config.base_url:
1137
+ kwargs["base_url"] = config.base_url
1138
+ if "gpt-oss" in config.model.lower():
1139
+ # gpt-oss 토크나이저 고유 stop token — 다른 모델엔 적용하지 않음.
1140
+ kwargs["extra_body"] = {"stop_token_ids": [200002, 199999, 200012]}
1141
+
1142
+ model_string = f"{config.provider}:{config.model}"
1143
+ _dbg(
1144
+ f"CREATE_MODEL: model_string={model_string} "
1145
+ f"base_url={config.base_url} "
1146
+ f"api_key_len={len(config.api_key) if config.api_key else 0} "
1147
+ f"api_key_prefix={config.api_key[:8] if config.api_key else 'EMPTY'}",
1148
+ )
1149
+ return init_chat_model(model_string, **kwargs)
1150
+
1151
+ def _create_all_tools(self) -> list[BaseTool]:
1152
+ """Jupyter, 파일, 셸 도구를 포함한 모든 LangChain 도구 생성.
1153
+
1154
+ Note: file_list과 search_code는 FilesystemFileSearchMiddleware가 대체
1155
+ (glob_search, grep_search 도구 자동 제공).
1156
+
1157
+ Returns:
1158
+ 모든 LangChain BaseTool 인스턴스 목록
1159
+
1160
+ """
1161
+ from .ast_tools import (
1162
+ create_analyze_dependencies_tool,
1163
+ create_analyze_file_structure_tool,
1164
+ create_search_symbol_definition_tool,
1165
+ )
1166
+ from .langchain_tools import (
1167
+ create_diagnostics_tool,
1168
+ create_file_find_tool,
1169
+ create_file_write_tool,
1170
+ create_jupyter_cell_tool,
1171
+ create_jupyter_markdown_tool,
1172
+ create_notebook_info_tool,
1173
+ create_rag_search_tool,
1174
+ create_references_tool,
1175
+ create_security_scan_tool,
1176
+ )
1177
+
1178
+ tools = [
1179
+ # Jupyter 도구
1180
+ create_jupyter_cell_tool(self._notebook_context),
1181
+ create_jupyter_markdown_tool(self._notebook_context),
1182
+ create_notebook_info_tool(self._notebook_context),
1183
+ # 파일 도구 (read_file은 DeepAgents FilesystemMiddleware가 제공)
1184
+ create_file_write_tool(self._workspace_root), # ruff 자동 포맷 유지
1185
+ # LSP 도구 (grep 기반 폴백) - search_code는 grep_search가 대체
1186
+ create_diagnostics_tool(self._workspace_root),
1187
+ create_references_tool(self._workspace_root),
1188
+ # 보안 스캔 도구
1189
+ create_security_scan_tool(self._workspace_root),
1190
+ # AST 기반 코드 분석 도구
1191
+ create_analyze_file_structure_tool(self._workspace_root),
1192
+ create_search_symbol_definition_tool(self._workspace_root),
1193
+ create_analyze_dependencies_tool(self._workspace_root),
1194
+ # Fuzzy 파일 검색 도구
1195
+ create_file_find_tool(self._workspace_root),
1196
+ ]
1197
+
1198
+ # RAG 검색 도구 (qdrant-client 미설치 시 무시)
1199
+ try:
1200
+ tools.append(create_rag_search_tool())
1201
+ except Exception:
1202
+ pass
1203
+
1204
+ return tools
1205
+
1206
+ def _create_middleware(
1207
+ self,
1208
+ model: BaseChatModel,
1209
+ session_id: str = "",
1210
+ ) -> list[Any]:
1211
+ """미들웨어 스택 생성.
1212
+
1213
+ 미들웨어 순서 (중요 - 첫 번째가 가장 안쪽):
1214
+ 1. PatchToolCallsMiddleware: dangling tool call 패치
1215
+ 2. ModelRetryMiddleware: 모델 호출 재시도
1216
+ 3. AutoFixMiddleware: 코드 자동 수정 + 에러 복구
1217
+ 4. LimitToolCallsMiddleware: tool_calls 1개 제한
1218
+ 5. SummarizationMiddleware: 컨텍스트 압축
1219
+ 6. ContinuationControlMiddleware: non-HITL 도구 후 LLM 가이드
1220
+ 7. DSMemoryMiddleware: 메모리 주입
1221
+ 8. TodoListMiddleware: 작업 계획
1222
+ 9. FilesystemFileSearchMiddleware: 파일 검색 도구
1223
+ 10. HumanInTheLoopMiddleware: 승인 게이트 (가장 바깥)
1224
+ 11. ObservabilityMiddleware: 전체 타이밍 JSONL 로깅 (가장 바깥)
1225
+
1226
+ Args:
1227
+ model: 요약에 사용할 LLM
1228
+ session_id: 세션 ID (Observability 로깅용)
1229
+
1230
+ Returns:
1231
+ 미들웨어 인스턴스 목록
1232
+
1233
+ """
1234
+ from .code_history_middleware import CodeHistoryMiddleware
1235
+ from .continuation_middleware import (
1236
+ ContinuationControlMiddleware,
1237
+ LimitToolCallsMiddleware,
1238
+ TodoActiveMiddleware,
1239
+ )
1240
+ from .guardrail_middleware import (
1241
+ InputGuardrailMiddleware,
1242
+ OutputGuardrailMiddleware,
1243
+ PiiMaskMiddleware,
1244
+ PlatformGuardMiddleware,
1245
+ )
1246
+ from .mermaid_middleware import MermaidFixMiddleware
1247
+ from .observability import ObservabilityMiddleware, default_user_identity
1248
+
1249
+ # 1. AutoFix: 코드 자동 수정 + 에러 복구
1250
+ autofix_middleware = AutoFixMiddleware(
1251
+ notebook_context=self._notebook_context,
1252
+ max_retries=2,
1253
+ session_id=session_id,
1254
+ )
1255
+
1256
+ # 2. ModelRetry: API 실패 시 자동 재시도
1257
+ retry_middleware = ModelRetryMiddleware(
1258
+ max_retries=3,
1259
+ backoff_factor=1.5,
1260
+ initial_delay=0.3,
1261
+ max_delay=30.0,
1262
+ on_failure=ds_error_formatter,
1263
+ )
1264
+
1265
+ # Note: create_deep_agent already provides these middlewares internally:
1266
+ # - PatchToolCallsMiddleware
1267
+ # - SummarizationMiddleware
1268
+ # - TodoListMiddleware
1269
+ # - FilesystemMiddleware
1270
+ # - HumanInTheLoopMiddleware (via interrupt_on param)
1271
+ # Only pass custom middlewares that are NOT provided by create_deep_agent.
1272
+
1273
+ # 3. LimitToolCalls: tool_calls 1개 제한 + kwargs 정리
1274
+ limit_tool_calls = LimitToolCallsMiddleware()
1275
+
1276
+ # 4. TodoActive: wrap_tool_call로 todo_active/last_tool_name state flag 관리
1277
+ todo_active = TodoActiveMiddleware()
1278
+
1279
+ # 5. ContinuationControl: state flag 기반 continuation 가이드
1280
+ continuation_control = ContinuationControlMiddleware()
1281
+
1282
+ # 6. CodeHistory: 서브에이전트에 코드 실행 이력 주입
1283
+ code_history = CodeHistoryMiddleware()
1284
+
1285
+ # DSMemory: 세션 메모리 주입/추출
1286
+ memory_middleware = DSMemoryMiddleware(
1287
+ memory_store=self._memory_store,
1288
+ max_memories_per_call=10,
1289
+ auto_extract=False,
1290
+ )
1291
+
1292
+ # Observability: 전체 파이프라인 타이밍 + 구조화된 JSONL 로깅
1293
+ observability = ObservabilityMiddleware(
1294
+ session_id=session_id,
1295
+ user_identity=default_user_identity(),
1296
+ )
1297
+
1298
+ # Guardrails: 입력/출력 안전 검사
1299
+ from codebuff_core.managers.config_manager import get_config_manager
1300
+
1301
+ guardrail_cfg = get_config_manager().get("guardrails", {})
1302
+
1303
+ middleware_list = [
1304
+ retry_middleware,
1305
+ autofix_middleware,
1306
+ limit_tool_calls,
1307
+ ]
1308
+ # Output Guardrail: limit_tool_calls 뒤, todo_active 앞
1309
+ if guardrail_cfg.get("enableOutput", True):
1310
+ middleware_list.append(
1311
+ OutputGuardrailMiddleware(
1312
+ max_code_length=guardrail_cfg.get("maxCodeLength", 10_000),
1313
+ ),
1314
+ )
1315
+
1316
+ # Mermaid Fix: guardrail 뒤 (차단된 응답엔 수정 불필요)
1317
+ middleware_list.append(MermaidFixMiddleware())
1318
+
1319
+ middleware_list.extend(
1320
+ [
1321
+ todo_active,
1322
+ continuation_control,
1323
+ code_history,
1324
+ memory_middleware,
1325
+ ],
1326
+ )
1327
+
1328
+ # Input Guardrail: memory_middleware 뒤 (가장 바깥 커스텀)
1329
+ if guardrail_cfg.get("enableInput", True):
1330
+ middleware_list.append(
1331
+ InputGuardrailMiddleware(
1332
+ max_input_length=guardrail_cfg.get("maxInputLength", 50_000),
1333
+ ),
1334
+ )
1335
+
1336
+ middleware_list.append(observability)
1337
+ # 개인정보 마스킹: 모델 호출을 감싸는 것 중 가장 안쪽 — 바깥이 더한 메시지까지 가린다.
1338
+ # codex 경로와 같은 규칙이다 (이슈 #155). 끄는 설정은 두지 않는다 — codex 에도 없다.
1339
+ middleware_list.append(PiiMaskMiddleware())
1340
+ # 사내 BDP: boto3 직접 사용을 돌려보낸다 (이슈 #120). 가장 안쪽에 둬야 막힌 호출도
1341
+ # 관측(tool_call 행)과 todo 상태 미들웨어를 지나간다 — codex 쪽 계측과 맞춘다.
1342
+ if is_bdp_platform():
1343
+ middleware_list.append(PlatformGuardMiddleware())
1344
+
1345
+ return middleware_list
1346
+
1347
+ def create_agent(
1348
+ self,
1349
+ config: AgentConfig,
1350
+ additional_tools: Sequence[BaseTool] | None = None,
1351
+ checkpointer=None,
1352
+ thread_id: str | None = None,
1353
+ ) -> AgentRunner:
1354
+ """Jupyter용으로 구성된 LangChain Agent 인스턴스 생성.
1355
+
1356
+ Args:
1357
+ config: 에이전트 설정
1358
+ additional_tools: 추가할 선택적 도구들
1359
+
1360
+ Returns:
1361
+ 실행 준비된 AgentRunner 인스턴스
1362
+
1363
+ """
1364
+ # LLM 생성
1365
+ model = self._create_model(config)
1366
+
1367
+ # 모든 도구 생성 (Jupyter + 파일 + 셸)
1368
+ all_tools = self._create_all_tools()
1369
+
1370
+ # 추가 도구가 있으면 결합
1371
+ if additional_tools:
1372
+ all_tools.extend(additional_tools)
1373
+
1374
+ # 미들웨어 스택 생성
1375
+ middleware = self._create_middleware(model, session_id=thread_id or "")
1376
+
1377
+ # TodoListMiddleware 기본값을 DS 특화 프롬프트로 교체.
1378
+ from langchain.agents.middleware import TodoListMiddleware as _TodoMW
1379
+
1380
+ _patch_todo_middleware()
1381
+ _dbg(
1382
+ f"TodoListMiddleware patched: system_prompt_len="
1383
+ f"{len(_TodoMW.__init__.__kwdefaults__.get('system_prompt', ''))}",
1384
+ )
1385
+ _dbg(
1386
+ f"TodoListMiddleware patched: tool_desc starts with="
1387
+ f"{_TodoMW.__init__.__kwdefaults__.get('tool_description', '')[:50]}",
1388
+ )
1389
+
1390
+ # HITL interrupt 설정
1391
+ interrupt_on = {
1392
+ name: InterruptOnConfig(
1393
+ allowed_decisions=["approve", "edit", "reject"],
1394
+ description=ds_tool_description,
1395
+ )
1396
+ for name in HITL_TOOL_NAMES
1397
+ }
1398
+
1399
+ # 멀티에이전트 모드 분기
1400
+ subagents = None
1401
+ if config.agent_mode.endswith("_direct"):
1402
+ # 서브에이전트 직접 실행 모드: Planner 바이패스
1403
+ from .skill_middleware import get_skill_middleware
1404
+
1405
+ # 직접 실행 모드 → 서브에이전트 프롬프트 키 (이슈 #218)
1406
+ _DIRECT_PROMPT_KEY = {
1407
+ "bq_agent_direct": "bq_agent",
1408
+ "athena_query_direct": "athena_query",
1409
+ "airflow_expert_direct": "airflow_expert",
1410
+ "emr_expert_direct": "emr_expert",
1411
+ "lambda_expert_direct": "lambda_expert",
1412
+ "spring_expert_direct": "spring_expert",
1413
+ }
1414
+
1415
+ custom_key = _DIRECT_PROMPT_KEY[config.agent_mode]
1416
+
1417
+ skill_mw = get_skill_middleware(self._workspace_root)
1418
+ skill_tools = skill_mw.get_tools()
1419
+ skill_section = skill_mw.get_prompt_section()
1420
+
1421
+ # jupyter_markdown 도구 추가 (노트북에 직접 출력)
1422
+ from .langchain_tools import create_jupyter_markdown_tool
1423
+
1424
+ all_tools.extend(skill_tools)
1425
+ all_tools.append(create_jupyter_markdown_tool(self._notebook_context))
1426
+
1427
+ system_prompt = prompt_value(custom_key) + "\n\n" + skill_section
1428
+ _dbg(
1429
+ f"DIRECT_MODE({config.agent_mode}): tools={len(all_tools)} "
1430
+ f"prompt_key={custom_key}",
1431
+ )
1432
+ elif config.agent_mode == "multi":
1433
+ from ..tools.user_tools import ask_user_tool, final_summary_tool
1434
+
1435
+ all_tools.extend([ask_user_tool, final_summary_tool])
1436
+ subagents = self._create_subagents()
1437
+
1438
+ # ask_user_tool HITL 설정
1439
+ interrupt_on["ask_user_tool"] = InterruptOnConfig(
1440
+ allowed_decisions=["approve", "edit", "reject"],
1441
+ description=ds_tool_description,
1442
+ )
1443
+
1444
+ # 관리자 값 → 스냅샷 (`prompts.planner`, 이슈 #218)
1445
+ system_prompt = _planner_system_prompt()
1446
+ _dbg(
1447
+ f"MULTI_AGENT: subagents={[s['name'] for s in subagents]} "
1448
+ f"tools_count={len(all_tools)}",
1449
+ )
1450
+ else:
1451
+ system_prompt = get_system_prompt(config.mode)
1452
+
1453
+ # 사내 플랫폼 규칙·AGENTS.md·스킬 목록을 붙인다 (이슈 #120, #134, #220).
1454
+ # direct 모드는 스킬 미들웨어가 목록과 도구를 이미 실었다.
1455
+ direct_mode = config.agent_mode.endswith("_direct")
1456
+ if not direct_mode:
1457
+ all_tools.extend(self._skill_tools())
1458
+ system_prompt += self._instruction_suffix(include_custom_skills=not direct_mode)
1459
+ if not direct_mode:
1460
+ system_prompt += self._builtin_skill_suffix()
1461
+
1462
+ # multi 모드: ContentInjectionMiddleware 추가
1463
+ if config.agent_mode == "multi":
1464
+ from .content_injection import ContentInjectionMiddleware
1465
+
1466
+ middleware.insert(0, ContentInjectionMiddleware())
1467
+
1468
+ # DeepAgents 그래프 생성 (checkpointer 포함 - HITL 인터럽트/리줌에 필수)
1469
+ backend = _create_safe_backend(root_dir=self._workspace_root, virtual_mode=True)
1470
+
1471
+ # DeepAgents 그래프 생성 (checkpointer 포함 - HITL 인터럽트/리줌에 필수)
1472
+ # Note: 서브에이전트의 write_todos 사용은 시스템 프롬프트에서 금지
1473
+ from deepagents import create_deep_agent
1474
+
1475
+ # execute 도구 제거: FilesystemMiddleware가 자동 생성하는 execute를 필터링
1476
+ from deepagents.middleware.filesystem import FilesystemMiddleware
1477
+
1478
+ _orig_fs_init = FilesystemMiddleware.__init__
1479
+
1480
+ def _fs_init_no_execute(self_mw, **kwargs):
1481
+ _orig_fs_init(self_mw, **kwargs)
1482
+ self_mw.tools = [t for t in self_mw.tools if t.name != "execute"]
1483
+
1484
+ FilesystemMiddleware.__init__ = _fs_init_no_execute
1485
+
1486
+ # 서브에이전트는 오케스트레이터의 미들웨어를 물려받지 않는다 — 각자 계측을
1487
+ # 붙여야 호출이 model_call·turn 토큰에 들어간다. single·direct 모드에도
1488
+ # task 도구로 부르는 general-purpose 가 있으므로 모드와 무관하게 건다 (이슈 #98).
1489
+ subagents = _observe_subagents(subagents or [], thread_id or "")
1490
+ subagents = _guard_subagents(subagents)
1491
+ subagents = _mask_subagents(subagents)
1492
+
1493
+ agent = create_deep_agent(
1494
+ model=model,
1495
+ tools=all_tools,
1496
+ system_prompt=system_prompt,
1497
+ middleware=middleware,
1498
+ subagents=subagents,
1499
+ backend=backend,
1500
+ checkpointer=checkpointer or MemorySaver(),
1501
+ interrupt_on=interrupt_on,
1502
+ ).with_config({"recursion_limit": 1000})
1503
+
1504
+ return AgentRunner(
1505
+ agent=agent,
1506
+ config=config,
1507
+ on_event=self._on_event,
1508
+ thread_id=thread_id,
1509
+ workspace_root=self._workspace_root,
1510
+ )
1511
+
1512
+
1513
+ def _observe_subagents(subagents: list, session_id: str) -> list:
1514
+ """서브에이전트마다 자기 이름의 관측 미들웨어를 붙인다 (이슈 #98).
1515
+
1516
+ deepagents 가 자동으로 붙이는 general-purpose 는 미들웨어를 받을 자리가 없다.
1517
+ 같은 이름의 명시 스펙은 기본값을 대신하므로(도구·모델은 부모 것을 물려받는다)
1518
+ 계측을 붙이려고 기본 스펙 그대로 선언한다.
1519
+ """
1520
+ from deepagents import SubAgent
1521
+ from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT
1522
+
1523
+ from .observability import ObservabilityMiddleware, default_user_identity
1524
+
1525
+ specs = list(subagents)
1526
+ if not any(s["name"] == GENERAL_PURPOSE_SUBAGENT["name"] for s in specs):
1527
+ # 기본 경로처럼 맨 앞에 둔다 — task 도구 설명의 나열 순서가 위임 선택에 닿는다
1528
+ specs.insert(0, SubAgent(**GENERAL_PURPOSE_SUBAGENT))
1529
+ identity = default_user_identity()
1530
+ for spec in specs:
1531
+ spec["middleware"] = [
1532
+ *spec.get("middleware", []),
1533
+ ObservabilityMiddleware(
1534
+ session_id=session_id,
1535
+ user_identity=identity,
1536
+ agent_name=spec["name"],
1537
+ ),
1538
+ ]
1539
+ return specs
1540
+
1541
+
1542
+ def _guard_subagents(specs: list) -> list:
1543
+ """사내면 서브에이전트에도 boto3 가드를 가장 안쪽에 붙인다 — 오케스트레이터 것을 물려받지 않는다 (이슈 #120)."""
1544
+ if not is_bdp_platform():
1545
+ return specs
1546
+ from .guardrail_middleware import PlatformGuardMiddleware
1547
+
1548
+ for spec in specs:
1549
+ spec["middleware"] = [*spec.get("middleware", []), PlatformGuardMiddleware()]
1550
+ return specs
1551
+
1552
+
1553
+ def _mask_subagents(specs: list) -> list:
1554
+ """서브에이전트에도 개인정보 마스킹을 붙인다 — 오케스트레이터 것을 물려받지 않는다 (이슈 #155).
1555
+
1556
+ task 도구로 넘기는 설명과 서브에이전트의 도구 결과도 모델로 간다.
1557
+ """
1558
+ from .guardrail_middleware import PiiMaskMiddleware
1559
+
1560
+ for spec in specs:
1561
+ spec["middleware"] = [*spec.get("middleware", []), PiiMaskMiddleware()]
1562
+ return specs
1563
+
1564
+
1565
+ # ---------------------------------------------------------------------------
1566
+ # AgentRunner
1567
+ # ---------------------------------------------------------------------------
1568
+
1569
+
1570
+ class AgentRunner:
1571
+ """이벤트 스트리밍이 포함된 LangChain Agent 실행기.
1572
+
1573
+ 컴파일된 LangChain Agent 그래프를 래핑하고 다음을 제공:
1574
+ - 이벤트 스트리밍이 포함된 비동기 실행
1575
+ - 반복 추적 및 제한
1576
+ - HITL 그래프 인터럽트 처리
1577
+ - 에러 처리
1578
+ """
1579
+
1580
+ def __init__(
1581
+ self,
1582
+ agent: Any, # LangChain의 CompiledStateGraph
1583
+ config: AgentConfig,
1584
+ on_event: Callable[[AgentEvent], Any] | None = None,
1585
+ thread_id: str | None = None,
1586
+ workspace_root: str = "",
1587
+ ):
1588
+ """실행기 초기화.
1589
+
1590
+ Args:
1591
+ agent: 컴파일된 LangChain Agent 그래프
1592
+ config: 에이전트 설정
1593
+ on_event: 에이전트 이벤트 콜백
1594
+ thread_id: 클라이언트에서 전달받은 스레드 ID (미지정 시 자동 생성)
1595
+ workspace_root: 승인 없는 파일 쓰기의 diff 를 잴 루트 (이슈 #147)
1596
+
1597
+ """
1598
+ self._agent = agent
1599
+ self._config = config
1600
+ self._on_event = on_event
1601
+ self._iteration = 0
1602
+ self._tool_count = 0
1603
+ self._is_cancelled = False
1604
+ # write_file/edit_file 의 before 를 run_id 별로 쥔다 — resume 루프도 같이 쓴다 (#147)
1605
+ self.file_diffs = FileDiffTracker(workspace_root)
1606
+ # 진행 중이거나 HITL 로 멈춘 턴의 계측 — 핸들러가 넣고 뺀다 (#98)
1607
+ self.turn_meter = None
1608
+ self.parked_at = (
1609
+ None # HITL 대기로 멈춘 시각 — client 유휴 회수가 본다 (이슈 #98)
1610
+ )
1611
+ self._thread_id: str = thread_id or str(__import__("uuid").uuid4())
1612
+ # Content dedup
1613
+ self._emitted_content_hashes: set = set()
1614
+ # Auto-terminate
1615
+ self._last_todos_data: list = []
1616
+ self._all_todos_completed: bool = False
1617
+ # Subagent tracking: task 도구 실행 중 서브에이전트 LLM 응답 필터링
1618
+ self._inside_subagent: bool = False
1619
+ # Streaming turn metrics
1620
+ self._turn_start_time: float = 0.0
1621
+ self._turn_token_estimate: int = 0
1622
+ # Truncation auto-continuation
1623
+ self._truncation_count: int = 0
1624
+ self._truncated_this_turn: bool = False
1625
+
1626
+ @property
1627
+ def agent(self) -> Any:
1628
+ """컴파일된 에이전트 그래프 접근."""
1629
+ return self._agent
1630
+
1631
+ @property
1632
+ def thread_id(self) -> str:
1633
+ """현재 실행 스레드 ID."""
1634
+ return self._thread_id
1635
+
1636
+ @property
1637
+ def iteration(self) -> int:
1638
+ """현재 반복 횟수."""
1639
+ return self._iteration
1640
+
1641
+ @property
1642
+ def emitted_content_hashes(self) -> set:
1643
+ """Emitted content hashes for dedup."""
1644
+ return self._emitted_content_hashes
1645
+
1646
+ async def _emit_event(self, event_type: str, data: dict[str, Any]) -> None:
1647
+ """콜백으로 이벤트 발생."""
1648
+ event = AgentEvent(type=event_type, data=data)
1649
+ if self._on_event:
1650
+ result = self._on_event(event)
1651
+ if asyncio.iscoroutine(result):
1652
+ await result
1653
+
1654
+ async def run(self, prompt: str) -> str:
1655
+ """주어진 프롬프트로 에이전트 실행.
1656
+
1657
+ HITL 인터럽트 발생 시 hitl_request 이벤트를 emit하고,
1658
+ 외부에서 resume()을 호출할 때까지 대기.
1659
+
1660
+ Args:
1661
+ prompt: 사용자 요청
1662
+
1663
+ Returns:
1664
+ 에이전트의 최종 응답
1665
+
1666
+ Raises:
1667
+ asyncio.CancelledError: 실행이 취소된 경우
1668
+ Exception: 실행 에러 발생 시
1669
+
1670
+ """
1671
+ await self._emit_event("start", {"prompt": prompt})
1672
+ _dbg(f"=== AGENT RUN START === prompt={prompt[:100]}")
1673
+ _dbg(f" agent_type={type(self._agent).__name__} model={self._config.model}")
1674
+ _dbg(f" agent_mode={self._config.agent_mode}")
1675
+
1676
+ try:
1677
+ # 초기 메시지 생성
1678
+ messages = [HumanMessage(content=prompt)]
1679
+
1680
+ # 실행 이벤트 스트리밍
1681
+ final_response = ""
1682
+
1683
+ config = {
1684
+ "configurable": {"thread_id": self._thread_id},
1685
+ "recursion_limit": 1000,
1686
+ }
1687
+
1688
+ _astream_input: dict | None = {"messages": messages}
1689
+ _run_astream = True
1690
+ _astream_loop_count = 0
1691
+ _MAX_ASTREAM_LOOPS = (
1692
+ _MAX_CONTINUATIONS + 1
1693
+ ) # 정상 1회 + continuation 최대 N회
1694
+
1695
+ while _run_astream:
1696
+ _astream_loop_count += 1
1697
+ if _astream_loop_count > _MAX_ASTREAM_LOOPS:
1698
+ _dbg(
1699
+ f"ASTREAM_LOOP_HARD_LIMIT: {_astream_loop_count} iterations, breaking",
1700
+ )
1701
+ break
1702
+ _run_astream = (
1703
+ False # 기본적으로 1회만 실행; truncation 시 True로 재설정
1704
+ )
1705
+ self._truncated_this_turn = False
1706
+ async for event in self._agent.astream_events(
1707
+ _astream_input,
1708
+ config=config,
1709
+ version="v2",
1710
+ ):
1711
+ if self._is_cancelled:
1712
+ raise asyncio.CancelledError()
1713
+
1714
+ event_kind = event.get("event", "")
1715
+ # DEBUG: 모든 이벤트 종류 로깅
1716
+ if event_kind not in ("on_chat_model_stream",):
1717
+ _dbg(
1718
+ f"ASTREAM_EVENT: kind={event_kind} name={event.get('name', '')} "
1719
+ f"tags={event.get('tags', [])} run_id={str(event.get('run_id', ''))[:8]}",
1720
+ )
1721
+
1722
+ # 반복 추적 (각 LLM 호출이 하나의 반복)
1723
+ if event_kind == "on_chat_model_start":
1724
+ # 서브에이전트 내부 LLM은 무시
1725
+ if self._inside_subagent:
1726
+ continue
1727
+ self._iteration += 1
1728
+ self._turn_start_time = time.monotonic()
1729
+ self._turn_token_estimate = 0
1730
+ # 턴별 콘텐츠 버퍼링
1731
+ # on_chat_model_end에서 최종 AIMessage의 tool_calls 유무로 판정:
1732
+ # tool_calls 있음 → content는 reasoning → 버림
1733
+ # tool_calls 없음 → content는 최종 응답 → emit
1734
+ self._stream_buffer: list[str] = []
1735
+ await self._emit_event(
1736
+ "progress",
1737
+ {
1738
+ "message": "LLM 응답 대기 중",
1739
+ "icon": "thinking",
1740
+ "turn": self._iteration,
1741
+ "step": self._tool_count,
1742
+ },
1743
+ )
1744
+
1745
+ # 반복 제한 확인
1746
+ if self._iteration > self._config.max_iterations:
1747
+ raise Exception("Maximum iterations reached")
1748
+
1749
+ # 도구 호출 추적
1750
+ elif event_kind == "on_tool_start":
1751
+ tool_name = event.get("name", "unknown")
1752
+ tool_input = event.get("data", {}).get("input", {})
1753
+ if not isinstance(tool_input, dict):
1754
+ tool_input = {}
1755
+ # 서브에이전트 진입/이탈 추적
1756
+ if tool_name == "task":
1757
+ self._inside_subagent = True
1758
+ self._tool_count += 1
1759
+ _dbg(
1760
+ f"TOOL_START: name={tool_name} input_keys={list(tool_input.keys())}",
1761
+ )
1762
+ logger.info(
1763
+ "Tool START: %s, input=%s",
1764
+ tool_name,
1765
+ str(tool_input)[:200],
1766
+ )
1767
+
1768
+ # 도구별 한국어 상태 메시지 emit
1769
+ status_msg = _get_tool_status_message(tool_name, tool_input)
1770
+ await self._emit_event(
1771
+ "progress",
1772
+ {
1773
+ "message": status_msg["status"],
1774
+ "icon": status_msg.get("icon"),
1775
+ "turn": self._iteration,
1776
+ "step": self._tool_count,
1777
+ "tool_label": status_msg.get("label", tool_name),
1778
+ },
1779
+ )
1780
+
1781
+ await self._emit_event(
1782
+ "tool_call",
1783
+ {
1784
+ "id": event.get("run_id", ""),
1785
+ "name": tool_name,
1786
+ "arguments": tool_input,
1787
+ },
1788
+ )
1789
+ self.file_diffs.start(
1790
+ event.get("run_id", ""), tool_name, tool_input
1791
+ )
1792
+
1793
+ # write_todos → 즉시 todos 이벤트 emit (실시간 UI 업데이트)
1794
+ if tool_name == "write_todos" and isinstance(tool_input, dict):
1795
+ todos_data = tool_input.get("todos", [])
1796
+ _dbg(f"WRITE_TODOS detected: {len(todos_data)} items")
1797
+ if todos_data:
1798
+ await self._emit_event("todos", {"todos": todos_data})
1799
+ # Auto-terminate: capture todos data
1800
+ self._last_todos_data = todos_data
1801
+
1802
+ # 도구 결과 추적
1803
+ elif event_kind == "on_tool_end":
1804
+ tool_name = event.get("name", "unknown")
1805
+ tool_output = event.get("data", {}).get("output", "")
1806
+ # 서브에이전트 이탈 추적
1807
+ if tool_name == "task":
1808
+ self._inside_subagent = False
1809
+ # task 도구 반환값 디버그 로깅 — Command에서 실제 ToolMessage 추출
1810
+ task_content = ""
1811
+ try:
1812
+ if hasattr(tool_output, "update"):
1813
+ # Command 객체 → update.messages[-1].content
1814
+ _msgs = tool_output.update.get("messages", [])
1815
+ if _msgs:
1816
+ task_content = getattr(
1817
+ _msgs[-1],
1818
+ "content",
1819
+ str(_msgs[-1]),
1820
+ )
1821
+ elif isinstance(tool_output, str):
1822
+ task_content = tool_output
1823
+ else:
1824
+ task_content = (
1825
+ str(tool_output) if tool_output else "EMPTY"
1826
+ )
1827
+ except Exception as _e:
1828
+ task_content = f"EXTRACT_ERROR: {_e}"
1829
+ _dbg(
1830
+ f"TASK_RESULT: type={type(tool_output).__name__} "
1831
+ f"content_len={len(task_content)} "
1832
+ f"has_CODE={'[CODE]' in task_content} "
1833
+ f"has_DESC={'[DESCRIPTION]' in task_content} "
1834
+ f"content_preview={task_content[:500]!r}",
1835
+ )
1836
+
1837
+ # 에러 여부 파싱
1838
+ tool_success = True
1839
+ try:
1840
+ parsed = (
1841
+ json.loads(tool_output)
1842
+ if isinstance(tool_output, str)
1843
+ else {}
1844
+ )
1845
+ if isinstance(parsed, dict):
1846
+ exec_result = parsed.get("execution_result")
1847
+ if (
1848
+ isinstance(exec_result, dict)
1849
+ and exec_result.get("success") is False
1850
+ ) or parsed.get("success") is False:
1851
+ tool_success = False
1852
+ except (json.JSONDecodeError, TypeError, ValueError):
1853
+ pass
1854
+
1855
+ await self._emit_event(
1856
+ "tool_result",
1857
+ {
1858
+ "id": event.get("run_id", ""),
1859
+ "name": tool_name,
1860
+ "result": {
1861
+ "success": tool_success,
1862
+ "data": tool_output,
1863
+ },
1864
+ },
1865
+ )
1866
+
1867
+ # Auto-terminate: write_todos 완료 체크
1868
+ if tool_name == "write_todos" and self._last_todos_data:
1869
+ all_completed = all(
1870
+ t.get("status") == "completed"
1871
+ for t in self._last_todos_data
1872
+ if isinstance(t, dict)
1873
+ )
1874
+ if all_completed and self._last_todos_data:
1875
+ self._all_todos_completed = True
1876
+ _dbg("ALL_TODOS_COMPLETED: auto-terminate armed")
1877
+
1878
+ file_diff = self.file_diffs.end(event.get("run_id", ""))
1879
+ if file_diff:
1880
+ await self._emit_event("file_diff", file_diff)
1881
+
1882
+ elif event_kind == "on_tool_error":
1883
+ self.file_diffs.discard(event.get("run_id", ""))
1884
+
1885
+ # LLM 턴 종료 - 최종 AIMessage의 tool_calls로 판정
1886
+ elif event_kind == "on_chat_model_end":
1887
+ # 서브에이전트 내부 LLM 출력도 로깅 (내용 확인용)
1888
+ if self._inside_subagent:
1889
+ _sub_out = event.get("data", {}).get("output", None)
1890
+ if _sub_out and hasattr(_sub_out, "message"):
1891
+ _sub_out = _sub_out.message
1892
+ _sub_content = (
1893
+ getattr(_sub_out, "content", "") if _sub_out else ""
1894
+ )
1895
+ _sub_tc = (
1896
+ getattr(_sub_out, "tool_calls", []) if _sub_out else []
1897
+ )
1898
+ _dbg(
1899
+ f"SUBAGENT_LLM_END: content_len={len(_sub_content) if isinstance(_sub_content, str) else 'list'} "
1900
+ f"has_tool_calls={bool(_sub_tc)} "
1901
+ f"has_CODE={'[CODE]' in _sub_content if isinstance(_sub_content, str) else 'N/A'} "
1902
+ f"content_preview={repr(_sub_content[:300]) if isinstance(_sub_content, str) else repr(str(_sub_content)[:300])}",
1903
+ )
1904
+ continue
1905
+ output_msg = event.get("data", {}).get("output", None)
1906
+ # output이 ChatGeneration인 경우 .message에서 추출
1907
+ if output_msg and hasattr(output_msg, "message"):
1908
+ output_msg = output_msg.message
1909
+ has_tool_calls = bool(
1910
+ output_msg
1911
+ and hasattr(output_msg, "tool_calls")
1912
+ and output_msg.tool_calls,
1913
+ )
1914
+ turn_elapsed_ms = (
1915
+ time.monotonic() - self._turn_start_time
1916
+ ) * 1000
1917
+ _dbg(
1918
+ f"LLM_TURN_END: has_tool_calls={has_tool_calls} "
1919
+ f"buffer_len={len(self._stream_buffer)} "
1920
+ f"output_msg_type={type(output_msg).__name__ if output_msg else 'None'} "
1921
+ f"content_preview={repr(''.join(self._stream_buffer)[:200]) if self._stream_buffer else 'empty'}",
1922
+ )
1923
+ _dbg(
1924
+ f"LLM_TURN_METRICS: turn={self._iteration} "
1925
+ f"elapsed_ms={turn_elapsed_ms:.0f} "
1926
+ f"est_tokens={self._turn_token_estimate}",
1927
+ )
1928
+ # finish_reason 추출 (truncation 감지용)
1929
+ _finish_reason = None
1930
+ if output_msg and hasattr(output_msg, "response_metadata"):
1931
+ _meta = output_msg.response_metadata or {}
1932
+ _finish_reason = _meta.get("finish_reason") or _meta.get(
1933
+ "stop_reason",
1934
+ )
1935
+ self._truncated_this_turn = False
1936
+
1937
+ if output_msg:
1938
+ _dbg(
1939
+ f" output_msg attrs: content={repr(getattr(output_msg, 'content', 'N/A'))[:100]} "
1940
+ f"tool_calls={getattr(output_msg, 'tool_calls', 'N/A')} "
1941
+ f"finish_reason={_finish_reason}",
1942
+ )
1943
+
1944
+ # Summarization 감지
1945
+ if output_msg and hasattr(output_msg, "additional_kwargs"):
1946
+ if (output_msg.additional_kwargs or {}).get(
1947
+ "lc_source",
1948
+ ) == "summarization":
1949
+ await self._emit_event(
1950
+ "progress",
1951
+ {
1952
+ "message": "대화가 자동으로 압축되었습니다.",
1953
+ "icon": "check",
1954
+ },
1955
+ )
1956
+
1957
+ if has_tool_calls and output_msg:
1958
+ logger.info(
1959
+ "Tool calls: %s",
1960
+ [
1961
+ tc.get("name", "?")
1962
+ for tc in getattr(output_msg, "tool_calls", [])
1963
+ ],
1964
+ )
1965
+ # Tool calling 응답의 content 제거:
1966
+ # 일부 LLM(vLLM/오픈소스)은 tool_calls와 함께 reasoning content를
1967
+ # 포함하는데, 이 content가 checkpointer에 저장되면 context window를
1968
+ # 불필요하게 소모함. AIMessage.content를 비워서 방지.
1969
+ if getattr(output_msg, "content", ""):
1970
+ old_len = (
1971
+ len(output_msg.content)
1972
+ if isinstance(output_msg.content, str)
1973
+ else len(str(output_msg.content))
1974
+ )
1975
+ _dbg(
1976
+ f"STRIP_TOOL_CONTENT: removing {old_len} chars from tool-calling AIMessage",
1977
+ )
1978
+ try:
1979
+ output_msg.content = ""
1980
+ except (AttributeError, TypeError):
1981
+ pass # immutable인 경우 무시
1982
+ # Premature summary 필터링: tool_calls 있으면서 summary 패턴도 있으면 스킵
1983
+ if self._stream_buffer:
1984
+ buf_check = "".join(self._stream_buffer)
1985
+ if (
1986
+ '"summary"' in buf_check
1987
+ and '"next_items"' in buf_check
1988
+ ):
1989
+ non_todo_tools = [
1990
+ tc
1991
+ for tc in output_msg.tool_calls
1992
+ if tc.get("name") not in ("write_todos",)
1993
+ ]
1994
+ if non_todo_tools:
1995
+ _dbg(
1996
+ "FILTERED: premature summary with active tool calls",
1997
+ )
1998
+ self._stream_buffer = []
1999
+
2000
+ if self._stream_buffer and not has_tool_calls:
2001
+ # tool_calls 없는 턴 → 최종 응답
2002
+ buf = "".join(self._stream_buffer)
2003
+ # assistantfinal 마커가 있으면 그 이후만 추출
2004
+ if "assistantfinal" in buf:
2005
+ buf = buf[
2006
+ buf.index("assistantfinal")
2007
+ + len("assistantfinal") :
2008
+ ]
2009
+ # JSON tool response 필터링
2010
+ if buf:
2011
+ stripped = buf.strip()
2012
+ is_json_tool = (
2013
+ stripped.startswith('{"tool":')
2014
+ or stripped.startswith('{"status":')
2015
+ or '"pending_execution"' in stripped
2016
+ )
2017
+ if is_json_tool:
2018
+ _dbg("FILTERED: JSON tool response")
2019
+ buf = ""
2020
+ # Summary JSON repair + content hash dedup
2021
+ if buf:
2022
+ buf = _repair_summary_json_content(buf)
2023
+ content_hash = hash(buf)
2024
+ if content_hash not in self._emitted_content_hashes:
2025
+ self._emitted_content_hashes.add(content_hash)
2026
+ final_response += buf
2027
+ await self._emit_event("stream", {"content": buf})
2028
+ else:
2029
+ _dbg(
2030
+ f"DEDUP: skipped duplicate content (hash={content_hash})",
2031
+ )
2032
+ # Truncation 감지: finish_reason이 length/max_tokens이면
2033
+ # done 이벤트를 보내지 않고 continuation을 위해 플래그 설정
2034
+ if (
2035
+ _finish_reason in ("length", "max_tokens")
2036
+ and not self._inside_subagent
2037
+ and self._truncation_count < _MAX_CONTINUATIONS
2038
+ ):
2039
+ self._truncation_count += 1
2040
+ self._truncated_this_turn = True
2041
+ _dbg(
2042
+ f"TRUNCATION_DETECTED: count={self._truncation_count}, "
2043
+ f"finish_reason={_finish_reason}",
2044
+ )
2045
+ self._stream_buffer = []
2046
+ continue # done/auto-terminate 스킵
2047
+ # Auto-terminate: summary 감지 후 자동 종료
2048
+ if self._all_todos_completed and buf:
2049
+ if '"summary"' in buf and '"next_items"' in buf:
2050
+ _dbg(
2051
+ "AUTO_TERMINATE: all todos completed + summary detected",
2052
+ )
2053
+ await self._emit_event("debug_clear", {})
2054
+ await self._emit_event(
2055
+ "done",
2056
+ {"reason": "all_todos_completed"},
2057
+ )
2058
+ return final_response
2059
+ elif (
2060
+ not self._stream_buffer
2061
+ and not has_tool_calls
2062
+ and output_msg
2063
+ ):
2064
+ # List content 핸들링 (multimodal)
2065
+ raw_content = getattr(output_msg, "content", "")
2066
+ if isinstance(raw_content, list):
2067
+ text_parts = []
2068
+ for part in raw_content:
2069
+ if isinstance(part, str):
2070
+ text_parts.append(part)
2071
+ elif (
2072
+ isinstance(part, dict)
2073
+ and part.get("type") == "text"
2074
+ ):
2075
+ text_parts.append(part.get("text", ""))
2076
+ buf = "".join(text_parts)
2077
+ else:
2078
+ buf = raw_content or ""
2079
+ if buf:
2080
+ # 미들웨어(예: ModelRetryMiddleware)가 스트리밍 없이
2081
+ # 생성한 AIMessage (에러 메시지 등)를 사용자에게 표시
2082
+ _dbg(f"NON_STREAMED_MSG: {buf[:200]!r}")
2083
+ content_hash = hash(buf)
2084
+ if content_hash not in self._emitted_content_hashes:
2085
+ self._emitted_content_hashes.add(content_hash)
2086
+ final_response += buf
2087
+ await self._emit_event("stream", {"content": buf})
2088
+ else:
2089
+ _dbg("DEDUP: skipped duplicate non-streamed msg")
2090
+ # tool_calls 있는 턴의 content는 reasoning → 버림
2091
+ self._stream_buffer = []
2092
+
2093
+ # 콘텐츠 버퍼링 (턴 종료 시 판정)
2094
+ elif event_kind == "on_chat_model_stream":
2095
+ # 서브에이전트 내부 LLM 스트리밍은 무시
2096
+ if self._inside_subagent:
2097
+ continue
2098
+ chunk = event.get("data", {}).get("chunk", None)
2099
+ if chunk and hasattr(chunk, "content") and chunk.content:
2100
+ self._stream_buffer.append(chunk.content)
2101
+ self._turn_token_estimate += len(chunk.content) // 4
2102
+ # Log first chunk of each buffer to verify streaming
2103
+ if len(self._stream_buffer) == 1:
2104
+ _dbg(f"STREAM_FIRST_CHUNK: {chunk.content[:100]!r}")
2105
+
2106
+ # async for 종료 후: truncation이 감지되었으면 continuation 주입
2107
+ if self._truncated_this_turn:
2108
+ if self._truncation_count >= _MAX_CONTINUATIONS:
2109
+ _dbg(
2110
+ f"CONTINUATION_LIMIT_REACHED: {self._truncation_count}/{_MAX_CONTINUATIONS}",
2111
+ )
2112
+ break
2113
+ _dbg(
2114
+ f"CONTINUATION_INJECTING: count={self._truncation_count}/{_MAX_CONTINUATIONS}",
2115
+ )
2116
+ await self._emit_event(
2117
+ "progress",
2118
+ {
2119
+ "message": f"응답이 잘려 이어서 생성 중... ({self._truncation_count}/{_MAX_CONTINUATIONS})",
2120
+ "icon": "spinner",
2121
+ },
2122
+ )
2123
+ # 새 입력으로 astream_events를 재호출하여 START→model 경로를 타게 함.
2124
+ # aupdate_state + astream_events(None)은 라우팅이 마지막 AIMessage의
2125
+ # tool_calls를 검사하여 END로 보내므로 LLM이 재호출되지 않음.
2126
+ _astream_input = {
2127
+ "messages": [
2128
+ HumanMessage(
2129
+ content=(
2130
+ "[SYSTEM] 응답이 max_tokens 한도에 도달하여 잘렸습니다. "
2131
+ "이전 내용에 이어서 계속 작성하세요. "
2132
+ "이전 내용을 반복하지 말고, 잘린 지점부터 이어서 작성하세요."
2133
+ ),
2134
+ ),
2135
+ ],
2136
+ }
2137
+ _run_astream = True # while 루프 재실행
2138
+ _dbg(f"CONTINUATION_INJECTED: count={self._truncation_count}")
2139
+ continue # while 루프의 다음 iteration으로
2140
+
2141
+ # GraphInterrupt 감지: astream_events()는 GraphInterrupt를
2142
+ # raise하지 않으므로, 루프 종료 후 state를 확인하여
2143
+ # 인터럽트 여부를 판정함.
2144
+ _dbg(
2145
+ f"=== ASTREAM LOOP DONE === iterations={self._iteration} final_response_len={len(final_response)}",
2146
+ )
2147
+ state = await self._agent.aget_state(config)
2148
+ _dbg(
2149
+ f" state.values keys={list(state.values.keys()) if hasattr(state, 'values') else 'N/A'}",
2150
+ )
2151
+ _dbg(
2152
+ f" state.tasks count={len(state.tasks) if hasattr(state, 'tasks') else 'N/A'}",
2153
+ )
2154
+
2155
+ # 최종 todos state emit (일관성 보장)
2156
+ state_values = state.values if hasattr(state, "values") else {}
2157
+ final_todos = state_values.get("todos", [])
2158
+ if final_todos:
2159
+ serializable = [
2160
+ {
2161
+ "content": t["content"] if isinstance(t, dict) else t.content,
2162
+ "status": t["status"] if isinstance(t, dict) else t.status,
2163
+ }
2164
+ for t in final_todos
2165
+ ]
2166
+ await self._emit_event("todos", {"todos": serializable})
2167
+
2168
+ if state.tasks:
2169
+ for task in state.tasks:
2170
+ if task.interrupts:
2171
+ hitl_request = task.interrupts[0].value
2172
+ hitl_event = _transform_interrupt_to_frontend(
2173
+ hitl_request,
2174
+ self._thread_id,
2175
+ )
2176
+ _dbg(
2177
+ f"HITL_INTERRUPT: action={hitl_event.get('action')} "
2178
+ f"args_keys={list(hitl_event.get('args', {}).keys())} "
2179
+ f"thread_id={hitl_event.get('thread_id')} "
2180
+ f"actionCount={hitl_event.get('actionCount')}",
2181
+ )
2182
+ # HITL interrupt 시 dedup 상태 저장
2183
+ self._saved_emitted_hashes = self._emitted_content_hashes.copy()
2184
+ await self._emit_event(
2185
+ "progress",
2186
+ {"message": "사용자 승인 대기 중", "icon": "pause"},
2187
+ )
2188
+ await self._emit_event("hitl_request", hitl_event)
2189
+ _dbg("HITL_INTERRUPT: hitl_request event emitted")
2190
+ return final_response # "complete" 안 보내고 조기 리턴
2191
+
2192
+ # 인터럽트 없으면 정상 완료
2193
+ await self._emit_event("debug_clear", {})
2194
+
2195
+ # 빈 응답 복구: ModelRetryMiddleware가 에러를 AIMessage로 래핑하여
2196
+ # astream_events에서 스트리밍되지 않은 경우, graph state에서 복원
2197
+ if not final_response and self._iteration > 0:
2198
+ try:
2199
+ last_messages = state_values.get("messages", [])
2200
+ if last_messages:
2201
+ last_msg = last_messages[-1]
2202
+ last_content = getattr(last_msg, "content", "")
2203
+ if isinstance(last_content, str) and last_content:
2204
+ _dbg(
2205
+ f"EMPTY_RESPONSE_RECOVERY: content={last_content[:200]!r}",
2206
+ )
2207
+ final_response = last_content
2208
+ await self._emit_event("stream", {"content": last_content})
2209
+ except Exception as e:
2210
+ _dbg(f"EMPTY_RESPONSE_RECOVERY_ERROR: {e}")
2211
+
2212
+ await self._emit_event(
2213
+ "complete",
2214
+ {
2215
+ "response": final_response,
2216
+ "iterations": self._iteration,
2217
+ },
2218
+ )
2219
+
2220
+ return final_response
2221
+
2222
+ except asyncio.CancelledError:
2223
+ logger.info("Agent execution cancelled")
2224
+ await self._emit_event(
2225
+ "cancelled",
2226
+ {"message": "작업이 사용자에 의해 중단되었습니다."},
2227
+ )
2228
+ raise
2229
+
2230
+ except Exception as e:
2231
+ logger.error(f"Agent execution error: {e}")
2232
+ await self._emit_event("error", {"error": str(e)})
2233
+ raise
2234
+
2235
+ def cancel(self) -> None:
2236
+ """실행 중인 에이전트 취소."""
2237
+ self._is_cancelled = True
2238
+
2239
+
2240
+ def config_from_dict(data: dict[str, Any]) -> AgentConfig:
2241
+ """딕셔너리(프론트엔드 형식)에서 AgentConfig 생성.
2242
+
2243
+ vllm (OpenAI-compatible) provider만 지원.
2244
+
2245
+ Args:
2246
+ data: 프론트엔드의 LLM 설정이 포함된 딕셔너리
2247
+
2248
+ Returns:
2249
+ AgentConfig 인스턴스
2250
+
2251
+ """
2252
+ vllm_config = data.get("vllm", {})
2253
+ api_key = vllm_config.get("apiKey", "")
2254
+ model = vllm_config.get("model", "")
2255
+ base_url = vllm_config.get("endpoint", "").rstrip("/")
2256
+ # OpenAI-compatible endpoints require /v1 suffix
2257
+ if base_url and not base_url.endswith("/v1"):
2258
+ base_url = f"{base_url}/v1"
2259
+
2260
+ return AgentConfig(
2261
+ provider="openai",
2262
+ model=model,
2263
+ api_key=api_key,
2264
+ base_url=base_url,
2265
+ # temperature 는 사용자 설정이 아니다 — AgentConfig 기본값 0.0 고정 (이슈 #171)
2266
+ max_tokens=data.get("maxTokens", 8192),
2267
+ max_iterations=data.get("maxIterations", 20),
2268
+ mode=data.get("mode", "default"),
2269
+ agent_mode=data.get("agentMode", "multi"),
2270
+ )