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,548 @@
1
+ """ContinuationControl + LimitToolCalls + TodoActive 미들웨어.
2
+
3
+ ContinuationControlMiddleware: 도구 완료 후 LLM에게 다음 행동 안내.
4
+ LimitToolCallsMiddleware: LLM 응답의 tool_calls를 1개로 제한 + additional_kwargs 정리.
5
+ TodoActiveMiddleware: wrap_tool_call로 todo_active state flag 관리 (Command 반환).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import json
12
+ import logging
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import Annotated, Any, NotRequired
15
+
16
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
17
+ from langgraph.types import Command
18
+
19
+ from langchain.agents.middleware.types import (
20
+ AgentMiddleware,
21
+ AgentState,
22
+ ModelCallResult,
23
+ ModelRequest,
24
+ ModelResponse,
25
+ )
26
+
27
+ from ..log_paths import get_debug_log_path
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Direct file-based debug logging (bypasses Python logging config)
32
+
33
+ _DEBUG_LOG = get_debug_log_path()
34
+
35
+
36
+ def _dbg(msg: str) -> None:
37
+ """Write debug message directly to file."""
38
+ try:
39
+ ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
40
+ with open(_DEBUG_LOG, "a") as f:
41
+ f.write(f"[{ts}] CONTINUATION: {msg}\n")
42
+ except OSError:
43
+ pass
44
+
45
+
46
+ # write_todos 후에는 "다시 write_todos 호출 금지" 별도 guidance 사용
47
+ _WRITE_TODOS_TOOLS = {"write_todos"}
48
+
49
+
50
+ def _get_tool_name(msg: Any) -> str:
51
+ """ToolMessage에서 도구 이름 추출."""
52
+ if hasattr(msg, "name"):
53
+ return msg.name or ""
54
+ if hasattr(msg, "additional_kwargs"):
55
+ return msg.additional_kwargs.get("name", "")
56
+ return ""
57
+
58
+
59
+ def _extract_ai_message(response: Any) -> AIMessage | None:
60
+ """ModelResponse/ModelCallResult에서 AIMessage 추출."""
61
+ if isinstance(response, AIMessage):
62
+ return response
63
+ if hasattr(response, "message") and isinstance(response.message, AIMessage):
64
+ return response.message
65
+ if hasattr(response, "messages"):
66
+ for msg in reversed(response.messages):
67
+ if isinstance(msg, AIMessage):
68
+ return msg
69
+ return None
70
+
71
+
72
+ def _get_todos_from_state(request: ModelRequest) -> list | None:
73
+ """LangGraph state에서 todos 직접 읽기. 없으면 None."""
74
+ state = getattr(request, "state", None)
75
+ if state and isinstance(state, dict):
76
+ todos = state.get("todos")
77
+ if isinstance(todos, list):
78
+ return todos
79
+ return None
80
+
81
+
82
+ def _get_todos_from_messages(messages: list) -> list | None:
83
+ """메시지에서 write_todos 결과를 찾아 todos 추출. 없으면 None."""
84
+ for msg in reversed(messages):
85
+ if not hasattr(msg, "type") or msg.type != "tool":
86
+ continue
87
+ if _get_tool_name(msg) != "write_todos":
88
+ continue
89
+ content = getattr(msg, "content", "")
90
+ if not isinstance(content, str):
91
+ continue
92
+ try:
93
+ data = json.loads(content)
94
+ todos = data.get("todos", [])
95
+ if todos:
96
+ return todos
97
+ except (json.JSONDecodeError, AttributeError):
98
+ continue
99
+ return None
100
+
101
+
102
+ def _classify_todos(todos: list | None) -> str:
103
+ """todos 상태 분류. 'none' | 'pending' | 'all_completed'."""
104
+ if not todos:
105
+ return "none"
106
+ pending = [
107
+ t
108
+ for t in todos
109
+ if isinstance(t, dict) and t.get("status") in ("pending", "in_progress")
110
+ ]
111
+ # dict가 아닌 경우 (Todo 객체): attribute 접근
112
+ if not pending:
113
+ pending = [
114
+ t
115
+ for t in todos
116
+ if hasattr(t, "status") and t.status in ("pending", "in_progress")
117
+ ]
118
+ return "pending" if pending else "all_completed"
119
+
120
+
121
+ def _has_summary_in_messages(messages: list) -> bool:
122
+ """메시지에서 final_summary 도구 호출 또는 요약 텍스트 존재 여부 확인."""
123
+ for msg in reversed(messages[-10:]):
124
+ msg_type = getattr(msg, "type", "")
125
+ name = _get_tool_name(msg) if msg_type == "tool" else ""
126
+
127
+ # final_summary 도구 결과
128
+ if name in ("final_summary_tool", "final_summary"):
129
+ return True
130
+
131
+ # AI 메시지의 summary JSON 패턴
132
+ content = getattr(msg, "content", "")
133
+ if isinstance(content, str):
134
+ if ('"summary"' in content and '"next_items"' in content) or (
135
+ "'summary'" in content and "'next_items'" in content
136
+ ):
137
+ return True
138
+ # 한국어 요약 패턴
139
+ if any(
140
+ kw in content
141
+ for kw in ("모든 작업이 완료", "작업 요약", "다음 단계 제안")
142
+ ):
143
+ return True
144
+ return False
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # TodoActiveState — state schema for todo_active flag
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ def _take_last(existing: str, new: str) -> str:
153
+ """Reducer: 마지막 값만 유지. 병렬 도구 호출 시 충돌 방지."""
154
+ return new
155
+
156
+
157
+ def _take_last_bool(existing: bool, new: bool) -> bool:
158
+ """Reducer: 마지막 bool 값만 유지."""
159
+ return new
160
+
161
+
162
+ class TodoActiveState(AgentState):
163
+ """todo_active flag를 포함하는 state schema.
164
+
165
+ - todo_active=True: write_todos가 호출된 후 (작업 진행 중)
166
+ - todo_active=False: final_summary_tool 호출 후 또는 초기 상태
167
+ - last_tool_name: 가장 최근 실행된 도구 이름 (messages[-1] 의존 제거)
168
+ """
169
+
170
+ todo_active: NotRequired[Annotated[bool, _take_last_bool]]
171
+ last_tool_name: NotRequired[Annotated[str, _take_last]]
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # TodoActiveMiddleware — wrap_tool_call로 state flag 관리
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ class TodoActiveMiddleware(AgentMiddleware):
180
+ """wrap_tool_call로 todo_active, last_tool_name state flag를 관리.
181
+
182
+ - write_todos → todo_active=True, last_tool_name="write_todos"
183
+ - final_summary_tool → todo_active=False, last_tool_name="final_summary_tool"
184
+ - task → last_tool_name="task"
185
+ - 기타 도구 → last_tool_name=<tool_name>
186
+
187
+ Command(update={...})로 LangGraph state를 확정적으로 업데이트.
188
+ messages[-1] 타입에 의존하지 않아 system 메시지 끼어들기 문제 해결.
189
+ """
190
+
191
+ state_schema = TodoActiveState
192
+
193
+ async def awrap_tool_call(self, request, handler):
194
+ """도구 실행 후 state flag 업데이트."""
195
+ result = await handler(request)
196
+ tool_name = request.tool_call.get("name", "")
197
+
198
+ if tool_name == "write_todos":
199
+ _dbg("TodoActive: write_todos → todo_active=True")
200
+ return self._wrap_with_state(
201
+ result, todo_active=True, last_tool_name=tool_name
202
+ )
203
+ elif tool_name in ("final_summary_tool", "final_summary"):
204
+ _dbg(f"TodoActive: {tool_name} → todo_active=False")
205
+ return self._wrap_with_state(
206
+ result, todo_active=False, last_tool_name=tool_name
207
+ )
208
+ elif tool_name:
209
+ extra = ""
210
+ if tool_name == "task":
211
+ args = request.tool_call.get("args", {})
212
+ if isinstance(args, str):
213
+ try:
214
+ args = json.loads(args)
215
+ except Exception:
216
+ args = {}
217
+ agent_type = args.get("subagent_type", args.get("agent_name", ""))
218
+ if agent_type:
219
+ extra = f":{agent_type}"
220
+ _dbg(f"TodoActive: {tool_name}{extra} → last_tool_name update")
221
+ return self._wrap_with_state(result, last_tool_name=f"{tool_name}{extra}")
222
+
223
+ return result
224
+
225
+ def _wrap_with_state(
226
+ self,
227
+ result: Any,
228
+ *,
229
+ todo_active: bool | None = None,
230
+ last_tool_name: str | None = None,
231
+ ) -> Command:
232
+ """도구 결과를 Command로 감싸서 state 업데이트."""
233
+ update: dict[str, Any] = {}
234
+ if todo_active is not None:
235
+ update["todo_active"] = todo_active
236
+ if last_tool_name is not None:
237
+ update["last_tool_name"] = last_tool_name
238
+
239
+ try:
240
+ if isinstance(result, Command):
241
+ # 기존 Command에 merge
242
+ existing = (
243
+ result.update if hasattr(result, "update") and result.update else {}
244
+ )
245
+ merged = {**existing, **update}
246
+ return Command(update=merged)
247
+ elif isinstance(result, ToolMessage):
248
+ update["messages"] = [result]
249
+ return Command(update=update)
250
+ else:
251
+ # Unknown type → ToolMessage로 변환
252
+ content = str(result) if result else ""
253
+ tool_msg = ToolMessage(content=content, tool_call_id="")
254
+ update["messages"] = [tool_msg]
255
+ return Command(update=update)
256
+ except Exception as e:
257
+ _dbg(f"TodoActive: wrap failed: {e}")
258
+ return result
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # ContinuationControlMiddleware — state flag 기반 continuation 제어
263
+ # ---------------------------------------------------------------------------
264
+
265
+
266
+ class ContinuationControlMiddleware(AgentMiddleware):
267
+ """state flag 기반으로 도구 완료 후 LLM에게 다음 행동을 안내.
268
+
269
+ TodoActiveMiddleware가 설정한 state flag를 읽어서 판단:
270
+ - todo_active=False + final_summary 완료 → 즉시 중단
271
+ - last_tool_name="task" → 코드 실행 유도 guidance 주입
272
+ - last_tool_name="write_todos" → 다음 작업 진행 유도
273
+ - pending todos → 계속 진행 유도
274
+ - 모든 todos 완료 → final_summary 호출 유도
275
+ """
276
+
277
+ def awrap_model_call(
278
+ self,
279
+ request: ModelRequest,
280
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
281
+ ) -> Awaitable[ModelCallResult]:
282
+ return self._impl(request, handler)
283
+
284
+ async def _impl(
285
+ self,
286
+ request: ModelRequest,
287
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
288
+ ) -> ModelCallResult:
289
+ messages = request.messages
290
+ state = getattr(request, "state", {}) or {}
291
+ todo_active = state.get("todo_active", False)
292
+ last_tool = state.get("last_tool_name", "")
293
+
294
+ _dbg(
295
+ f"_impl called, msg_count={len(messages) if messages else 0}, "
296
+ f"todo_active={todo_active}, last_tool={last_tool}"
297
+ )
298
+
299
+ # ── Guard 1: final_summary 완료 후 즉시 중단 ──
300
+ # todo_active=False이고 메시지에 summary가 있으면 agent 중단
301
+ if not todo_active and last_tool in ("final_summary_tool", "final_summary"):
302
+ _dbg("final_summary tool + todo_active=False → stopping agent")
303
+ return AIMessage(content="", tool_calls=[])
304
+
305
+ # todo_active=False이면서 summary가 messages에 있으면 중단
306
+ if not todo_active and _has_summary_in_messages(messages):
307
+ _dbg("todo_active=False + summary in messages → stopping agent")
308
+ return AIMessage(content="", tool_calls=[])
309
+
310
+ # ── Guard 2: task 완료 후 결과 처리 유도 ──
311
+ if last_tool.startswith("task"):
312
+ subagent = last_tool.split(":", 1)[1] if ":" in last_tool else ""
313
+ if subagent in ("bq_agent", "athena_query", "airflow_expert"):
314
+ guidance = (
315
+ "[SYSTEM] Sub-agent returned analysis/SQL. "
316
+ "You MUST display it with jupyter_markdown() BEFORE calling write_todos. "
317
+ "Do NOT skip display. Do NOT call jupyter_cell for analysis results."
318
+ )
319
+ else:
320
+ guidance = (
321
+ "[SYSTEM] Sub-agent returned code. "
322
+ "You MUST execute it with jupyter_cell() BEFORE calling write_todos. "
323
+ "Do NOT skip execution. Do NOT mark the todo as completed without running the code."
324
+ )
325
+ new_messages = list(messages) + [HumanMessage(content=guidance)]
326
+ _dbg(f"injected post-task guidance for subagent={subagent} (state-based)")
327
+ request = request.override(messages=new_messages)
328
+ response = await handler(request)
329
+ return self._strip_write_todos_if_summary(response)
330
+
331
+ # ── Guard 3: todo_active=False → 간단한 작업, guidance 불필요 ──
332
+ if not todo_active:
333
+ _dbg(f"todo_active=False, last_tool={last_tool} → skip guidance")
334
+ response = await handler(request)
335
+ return self._strip_write_todos_if_summary(response)
336
+
337
+ # ── todo_active=True: continuation guidance 주입 ──
338
+ if last_tool:
339
+ is_write_todos = last_tool in _WRITE_TODOS_TOOLS
340
+ modified = self._inject_continuation(
341
+ request,
342
+ last_tool,
343
+ after_write_todos=is_write_todos,
344
+ )
345
+ if modified is None:
346
+ _dbg("summary exists → stopping agent")
347
+ return AIMessage(content="", tool_calls=[])
348
+ request = modified
349
+
350
+ response = await handler(request)
351
+ return self._strip_write_todos_if_summary(response)
352
+
353
+ def _get_last_tool_result(self, messages: list) -> dict | None:
354
+ """마지막 ToolMessage에서 결과를 dict로 추출. 없으면 None."""
355
+ for msg in reversed(messages):
356
+ if not isinstance(msg, ToolMessage):
357
+ continue
358
+ content = getattr(msg, "content", "")
359
+ if not isinstance(content, str):
360
+ continue
361
+ try:
362
+ return json.loads(content)
363
+ except (json.JSONDecodeError, TypeError):
364
+ # Plain text error message → wrap in dict
365
+ if content.strip():
366
+ return {"error": content, "success": False}
367
+ break
368
+ return None
369
+
370
+ def _inject_continuation(
371
+ self,
372
+ request: ModelRequest,
373
+ tool_name: str,
374
+ *,
375
+ after_write_todos: bool = False,
376
+ ) -> ModelRequest | None:
377
+ """continuation 메시지 주입.
378
+
379
+ Returns:
380
+ 수정된 request, 또는 None (에이전트 중단 신호)
381
+ """
382
+ messages = request.messages
383
+
384
+ # ── Error recovery: 마지막 도구 실패 시 복구 guidance 주입 ──
385
+ last_tool_result = self._get_last_tool_result(messages)
386
+ if last_tool_result and not last_tool_result.get("success", True):
387
+ error_msg = last_tool_result.get("error", "")
388
+ if "command not found" in error_msg or "not found" in error_msg:
389
+ guidance = (
390
+ f"[SYSTEM] Tool '{tool_name}' failed: {error_msg[:100]}. "
391
+ "Replan: if 'pip' not found, use jupyter_cell(code='!pip install ...') instead. "
392
+ "Do NOT retry the same command."
393
+ )
394
+ new_messages = list(messages) + [HumanMessage(content=guidance)]
395
+ _dbg(f"injected error recovery guidance for {tool_name}")
396
+ return request.override(messages=new_messages)
397
+
398
+ # todos 가져오기: state 우선, 없으면 메시지에서 추출
399
+ todos = _get_todos_from_state(request) or _get_todos_from_messages(messages)
400
+ todos_status = _classify_todos(todos)
401
+ _dbg(
402
+ f"_inject tool={tool_name} todos_status={todos_status} after_wt={after_write_todos} todos={todos!r:.200s}"
403
+ )
404
+
405
+ # todos 없음 → 간단한 작업, guidance 불필요
406
+ if todos_status == "none":
407
+ _dbg("no todos → skip guidance")
408
+ return request
409
+
410
+ # pending todos 있음 → 계속 진행
411
+ if todos_status == "pending":
412
+ pending_preview = self._build_pending_preview(todos)
413
+
414
+ if after_write_todos:
415
+ guidance = (
416
+ f"[SYSTEM] Todo list updated.{pending_preview} "
417
+ "Now continue working on the next pending todo item. "
418
+ "Use 'task' for code generation. "
419
+ "Package installs → jupyter_cell(code='!pip install ...'). "
420
+ "Exploration only (file check, head, pip list) → execute. "
421
+ "Do NOT call write_todos again until the next task is done. "
422
+ "Do NOT repeat tools that already succeeded."
423
+ )
424
+ else:
425
+ guidance = (
426
+ f"[SYSTEM] Tool '{tool_name}' completed successfully.{pending_preview} "
427
+ "Update the current todo status with write_todos, then continue "
428
+ "with the next pending todo item. "
429
+ "Use 'task' for code generation. "
430
+ "Package installs → jupyter_cell(code='!pip install ...'). "
431
+ "Exploration only (file check, head, pip list) → execute. "
432
+ "Do NOT repeat tools that already succeeded."
433
+ )
434
+ else:
435
+ # 모든 todos 완료 → summary 이미 있으면 중단, 없으면 호출 유도
436
+ if _has_summary_in_messages(messages):
437
+ _dbg("all_completed + summary exists → stopping agent")
438
+ return None
439
+
440
+ guidance = (
441
+ "[SYSTEM] 모든 작업이 완료되었습니다. "
442
+ "반드시 final_summary_tool을 호출하여 작업 요약과 다음 단계를 제시하세요. "
443
+ "final_summary_tool(summary='완료된 작업 요약', "
444
+ "next_items=[{'subject': '제목', 'description': '설명'}, ...]) "
445
+ "(next_items 3개 이상 필수). "
446
+ "텍스트로 JSON을 출력하지 말고, 반드시 도구 호출로 실행하세요."
447
+ )
448
+
449
+ new_messages = list(messages) + [HumanMessage(content=guidance)]
450
+ _dbg(f"injected guidance for tool={tool_name} status={todos_status}")
451
+ _dbg(f"guidance: {guidance[:200]}")
452
+ return request.override(messages=new_messages)
453
+
454
+ def _build_pending_preview(self, todos: list | None) -> str:
455
+ """pending 항목 미리보기 문자열 생성."""
456
+ if not todos:
457
+ return ""
458
+ pending_items = [
459
+ t
460
+ for t in todos
461
+ if (isinstance(t, dict) and t.get("status") in ("pending", "in_progress"))
462
+ or (hasattr(t, "status") and t.status in ("pending", "in_progress"))
463
+ ]
464
+ if not pending_items:
465
+ return ""
466
+ previews = []
467
+ for t in pending_items[:3]:
468
+ c = (
469
+ t.get("content", "")
470
+ if isinstance(t, dict)
471
+ else getattr(t, "content", "")
472
+ )
473
+ previews.append(c[:40])
474
+ return f" Pending: {', '.join(previews)}."
475
+
476
+ def _strip_write_todos_if_summary(self, response: Any) -> Any:
477
+ """summary JSON이 있는 응답에서 write_todos 호출 제거."""
478
+ ai_msg = _extract_ai_message(response)
479
+ if not ai_msg or not hasattr(ai_msg, "tool_calls") or not ai_msg.tool_calls:
480
+ return response
481
+
482
+ content = getattr(ai_msg, "content", "")
483
+ has_summary = isinstance(content, str) and (
484
+ ('"summary"' in content and '"next_items"' in content)
485
+ or ("'summary'" in content and "'next_items'" in content)
486
+ )
487
+
488
+ if not has_summary:
489
+ return response
490
+
491
+ non_todo = [tc for tc in ai_msg.tool_calls if tc.get("name") != "write_todos"]
492
+ if len(non_todo) < len(ai_msg.tool_calls):
493
+ ai_msg.tool_calls = non_todo
494
+ logger.debug("CONTINUATION: stripped write_todos from summary response")
495
+
496
+ return response
497
+
498
+
499
+ class LimitToolCallsMiddleware(AgentMiddleware):
500
+ """LLM 응답의 tool_calls를 1개로 제한 + additional_kwargs 정리.
501
+
502
+ HITL decision 수 불일치 방지:
503
+ - LLM이 2개 이상 tool_calls를 반환하면 첫 번째만 유지
504
+ - additional_kwargs['tool_calls'] 중복 제거
505
+ - tool_calls가 있으면 content(thinking text) 비우기
506
+ """
507
+
508
+ def awrap_model_call(
509
+ self,
510
+ request: ModelRequest,
511
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
512
+ ) -> Awaitable[ModelCallResult]:
513
+ return self._impl(request, handler)
514
+
515
+ async def _impl(
516
+ self,
517
+ request: ModelRequest,
518
+ handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
519
+ ) -> ModelCallResult:
520
+ response = await handler(request)
521
+
522
+ ai_msg = _extract_ai_message(response)
523
+ if not ai_msg:
524
+ return response
525
+
526
+ tool_calls = getattr(ai_msg, "tool_calls", None)
527
+ if not tool_calls:
528
+ return response
529
+
530
+ # tool_calls가 2개 이상이면 첫 번째만 유지
531
+ if len(tool_calls) > 1:
532
+ logger.debug(
533
+ "LIMIT_TOOL_CALLS: truncating %d -> 1 (kept: %s)",
534
+ len(tool_calls),
535
+ tool_calls[0].get("name", "unknown"),
536
+ )
537
+ ai_msg.tool_calls = [tool_calls[0]]
538
+
539
+ # additional_kwargs['tool_calls'] 중복 제거
540
+ additional_kwargs = getattr(ai_msg, "additional_kwargs", {})
541
+ if additional_kwargs.get("tool_calls"):
542
+ del additional_kwargs["tool_calls"]
543
+
544
+ # tool_calls 있으면 content 비우기 (thinking text 제거)
545
+ if ai_msg.tool_calls and ai_msg.content:
546
+ ai_msg.content = ""
547
+
548
+ return response