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,3339 @@
1
+ """centri 의 codex 서버사이드와 말하는 브릿지 (이슈 #88).
2
+
3
+ 코드버프는 codex app-server 의 JSON-RPC 를 **기존 agent SSE 계약**으로 번역한다.
4
+ 그 반대 방향(centri 가 코드버프 계약을 내게 하는 것)을 택하지 않은 이유는 외부에
5
+ 발행한 계약이 브랜치 안에서 되돌릴 수 없기 때문이다.
6
+
7
+ 도구는 두 갈래다.
8
+
9
+ - pod 실행분: 이 프로세스가 직접 돌리고 같은 JSON-RPC id 로 회신한다.
10
+ - 브라우저 실행분: pod 에 커널도 노트북 모델도 없으므로 `hitl_request` 로 스트림을
11
+ 끊고, 프론트가 실행한 결과를 `/agent/codex/resume` 으로 받아 회신한다.
12
+
13
+ 사이드패널은 터미널의 codex CLI 처럼 동작한다. 코드 실행·빌드는 `execute_command`(셸),
14
+ 수정은 물리 파일에 직접 한다. 노트북 도구는 코드 셀(`jupyter_cell`) 하나만 알린다 —
15
+ 셸 프로세스는 노트북 커널의 변수를 못 보므로, 그 변수를 쓰는 분석만 셀로 보낸다
16
+ (이슈 #155). 마크다운 셀 도구는 알리지 않는다.
17
+
18
+ `codexPermissionMode` 는 **pod 에서 돌릴 수 있는 변경 작업**에만 걸린다. 브라우저
19
+ 실행분은 권한과 무관하게 왕복해야 한다 — 그것이 유일한 실행 경로다. 다만 bypass 면
20
+ `hitl_request` 에 `autoApprove` 를 실어 프론트가 묻지 않고 바로 실행하게 한다.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import codecs
27
+ import contextlib
28
+ import copy
29
+ import difflib
30
+ import hashlib
31
+ import json
32
+ import logging
33
+ import os
34
+ import shlex
35
+ import time
36
+ from typing import (
37
+ Any,
38
+ AsyncIterator,
39
+ Callable,
40
+ Dict,
41
+ Iterator,
42
+ List,
43
+ Optional,
44
+ Set,
45
+ Tuple,
46
+ )
47
+
48
+ import httpx
49
+ from codebuff_core.managers.config_manager import prompt_value
50
+ from codebuff_core.platform_rules import platform_rules_block, tool_call_violation
51
+ from codebuff_core.prompts.auto_agent_prompts import NEXUS_URL_PATH, read_pip_index_url
52
+ from codebuff_core.services.pii_guardrail import mask_pii
53
+
54
+ from . import background_procs, codex_hooks, custom_skills
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+
59
+ def _mask(text: str) -> Tuple[str, List[str]]:
60
+ """pod 밖으로 나가는 본문에서 개인정보를 가린다.
61
+
62
+ LangChain chat 경로는 `chat_service` 가 입력을 가리고, LangChain Agent 경로는
63
+ `PiiMaskMiddleware` 가 모델 호출 직전에 가린다. codex 경로는 프롬프트와 파일 내용이 pod 밖의 서버로 나가므로 여기가
64
+ 마지막 관문이다. 도구 결과는 `_tool_result` 가 문맥 조건을 더한 모드로 가린다.
65
+ """
66
+ if not text:
67
+ return text, []
68
+ result = mask_pii(text)
69
+ return result.masked_text, list(result.detected_types)
70
+
71
+
72
+ def _mask_except(text: str, keep: str) -> str:
73
+ """`keep` 문자열은 그대로 두고 나머지를 `_mask` 로 가린다.
74
+
75
+ 포트 접두(`/user/<사번>/proxy/`)는 사용자 자신의 허브 경로다. 사번이 전화번호
76
+ 꼴이면 `mask_pii` 가 그것을 지워 codex 가 안 열리는 주소를 안내한다 (이슈 #136).
77
+ """
78
+ if not keep:
79
+ return _mask(text)[0]
80
+ return keep.join(_mask(part)[0] for part in text.split(keep))
81
+
82
+
83
+ # (agent_call_hst 컬럼, codex TokenUsageBreakdown 키) — codex 0.154.0 스키마
84
+ _USAGE_KEYS = (
85
+ ("input_tokens", "inputTokens"),
86
+ ("output_tokens", "outputTokens"),
87
+ ("cached_tokens", "cachedInputTokens"),
88
+ )
89
+
90
+
91
+ def _identity() -> Dict[str, str]:
92
+ """client 경로와 같은 사용자 식별 — jsonl 과 RDS 행이 같은 값을 갖게 한다."""
93
+ from jupyter_ext.agent.observability import default_user_identity
94
+
95
+ return default_user_identity()
96
+
97
+
98
+ def _default_recorder(event: Dict[str, Any]) -> None:
99
+ """관측 이벤트를 client 경로와 같은 적재기로 흘린다."""
100
+ from jupyter_ext.agent.observability import _write_structured_event
101
+
102
+ _write_structured_event(event)
103
+
104
+
105
+ # 프론트가 스트림 종료로 인정하는 이벤트 (ApiService 가 그 외에는 예외를 던진다)
106
+
107
+ # 브라우저에서만 실행되는 도구 — pod 에는 커널도 노트북 모델도 없다
108
+ BROWSER_TOOLS = {"jupyter_cell"}
109
+
110
+ # 승인을 받아야 하는 pod 실행분 (permission_mode == "ask" 일 때)
111
+ GUARDED_POD_TOOLS = {"write_file", "edit_file", "execute_command", "kill_process"}
112
+
113
+ # codex 알림의 도구 이름 → 프론트가 아는 action 이름
114
+ _ACTION_NAME_MAP = {
115
+ "jupyter_cell": "jupyter_cell_tool",
116
+ "write_file": "write_file_tool",
117
+ "edit_file": "edit_file_tool",
118
+ "execute_command": "execute_command_tool",
119
+ "kill_process": "kill_process_tool",
120
+ }
121
+
122
+ # codex 계획 단계 상태 → deepagents todo 상태
123
+ #: 본문을 버려야 하는 "도구를 부른 턴" 의 item 종류. dynamic tool 만 세던 것을
124
+ #: 운영 게이트웨이가 여는 codex 네이티브 도구까지 넓혔다 (이슈 #155)
125
+ _TOOL_ITEM_TYPES = frozenset(
126
+ {"dynamicToolCall", "mcpToolCall", "commandExecution", "webSearch"}
127
+ )
128
+
129
+ _PLAN_STATUS = {
130
+ "pending": "pending",
131
+ "inProgress": "in_progress",
132
+ "completed": "completed",
133
+ }
134
+ #: deepagents `write_todos` 의 상태 — 프론트 todo 패널이 아는 셋
135
+ _TODO_STATUSES = frozenset(_PLAN_STATUS.values())
136
+
137
+ # deepagents 의 승인 설명(`agent_factory.ds_tool_description`)에서 쓰는 도구 이름
138
+ _HITL_LANGCHAIN_NAME = {"write_file": "file_write"}
139
+
140
+
141
+ def _hitl_description(tool: str, args: Dict[str, Any]) -> str:
142
+ """승인 요청 설명 — deepagents 경로와 같은 꼴로 쓴다 (이슈 #155).
143
+
144
+ 카드는 args 로 그리지만, 이 문구가 대화 기록에 남는 승인 메시지 본문이다.
145
+ `test_codex_parity.py::TestHitlDescription` 이 두 꼴을 대조한다.
146
+ """
147
+ from .middleware_prompts import DS_HITL_DESCRIPTION_PREFIX
148
+
149
+ name = _HITL_LANGCHAIN_NAME.get(tool, tool)
150
+ if name == "jupyter_cell":
151
+ code = args.get("code", "")
152
+ lines = code.split("\n") if code else []
153
+ if len(lines) > 5:
154
+ preview = "\n".join(lines[:5])
155
+ return (
156
+ f"{DS_HITL_DESCRIPTION_PREFIX}\n\n"
157
+ f"**Jupyter Cell Execution** ({len(lines)} lines):\n"
158
+ f"```python\n{preview}\n# ... {len(lines) - 5} more lines\n```"
159
+ )
160
+ return (
161
+ f"{DS_HITL_DESCRIPTION_PREFIX}\n\n"
162
+ f"**Jupyter Cell Execution:**\n```python\n{code}\n```"
163
+ )
164
+ if name == "file_write":
165
+ path = args.get("path", "unknown")
166
+ size = len(args.get("content", ""))
167
+ return (
168
+ f"{DS_HITL_DESCRIPTION_PREFIX}\n\n**File Write:** `{path}` ({size} bytes)"
169
+ )
170
+ return (
171
+ f"{DS_HITL_DESCRIPTION_PREFIX}\n\n"
172
+ f"**Tool:** {tool}\n"
173
+ f"**Args:** {json.dumps(args, ensure_ascii=False, indent=2)}"
174
+ )
175
+
176
+
177
+ # 샌드박스는 **두 모드 다 `read-only`** 다. codex 는 centri 쪽에서 도는데 그 쪽
178
+ # 파일시스템은 사용자의 작업공간이 아니다 — 쓰기를 열어 주면 네이티브
179
+ # `apply_patch` 가 dynamic tool 을 제치고 이겨서, 사용자 pod 이 아니라 centri
180
+ # 서버의 파일이 바뀐다 (이슈 #96 실측). 읽기는 막지 않는다: codex 가 자기 쪽
181
+ # 임시 파일을 읽는 것까지 막을 이유는 없고, pod 파일은 어차피 도구로만 닿는다.
182
+ _PERMISSION_POLICY = {
183
+ "ask": {"approvalPolicy": "on-request", "sandbox": "read-only"},
184
+ "bypass": {"approvalPolicy": "never", "sandbox": "read-only"},
185
+ }
186
+
187
+ # Chat/Q&A는 코드 작업 에이전트가 아니다. `dynamicTools`만 줄여서는 codex 내장
188
+ # shell·web·browser·computer·app·subagent가 그대로 남으므로 스레드 config에서도 끈다.
189
+ # feature 이름은 `codex features list`(0.157.0) 실측이다 — config 는 스키마상 열린
190
+ # 객체라 오타 키도 서버가 받으므로, 이름은 `test_codex_live` 가 실물 목록과 대조한다.
191
+ # 기본값이 켜진 것 중 모델에게 도구로 노출되는 것만 끈다(hooks·goals 등은 그대로).
192
+ # 빈 MCP/plugin 표는 이 통합이 별도 서버를 싣지 않는다는 선언이다.
193
+ _SEARCH_ONLY_FEATURES_OFF = (
194
+ "shell_tool",
195
+ "unified_exec",
196
+ "view_image",
197
+ "image_generation",
198
+ "browser_use",
199
+ "browser_use_external",
200
+ "computer_use",
201
+ "in_app_browser",
202
+ "multi_agent",
203
+ "apps",
204
+ "plugins",
205
+ "remote_plugin",
206
+ "skill_search",
207
+ "tool_suggest",
208
+ "sleep_tool",
209
+ )
210
+ #: 답 없이 끝난 Agent 밖 턴에 한 번 더 보내는 요청 (LangChain `_fallback_llm_call` 대응)
211
+ EMPTY_ANSWER_FOLLOW_UP = (
212
+ "직전 턴이 최종 답변 없이 끝났습니다. 도구를 더 부르지 말고, 지금까지의 대화와 "
213
+ "도구 결과만으로 사용자 요청에 대한 최종 답변을 작성하세요."
214
+ )
215
+ #: Agent 가 아무것도 바꾸지 않고 답 없이 끝났을 때 보내는 요청. Agent 는 도구로 일을 마쳐야
216
+ #: 하므로 "도구를 부르지 말라" 는 위 문구를 쓰지 않는다 (이슈 #176)
217
+ AGENT_EMPTY_ANSWER_FOLLOW_UP = (
218
+ "직전 턴이 최종 답변 없이 끝났습니다. 요청한 작업이 끝나지 않았으면 도구로 이어서 "
219
+ "끝내고, 끝났으면 무엇을 바꾸고 실행했는지 짧게 요약하세요."
220
+ )
221
+ #: 워크스페이스를 바꾸는 도구. 이 중 하나라도 부른 Agent 턴은 답이 비어도 일을 한 것이다
222
+ _CHANGING_TOOLS = frozenset(
223
+ {"write_file", "edit_file", "execute_command", "jupyter_cell", "kill_process"}
224
+ )
225
+
226
+ #: codex base 지시문은 코드 상수가 아니다 (이슈 #218). Agent 밖 스레드는 짧은 정체성 문구
227
+ #: `prompts.codex_base_instructions`(이슈 #155·#176·#178), Agent 스레드는 셸이 pod 도구뿐이라고
228
+ #: 못 박는 `prompts.codex_agent_base_instructions`(이슈 #176)다 — codex 기본 base 는 read-only
229
+ #: 샌드박스의 네이티브 셸을 쓰라고 가르쳐 gpt-oss 가 execute_command 까지 거절했다.
230
+ #: Agent 스레드에서 끄는 codex 네이티브 셸. 켜 두면 모델이 read-only 샌드박스의 셸을 먼저
231
+ #: 집어 거절당한다. centri 게이트웨이는 false 값만 통과시킨다 (centri #1774)
232
+ _AGENT_NATIVE_SHELL_OFF = {"shell_tool": False, "unified_exec": False}
233
+
234
+ _SEARCH_ONLY_THREAD_CONFIG = {
235
+ "web_search": "disabled",
236
+ "features": {name: False for name in _SEARCH_ONLY_FEATURES_OFF},
237
+ "mcp_servers": {},
238
+ "plugins": {},
239
+ }
240
+
241
+ # 서버가 보내는 승인 요청 중 우리가 답하는 것. 승인 흐름은 dynamic tool 쪽
242
+ # `hitl_request` 가 이미 담당하므로, 네이티브 쓰기 요청은 여기서 거절한다.
243
+ # 회신 모양이 메서드마다 다르다 — `decision` 을 쓰는 것만 여기 둔다
244
+ # (`item/permissions/requestApproval` 은 `permissions` 를 요구해 모양이 다르다).
245
+ _DECLINE_RESPONSE = {
246
+ "item/commandExecution/requestApproval": {"decision": "decline"},
247
+ "item/fileChange/requestApproval": {"decision": "decline"},
248
+ "applyPatchApproval": {"decision": "abort"},
249
+ "execCommandApproval": {"decision": "abort"},
250
+ }
251
+
252
+ # 진행 줄은 터미널처럼 한 줄씩 쌓인다 — 너무 긴 인자·요약은 자른다.
253
+ _PROGRESS_MAX = 160
254
+ # 도구 인자 중 "무엇을 하는지" 가 드러나는 키. 앞에 있는 것이 이긴다.
255
+ _ARG_HINT_KEYS = (
256
+ "path",
257
+ "file_path",
258
+ "command",
259
+ "query",
260
+ "pattern",
261
+ "url",
262
+ "process_id",
263
+ )
264
+
265
+
266
+ def _one_line(text: Any) -> str:
267
+ flat = " ".join(str(text).replace("**", "").split())
268
+ return flat if len(flat) <= _PROGRESS_MAX else flat[: _PROGRESS_MAX - 1] + "…"
269
+
270
+
271
+ def _describe_started(item: Dict[str, Any]) -> Optional[str]:
272
+ """`item/started` 를 진행 줄 하나로. 원시 타입명은 내보내지 않는다."""
273
+ kind = item.get("type")
274
+ if kind == "commandExecution":
275
+ return _one_line(f"$ {item.get('command') or ''}")
276
+ if kind in ("dynamicToolCall", "mcpToolCall"):
277
+ name = item.get("tool") or "?"
278
+ args = item.get("arguments")
279
+ if name == "write_todos":
280
+ # LangChain `agent_factory` 의 진행 문구와 같다
281
+ return "작업 계획 수립 중..."
282
+ if name == "execute_command" and isinstance(args, dict):
283
+ # 터미널의 프롬프트처럼 보인다 — 명령 자체가 진행 줄이다
284
+ suffix = " &" if args.get("background") else ""
285
+ return _one_line(f"$ {args.get('command') or ''}{suffix}")
286
+ if kind == "mcpToolCall" and item.get("server"):
287
+ name = f"{item['server']}.{name}"
288
+ hint = next(
289
+ (args[k] for k in _ARG_HINT_KEYS if isinstance(args, dict) and args.get(k)),
290
+ "",
291
+ )
292
+ return _one_line(f"도구 {name} {hint}".rstrip())
293
+ if kind == "fileChange":
294
+ paths = [
295
+ c.get("path", "") for c in item.get("changes") or [] if isinstance(c, dict)
296
+ ]
297
+ return _one_line(f"파일 변경 {', '.join(p for p in paths if p)}".rstrip())
298
+ if kind == "webSearch":
299
+ return _one_line(f"웹 검색 {item.get('query') or ''}".rstrip())
300
+ if kind == "contextCompaction":
301
+ return "컨텍스트 압축"
302
+ if kind == "collabAgentToolCall":
303
+ prompt = item.get("prompt") or ""
304
+ return _one_line(f"서브에이전트 {_collab_label(item)} {prompt}".rstrip())
305
+ return None
306
+
307
+
308
+ # 서브에이전트 도구·상태를 진행 줄 낱말로 (codex 0.154.0 `CollabAgentTool` 등).
309
+ # 모르는 값은 원문 대신 뭉뚱그린 낱말로 — 원시 enum 을 화면에 내지 않는다.
310
+ _COLLAB_TOOL_LABEL = {
311
+ "spawnAgent": "시작",
312
+ "sendInput": "입력 전달",
313
+ "resumeAgent": "재개",
314
+ "wait": "대기",
315
+ "closeAgent": "종료",
316
+ "sendMessage": "메시지",
317
+ "followupTask": "후속 작업",
318
+ "interruptAgent": "중단",
319
+ "listAgents": "목록",
320
+ }
321
+ _COLLAB_STATE_LABEL = {
322
+ "pendingInit": "준비",
323
+ "running": "실행 중",
324
+ "interrupted": "중단",
325
+ "completed": "완료",
326
+ "errored": "오류",
327
+ "shutdown": "종료",
328
+ "notFound": "없음",
329
+ }
330
+ _SUBAGENT_ACTIVITY_LABEL = {
331
+ "started": "시작",
332
+ "interacted": "응답",
333
+ "interrupted": "중단",
334
+ "completed": "완료",
335
+ }
336
+
337
+
338
+ def _collab_label(item: Dict[str, Any]) -> str:
339
+ return _COLLAB_TOOL_LABEL.get(str(item.get("tool") or ""), "작업")
340
+
341
+
342
+ def _collab_states(item: Dict[str, Any]) -> str:
343
+ """`agentsStates` 를 "완료 2 · 실행 중 1" 처럼 센다."""
344
+ counts: Dict[str, int] = {}
345
+ states = item.get("agentsStates")
346
+ for state in states.values() if isinstance(states, dict) else []:
347
+ if not isinstance(state, dict):
348
+ continue
349
+ label = _COLLAB_STATE_LABEL.get(str(state.get("status") or ""), "기타")
350
+ counts[label] = counts.get(label, 0) + 1
351
+ return " · ".join(f"{label} {n}" for label, n in counts.items())
352
+
353
+
354
+ def _describe_completed(item: Dict[str, Any]) -> Optional[str]:
355
+ """`item/completed` 중 결과가 궁금한 것만 한 줄로."""
356
+ kind = item.get("type")
357
+ if kind == "reasoning":
358
+ summary = " ".join(str(s) for s in item.get("summary") or [] if s)
359
+ return _one_line(f"생각: {summary}") if summary.strip() else None
360
+ if kind == "commandExecution":
361
+ code = item.get("exitCode")
362
+ if code not in (None, 0):
363
+ return f"↳ 종료 코드 {code}"
364
+ if kind in ("dynamicToolCall", "mcpToolCall") and (
365
+ item.get("success") is False or item.get("error")
366
+ ):
367
+ return f"↳ {item.get('tool') or '도구'} 실패"
368
+ if kind == "collabAgentToolCall":
369
+ if item.get("status") == "failed":
370
+ return f"↳ 서브에이전트 {_collab_label(item)} 실패"
371
+ states = _collab_states(item)
372
+ return f"↳ 서브에이전트 {states}" if states else None
373
+ if kind == "subAgentActivity":
374
+ label = _SUBAGENT_ACTIVITY_LABEL.get(str(item.get("kind") or ""), "활동")
375
+ path = item.get("agentPath") or ""
376
+ return _one_line(f"서브에이전트 {path} {label}".replace(" ", " "))
377
+ return None
378
+
379
+
380
+ # Agent 모드 developer 지시문은 `prompts.codex_agent_instructions`(도구·응답 규칙) 뒤에
381
+ # 두 엔진 공용 `prompts.todo_rules`(write_todos 규칙)를 붙인 것이다 (이슈 #155, #218).
382
+ # write_todos 는 deepagents 와 같은 이름의 dynamic 도구다 — 계획은 패널의 todo 목록으로
383
+ # 보인다. codex 네이티브 update_plan 은 모델에 따라 안 보인다(gpt-5.6-sol·0.157.0 실측).
384
+
385
+
386
+ # 셸 명령 한도. 기본 한도는 **출력이 없는 시간**이다 — 출력이 흐르는 동안은
387
+ # 살아 있는 것이므로 기다린다. 무한 루프를 막는 절대 한도는 따로 둔다.
388
+ _COMMAND_IDLE_TIMEOUT = 120
389
+ _COMMAND_HARD_LIMIT = 1800
390
+ # background 로그 읽기 한도 — 모델에 알리는 값이 실제 동작과 갈리지 않게 한 곳에서 가져온다
391
+ _BG_TAIL_DEFAULT = background_procs._TAIL_DEFAULT
392
+ _BG_TAIL_MAX = background_procs._TAIL_MAX
393
+ _BG_TAIL_READ_KB = background_procs._TAIL_READ_BYTES // 1024
394
+ # 모델에 돌려주는 명령 출력의 상한 (codex CLI 와 같은 앞뒤 보존 방식)
395
+ _OUTPUT_HEAD_LINES = 100
396
+ _OUTPUT_TAIL_LINES = 150
397
+ _OUTPUT_MAX_CHARS = 16000
398
+ # read_file 이 모델에 돌려주는 내용의 상한. `package-lock.json` 은 수백 KB 다 (이슈 #100)
399
+ _READ_FILE_MAX_CHARS = 64000
400
+ # 셸의 pip 이 client 경로(`PIP_INDEX_OPTION`)와 같은 사내 인덱스를 보게 한다 (이슈 #100)
401
+ _PIP_INDEX_FILE = NEXUS_URL_PATH
402
+ # 패널에 보여 주는 파일 변경 diff 의 상한
403
+ _DIFF_MAX_LINES = 80
404
+
405
+ # dynamic tool 카탈로그. 설명은 언어 중립으로 쓴다 — 코드버프는 파이썬과 자바를
406
+ # 가리지 않는다. 노트북 도구는 코드 셀(`jupyter_cell`)만 싣는다 (모듈 설명 참고).
407
+ CODEX_DYNAMIC_TOOLS: List[Dict[str, Any]] = [
408
+ {
409
+ "type": "function",
410
+ "name": "read_file",
411
+ "description": "작업공간의 파일 하나를 텍스트로 읽는다.",
412
+ "inputSchema": {
413
+ "type": "object",
414
+ "properties": {
415
+ "path": {"type": "string", "description": "작업공간 기준 상대 경로"},
416
+ "encoding": {"type": "string", "default": "utf-8"},
417
+ },
418
+ "required": ["path"],
419
+ },
420
+ },
421
+ {
422
+ "type": "function",
423
+ "name": "write_file",
424
+ "description": (
425
+ "작업공간의 파일에 내용을 쓴다. 있는 파일은 덮어쓰고, append 를 켜면 뒤에 "
426
+ "붙인다. 확장자에 맞는 포매터가 있으면 함께 돌린다."
427
+ ),
428
+ "inputSchema": {
429
+ "type": "object",
430
+ "properties": {
431
+ "path": {"type": "string"},
432
+ "content": {"type": "string"},
433
+ "append": {"type": "boolean", "default": False},
434
+ },
435
+ "required": ["path", "content"],
436
+ },
437
+ },
438
+ {
439
+ "type": "function",
440
+ "name": "edit_file",
441
+ "description": (
442
+ "작업공간의 파일에서 old_string 을 new_string 으로 바꿔 필요한 부분만 고친다. "
443
+ "old_string 은 파일에서 한 곳만 일치해야 하며, 여러 곳을 모두 바꾸려면 "
444
+ "replace_all 을 켠다."
445
+ ),
446
+ "inputSchema": {
447
+ "type": "object",
448
+ "properties": {
449
+ "path": {"type": "string", "description": "작업공간 기준 상대 경로"},
450
+ "old_string": {"type": "string"},
451
+ "new_string": {"type": "string"},
452
+ "replace_all": {"type": "boolean", "default": False},
453
+ },
454
+ "required": ["path", "old_string", "new_string"],
455
+ },
456
+ },
457
+ {
458
+ "type": "function",
459
+ "name": "list_files",
460
+ "description": "작업공간의 디렉토리 목록을 조회한다.",
461
+ "inputSchema": {
462
+ "type": "object",
463
+ "properties": {
464
+ "path": {"type": "string", "default": "."},
465
+ "recursive": {"type": "boolean", "default": False},
466
+ "pattern": {"type": "string"},
467
+ },
468
+ },
469
+ },
470
+ {
471
+ "type": "function",
472
+ "name": "search_code",
473
+ "description": "작업공간에서 정규식으로 코드를 검색한다. 언어를 가리지 않는다.",
474
+ "inputSchema": {
475
+ "type": "object",
476
+ "properties": {
477
+ "pattern": {"type": "string"},
478
+ "path": {"type": "string", "default": "."},
479
+ "file_pattern": {"type": "string", "default": "*"},
480
+ "max_results": {"type": "integer", "default": 50},
481
+ },
482
+ "required": ["pattern"],
483
+ },
484
+ },
485
+ {
486
+ "type": "function",
487
+ "name": "execute_command",
488
+ "description": (
489
+ "작업공간 루트에서 셸 명령을 실행하고 출력과 종료 코드를 돌려준다. "
490
+ "코드 실행·빌드·테스트·포매터는 이 도구로 돌린다 "
491
+ "(예: python app.py, ./mvnw package, gradle test, javac, pytest, npm run build). "
492
+ "셸 스크립트는 bash build.sh 처럼 돌리고, 하위 디렉토리에서는 cd sub && … 로 "
493
+ "한 명령에 묶는다 — 호출마다 루트에서 새 셸이 뜬다. 입력을 기다리는 대화형 "
494
+ "명령은 쓰지 않는다(stdin 은 닫혀 있다). "
495
+ f"timeout 은 출력이 없는 시간의 한도(초, 최대 {_COMMAND_HARD_LIMIT})라서 "
496
+ "출력이 이어지는 긴 빌드는 끊기지 않는다. 긴 출력은 앞뒤만 남기고 잘린다. "
497
+ "개발 서버처럼 끝나지 않는 명령은 background 를 켠다 — 바로 process_id 를 "
498
+ "돌려주고 출력은 로그에 쌓인다. 사용자는 pod 밖에 있어 localhost 로는 못 연다 — "
499
+ "지시문에 '## pod 포트 열기' 가 있으면 그 주소 규칙으로 안내하고 앱을 맞춘다. "
500
+ "서버는 0.0.0.0 이 아니라 127.0.0.1 로 띄워도 된다. background 명령에 "
501
+ "&·nohup·setsid·--daemon 을 붙이지 않는다 — 그룹을 벗어난 프로세스는 목록과 "
502
+ "종료에서 빠진다."
503
+ ),
504
+ "inputSchema": {
505
+ "type": "object",
506
+ "properties": {
507
+ "command": {"type": "string"},
508
+ "timeout": {"type": "integer", "default": _COMMAND_IDLE_TIMEOUT},
509
+ "background": {"type": "boolean", "default": False},
510
+ },
511
+ "required": ["command"],
512
+ },
513
+ },
514
+ # 브라우저 실행분 — 인자는 LangChain `jupyter_cell` 도구와 같다 (이슈 #155)
515
+ {
516
+ "type": "function",
517
+ "name": "jupyter_cell",
518
+ "description": (
519
+ "열려 있는 노트북 끝에 코드 셀을 넣고 그 노트북의 커널에서 실행한 뒤 출력을 "
520
+ "돌려준다. 커널에 이미 있는 변수(데이터프레임 등)를 쓰는 분석·시각화에 쓴다. "
521
+ "커널은 python3 하나뿐이므로 빌드·테스트·스크립트와 다른 언어의 코드는 "
522
+ "execute_command 로 돌린다. 열린 노트북이 없으면 커널 대신 별도 프로세스에서 "
523
+ "돌아 커널 변수가 없다(NameError) — 그때는 사용자에게 노트북을 열어 달라고 한다."
524
+ ),
525
+ "inputSchema": {
526
+ "type": "object",
527
+ "properties": {
528
+ "code": {"type": "string", "description": "셀에 넣어 실행할 코드"},
529
+ "description": {
530
+ "type": "string",
531
+ "description": "셀이 하는 일 한 줄 (승인 카드에 보인다)",
532
+ },
533
+ },
534
+ "required": ["code"],
535
+ },
536
+ },
537
+ {
538
+ "type": "function",
539
+ "name": "list_processes",
540
+ "description": (
541
+ "background 로 띄운 프로세스 목록을 돌려준다 "
542
+ "(process_id·pid·명령·상태·종료 코드·LISTEN 포트)."
543
+ ),
544
+ "inputSchema": {"type": "object", "properties": {}},
545
+ },
546
+ {
547
+ "type": "function",
548
+ "name": "read_process_output",
549
+ "description": (
550
+ "background 프로세스의 로그 끝부분을 읽는다. tail 은 줄 수이고 최대 "
551
+ f"{_BG_TAIL_MAX}줄, 로그 마지막 {_BG_TAIL_READ_KB}KB 안에서만 센다."
552
+ ),
553
+ "inputSchema": {
554
+ "type": "object",
555
+ "properties": {
556
+ "process_id": {"type": "string"},
557
+ "tail": {"type": "integer", "default": _BG_TAIL_DEFAULT},
558
+ },
559
+ "required": ["process_id"],
560
+ },
561
+ },
562
+ {
563
+ "type": "function",
564
+ "name": "kill_process",
565
+ "description": "background 프로세스를 자식까지 함께 종료한다.",
566
+ "inputSchema": {
567
+ "type": "object",
568
+ "properties": {"process_id": {"type": "string"}},
569
+ "required": ["process_id"],
570
+ },
571
+ },
572
+ {
573
+ "type": "function",
574
+ "name": "load_skill",
575
+ "description": (
576
+ "developer 지시문의 Custom Skills·Built-in Skills 목록에 있는 스킬 하나의 "
577
+ "본문을 읽는다. 작업이 스킬 설명과 맞으면 먼저 읽고 그 절차를 따른다."
578
+ ),
579
+ "inputSchema": {
580
+ "type": "object",
581
+ "properties": {
582
+ "name": {"type": "string", "description": "스킬 이름"},
583
+ },
584
+ "required": ["name"],
585
+ },
586
+ },
587
+ {
588
+ "type": "function",
589
+ "name": "write_todos",
590
+ "description": (
591
+ "여러 단계인 작업의 계획을 패널의 todo 목록으로 보여 준다. 호출할 때마다 "
592
+ "목록 전체를 보낸다. 상태는 pending·in_progress·completed 중 하나다."
593
+ ),
594
+ "inputSchema": {
595
+ "type": "object",
596
+ "properties": {
597
+ "todos": {
598
+ "type": "array",
599
+ "items": {
600
+ "type": "object",
601
+ "properties": {
602
+ "content": {"type": "string"},
603
+ "status": {
604
+ "type": "string",
605
+ "enum": ["pending", "in_progress", "completed"],
606
+ },
607
+ },
608
+ "required": ["content", "status"],
609
+ },
610
+ },
611
+ },
612
+ "required": ["todos"],
613
+ },
614
+ },
615
+ ]
616
+
617
+ CODEX_QA_TOOL_NAMES = frozenset(
618
+ {
619
+ "rag_search",
620
+ "get_table_columns",
621
+ "get_iceberg_snapshots",
622
+ "search_l2_table",
623
+ "load_skills",
624
+ }
625
+ )
626
+
627
+
628
+ def _langchain_tool_spec(tool: Any) -> Dict[str, Any]:
629
+ """LangChain BaseTool을 codex app-server dynamic tool 스키마로 바꾼다."""
630
+ schema = tool.args_schema.model_json_schema() if tool.args_schema else {}
631
+ return {
632
+ "type": "function",
633
+ "name": tool.name,
634
+ "description": tool.description or "",
635
+ "inputSchema": schema or {"type": "object", "properties": {}},
636
+ }
637
+
638
+
639
+ def _qa_tools(workspace_root: Optional[str] = None) -> Dict[str, Any]:
640
+ """기존 Q&A 도구 구현을 Codex 호출기에서도 그대로 쓴다."""
641
+ from .langchain_tools import (
642
+ create_enhanced_rag_search_tool,
643
+ create_get_table_columns_tool,
644
+ create_iceberg_snapshots_tool,
645
+ create_load_skills_tool,
646
+ create_search_l2_table_tool,
647
+ )
648
+
649
+ tools = [
650
+ create_enhanced_rag_search_tool(),
651
+ create_get_table_columns_tool(),
652
+ create_iceberg_snapshots_tool(),
653
+ create_search_l2_table_tool(),
654
+ create_load_skills_tool(workspace_root),
655
+ ]
656
+ return {tool.name: tool for tool in tools}
657
+
658
+
659
+ # 응답 서식 규칙(`prompts.answer_format_rules`)은 이슈 #155 에서 codex 에만 붙였는데, 이슈 #218
660
+ # 에서 Chat·BDP Q&A 공용 빌더(`build_chat_mode_system_prompt`)로 옮겨 두 엔진이 같이 쓴다.
661
+
662
+
663
+ # Q&A 계열(BDP Q&A·노트북·EDA) 한 턴의 도구 호출 상한. LangChain `stream_response` 의
664
+ # `_MAX_TOOL_CALLS` 와 같은 값이다
665
+ QA_MAX_TOOL_CALLS = 30
666
+
667
+
668
+ def _mode_instructions(mode: str) -> str:
669
+ if mode == "agent":
670
+ return f"{prompt_value('codex_agent_instructions')}\n\n{prompt_value('todo_rules')}"
671
+ from codebuff_core.services.rag_chat_agent import build_chat_mode_system_prompt
672
+
673
+ chat_mode = "knowledge" if mode == "qa" else "simple"
674
+ return build_chat_mode_system_prompt(chat_mode)
675
+
676
+
677
+ # 도구별로 핸들러가 받는 인자. 카탈로그 `properties` 는 이것의 부분집합이어야 한다 —
678
+ # 알려 놓고 여기서 버리면 모델의 기대와 실제 동작이 조용히 갈린다 (이슈 #100).
679
+ POD_TOOL_ARGS: Dict[str, set] = {
680
+ "read_file": {"path", "encoding"},
681
+ "write_file": {"path", "content", "append"},
682
+ "edit_file": {"path", "old_string", "new_string", "replace_all"},
683
+ "list_files": {"path", "recursive", "pattern"},
684
+ "search_code": {"pattern", "path", "file_pattern", "max_results"},
685
+ "execute_command": {"command", "timeout", "background"},
686
+ "list_processes": set(),
687
+ "read_process_output": {"process_id", "tail"},
688
+ "kill_process": {"process_id"},
689
+ "load_skill": {"name"},
690
+ "write_todos": {"todos"},
691
+ }
692
+
693
+
694
+ class CodexError(Exception):
695
+ """codex 쪽이 돌려준 오류."""
696
+
697
+
698
+ class CodexRejected(CodexError):
699
+ """codex 가 요청 하나를 JSON-RPC 오류로 거부했다 — 연결과 스레드는 멀쩡하다.
700
+
701
+ 슬래시 명령(이슈 #121)은 이것을 받으면 문구만 내고 세션을 살려 둔다. 배포에
702
+ 없는 메서드(`-32601`)는 그 명령만 못 쓰는 것이지 대화가 끊긴 것이 아니다.
703
+ """
704
+
705
+
706
+ class CodexUnavailable(CodexError):
707
+ """codex 서버에 닿지 못했다."""
708
+
709
+
710
+ class CodexGatewayRejected(CodexUnavailable):
711
+ """centri 게이트웨이가 연결을 accept 전에 거절했다 (이슈 #158, #167).
712
+
713
+ 구 게이트웨이는 동시 상한 초과·키 불일치·사이드카 미설정을 모두 accept 전
714
+ `websocket.close` 로 거절하고, uvicorn 이 그것을 close code 없는 403 으로
715
+ 내보낸다 — 그래서 원인은 셋 중 무엇인지 모른다. centri#1768 이후에는 상태
716
+ 코드와 JSON `reason` 본문으로 오고, 포화만 `CodexGatewaySaturated` 로 가른다.
717
+ 서버에 못 닿은 것과는 가른다: turn status 가 `error:CodexGatewayRejected` 로
718
+ 따로 적재된다. `CodexRejected`(JSON-RPC 요청 하나 거부, 세션 유지)와는 뜻이
719
+ 반대다.
720
+
721
+ `reason` 은 centri 가 본문에 실은 거절 종류다(모르면 None). 오류 이벤트의
722
+ `gatewayReason` 으로 화면까지 가서 안내 문구를 가른다 (#183).
723
+ """
724
+
725
+ def __init__(self, message: str, reason: Optional[str] = None) -> None:
726
+ super().__init__(message)
727
+ self.reason = reason
728
+
729
+
730
+ class CodexGatewaySaturated(CodexGatewayRejected):
731
+ """게이트웨이 동시 사용 한도에 걸렸다 — 503 + `reason: saturated` (이슈 #167).
732
+
733
+ 설정 오류와 달리 기다리면 풀리는 거절이라 문구가 다르고, turn status 도
734
+ `error:CodexGatewaySaturated` 로 따로 센다.
735
+ """
736
+
737
+
738
+ # 설정 문제 거절의 원인 문구 — 포화(`saturated`)는 따로 다룬다. 프론트
739
+ # `utils/chatError.ts` 의 CONFIG_REJECTIONS 가 같은 키를 본다(가드: test_codex_gateway_rejection).
740
+ _GATEWAY_CONFIG_CAUSES = {
741
+ "unauthorized": "API 키가 게이트웨이와 맞지 않습니다",
742
+ "sidecar_missing": "게이트웨이에 에이전트 실행 서버가 설정되지 않았습니다",
743
+ "sidecar_unreachable": "게이트웨이가 에이전트 실행 서버에 닿지 못했습니다",
744
+ }
745
+
746
+
747
+ # centri 게이트웨이가 accept 전 거절 본문에 싣는 `reason` (centri#1768).
748
+ _GATEWAY_REJECT_REASONS = frozenset({"saturated", *_GATEWAY_CONFIG_CAUSES})
749
+
750
+
751
+ def _gateway_reject_body(response: Any) -> Dict[str, Any]:
752
+ """거절 응답의 JSON 본문 — 본문이 없거나 JSON 객체가 아니면 빈 dict."""
753
+ try:
754
+ body = json.loads(getattr(response, "body", None) or b"")
755
+ except (TypeError, ValueError):
756
+ return {}
757
+ return body if isinstance(body, dict) else {}
758
+
759
+
760
+ def _gateway_rejection(code: int, response: Any) -> Optional[CodexGatewayRejected]:
761
+ """HTTP 거절 응답을 게이트웨이 거절로 읽는다. 게이트웨이 것이 아니면 None.
762
+
763
+ 403 은 구 게이트웨이(빈 본문)도 내므로 본문 없이도 거절이다. 503 은 인그레스가
764
+ 본문을 오류 페이지로 바꿀 수 있어 본문 없이도 거절로 읽는다. 그 밖의 상태
765
+ 코드는 centri `reason` 이 있을 때만 게이트웨이 것이다.
766
+ """
767
+ body = _gateway_reject_body(response)
768
+ reason = body.get("reason")
769
+ if not isinstance(reason, str) or reason not in _GATEWAY_REJECT_REASONS:
770
+ reason = None
771
+ if reason == "saturated":
772
+ limit = body.get("max_concurrency")
773
+ cap = f"({limit}명)" if isinstance(limit, int) else ""
774
+ return CodexGatewaySaturated(
775
+ f"codex 게이트웨이의 동시 사용 한도{cap}에 걸렸습니다(HTTP {code}). "
776
+ "다른 사용자의 작업이 끝나면 풀리니 잠시 후 다시 시도하세요.",
777
+ reason="saturated",
778
+ )
779
+ if reason is None and code not in (403, 503):
780
+ return None
781
+ if reason is not None:
782
+ # 설정 문제는 기다려도 안 풀린다. 확인할 곳은 프론트가 `gatewayReason` 으로
783
+ # 안내하므로 여기서는 원인만 적는다 — 두 층이 같은 안내를 겹쳐 쓰지 않는다 (#183)
784
+ return CodexGatewayRejected(
785
+ f"에이전트 게이트웨이가 연결을 거절했습니다(HTTP {code}, {reason}) — "
786
+ f"{_GATEWAY_CONFIG_CAUSES[reason]}. 계속되면 관리자에게 문의하세요.",
787
+ reason=reason,
788
+ )
789
+ return CodexGatewayRejected(
790
+ f"codex 게이트웨이가 연결을 거절했습니다(HTTP {code}"
791
+ + ") — 동시 사용 한도 초과이거나 API 키·사이드카 설정 문제입니다. "
792
+ "잠시 후 다시 시도하고, 계속되면 관리자에게 문의하세요."
793
+ )
794
+
795
+
796
+ class Transport:
797
+ """JSON-RPC 한 쌍의 메시지 통로. 테스트는 이 자리를 스텁으로 바꾼다."""
798
+
799
+ async def connect(self) -> None: # pragma: no cover - 인터페이스
800
+ raise NotImplementedError
801
+
802
+ async def send(self, message: Dict[str, Any]) -> None: # pragma: no cover
803
+ raise NotImplementedError
804
+
805
+ async def receive(self) -> Optional[Dict[str, Any]]: # pragma: no cover
806
+ raise NotImplementedError
807
+
808
+ async def close(self) -> None: # pragma: no cover
809
+ raise NotImplementedError
810
+
811
+
812
+ # `codebuff.agent_call_hst.status` 컬럼 폭 (schemas/rds/codebuff_mysql.sql)
813
+ _STATUS_MAX = 64
814
+
815
+ #: 핸드셰이크 요청 하나가 답을 기다리는 상한(초). LLM 이 돌기 전 단계라
816
+ #: 왕복이 짧다 — 실측(0.154.0, 로컬) `initialize`·`thread/start` 각 0.1초 미만.
817
+ _HANDSHAKE_TIMEOUT = 30.0
818
+
819
+
820
+ class StubTransport(Transport):
821
+ """스크립트된 서버 메시지를 순서대로 돌려주는 테스트용 통로."""
822
+
823
+ def __init__(
824
+ self,
825
+ scripted: List[Dict[str, Any]],
826
+ *,
827
+ fail_on_connect: Optional[Exception] = None,
828
+ reject: Optional[Dict[str, Dict[str, Any]]] = None,
829
+ ) -> None:
830
+ self.scripted = list(scripted)
831
+ self.sent: List[Dict[str, Any]] = []
832
+ self.closed = False
833
+ self._fail_on_connect = fail_on_connect
834
+ # 메서드 → JSON-RPC error 객체. 배포에 없는 메서드(-32601)를 흉내 낸다 (이슈 #121)
835
+ self._reject = dict(reject or {})
836
+ self._forks = 0
837
+
838
+ async def connect(self) -> None:
839
+ if self._fail_on_connect is not None:
840
+ raise self._fail_on_connect
841
+
842
+ #: 요청 메서드별 자동 응답. **빈 결과를 돌려주지 않는다** — 핸드셰이크가
843
+ #: `thread/start` 응답에서 thread id 를 꺼내는데, 빈 결과로 답하면 테스트가
844
+ #: 실물과 다른 경로를 타고 그 결함을 못 잡는다 (이슈 #96).
845
+ #:
846
+ #: 브릿지가 읽는 것은 `thread.id`·`turn.id` 뿐이지만 **스냅샷이 요구하는
847
+ #: 필수 필드를 다 채운다.** 손으로 줄인 응답을 쓰면 아웃바운드만 스키마로
848
+ #: 재고 인바운드는 자기가 지어낸 값을 자기가 확인하는 꼴이 된다 — 이 이슈가
849
+ #: 고치려던 자기참조가 방향만 바꿔 남는다.
850
+ #: `test_codex_contract.py::TestStubFidelity` 가 이 값들을 스냅샷과 대조한다.
851
+ AUTO_RESULT = {
852
+ "thread/start": {
853
+ "approvalPolicy": "on-request",
854
+ "approvalsReviewer": "user",
855
+ "cwd": "/ws",
856
+ "model": "gpt-5-codex",
857
+ "modelProvider": "openai",
858
+ "sandbox": {"type": "readOnly"},
859
+ "thread": {
860
+ "cliVersion": "0.154.0",
861
+ "createdAt": 0,
862
+ "cwd": "/ws",
863
+ "ephemeral": False,
864
+ "id": "codex-thread-stub",
865
+ "modelProvider": "openai",
866
+ "preview": "",
867
+ "projectId": "proj-stub",
868
+ "sessionId": "sess-stub",
869
+ "source": "cli",
870
+ "status": {"type": "idle"},
871
+ "turns": [],
872
+ "updatedAt": 0,
873
+ },
874
+ },
875
+ "turn/start": {
876
+ "turn": {"id": "codex-turn-stub", "status": "inProgress", "items": []}
877
+ },
878
+ # 스레드 관리 (이슈 #121). resume·fork·read 는 클래스 아래에서 thread/start 로
879
+ # 채운다 — `send()` 가 thread.id 를 요청에 맞춰 바꾼다
880
+ "thread/name/set": {},
881
+ "thread/archive": {},
882
+ "thread/delete": {},
883
+ # 슬래시 명령 (이슈 #121)
884
+ "thread/compact/start": {},
885
+ "review/start": {
886
+ "reviewThreadId": "codex-thread-stub",
887
+ "turn": {"id": "codex-turn-stub", "status": "inProgress", "items": []},
888
+ },
889
+ "model/list": {
890
+ "data": [
891
+ {
892
+ "id": "gpt-5-codex",
893
+ "model": "gpt-5-codex",
894
+ "displayName": "GPT-5 Codex",
895
+ "description": "코딩용 기본 모델",
896
+ "hidden": False,
897
+ "isDefault": True,
898
+ "defaultReasoningEffort": "medium",
899
+ "supportedReasoningEfforts": [
900
+ {"reasoningEffort": "low", "description": "빠름"},
901
+ {"reasoningEffort": "medium", "description": "균형"},
902
+ {"reasoningEffort": "high", "description": "깊게"},
903
+ ],
904
+ },
905
+ {
906
+ "id": "gpt-5-mini",
907
+ "model": "gpt-5-mini",
908
+ "displayName": "GPT-5 mini",
909
+ "description": "가벼운 모델",
910
+ "hidden": False,
911
+ "isDefault": False,
912
+ "defaultReasoningEffort": "low",
913
+ "supportedReasoningEfforts": [
914
+ {"reasoningEffort": "low", "description": "빠름"}
915
+ ],
916
+ },
917
+ ]
918
+ },
919
+ "mcpServerStatus/list": {
920
+ "data": [
921
+ {
922
+ "name": "bdp",
923
+ "authStatus": "unsupported",
924
+ "runtimeStatus": "connected",
925
+ "tools": {
926
+ "query": {"name": "query", "inputSchema": {"type": "object"}}
927
+ },
928
+ "resources": [],
929
+ "resourceTemplates": [],
930
+ }
931
+ ]
932
+ },
933
+ "skills/list": {
934
+ "data": [
935
+ {
936
+ "cwd": "/ws",
937
+ "errors": [],
938
+ "skills": [
939
+ {
940
+ "name": "pdf",
941
+ "description": "PDF 를 읽는다",
942
+ "enabled": True,
943
+ "path": "/home/u/.codex/skills/pdf/SKILL.md",
944
+ "scope": "user",
945
+ }
946
+ ],
947
+ }
948
+ ]
949
+ },
950
+ "hooks/list": {
951
+ "data": [{"cwd": "/ws", "errors": [], "hooks": [], "warnings": []}]
952
+ },
953
+ }
954
+
955
+ async def send(self, message: Dict[str, Any]) -> None:
956
+ self.sent.append(message)
957
+ # 요청에는 즉시 결과를 돌려준다 — 핸드셰이크가 막히지 않게.
958
+ if "id" in message and message.get("method"):
959
+ error = self._reject.get(message["method"])
960
+ if error is not None:
961
+ self.scripted.insert(
962
+ 0, {"jsonrpc": "2.0", "id": message["id"], "error": dict(error)}
963
+ )
964
+ return
965
+ result = copy.deepcopy(self.AUTO_RESULT.get(message["method"], {}))
966
+ thread = result.get("thread")
967
+ params = message.get("params") or {}
968
+ if isinstance(thread, dict):
969
+ # 스레드를 가리키는 요청은 그 스레드로 답하고, fork 는 새 id 를 준다
970
+ if message["method"] == "thread/fork":
971
+ self._forks += 1
972
+ thread["id"] = f"codex-thread-fork-{self._forks}"
973
+ thread["forkedFromId"] = params.get("threadId")
974
+ elif params.get("threadId"):
975
+ thread["id"] = params["threadId"]
976
+ self.scripted.insert(
977
+ 0, {"jsonrpc": "2.0", "id": message["id"], "result": result}
978
+ )
979
+
980
+ async def receive(self) -> Optional[Dict[str, Any]]:
981
+ if not self.scripted:
982
+ return None
983
+ return self.scripted.pop(0)
984
+
985
+ async def close(self) -> None:
986
+ self.closed = True
987
+
988
+ # 테스트 편의
989
+ def sent_by_method(self, method: str) -> Dict[str, Any]:
990
+ for m in self.sent:
991
+ if m.get("method") == method:
992
+ return m
993
+ raise AssertionError(f"보내지 않은 메서드: {method}")
994
+
995
+ def responses_for(self, call_id: Any) -> List[Dict[str, Any]]:
996
+ return [m for m in self.sent if m.get("id") == call_id and "result" in m]
997
+
998
+ def errors_for(self, call_id: Any) -> List[Dict[str, Any]]:
999
+ return [m for m in self.sent if m.get("id") == call_id and "error" in m]
1000
+
1001
+
1002
+ #: 스텁 스트림에 넣는 서버 알림. `StubTransport.AUTO_RESULT` 와 같은 이유로
1003
+ #: **스냅샷이 요구하는 필수 필드를 다 채운다** — 테스트가 손으로 줄인 알림을
1004
+ #: 쓰면 브릿지가 읽는 자리만 맞고 실물과 다른 모양을 검증하게 된다 (이슈 #96).
1005
+ # resume·fork 의 응답은 thread/start 와 같은 모양이고, read 는 그 안의 thread 만 준다
1006
+ StubTransport.AUTO_RESULT["thread/resume"] = copy.deepcopy(
1007
+ StubTransport.AUTO_RESULT["thread/start"]
1008
+ )
1009
+ StubTransport.AUTO_RESULT["thread/fork"] = copy.deepcopy(
1010
+ StubTransport.AUTO_RESULT["thread/start"]
1011
+ )
1012
+ StubTransport.AUTO_RESULT["thread/read"] = {
1013
+ "thread": copy.deepcopy(StubTransport.AUTO_RESULT["thread/start"]["thread"])
1014
+ }
1015
+
1016
+
1017
+ def stub_turn_completed(
1018
+ status: str = "completed", *, error: Optional[str] = None
1019
+ ) -> Dict[str, Any]:
1020
+ turn: Dict[str, Any] = {
1021
+ "id": "codex-turn-stub",
1022
+ "items": [],
1023
+ "status": status,
1024
+ }
1025
+ if error is not None:
1026
+ turn["error"] = {"message": error}
1027
+ return {
1028
+ "jsonrpc": "2.0",
1029
+ "method": "turn/completed",
1030
+ "params": {"threadId": "codex-thread-stub", "turn": turn},
1031
+ }
1032
+
1033
+
1034
+ def stub_error_notification(
1035
+ message: str, *, will_retry: bool = False
1036
+ ) -> Dict[str, Any]:
1037
+ return {
1038
+ "jsonrpc": "2.0",
1039
+ "method": "error",
1040
+ "params": {
1041
+ "threadId": "codex-thread-stub",
1042
+ "turnId": "codex-turn-stub",
1043
+ "willRetry": will_retry,
1044
+ "error": {"message": message},
1045
+ },
1046
+ }
1047
+
1048
+
1049
+ SseEvent = Tuple[Optional[str], Dict[str, Any]]
1050
+
1051
+
1052
+ class WebSocketTransport(Transport):
1053
+ """centri 의 codex app-server 와 WebSocket 으로 JSON-RPC 를 주고받는다.
1054
+
1055
+ codex 는 stdio 가 기본이고 `--listen` 이 실험 기능이다. 서버사이드 codex 는
1056
+ pod 밖에 있어 stdio 를 쓸 수 없으므로 이쪽을 쓴다.
1057
+ """
1058
+
1059
+ def __init__(self, url: str, headers: Dict[str, str]) -> None:
1060
+ self.url = url
1061
+ self.headers = headers
1062
+ self._conn = None
1063
+
1064
+ async def connect(self) -> None:
1065
+ from tornado.httpclient import HTTPClientError, HTTPRequest
1066
+ from tornado.websocket import websocket_connect
1067
+
1068
+ try:
1069
+ self._conn = await websocket_connect(
1070
+ HTTPRequest(self.url, headers=self.headers)
1071
+ )
1072
+ except HTTPClientError as e:
1073
+ rejected = _gateway_rejection(e.code, e.response)
1074
+ if rejected is not None:
1075
+ raise rejected from e
1076
+ raise CodexUnavailable(f"codex 서버에 접속하지 못했습니다: {e}") from e
1077
+ except Exception as e:
1078
+ raise CodexUnavailable(f"codex 서버에 접속하지 못했습니다: {e}") from e
1079
+
1080
+ async def send(self, message: Dict[str, Any]) -> None:
1081
+ if self._conn is None:
1082
+ raise CodexUnavailable("codex 연결이 열려 있지 않습니다.")
1083
+ await self._conn.write_message(json.dumps(message, ensure_ascii=False))
1084
+
1085
+ async def receive(self) -> Optional[Dict[str, Any]]:
1086
+ if self._conn is None:
1087
+ return None
1088
+ raw = await self._conn.read_message()
1089
+ if raw is None:
1090
+ return None
1091
+ try:
1092
+ return json.loads(raw)
1093
+ except (TypeError, ValueError) as e:
1094
+ raise CodexError(f"codex 응답을 해석하지 못했습니다: {e}") from e
1095
+
1096
+ async def close(self) -> None:
1097
+ if self._conn is not None:
1098
+ self._conn.close()
1099
+ self._conn = None
1100
+
1101
+
1102
+ #: `AgentFactory._AGENTS_MD_MAX_CHARS` 와 같은 값이어야 한다 — 엔진을 바꿔도 모델이 받는
1103
+ #: `AGENTS.md` 길이가 같아야 해서다. 둘이 갈라지면 테스트가 잡는다 (이슈 #99).
1104
+ #: 상태 표시(`AgentsMdStatusHandler`)도 이 상수와 글자 수로 "잘림" 을 판정한다.
1105
+ AGENTS_MD_MAX_CHARS = 10_000
1106
+
1107
+
1108
+ def read_project_instructions(workspace_root: str) -> str:
1109
+ """워크스페이스 루트의 `AGENTS.md` 를 읽는다 (대소문자 무시).
1110
+
1111
+ 이슈 #139 에서 `codebuff.md` 를 `AGENTS.md` 로 바꿨다. 옛 이름은 읽지 않는다.
1112
+ client 경로에서는 `AgentFactory._read_agents_md` 가 같은 일을 하는데 그쪽은
1113
+ 팩토리 인스턴스에 묶여 있어 codex 경로에서 재사용할 수 없다. 상한도 그쪽과 같다.
1114
+ """
1115
+ try:
1116
+ for name in os.listdir(workspace_root):
1117
+ if name.lower() == "agents.md":
1118
+ path = os.path.join(workspace_root, name)
1119
+ if os.path.isfile(path):
1120
+ with open(path, encoding="utf-8") as f:
1121
+ return f.read()[:AGENTS_MD_MAX_CHARS]
1122
+ except OSError:
1123
+ pass
1124
+ return ""
1125
+
1126
+
1127
+ def proxy_rules_block(proxy_prefix: str) -> str:
1128
+ """pod 에서 띄운 서버를 사용자가 여는 주소 규칙 (이슈 #136).
1129
+
1130
+ 사용자는 pod 밖(관리공간 VDI)에서 주피터 허브가 발급한 URL 로만 들어온다. 그래서
1131
+ 포트는 주소의 `:포트` 가 아니라 같은 URL 의 경로(`<base_url>proxy/<포트>/`)로 열리고,
1132
+ `jupyter-server-proxy` 가 pod 안의 `localhost:<포트>` 로 중계한다. 앱은 그 접두
1133
+ 아래에서 돌므로 절대경로를 쓰는 앱은 접두를 알아야 한다.
1134
+ """
1135
+ if not proxy_prefix:
1136
+ return ""
1137
+ p = proxy_prefix
1138
+ return (
1139
+ "## pod 포트 열기\n"
1140
+ "사용자는 pod 밖의 브라우저에서 접속하므로 `localhost:<포트>` 는 열리지 않는다. "
1141
+ f"pod 에서 띄운 서버는 같은 주피터 주소에서 경로만 바꾼 `{p}<포트>/` 로 열린다.\n"
1142
+ "- 서버를 띄웠으면 사용자에게 이 주소를 마크다운 링크로 안내한다 "
1143
+ f"(예: 8080 이면 `[열기]({p}8080/)`). localhost 주소를 안내하지 않는다.\n"
1144
+ "- 이 주소는 접두를 떼고 앱에 `/` 부터 넘긴다. streamlit·`python -m http.server` "
1145
+ "처럼 상대경로를 쓰는 앱은 그대로 된다. Spring Boot 는 "
1146
+ "`server.forward-headers-strategy=framework` 로 중계가 보내는 `X-Forwarded-Prefix` 를 따른다.\n"
1147
+ "- base 경로를 자기 경로로 받는 앱은 접두를 떼지 않는 "
1148
+ f"`{p}absolute/<포트>/` 로 열고, 링크와 base 를 끝의 `/` 까지 같은 문자열로 맞춘다. "
1149
+ f"Vite 는 `base: '{p}absolute/<포트>/'` 와 `server.allowedHosts: true` "
1150
+ "(중계가 사용자 Host 를 그대로 넘긴다)를 두고 `server.hmr` 은 건드리지 않는다. "
1151
+ "base 는 기동할 때 읽으므로 이미 띄운 dev 서버는 다시 띄운다."
1152
+ )
1153
+
1154
+
1155
+ # 목록의 설명만으로는 gpt-oss-120b 가 이 스킬을 읽지 않았다(0/4, 이슈 #228) — 이름으로 지시한다
1156
+ _PYTHON_QUALITY_DIRECTIVE = (
1157
+ "Python 코드(.py 파일·노트북 셀)를 쓰거나 고치기 전에는 반드시 `load_skill` 로 "
1158
+ "`python_code_quality` 스킬을 먼저 읽고 그 원칙을 코드에 반영한다."
1159
+ )
1160
+
1161
+
1162
+ def developer_context(workspace_root: str, proxy_prefix: str = "") -> str:
1163
+ """`developerInstructions` 에 덧붙일 것 — 사내 플랫폼 규칙, 포트 주소 규칙,
1164
+ `AGENTS.md`, 커스텀 스킬 목록, 내장 스킬 목록.
1165
+
1166
+ 사내가 아니고 중계가 없으면 `AGENTS.md` 뒤에 스킬 목록만 붙는다 (이슈 #120, #134,
1167
+ #136). 내장 스킬은 늘 있으므로 목록도 늘 붙는다 (이슈 #219). 목록은 `extra_context`
1168
+ 로 들어가 `_mask` 를 거친다.
1169
+ """
1170
+ custom = custom_skills.discover(workspace_root)
1171
+ parts = [
1172
+ platform_rules_block(),
1173
+ proxy_rules_block(proxy_prefix),
1174
+ read_project_instructions(workspace_root),
1175
+ custom_skills.prompt_section(custom, "load_skill"),
1176
+ custom_skills.builtin_section(custom, "load_skill"),
1177
+ _PYTHON_QUALITY_DIRECTIVE,
1178
+ ]
1179
+ return "\n\n".join(p for p in parts if p)
1180
+
1181
+
1182
+ def make_codex_transport(endpoint: str, api_key: str) -> WebSocketTransport:
1183
+ """설정의 endpoint 를 WebSocket URL 로 바꾼다.
1184
+
1185
+ `CODEX_SERVER_URL` 은 사람이 적는 값이라 `https://` 로 들어오는 경우가 많다.
1186
+ """
1187
+ if not endpoint:
1188
+ raise CodexUnavailable(
1189
+ "codex 서버 주소가 설정되지 않았습니다. 관리자에게 문의하세요."
1190
+ )
1191
+ url = endpoint.strip()
1192
+ if url.startswith("https://"):
1193
+ url = "wss://" + url[len("https://") :]
1194
+ elif url.startswith("http://"):
1195
+ url = "ws://" + url[len("http://") :]
1196
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
1197
+ return WebSocketTransport(url, headers)
1198
+
1199
+
1200
+ #: centri 준비 신호의 `reason` → 화면이 쓰는 단계 (centri#1860, 이슈 #254 코멘트).
1201
+ #: 모르는 `reason` 은 `gateway` 로 둔다 — 연결 안 됨이 맞고 원문은 detail 에 남는다.
1202
+ READY_REASON_STAGE = {
1203
+ "unauthorized": "auth",
1204
+ "gateway_key_missing": "gateway",
1205
+ "model_config_missing": "model",
1206
+ "sidecar_unreachable": "bridge",
1207
+ }
1208
+
1209
+
1210
+ def ready_url(endpoint: str) -> str:
1211
+ """codex ws 주소 옆의 준비 신호 주소 — `…/codex` → `…/codex/ready` (http·https)."""
1212
+ url = endpoint.strip().rstrip("/")
1213
+ if url.startswith("wss://"):
1214
+ url = "https://" + url[len("wss://") :]
1215
+ elif url.startswith("ws://"):
1216
+ url = "http://" + url[len("ws://") :]
1217
+ return url + "/ready"
1218
+
1219
+
1220
+ async def check_codex_ready(
1221
+ endpoint: str, api_key: str, timeout: float = 10.0
1222
+ ) -> Optional[Dict[str, Any]]:
1223
+ """centri 준비 신호를 읽는다. 신호가 없으면 None 이다.
1224
+
1225
+ 토큰도 codex 프로세스도 쓰지 않는 확인이다(centri#1860). 신호가 없는 centri
1226
+ (404·다른 모양·접속 실패)는 None 을 돌려주고, 부르는 쪽이 모델 턴으로 대신 잰다 —
1227
+ 신호가 없다는 것만으로 연결 안 됨이라 하지 않는다.
1228
+ """
1229
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
1230
+ try:
1231
+ async with httpx.AsyncClient(timeout=timeout) as client:
1232
+ resp = await client.get(ready_url(endpoint), headers=headers)
1233
+ except httpx.HTTPError:
1234
+ return None
1235
+ # 인증 거부는 본문 모양과 무관하게 인증이다 — 평문으로 와도 "게이트웨이" 로 흐리지 않는다
1236
+ if resp.status_code == 403:
1237
+ return {"ready": False, "reason": "unauthorized"}
1238
+ try:
1239
+ body = resp.json()
1240
+ except ValueError:
1241
+ return None
1242
+ if not isinstance(body, dict) or "ready" not in body:
1243
+ return None
1244
+ return body
1245
+
1246
+
1247
+ class CodexProbeFailed(CodexError):
1248
+ """연결 확인이 어느 단계에서 실패했는지 싣는다 (이슈 #254).
1249
+
1250
+ `stage` 는 `gateway`(소켓·인증) · `bridge`(`initialize`) · `model`(모델 턴) 중 하나다.
1251
+ 화면은 이 이름으로 무엇이 빠졌는지 보인다.
1252
+ """
1253
+
1254
+ def __init__(self, stage: str, message: str) -> None:
1255
+ super().__init__(message)
1256
+ self.stage = stage
1257
+
1258
+
1259
+ #: 모델 턴 확인에 보내는 입력. 짧게 답할 수 있는 것이어야 토큰이 적다.
1260
+ PROBE_TURN_PROMPT = "Reply with exactly one word: ok"
1261
+
1262
+
1263
+ async def probe_codex(
1264
+ transport: Transport,
1265
+ timeout: float = 10.0,
1266
+ *,
1267
+ model_turn: bool = False,
1268
+ turn_timeout: float = 60.0,
1269
+ prompt: str = PROBE_TURN_PROMPT,
1270
+ ) -> Dict[str, Any]:
1271
+ """`initialize` 를 왕복해 연결이 사는지 본다. `model_turn` 이면 모델 턴 한 번까지 간다.
1272
+
1273
+ `initialize` 만으로는 주소·인증·프로토콜까지만 판정된다 — 브리지에 모델 키가 빠져도
1274
+ 초록이었다(이슈 #254). 그래서 설정 화면의 확인은 `model_turn=True` 로 부른다.
1275
+ 그때 스레드는 `ephemeral` 로 열어 codex 쪽에 저장되는 스레드를 남기지 않는다.
1276
+
1277
+ 실패는 `CodexProbeFailed` 로 단계(`gateway`·`bridge`·`model`)를 싣는다. 성공하면
1278
+ `initialize` 결과에 모델 턴의 답(`reply`)을 얹어 돌려준다.
1279
+ """
1280
+ try:
1281
+ await asyncio.wait_for(transport.connect(), timeout)
1282
+ except asyncio.TimeoutError as e:
1283
+ raise CodexProbeFailed(
1284
+ "gateway", f"codex 서버가 {timeout:.0f}초 안에 연결을 받지 않았습니다."
1285
+ ) from e
1286
+ except CodexError as e:
1287
+ raise CodexProbeFailed("gateway", str(e)) from e
1288
+
1289
+ next_id = 0
1290
+
1291
+ async def call(method: str, params: Dict[str, Any], stage: str, limit: float):
1292
+ nonlocal next_id
1293
+ next_id += 1
1294
+ call_id = next_id
1295
+ await transport.send(
1296
+ {"jsonrpc": "2.0", "id": call_id, "method": method, "params": params}
1297
+ )
1298
+ deadline = time.monotonic() + limit
1299
+ while True:
1300
+ message = await receive(stage, deadline, limit)
1301
+ if message.get("id") != call_id:
1302
+ # 응답 전에 오는 알림은 확인에 쓸 것이 없다.
1303
+ continue
1304
+ error = message.get("error")
1305
+ if error:
1306
+ raise CodexProbeFailed(
1307
+ stage, f"codex 가 {method} 를 거부했습니다: {error}"
1308
+ )
1309
+ return message.get("result") or {}
1310
+
1311
+ async def receive(stage: str, deadline: float, limit: float) -> Dict[str, Any]:
1312
+ remaining = deadline - time.monotonic()
1313
+ try:
1314
+ if remaining <= 0:
1315
+ raise asyncio.TimeoutError
1316
+ message = await asyncio.wait_for(transport.receive(), remaining)
1317
+ except asyncio.TimeoutError as e:
1318
+ raise CodexProbeFailed(
1319
+ stage, f"codex 서버가 {limit:.0f}초 안에 응답하지 않았습니다."
1320
+ ) from e
1321
+ if message is None:
1322
+ raise CodexProbeFailed(stage, "codex 서버가 연결을 닫았습니다.")
1323
+ return message
1324
+
1325
+ try:
1326
+ info = await call(
1327
+ "initialize",
1328
+ {
1329
+ "clientInfo": {"name": "codebuff", "version": "1"},
1330
+ "capabilities": {"experimentalApi": True},
1331
+ },
1332
+ "bridge",
1333
+ timeout,
1334
+ )
1335
+ if not model_turn:
1336
+ return info
1337
+ await transport.send({"jsonrpc": "2.0", "method": "initialized"})
1338
+ thread = await call("thread/start", {"ephemeral": True}, "model", timeout)
1339
+ thread_id = (thread.get("thread") or {}).get("id")
1340
+ await call(
1341
+ "turn/start",
1342
+ {"threadId": thread_id, "input": [{"type": "text", "text": prompt}]},
1343
+ "model",
1344
+ timeout,
1345
+ )
1346
+ reply: List[str] = []
1347
+ errors: List[str] = []
1348
+ deadline = time.monotonic() + turn_timeout
1349
+ while True:
1350
+ message = await receive("model", deadline, turn_timeout)
1351
+ method = message.get("method")
1352
+ params = message.get("params") or {}
1353
+ if method == "item/agentMessage/delta":
1354
+ reply.append(str(params.get("delta", "")))
1355
+ elif method == "error" and not params.get("willRetry"):
1356
+ errors.append(str((params.get("error") or {}).get("message", "")))
1357
+ elif method == "turn/completed":
1358
+ turn = params.get("turn") or {}
1359
+ if turn.get("status") != "completed":
1360
+ detail = (turn.get("error") or {}).get("message") or (
1361
+ errors[-1] if errors else f"턴 상태 {turn.get('status')}"
1362
+ )
1363
+ raise CodexProbeFailed("model", detail)
1364
+ return {**info, "reply": "".join(reply)}
1365
+ finally:
1366
+ await transport.close()
1367
+
1368
+
1369
+ class CodexSession:
1370
+ """codex thread 하나의 수명을 든다.
1371
+
1372
+ `run()` 과 `resume()` 은 (event, data) 를 내는 비동기 제너레이터다. `event` 가
1373
+ None 이면 무명 `data:` 줄이고, 그 외에는 `event: <이름>` 줄이 앞에 붙는다.
1374
+ """
1375
+
1376
+ def __init__(
1377
+ self,
1378
+ transport: Transport,
1379
+ *,
1380
+ workspace_root: str,
1381
+ thread_id: str,
1382
+ permission_mode: str = "ask",
1383
+ endpoint: str = "",
1384
+ api_key: str = "",
1385
+ developer_instructions: Optional[str] = None,
1386
+ mode: str = "agent",
1387
+ extra_context: str = "",
1388
+ mask_exempt: str = "",
1389
+ recovery_context: Optional[Callable[[], Optional[str]]] = None,
1390
+ on_record: Optional[Callable[[Dict[str, Any]], None]] = None,
1391
+ on_thread: Optional[Callable[[str, Dict[str, Any]], None]] = None,
1392
+ qa_postprocessor: Any = None,
1393
+ ) -> None:
1394
+ self._t = transport
1395
+ self._workspace_root = workspace_root
1396
+ self._thread_id = thread_id
1397
+ self._permission_mode = (
1398
+ permission_mode if permission_mode in _PERMISSION_POLICY else "ask"
1399
+ )
1400
+ self._endpoint = endpoint
1401
+ # 키 원문은 세션에 남기지 않는다. 중앙 설정이 바뀌었는지만 재사용 판정에 쓴다.
1402
+ self._api_key_digest = hashlib.sha256(api_key.encode("utf-8")).digest()
1403
+ self._mode = mode if mode in {"agent", "chat", "qa"} else "agent"
1404
+ self._developer_instructions = developer_instructions or _mode_instructions(
1405
+ self._mode
1406
+ )
1407
+ self._qa_tool_runners = _qa_tools(workspace_root) if self._mode == "qa" else {}
1408
+ if self._mode == "qa":
1409
+ from codebuff_core.services.rag_chat_agent import CodexQAToolPostprocessor
1410
+
1411
+ # 노트북·EDA 는 테이블 확인 방식·grounding 문구가 다른 후처리기를 넣는다 (이슈 #155)
1412
+ self._qa_postprocessor = qa_postprocessor or CodexQAToolPostprocessor()
1413
+ else:
1414
+ self._qa_postprocessor = None
1415
+ if self._mode == "agent":
1416
+ self._dynamic_tools = CODEX_DYNAMIC_TOOLS
1417
+ elif self._mode == "qa":
1418
+ self._dynamic_tools = [
1419
+ _langchain_tool_spec(tool) for tool in self._qa_tool_runners.values()
1420
+ ]
1421
+ else:
1422
+ self._dynamic_tools = []
1423
+ self._allowed_tools = {spec["name"] for spec in self._dynamic_tools}
1424
+ # 지시문도 pod 밖으로 나간다 — 프롬프트와 같은 마스킹을 건다. 포트 접두
1425
+ # (`mask_exempt`)만 뺀다 — 사번이 전화번호 꼴이면 주소가 지워진다 (이슈 #136)
1426
+ self._extra_context = _mask_except(extra_context, mask_exempt)
1427
+ self._recovery_context = recovery_context
1428
+ self._on_record = on_record or _default_recorder
1429
+ # 이 세션이 **새로 만든** 스레드를 알린다 — ("start"|"fork", thread). 핸들러가
1430
+ # 사용자별 스레드 목록(`/resume`)에 적는다 (이슈 #121)
1431
+ self._on_thread = on_thread
1432
+ # `initialize` 를 마친 연결인가. `/resume` 은 스레드를 새로 열지 않고 이 위에서
1433
+ # 기존 스레드를 붙인다
1434
+ self._initialized = False
1435
+ self._turn_started_at = 0.0
1436
+ self._tool_calls = 0
1437
+ # Q&A 도구 결과가 나온 뒤 본문이 시작되기 전인가 — 프론트의 "분석 중" 표시를 켜고 끈다
1438
+ self._tool_processing = False
1439
+ # 이번 턴에 센 dynamic tool 의 callId — `item/started` 와 실행 지점이 같은
1440
+ # 호출을 두 번 세지 않게 한다 (이슈 #119)
1441
+ self._counted_calls: Set[str] = set()
1442
+ # 이번 턴에 워크스페이스를 바꾸는 도구를 불렀나 — Agent 후속 턴을 가른다 (이슈 #176)
1443
+ self._changed_workspace = False
1444
+ # 요청마다 핸들러가 넣는 UX 세션 id — turn·tool_call 행을 ux_event_hst 에 잇는다 (#98)
1445
+ self.ux_session_id: Optional[str] = None
1446
+ # `thread/tokenUsage/updated` 의 `total` 은 스레드 누적이다. 턴 시작 때 값을
1447
+ # 기준으로 두고 턴 끝의 차이를 그 턴의 토큰으로 본다 (#98)
1448
+ self._usage_total: Optional[Dict[str, int]] = None
1449
+ self._usage_base: Optional[Dict[str, int]] = None
1450
+ # 시작했지만 turn 행을 아직 안 낸 턴이 있다. 승인 대기 중 세션이 닫히면
1451
+ # `_pump` 가 안 돌아 아무도 기록하지 않으므로 `close()` 가 이것을 보고 닫는다 (#98)
1452
+ self._turn_open = False
1453
+ # 빈 답 후속 턴(이슈 #155). `run()` 안에서만 켜진다 — 켜져 있으면 답 없이 끝난 턴의
1454
+ # turn 행을 미뤄 후속 턴까지 한 행으로 적는다(프롬프트 1건 = 1행, #98)
1455
+ self._follow_up_armed = False
1456
+ self._turn_has_answer = False
1457
+ self._next_id = 0
1458
+ self._started = False
1459
+ self._closed = False
1460
+ self._cancelled = False
1461
+ # async generator 본문이 첫 __anext__ 에서 시작되기 전의 경쟁 구간을 막는다.
1462
+ self._request_reserved = False
1463
+ # hitl 로 프론트에 넘긴 도구 호출 — resume 이 이 id 로 회신한다
1464
+ self._pending_call: Optional[Dict[str, Any]] = None
1465
+ # 지금 이 세션에서 턴이 돌고 있는가 (이슈 #93)
1466
+ self._turn_active = False
1467
+ # 턴 밖 슬래시 명령이 소켓을 쓰는 중인가 (이슈 #121)
1468
+ self._slash_active = False
1469
+ # 재사용 턴의 turn/start — codex 가 거부하면 새 스레드로 한 번 다시 연다
1470
+ self._retry_prompt: Optional[str] = None
1471
+ self._retry_call_id: Optional[int] = None
1472
+ # codex 쪽 식별자. `thread_id` 는 코드버프가 만든 것이고 이 둘은 codex 가
1473
+ # 준 것이다 — `turn/start` 와 `turn/interrupt` 가 필수로 요구한다 (이슈 #96).
1474
+ self._codex_thread_id = ""
1475
+ self._codex_turn_id = ""
1476
+ # 핸드셰이크가 응답을 기다리는 동안 먼저 온 알림. 버리면 첫 토큰이 샌다.
1477
+ self._buffered: List[Dict[str, Any]] = []
1478
+ # `thread/start` 응답 — `/status` 가 모델·샌드박스를 여기서 읽는다 (이슈 #121)
1479
+ self._thread_info: Dict[str, Any] = {}
1480
+ # `/model` 로 고른 모델·effort. codex 는 이 값을 "이 턴과 이후 턴" 에 적용하지만
1481
+ # 새 스레드로 다시 열리면 사라지므로 턴마다 싣는다 (이슈 #121)
1482
+ self._model_override: Optional[str] = None
1483
+ self._effort_override: Optional[str] = None
1484
+ # 이미 패널에 띄운 hook 알림 — 같은 문장은 세션당 한 번만 띄운다 (이슈 #145)
1485
+ self._hook_notices: Set[str] = set()
1486
+
1487
+ # --- 내부 ---
1488
+
1489
+ def _new_id(self) -> int:
1490
+ self._next_id += 1
1491
+ return self._next_id
1492
+
1493
+ async def _request(self, method: str, params: Dict[str, Any]) -> int:
1494
+ call_id = self._new_id()
1495
+ await self._t.send(
1496
+ {"jsonrpc": "2.0", "id": call_id, "method": method, "params": params}
1497
+ )
1498
+ return call_id
1499
+
1500
+ async def _notify(self, method: str, params: Dict[str, Any]) -> None:
1501
+ await self._t.send({"jsonrpc": "2.0", "method": method, "params": params})
1502
+
1503
+ async def _respond(self, call_id: Any, result: Dict[str, Any]) -> None:
1504
+ await self._t.send({"jsonrpc": "2.0", "id": call_id, "result": result})
1505
+
1506
+ async def _respond_error(self, call_id: Any, message: str) -> None:
1507
+ """지원하지 않는 서버 요청에는 JSON-RPC 오류로 답한다.
1508
+
1509
+ 모양을 모르는 회신을 지어내면 codex 가 역직렬화에서 죽는다. 답을
1510
+ 아예 안 하면 턴이 영원히 매달린다 (이슈 #88).
1511
+ """
1512
+ await self._t.send(
1513
+ {
1514
+ "jsonrpc": "2.0",
1515
+ "id": call_id,
1516
+ "error": {"code": -32601, "message": message},
1517
+ }
1518
+ )
1519
+
1520
+ async def _await_response(self, call_id: int, label: str) -> Dict[str, Any]:
1521
+ """요청 하나의 응답을 기다린다. 그 사이 온 알림은 버퍼에 쌓는다.
1522
+
1523
+ **데드라인을 건다.** centri 앞단 게이트웨이가 WebSocket 만 받고 codex 가
1524
+ 안 붙은 상태면 소켓이 열린 채 영영 조용하다 — 그러면 SSE 가 아무 출력
1525
+ 없이 매달리고, 게이트에서는 실물 테스트가 skip 이 아니라 hang 이 된다.
1526
+ """
1527
+ deadline = time.monotonic() + _HANDSHAKE_TIMEOUT
1528
+ while True:
1529
+ left = deadline - time.monotonic()
1530
+ if left <= 0:
1531
+ raise CodexError(f"codex 가 {label} 에 제때 답하지 않았습니다.")
1532
+ try:
1533
+ message = await asyncio.wait_for(self._t.receive(), timeout=left)
1534
+ except asyncio.TimeoutError:
1535
+ raise CodexError(f"codex 가 {label} 에 제때 답하지 않았습니다.")
1536
+ if message is None:
1537
+ raise CodexError(f"codex 가 {label} 에 답하지 않고 끊었습니다.")
1538
+ if message.get("method") is None and message.get("id") == call_id:
1539
+ if "error" in message:
1540
+ err = message["error"]
1541
+ detail = err.get("message") if isinstance(err, dict) else str(err)
1542
+ raise CodexRejected(f"codex 가 {label} 을 거부했습니다: {detail}")
1543
+ return message
1544
+ self._buffered.append(message)
1545
+
1546
+ def _remember_ids(self, message: Dict[str, Any]) -> None:
1547
+ """지나가는 메시지에서 codex 의 thread/turn id 를 줍는다.
1548
+
1549
+ `turn/start` 는 답을 기다리지 않는다 — 기다리면 첫 토큰까지 스트림이
1550
+ 멈춘다. 그래서 turn id 는 그 응답·`turn/started` 알림·도구 호출 중
1551
+ **먼저 오는 것**에서 잡는다. `turn/interrupt` 가 둘 다 요구한다.
1552
+
1553
+ 스레드 id 는 **한 번 정해지면 바꾸지 않는다.** codex 는 `thread/started`·
1554
+ `thread/status/changed` 를 모든 연결에 뿌려서(실측 0.154.0), 공유 서버에서는
1555
+ 남의 스레드 id 가 이 소켓으로 온다. 덮어쓰면 중지·`/rename`·`/delete` 가 남의
1556
+ 스레드를 겨냥한다 (이슈 #121). 스레드를 바꾸는 것은 `_reopen` 과 스레드 전환뿐이다.
1557
+ """
1558
+ for holder in (message.get("params"), message.get("result")):
1559
+ if not isinstance(holder, dict):
1560
+ continue
1561
+ thread = holder.get("thread")
1562
+ tid = ""
1563
+ if isinstance(thread, dict) and thread.get("id"):
1564
+ tid = str(thread["id"])
1565
+ if holder.get("threadId"):
1566
+ tid = str(holder["threadId"])
1567
+ if tid:
1568
+ if not self._codex_thread_id:
1569
+ self._codex_thread_id = tid
1570
+ elif tid != self._codex_thread_id:
1571
+ continue
1572
+ turn = holder.get("turn")
1573
+ if isinstance(turn, dict) and turn.get("id"):
1574
+ self._codex_turn_id = str(turn["id"])
1575
+ if holder.get("turnId"):
1576
+ self._codex_turn_id = str(holder["turnId"])
1577
+
1578
+ def _is_foreign(self, params: Any) -> bool:
1579
+ """이 알림이 이 세션의 스레드가 아닌 스레드(서브에이전트)의 것인가."""
1580
+ if not isinstance(params, dict) or not params.get("threadId"):
1581
+ return False
1582
+ own = self._codex_thread_id
1583
+ return bool(own) and str(params["threadId"]) != own
1584
+
1585
+ @staticmethod
1586
+ def _describe_foreign(method: Any, params: Dict[str, Any]) -> Optional[str]:
1587
+ item = params.get("item") or {}
1588
+ if not isinstance(item, dict) or item.get("type") == "reasoning":
1589
+ return None
1590
+ if method == "item/started":
1591
+ line = _describe_started(item)
1592
+ elif method == "item/completed":
1593
+ line = _describe_completed(item)
1594
+ else:
1595
+ return None
1596
+ if not line or line.lstrip("↳ ").startswith("서브에이전트"):
1597
+ return line
1598
+ return _one_line(f"서브에이전트 › {line}")
1599
+
1600
+ def _error_event(
1601
+ self, message: str, gateway_reason: Optional[str] = None
1602
+ ) -> SseEvent:
1603
+ suffix = f" (codex endpoint: {self._endpoint})" if self._endpoint else ""
1604
+ data: Dict[str, Any] = {"error": f"{message}{suffix}"}
1605
+ if gateway_reason:
1606
+ data["gatewayReason"] = gateway_reason
1607
+ return (None, data)
1608
+
1609
+ def _complete_event(self) -> SseEvent:
1610
+ """종료 이벤트. 중단된 턴은 complete 대신 cancelled 로 보고한다."""
1611
+ name = "cancelled" if self._cancelled else "complete"
1612
+ return (name, {"thread_id": self._thread_id})
1613
+
1614
+ def _record(self, event: Dict[str, Any]) -> None:
1615
+ """적재는 부가 기능이다 — 실패해도 스트림을 끊지 않는다."""
1616
+ try:
1617
+ self._on_record(
1618
+ dict(
1619
+ event,
1620
+ session_id=self._thread_id,
1621
+ ux_session_id=self.ux_session_id,
1622
+ agent_backend="codex",
1623
+ **_identity(),
1624
+ )
1625
+ )
1626
+ except Exception as e: # noqa: BLE001 - 적재 실패가 대화를 막으면 안 된다
1627
+ logger.warning("codex 관측 적재 실패: %s", e)
1628
+
1629
+ def _started_line(self, item: Dict[str, Any]) -> Any:
1630
+ """`item/started` 의 진행 줄. Q&A 도구는 LangChain 과 같은 문구(dict)다."""
1631
+ if item.get("type") == "dynamicToolCall":
1632
+ tool = str(item.get("tool") or "")
1633
+ if tool in self._qa_tool_runners:
1634
+ from codebuff_core.services.rag_chat_agent import qa_tool_start_status
1635
+
1636
+ return qa_tool_start_status(tool, item.get("arguments") or {})
1637
+ return _describe_started(item)
1638
+
1639
+ def _count_tool_call(self, tool: str, call_id: Any = None) -> None:
1640
+ """도구 호출 1건을 세고 적재한다 — 실행 위치와 무관하다.
1641
+
1642
+ 게이트웨이가 가로채 답한 호출은 `item/tool/call` 이 안 오고 `item/started`
1643
+ 만 온다. 그래서 거기서도 세고, 같은 callId 는 한 번만 센다 (이슈 #119).
1644
+ """
1645
+ if call_id:
1646
+ if str(call_id) in self._counted_calls:
1647
+ return
1648
+ self._counted_calls.add(str(call_id))
1649
+ self._tool_calls += 1
1650
+ if tool in _CHANGING_TOOLS:
1651
+ self._changed_workspace = True
1652
+ self._record({"event": "tool_call", "tool_name": tool})
1653
+
1654
+ def _turn_usage(self) -> Dict[str, Optional[int]]:
1655
+ """이번 턴의 토큰. 턴 중 사용량 알림이 없었으면 None (0 이 아니다)."""
1656
+ now = self._usage_total
1657
+ if now is None or now is self._usage_base:
1658
+ return {"input_tokens": None, "output_tokens": None, "cached_tokens": None}
1659
+ base = self._usage_base or {}
1660
+ out: Dict[str, Optional[int]] = {}
1661
+ for col, key in _USAGE_KEYS:
1662
+ diff = now.get(key, 0) - base.get(key, 0)
1663
+ # 누적이 줄었으면 스레드가 새로 열린 것이다 — 기준 없이 지금 값을 쓴다
1664
+ out[col] = diff if diff >= 0 else now.get(key, 0)
1665
+ return out
1666
+
1667
+ @property
1668
+ def turn_open(self) -> bool:
1669
+ """턴이 아직 안 닫혔다 — 스트리밍 중이거나 승인을 기다린다."""
1670
+ return self._turn_open
1671
+
1672
+ def _record_turn(self, status: str) -> None:
1673
+ # 한 턴은 한 번만 닫는다. 중지는 `close()` 가 먼저 적고, `receive()` 에 매달려
1674
+ # 있던 `_pump` 가 소켓이 닫혀 깨어나 한 번 더 적으려 한다 (#98)
1675
+ if not self._turn_open:
1676
+ return
1677
+ self._turn_open = False
1678
+ elapsed = int((time.monotonic() - self._turn_started_at) * 1000)
1679
+ self._record(
1680
+ {
1681
+ # 프롬프트 1건 = 1행이다. client 의 model_call(호출 1건)과 grain 이
1682
+ # 달라 `turn` 으로 가른다 (이슈 #98)
1683
+ "event": "turn",
1684
+ "elapsed_ms": elapsed,
1685
+ "output_tool_calls": self._tool_calls,
1686
+ # `agent_call_hst.status` 는 VARCHAR(64) 다. 넘겨 보내면
1687
+ # INSERT IGNORE 가 경고로 낮춰 **조용히 잘라 넣는다** (이슈 #92).
1688
+ "status": status[:_STATUS_MAX],
1689
+ # 실제로 돈 모델 — 요청 모델이 운영에서 강제로 바뀌어도 행에 남는다 (이슈 #176)
1690
+ "model_name": self._model_override
1691
+ or str(self._thread_info.get("model") or "")
1692
+ or None,
1693
+ **self._turn_usage(),
1694
+ }
1695
+ )
1696
+
1697
+ async def _initialize(self) -> None:
1698
+ """`initialize` 왕복. 연결마다 한 번이다."""
1699
+ await self._await_response(
1700
+ await self._request(
1701
+ "initialize",
1702
+ {
1703
+ "clientInfo": {"name": "codebuff", "version": "1"},
1704
+ # dynamicTools 가 experimental 이라 이 플래그가 없으면
1705
+ # thread/start 가 `-32600 … requires experimentalApi
1706
+ # capability` 로 **거부된다** (무시가 아니다. 실측 0.154.0,
1707
+ # `test_codex_live.py::test_dynamic_tools_need_the_flag`).
1708
+ "capabilities": {"experimentalApi": True},
1709
+ },
1710
+ ),
1711
+ "initialize",
1712
+ )
1713
+ await self._notify("initialized", {})
1714
+ self._initialized = True
1715
+
1716
+ def _thread_params(self) -> Dict[str, Any]:
1717
+ """스레드를 열 때마다 싣는 설정. `thread/resume`·`thread/fork` 도 같은 값을 받는다.
1718
+
1719
+ 두 요청에는 `dynamicTools` 필드가 없다 — 도구는 원 스레드의 것이 이어진다
1720
+ (실측 0.154.0: resume 은 새 연결에서, fork 는 같은 연결에서 `list_files` 호출).
1721
+ """
1722
+ params: Dict[str, Any] = {
1723
+ "cwd": self._workspace_root,
1724
+ **_PERMISSION_POLICY[self._permission_mode],
1725
+ }
1726
+ instructions = self._developer_instructions
1727
+ if self._extra_context:
1728
+ instructions = f"{instructions}\n\n{self._extra_context}"
1729
+ # 모드 지시문은 developer 층에 싣는다 (이슈 #155). base·config 는 centri 게이트웨이가
1730
+ # #1774 부터 규칙(`schemas/centri/code_agent_gateway.json` 의 thread_start_config)대로
1731
+ # 통과시킨다 — 그 규칙에 걸리는 값을 넣으면 운영에서만 조용히 빠진다 (이슈 #176)
1732
+ params["developerInstructions"] = instructions
1733
+ if self._mode == "agent":
1734
+ params["baseInstructions"] = prompt_value("codex_agent_base_instructions")
1735
+ # AGENTS.md 는 위 지시문에 이미 있다 — codex 가 cwd 에서 다시 읽으면 두 벌이 된다
1736
+ params["config"] = {
1737
+ "project_doc_max_bytes": 0,
1738
+ "features": dict(_AGENT_NATIVE_SHELL_OFF),
1739
+ }
1740
+ return params
1741
+ # codex 기본 base("You are Codex…")·작업 폴더 AGENTS.md·`/root` 멀티에이전트
1742
+ # 지시문은 LangChain 경로에 없는 입력이라 걷어낸다(실측)
1743
+ params["baseInstructions"] = prompt_value("codex_base_instructions")
1744
+ config = copy.deepcopy(_SEARCH_ONLY_THREAD_CONFIG)
1745
+ config["project_doc_max_bytes"] = 0
1746
+ config["agents"] = {"enabled": False}
1747
+ params["config"] = config
1748
+ return params
1749
+
1750
+ def _adopt(self, result: Dict[str, Any]) -> None:
1751
+ """`thread/start`·`resume`·`fork` 의 응답을 이 세션의 현재 스레드로 삼는다."""
1752
+ thread = result.get("thread") or {}
1753
+ tid = str(thread.get("id") or "") if isinstance(thread, dict) else ""
1754
+ if not tid:
1755
+ raise CodexError("codex 가 thread id 를 돌려주지 않았습니다.")
1756
+ self._codex_thread_id = tid
1757
+ self._codex_turn_id = ""
1758
+ self._thread_info = result
1759
+ # 토큰 누적은 스레드마다 따로다 — 옛 스레드의 누적을 기준으로 두면 키마다 틀린다
1760
+ self._usage_total = None
1761
+ self._usage_base = None
1762
+ self._started = True
1763
+
1764
+ def _announce(self, kind: str, parent: str = "") -> None:
1765
+ if self._on_thread is None:
1766
+ return
1767
+ thread = dict(self._thread_info.get("thread") or {})
1768
+ if parent and not thread.get("forkedFromId"):
1769
+ thread["forkedFromId"] = parent
1770
+ try:
1771
+ self._on_thread(kind, thread)
1772
+ except Exception as e: # noqa: BLE001 - 목록 기록이 턴을 막으면 안 된다
1773
+ logger.warning("codex 스레드 기록 실패: %s", e)
1774
+
1775
+ async def _handshake(self) -> None:
1776
+ """스레드를 연다. **턴은 시작하지 않는다.**
1777
+
1778
+ `thread/start` 의 응답을 기다려야 한다 — codex 의 thread id 가 그 응답에만
1779
+ 있고 `turn/start` 가 그것을 필수로 요구한다. 예전에는 기다리지 않고 바로
1780
+ 턴을 얹어서 codex 가 `-32600 missing field 'threadId'` 로 거부했고,
1781
+ 그래서 codex 모드는 **한 턴도 성공하지 못했다** (이슈 #96).
1782
+ """
1783
+ if not self._initialized:
1784
+ await self._initialize()
1785
+ started = await self._await_response(
1786
+ await self._request(
1787
+ "thread/start",
1788
+ {**self._thread_params(), "dynamicTools": self._dynamic_tools},
1789
+ ),
1790
+ "thread/start",
1791
+ )
1792
+ self._adopt(started.get("result") or {})
1793
+ self._announce("start")
1794
+
1795
+ async def _reopen_if_thread_is_gone(self, message: Dict[str, Any]) -> bool:
1796
+ """재사용 턴의 `turn/start` 가 거부됐으면 새 스레드로 다시 연다.
1797
+
1798
+ `_start_turn` 은 보내기만 하고 답을 안 기다린다. codex 가 유휴로 스레드를
1799
+ 걷었어도 **연결은 살아 있어서**, 그 사실은 소켓 예외가 아니라 이 JSON-RPC
1800
+ 오류 응답으로 온다 (이슈 #93). 한 턴에 한 번만 시도한다.
1801
+ """
1802
+ if self._retry_call_id is None or message.get("id") != self._retry_call_id:
1803
+ return False
1804
+ prompt, self._retry_prompt = self._retry_prompt, None
1805
+ self._retry_call_id = None
1806
+ if prompt is None:
1807
+ return False
1808
+ logger.info("codex 가 스레드를 거부해 새로 엽니다")
1809
+ try:
1810
+ await self._reopen(prompt)
1811
+ except Exception as e: # noqa: BLE001 - 재시도까지 실패하면 원 오류를 보인다
1812
+ logger.error("codex 스레드 재개 실패: %s", e, exc_info=True)
1813
+ return False
1814
+ return True
1815
+
1816
+ async def _reopen(self, prompt: str) -> None:
1817
+ """끊긴 스레드를 걷고 새 스레드로 같은 턴을 다시 시작한다."""
1818
+ if self._recovery_context is not None:
1819
+ recovery = self._recovery_context()
1820
+ if recovery:
1821
+ prompt = _mask(recovery)[0] + "\n\n" + prompt
1822
+ self._started = False
1823
+ self._pending_call = None
1824
+ self._next_id = 0
1825
+ self._retry_prompt = None
1826
+ self._retry_call_id = None
1827
+ self._initialized = False
1828
+ # 새 스레드는 새 id 다. 남겨 두면 `turn/interrupt` 가 죽은 스레드를 가리킨다.
1829
+ self._codex_thread_id = ""
1830
+ self._codex_turn_id = ""
1831
+ self._buffered = []
1832
+ # 새 스레드의 누적은 0 부터 다시 센다 — 옛 누적을 기준으로 두면 키마다 틀린다
1833
+ self._usage_total = None
1834
+ self._usage_base = None
1835
+ try:
1836
+ await self._t.close()
1837
+ except Exception as e: # noqa: BLE001 - 이미 끊긴 소켓이다
1838
+ logger.debug("끊긴 codex 소켓 정리 실패: %s", e)
1839
+ await self._t.connect()
1840
+ await self._handshake()
1841
+ await self._start_turn(prompt)
1842
+
1843
+ async def _start_turn(self, prompt: str) -> int:
1844
+ """이미 열린 스레드에 턴 하나를 더 얹는다.
1845
+
1846
+ 같은 스레드에 실어야 codex 가 앞 턴을 기억한다 (이슈 #93).
1847
+ """
1848
+ self._codex_turn_id = ""
1849
+ params: Dict[str, Any] = {
1850
+ "threadId": self._codex_thread_id,
1851
+ "input": [{"type": "text", "text": prompt}],
1852
+ }
1853
+ if self._model_override:
1854
+ params["model"] = self._model_override
1855
+ if self._effort_override:
1856
+ params["effort"] = self._effort_override
1857
+ return await self._request("turn/start", params)
1858
+
1859
+ # --- 공개 ---
1860
+
1861
+ async def run(self, prompt: str) -> AsyncIterator[SseEvent]:
1862
+ self._turn_started_at = time.monotonic()
1863
+ self._tool_calls = 0
1864
+ self._tool_processing = False
1865
+ self._counted_calls = set()
1866
+ self._changed_workspace = False
1867
+ self._usage_base = self._usage_total
1868
+ self._cancelled = False
1869
+ self._turn_active = True
1870
+ self._turn_open = True
1871
+ self._follow_up_armed = True
1872
+ self._turn_has_answer = False
1873
+ if self._qa_postprocessor is not None:
1874
+ # grounding 이 echo 할 질문은 이력·파일 문맥이 붙기 전의 사용자 원문이다
1875
+ from .langchain_tools import _last_user_message
1876
+
1877
+ self._qa_postprocessor.reset(_last_user_message.get() or prompt)
1878
+ try:
1879
+ masked_prompt, detected = _mask(prompt)
1880
+ if detected:
1881
+ yield (
1882
+ None,
1883
+ {"status": f"개인정보를 마스킹해 보냅니다: {', '.join(detected)}"},
1884
+ )
1885
+ try:
1886
+ if self._closed:
1887
+ raise CodexError("이 codex 세션은 이미 닫혔습니다.")
1888
+ if self._started:
1889
+ # 두 번째 턴부터는 연결도 thread/start 도 다시 하지 않는다 —
1890
+ # 새 스레드를 열면 codex 쪽 컨텍스트가 끊긴다 (이슈 #93).
1891
+ try:
1892
+ # 거부 응답은 소켓이 아니라 `_pump` 로 온다 — 그 자리에서
1893
+ # 되살리려고 프롬프트와 호출 id 를 들고 간다.
1894
+ self._retry_prompt = masked_prompt
1895
+ self._retry_call_id = await self._start_turn(masked_prompt)
1896
+ except Exception as e: # noqa: BLE001
1897
+ # 소켓 자체가 죽은 경우다. 컨텍스트는 잃지만 턴까지
1898
+ # 잃지는 않게 새 스레드로 한 번 다시 연다.
1899
+ logger.info("codex 스레드가 끊겨 새로 엽니다: %s", e)
1900
+ await self._reopen(masked_prompt)
1901
+ else:
1902
+ if not self._initialized:
1903
+ await self._t.connect()
1904
+ await self._handshake()
1905
+ await self._start_turn(masked_prompt)
1906
+ except CodexError as e:
1907
+ self._record_turn(f"error:{type(e).__name__}")
1908
+ yield self._error_event(str(e), getattr(e, "reason", None))
1909
+ yield self._complete_event()
1910
+ await self.close()
1911
+ return
1912
+ except Exception as e: # 네트워크·직렬화 등 예상 못 한 실패
1913
+ logger.error("codex 세션 시작 실패: %s", e, exc_info=True)
1914
+ self._record_turn(f"error:{type(e).__name__}")
1915
+ yield self._error_event(f"codex 서버에 연결하지 못했습니다: {e}")
1916
+ yield self._complete_event()
1917
+ await self.close()
1918
+ return
1919
+
1920
+ while True:
1921
+ held: Optional[SseEvent] = None
1922
+ async for event in self._pump():
1923
+ # 판정은 `_pump` 가 turn 행을 쓰기 전에 끝냈다 — 행이 아직 열려 있으면
1924
+ # 후속 턴을 얹을 턴이다. 오류로 끝난 턴은 행이 닫혀 여기 안 걸린다
1925
+ if event[0] == "complete" and self._awaiting_follow_up():
1926
+ held = event
1927
+ continue
1928
+ yield event
1929
+ if held is None:
1930
+ break
1931
+ # LangChain 은 답 없이 끝난 턴을 도구 결과로 한 번 더 부른다(`_fallback_llm_call`).
1932
+ # codex 에서는 같은 스레드에 턴을 하나 더 얹는다 — 한 번뿐이다 (이슈 #155)
1933
+ self._follow_up_armed = False
1934
+ yield (None, {"status": "답변이 비어 있어 한 번 더 요청합니다..."})
1935
+ follow_up = (
1936
+ AGENT_EMPTY_ANSWER_FOLLOW_UP
1937
+ if self._mode == "agent"
1938
+ else EMPTY_ANSWER_FOLLOW_UP
1939
+ )
1940
+ try:
1941
+ self._retry_prompt = follow_up
1942
+ self._retry_call_id = await self._start_turn(follow_up)
1943
+ except Exception as e: # noqa: BLE001
1944
+ logger.info("codex 후속 턴을 열지 못했습니다: %s", e)
1945
+ self._record_turn("ok")
1946
+ yield held
1947
+ break
1948
+ finally:
1949
+ self._follow_up_armed = False
1950
+ self._turn_active = False
1951
+ self._close_unrecorded_turn()
1952
+ self._retry_prompt = None
1953
+ self._retry_call_id = None
1954
+
1955
+ def _awaiting_follow_up(self) -> bool:
1956
+ """`turn/completed` 를 받고도 turn 행을 미뤄 둔 상태 — 후속 턴을 얹는다."""
1957
+ return self._follow_up_armed and self._turn_open and not self._closed
1958
+
1959
+ def _wants_answer_follow_up(self) -> bool:
1960
+ """답 없이 끝난 턴에 후속 턴을 얹을지.
1961
+
1962
+ Agent 는 도구 실행만으로 끝날 수 있다 — 워크스페이스를 바꾼 턴이나 도구를 하나도
1963
+ 안 부른 턴은 얹지 않는다. 읽기 도구만 부르고 멈춘 Agent 턴(목록만 보고 끝남)만 한 번
1964
+ 이어 가게 한다 (이슈 #176).
1965
+ """
1966
+ if not self._follow_up_armed or self._turn_has_answer:
1967
+ return False
1968
+ if self._cancelled or self._closed:
1969
+ return False
1970
+ if self._mode == "agent" and (self._changed_workspace or not self._tool_calls):
1971
+ return False
1972
+ if self._pending_call is not None:
1973
+ return False
1974
+ post = self._qa_postprocessor
1975
+ return getattr(post, "pending_confirmation", None) is None
1976
+
1977
+ async def resume(self, decision: Dict[str, Any]) -> AsyncIterator[SseEvent]:
1978
+ """사람의 승인·거부를 받아 도구를 마무리하고 스트림을 이어 간다.
1979
+
1980
+ 브라우저 도구는 프론트가 이미 실행했으므로 그 결과를 그대로 회신한다.
1981
+ pod 도구는 **승인만 받은 상태**라 여기서 실행한다 — 프론트에는 실행
1982
+ 분기가 없어서, 회신만 하면 명령이 안 돌고 성공으로 보고된다 (이슈 #88).
1983
+ """
1984
+ pending = self._pending_call
1985
+ if pending is None:
1986
+ yield self._error_event("재개할 도구 호출이 없습니다.")
1987
+ yield self._complete_event()
1988
+ return
1989
+
1990
+ decision_type = decision.get("type") if isinstance(decision, dict) else None
1991
+ args = decision.get("args") if isinstance(decision, dict) else None
1992
+ invalid_decision = not isinstance(decision_type, str) or decision_type not in {
1993
+ "approve",
1994
+ "edit",
1995
+ "reject",
1996
+ }
1997
+ if decision_type == "edit" and (not isinstance(args, dict) or not args):
1998
+ invalid_decision = True
1999
+ if (
2000
+ decision_type == "approve"
2001
+ and args is not None
2002
+ and not isinstance(args, dict)
2003
+ ):
2004
+ invalid_decision = True
2005
+ self._pending_call = None
2006
+ if invalid_decision:
2007
+ # 실행하지 않았다는 실패를 codex 에 돌려 턴이 승인 대기에 고립되지 않게 한다.
2008
+ await self._respond(
2009
+ pending["id"],
2010
+ _tool_result(
2011
+ {"success": False, "error": "승인 응답이 올바르지 않습니다."}
2012
+ ),
2013
+ )
2014
+ self._count_tool_call(pending["tool"], pending.get("call_id"))
2015
+
2016
+ # 승인 왕복도 이 스레드를 쓰는 중이다 — 그 사이 새 메시지가 같은 세션에
2017
+ # 얹히면 두 `_pump` 가 한 소켓을 읽는다 (이슈 #93).
2018
+ self._turn_active = True
2019
+ try:
2020
+ # 턴 시작 시각을 다시 찍지 않는다 — 승인 왕복은 턴의 일부다. 다시 찍으면
2021
+ # 승인 대기 시간이 `elapsed_ms` 에서 빠져 codex 가 client 보다 체계적으로
2022
+ # 빨라 보인다 (이슈 #92).
2023
+ tool = pending["tool"]
2024
+ if invalid_decision:
2025
+ pass
2026
+ elif (decision.get("type") or "") == "reject":
2027
+ feedback = (
2028
+ decision.get("feedback") or "사용자가 이 작업을 거부했습니다."
2029
+ )
2030
+ await self._respond(
2031
+ pending["id"],
2032
+ # 거부 사유는 사람이 친 글이라 사용자 입력처럼 엄격하게 가린다 (이슈 #155)
2033
+ _tool_result(
2034
+ {"success": False, "error": feedback}, tool_output=False
2035
+ ),
2036
+ )
2037
+ self._count_tool_call(tool, pending.get("call_id"))
2038
+ elif tool in BROWSER_TOOLS:
2039
+ await self._respond(
2040
+ pending["id"], _tool_result(decision.get("args") or {})
2041
+ )
2042
+ # 브라우저가 실행했어도 도구 호출 1건이다. 안 세면 codex 에서 가장 잦은
2043
+ # `jupyter_cell` 이 `agent_call_hst` 에서 통째로 빠진다 (이슈 #92).
2044
+ self._count_tool_call(tool, pending.get("call_id"))
2045
+ else:
2046
+ args = (
2047
+ decision["args"]
2048
+ if decision_type == "edit"
2049
+ else decision.get("args") or pending["args"]
2050
+ )
2051
+ violation = tool_call_violation(tool, args)
2052
+ if violation:
2053
+ await self._respond(
2054
+ pending["id"],
2055
+ _tool_result({"success": False, "error": violation}),
2056
+ )
2057
+ self._count_tool_call(tool, pending.get("call_id"))
2058
+ yield (
2059
+ None,
2060
+ {"status": "⛔ 수정된 인자가 플랫폼 규칙을 위반했습니다"},
2061
+ )
2062
+ else:
2063
+ async for event in self._run_and_respond(
2064
+ pending["id"], tool, args, pending.get("call_id")
2065
+ ):
2066
+ yield event
2067
+
2068
+ async for event in self._pump():
2069
+ yield event
2070
+ finally:
2071
+ self._turn_active = False
2072
+ self._close_unrecorded_turn()
2073
+
2074
+ async def cancel(self) -> None:
2075
+ """도는 턴을 멈춘다.
2076
+
2077
+ `turn/interrupt` 는 알림이 아니라 **요청**이고 `threadId`·`turnId` 를 둘 다
2078
+ 요구한다. 예전에는 빈 알림으로 보내서 codex 가 조용히 무시했다 (이슈 #96).
2079
+ """
2080
+ if not self._started or self._closed:
2081
+ return
2082
+ self._cancelled = True
2083
+ if not (self._codex_thread_id and self._codex_turn_id):
2084
+ # 아직 turn id 를 못 잡았다. 보내 봐야 거부되므로 화면 쪽만 취소로 둔다.
2085
+ logger.info("codex turn id 가 없어 turn/interrupt 를 보내지 않습니다")
2086
+ return
2087
+ await self._request(
2088
+ "turn/interrupt",
2089
+ {"threadId": self._codex_thread_id, "turnId": self._codex_turn_id},
2090
+ )
2091
+
2092
+ def mark_cancelled(self) -> None:
2093
+ """사용자가 세션을 걷었다(`/reset`) — 도는 턴이 소켓을 잃어도 오류로 알리지 않는다."""
2094
+ self._cancelled = True
2095
+
2096
+ # --- 슬래시 명령 (이슈 #121) ---
2097
+
2098
+ async def ensure_open(self) -> None:
2099
+ """스레드가 없으면 연다. 턴은 시작하지 않는다.
2100
+
2101
+ `/model`·`/mcp` 처럼 첫 메시지 전에 부를 수 있는 명령이 쓴다. 여기서 연
2102
+ 스레드는 다음 턴이 그대로 이어 쓴다 — 핸들러가 같은 세션을 들고 있다.
2103
+ """
2104
+ if self._closed:
2105
+ raise CodexError("이 codex 세션은 이미 닫혔습니다.")
2106
+ if self._started:
2107
+ return
2108
+ # `/resume` 이 거부돼 연결만 열려 있을 수 있다 — 소켓을 두 벌 열지 않는다
2109
+ if not self._initialized:
2110
+ await self._t.connect()
2111
+ await self._handshake()
2112
+
2113
+ async def switch_thread(self, thread_id: str) -> Dict[str, Any]:
2114
+ """기존 스레드(`/resume`)로 옮긴다. 새 스레드는 열지 않는다.
2115
+
2116
+ 거부되면(`CodexRejected` — 예: 턴이 한 번도 없어 rollout 이 없는 스레드) 지금
2117
+ 스레드를 그대로 둔다. 옮긴 뒤의 `turn/start` 는 새 스레드 id 로 간다.
2118
+ """
2119
+ await self.ensure_connected()
2120
+ result = await self.call(
2121
+ "thread/resume", {"threadId": thread_id, **self._thread_params()}
2122
+ )
2123
+ self._adopt(result)
2124
+ return result
2125
+
2126
+ async def fork_thread(self, kind: str = "fork") -> Dict[str, Any]:
2127
+ """지금 스레드를 문맥째 갈라 그쪽으로 옮긴다 (`/fork`·`/side`).
2128
+
2129
+ `ephemeral` fork 는 쓰지 않는다 — `excludeTurns:true` 를 함께 요구해 문맥이
2130
+ 0턴이 된다(실측). 곁가지(`/side`)도 보통 fork 이고 `kind` 로만 구별한다.
2131
+ """
2132
+ await self.ensure_open()
2133
+ parent = self._codex_thread_id
2134
+ result = await self.call(
2135
+ "thread/fork", {"threadId": parent, **self._thread_params()}
2136
+ )
2137
+ self._adopt(result)
2138
+ self._announce(kind, parent)
2139
+ return result
2140
+
2141
+ async def ensure_connected(self) -> None:
2142
+ """연결과 `initialize` 까지만 한다. 스레드는 열지 않는다 (`/resume` 목록)."""
2143
+ if self._closed:
2144
+ raise CodexError("이 codex 세션은 이미 닫혔습니다.")
2145
+ if not self._initialized:
2146
+ await self._t.connect()
2147
+ await self._initialize()
2148
+
2149
+ def leave_thread(self) -> None:
2150
+ """지금 스레드에서 손을 뗀다 (`/archive`·`/delete` 뒤). 다음 턴은 새 스레드다.
2151
+
2152
+ 뒤따르는 슬래시 명령은 이 연결 위에 `thread/start` 만 다시 보낸다. 보통
2153
+ 메시지는 `reusable` 이 False 라 핸들러가 세션째 새로 연다.
2154
+ """
2155
+ self._started = False
2156
+ self._codex_thread_id = ""
2157
+ self._codex_turn_id = ""
2158
+ self._thread_info = {}
2159
+
2160
+ def restart_thread(
2161
+ self,
2162
+ developer_instructions: Optional[str] = None,
2163
+ dynamic_tools: Optional[List[Dict[str, Any]]] = None,
2164
+ ) -> None:
2165
+ """다음 턴을 새 스레드에서, 바꾼 지시문·도구로 연다. 연결은 그대로 쓴다.
2166
+
2167
+ EDA 는 단계마다 앞 단계의 대화가 섞이지 않아야 한다(LangChain 은 thread_id 를
2168
+ 갈았다). 단계마다 세션을 새로 열면 요청 하나가 centri 연결 상한을 여럿
2169
+ 차지하므로 한 연결에서 스레드만 바꾼다 (이슈 #155).
2170
+ """
2171
+ self.leave_thread()
2172
+ if developer_instructions is not None:
2173
+ self._developer_instructions = developer_instructions
2174
+ if dynamic_tools is not None:
2175
+ self._dynamic_tools = dynamic_tools
2176
+ self._allowed_tools = {spec["name"] for spec in dynamic_tools}
2177
+
2178
+ @property
2179
+ def qa_postprocessor(self) -> Any:
2180
+ return self._qa_postprocessor
2181
+
2182
+ @property
2183
+ def dynamic_tools(self) -> List[Dict[str, Any]]:
2184
+ """지금 스레드에 알리는 도구 — `restart_thread` 로 뺐다가 되돌릴 때 쓴다."""
2185
+ return list(self._dynamic_tools)
2186
+
2187
+ @contextlib.contextmanager
2188
+ def occupied(self) -> Iterator[None]:
2189
+ """턴 밖 슬래시 명령이 도는 동안 `busy` 로 보이게 한다.
2190
+
2191
+ 그 명령도 이 소켓에서 응답을 읽는다. 그 사이 두 번째 요청이 세션을 얻으면
2192
+ 응답 프레임을 `_pump` 가 가로채 삼킨다.
2193
+ """
2194
+ self._slash_active = True
2195
+ try:
2196
+ yield
2197
+ finally:
2198
+ self._slash_active = False
2199
+
2200
+ async def call(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
2201
+ """턴 밖 요청 하나를 보내고 `result` 를 돌려준다.
2202
+
2203
+ 거부는 `CodexRejected` 로 올리고 **세션을 닫지 않는다** — `_pump` 의 오류
2204
+ 경로와 다르다. 응답이 안 오거나 소켓이 끊긴 것은 세션이 죽은 것이라 닫는다.
2205
+ """
2206
+ if self._closed:
2207
+ raise CodexError("이 codex 세션은 이미 닫혔습니다.")
2208
+ try:
2209
+ response = await self._await_response(
2210
+ await self._request(method, params), method
2211
+ )
2212
+ except CodexRejected:
2213
+ raise
2214
+ except Exception:
2215
+ await self.close()
2216
+ raise
2217
+ return response.get("result") or {}
2218
+
2219
+ async def run_request(
2220
+ self,
2221
+ method: str,
2222
+ params: Dict[str, Any],
2223
+ *,
2224
+ deadline_s: Optional[float] = None,
2225
+ ) -> AsyncIterator[SseEvent]:
2226
+ """턴을 여는 요청(`thread/compact/start`·`review/start`)을 보내고 턴 끝까지 흘린다.
2227
+
2228
+ 두 요청은 응답이 먼저 오고 그 뒤에 turn/started … turn/completed 가 온다
2229
+ (실측 0.154.0). `thread/compacted` 알림은 오지 않았다 — 그래서 끝은
2230
+ `turn/completed` 하나로 본다. 거부 응답은 `run()` 과 달리 세션을 닫지 않는다.
2231
+
2232
+ `deadline_s` 가 지나면 소켓을 닫는다. 열어 두면 늦게 온 `turn/completed` 가
2233
+ 다음 턴의 `_pump` 를 그 자리에서 끝내 버린다.
2234
+ """
2235
+ self._turn_started_at = time.monotonic()
2236
+ self._tool_calls = 0
2237
+ self._counted_calls = set()
2238
+ self._usage_base = self._usage_total
2239
+ self._cancelled = False
2240
+ self._turn_active = True
2241
+ try:
2242
+ try:
2243
+ await self.call(method, dict(params, threadId=self._codex_thread_id))
2244
+ except CodexError as e:
2245
+ yield self._error_event(str(e))
2246
+ yield self._complete_event()
2247
+ return
2248
+ self._turn_open = True
2249
+ stream = self._pump()
2250
+ end = None if deadline_s is None else time.monotonic() + deadline_s
2251
+ while True:
2252
+ try:
2253
+ if end is None:
2254
+ event = await stream.__anext__()
2255
+ else:
2256
+ event = await asyncio.wait_for(
2257
+ stream.__anext__(), max(0.0, end - time.monotonic())
2258
+ )
2259
+ except StopAsyncIteration:
2260
+ return
2261
+ except asyncio.TimeoutError:
2262
+ self._record_turn("error: 시간 초과")
2263
+ yield self._error_event(
2264
+ f"codex 가 {deadline_s:.0f}초 안에 끝내지 않아 세션을 닫았습니다."
2265
+ )
2266
+ yield self._complete_event()
2267
+ await self.close()
2268
+ return
2269
+ yield event
2270
+ finally:
2271
+ self._turn_active = False
2272
+ self._close_unrecorded_turn()
2273
+
2274
+ def set_model(self, model: Optional[str], effort: Optional[str]) -> None:
2275
+ """다음 `turn/start` 부터 실을 모델·effort. None 이면 서버 기본값이다."""
2276
+ self._model_override = model
2277
+ self._effort_override = effort
2278
+
2279
+ @property
2280
+ def thread_id(self) -> str:
2281
+ """코드버프가 만든 스레드 id (codex 의 것이 아니다)."""
2282
+ return self._thread_id
2283
+
2284
+ @property
2285
+ def started(self) -> bool:
2286
+ return self._started and not self._closed
2287
+
2288
+ @property
2289
+ def busy(self) -> bool:
2290
+ """턴·슬래시 명령이 돌거나 승인을 기다리는 중이다 — 슬래시 명령을 얹으면 안 된다."""
2291
+ return (
2292
+ self._request_reserved
2293
+ or self._turn_active
2294
+ or self._slash_active
2295
+ or self._pending_call is not None
2296
+ )
2297
+
2298
+ def try_reserve(self, *, resume: bool = False) -> bool:
2299
+ """첫 await 전에 이 세션의 소켓 읽기 권한을 한 요청에 예약한다."""
2300
+ if self._request_reserved or self._turn_active or self._slash_active:
2301
+ return False
2302
+ if resume != (self._pending_call is not None):
2303
+ return False
2304
+ self._request_reserved = True
2305
+ return True
2306
+
2307
+ def release_reservation(self) -> None:
2308
+ self._request_reserved = False
2309
+
2310
+ @property
2311
+ def thread_info(self) -> Dict[str, Any]:
2312
+ """`thread/start` 응답. 핸드셰이크 전에는 빈 dict 다."""
2313
+ return self._thread_info
2314
+
2315
+ @property
2316
+ def model_override(self) -> Optional[str]:
2317
+ return self._model_override
2318
+
2319
+ @property
2320
+ def effort_override(self) -> Optional[str]:
2321
+ return self._effort_override
2322
+
2323
+ @property
2324
+ def permission_mode(self) -> str:
2325
+ return self._permission_mode
2326
+
2327
+ @property
2328
+ def mode(self) -> str:
2329
+ return self._mode
2330
+
2331
+ @property
2332
+ def workspace_root(self) -> str:
2333
+ return self._workspace_root
2334
+
2335
+ @property
2336
+ def connection_identity(self) -> Tuple[str, bytes]:
2337
+ """세션을 만든 중앙 접속 설정. 키 원문은 노출하지 않는다."""
2338
+ return self._endpoint, self._api_key_digest
2339
+
2340
+ @property
2341
+ def turn_active(self) -> bool:
2342
+ """유휴 회수에서 보호할 실제 실행 상태 (승인 대기는 제외)."""
2343
+ return self._request_reserved or self._turn_active or self._slash_active
2344
+
2345
+ @property
2346
+ def closed(self) -> bool:
2347
+ return self._closed
2348
+
2349
+ @property
2350
+ def codex_thread_id(self) -> str:
2351
+ """codex 가 준 스레드 id. 핸드셰이크 전에는 빈 문자열이다."""
2352
+ return self._codex_thread_id
2353
+
2354
+ @property
2355
+ def reusable(self) -> bool:
2356
+ """다음 턴을 이 스레드에 실을 수 있는가.
2357
+
2358
+ 셋 다 아니어야 한다.
2359
+
2360
+ - 열려 있고 이미 핸드셰이크를 마쳤다
2361
+ - 승인 대기(`_pending_call`)가 없다 — codex 는 그 도구 호출의 회신을
2362
+ 기다리는 중이라, 답하지 않은 채 `turn/start` 를 얹으면 두 흐름이 한
2363
+ 스레드에서 겹친다. 사용자가 승인 대화상자를 두고 새 메시지를 보내는
2364
+ 경로가 실제로 있다
2365
+ - 도는 턴이 없다 — 한 소켓을 두 스트림이 같이 읽으면 codex 의 프레임이
2366
+ 둘로 갈린다 (탭 두 개, 취소 없이 끊은 요청의 재시도)
2367
+
2368
+ 셋 중 하나라도 아니면 핸들러가 이 세션을 걷고 새 스레드를 연다 —
2369
+ 재사용 이전의 동작과 같다 (이슈 #93).
2370
+ """
2371
+ return (
2372
+ self._started
2373
+ and bool(self._codex_thread_id)
2374
+ and not self._closed
2375
+ and self._pending_call is None
2376
+ and not self._request_reserved
2377
+ and not self._turn_active
2378
+ and not self._slash_active
2379
+ )
2380
+
2381
+ def _close_unrecorded_turn(self) -> None:
2382
+ """승인 대기가 아닌데 turn 행 없이 끝났으면 오류로 닫는다.
2383
+
2384
+ 회신(`_respond`)이 끊긴 소켓에서 실패하거나 소비자가 스트림을 끊으면 예외가
2385
+ `_pump` 밖으로 새어 기록 지점을 건너뛴다. 승인 대기는 턴이 아직 안 끝난
2386
+ 것이라 두고, `resume()` 이나 `close()` 가 닫는다.
2387
+ """
2388
+ if self._turn_open and self._pending_call is None:
2389
+ self._record_turn("error: 턴이 기록 없이 끝남")
2390
+
2391
+ async def close(self) -> None:
2392
+ if self._turn_open:
2393
+ # 턴이 열린 채 닫혔다(승인 대기 중이거나 `_pump` 가 읽는 중에 중지·새 프롬프트·
2394
+ # /reset·유휴 회수·클라이언트 끊김). 깨어난 `_pump` 는 다시 적지 않는다. 안 적으면
2395
+ # 포기한 턴만 분모에서 빠져 codex 의 성공률이 부푼다 (#98)
2396
+ self._record_turn("cancelled" if self._cancelled else "abandoned")
2397
+ if not self._closed:
2398
+ self._closed = True
2399
+ await self._t.close()
2400
+
2401
+ async def _pump(self) -> AsyncIterator[SseEvent]:
2402
+ """종료 이벤트가 나올 때까지 서버 메시지를 읽어 SSE 로 바꾼다."""
2403
+ spoke = False # 이 스트림에서 agentMessage 본문을 이미 냈는가
2404
+ # Agent 밖(Chat/Q&A/노트북/EDA)은 LangChain 처럼 최종 답만 본문으로 낸다 (이슈 #155).
2405
+ # agentMessage 의 phase 는 `item/started` 에만 오므로 아이템별로 기억한다
2406
+ answer_only = self._mode != "agent"
2407
+ phases: Dict[str, Any] = {}
2408
+ commentary: Dict[str, List[str]] = {}
2409
+ while True:
2410
+ try:
2411
+ # 핸드셰이크가 응답을 기다리는 동안 먼저 온 알림부터 흘린다.
2412
+ message = (
2413
+ self._buffered.pop(0) if self._buffered else await self._t.receive()
2414
+ )
2415
+ except Exception as e:
2416
+ logger.error("codex 스트림 오류: %s", e, exc_info=True)
2417
+ self._record_turn(f"error: {e}")
2418
+ yield self._error_event(f"codex 스트림이 끊겼습니다: {e}")
2419
+ yield self._complete_event()
2420
+ await self.close()
2421
+ return
2422
+
2423
+ if message is None:
2424
+ # 서버가 종료 알림 없이 끊었다. 프론트가 예외를 던지지 않도록
2425
+ # 여기서 종료 이벤트를 책임지고 낸다.
2426
+ #
2427
+ # 취소해서 끊긴 것은 오류가 아니다. 핸들러가 `cancel()` 직후
2428
+ # 소켓을 닫으므로 `turn/completed{interrupted}` 가 올 자리가
2429
+ # 없고, 그것을 오류로 적으면 **모든 취소가 RDS 에 실패로
2430
+ # 집계된다** — 두 엔진을 같은 축으로 보려던 이슈 #92 의 관측이
2431
+ # 그만큼 왜곡된다.
2432
+ self._record_turn(
2433
+ "cancelled" if self._cancelled else "error: 종료 알림 없이 끊김"
2434
+ )
2435
+ if not self._cancelled:
2436
+ yield self._error_event(
2437
+ "codex 서버 연결이 종료 알림 없이 끊겼습니다."
2438
+ )
2439
+ yield self._complete_event()
2440
+ await self.close()
2441
+ return
2442
+
2443
+ self._remember_ids(message)
2444
+ method = message.get("method")
2445
+ if method is None:
2446
+ # 우리 요청에 대한 응답 — 오류만 사용자에게 보인다.
2447
+ if "error" in message:
2448
+ if await self._reopen_if_thread_is_gone(message):
2449
+ continue
2450
+ err = message["error"]
2451
+ detail = err.get("message") if isinstance(err, dict) else str(err)
2452
+ self._record_turn(f"error: {detail}")
2453
+ yield self._error_event(f"codex 요청이 거부됐습니다: {detail}")
2454
+ yield self._complete_event()
2455
+ await self.close()
2456
+ return
2457
+ continue
2458
+
2459
+ params = message.get("params") or {}
2460
+
2461
+ if message.get("id") is None and self._is_foreign(params):
2462
+ # 서브에이전트 스레드의 알림 (이슈 #130). 본문·턴 종료·토큰을 부모
2463
+ # 것으로 읽으면 자식의 글이 답에 섞이고, 자식 턴이 끝날 때 부모
2464
+ # 스트림이 닫힌다. 무엇을 하는지만 진행 줄로 보인다. 요청(id 있음)은
2465
+ # 아래로 그대로 보낸다 — 답을 안 하면 자식 턴이 매달린다.
2466
+ line = self._describe_foreign(method, params)
2467
+ if line:
2468
+ yield (None, {"status": line})
2469
+ continue
2470
+
2471
+ if method == "item/agentMessage/delta":
2472
+ delta = params.get("delta", "")
2473
+ item_id = str(params.get("itemId") or "")
2474
+ if delta and answer_only and phases.get(item_id) == "commentary":
2475
+ # 도구를 부르기 전의 "찾아보겠습니다" 류. LangChain 은 이것을 본문에
2476
+ # 안 낸다 — 모아 두었다가 진행 줄 하나로 보인다
2477
+ commentary.setdefault(item_id, []).append(delta)
2478
+ elif delta:
2479
+ if self._tool_processing:
2480
+ self._tool_processing = False
2481
+ yield (None, {"tool_processing_end": True})
2482
+ spoke = True
2483
+ if delta.strip():
2484
+ self._turn_has_answer = True
2485
+ yield (None, {"content": delta})
2486
+ elif method == "item/started":
2487
+ item = params.get("item") or {}
2488
+ if item.get("type") == "agentMessage":
2489
+ phases[str(item.get("id") or "")] = item.get("phase")
2490
+ if spoke and not (
2491
+ answer_only and item.get("phase") == "commentary"
2492
+ ):
2493
+ # 메시지 경계를 본문에 남긴다 — 없으면 두 메시지가 한 문장으로 붙는다
2494
+ yield (None, {"content": "\n\n"})
2495
+ if item.get("type") == "dynamicToolCall":
2496
+ self._count_tool_call(item.get("tool") or "", item.get("id"))
2497
+ if item.get("type") in _TOOL_ITEM_TYPES and answer_only and spoke:
2498
+ # phase 를 안 주는 모델이다. 도구를 부른 턴의 본문은 LangChain 처럼
2499
+ # 버린다 — 이미 흘렸으므로 프론트에 지우라고 알린다(`content_clear`).
2500
+ # #1774 전 게이트웨이는 검색 전용 config 를 버려 셸·웹·MCP 도구도 열렸다
2501
+ spoke = False
2502
+ self._turn_has_answer = False
2503
+ yield (None, {"content_clear": True})
2504
+ line = self._started_line(item)
2505
+ if isinstance(line, dict):
2506
+ yield (None, line)
2507
+ elif line:
2508
+ yield (None, {"status": line})
2509
+ elif method == "item/completed":
2510
+ item = params.get("item") or {}
2511
+ item_id = str(item.get("id") or "")
2512
+ if item_id in commentary:
2513
+ text = str(item.get("text") or "".join(commentary.pop(item_id)))
2514
+ if text.strip():
2515
+ yield (None, {"status": _one_line(f"💬 {text}")})
2516
+ continue
2517
+ if answer_only and item.get("type") == "reasoning":
2518
+ # LangChain 경로에는 모델의 생각 요약 줄이 없다
2519
+ continue
2520
+ if item.get("type") == "exitedReviewMode" and not spoke:
2521
+ # 리뷰 결과는 이 item 의 `review` 에 평문으로 온다. 같은 글이
2522
+ # agentMessage 로도 흐르면 그쪽이 이미 냈으므로 두 번 싣지 않는다 (이슈 #121)
2523
+ review = str(item.get("review") or "")
2524
+ if review:
2525
+ spoke = True
2526
+ yield (None, {"content": review})
2527
+ line = _describe_completed(item)
2528
+ if line:
2529
+ yield (None, {"status": line})
2530
+ elif method == "item/tool/call":
2531
+ async for event in self._handle_tool_call(message):
2532
+ yield event
2533
+ if self._pending_call is not None or self._closed:
2534
+ return
2535
+ elif method == "turn/plan/updated":
2536
+ # deepagents 의 write_todos 가 내던 todo 패널과 같은 꼴로 옮긴다 (이슈 #155)
2537
+ if self._mode == "agent":
2538
+ yield (
2539
+ "todos",
2540
+ {
2541
+ "todos": [
2542
+ {
2543
+ "content": str(step.get("step") or ""),
2544
+ "status": _PLAN_STATUS.get(
2545
+ str(step.get("status")), "pending"
2546
+ ),
2547
+ }
2548
+ for step in params.get("plan") or []
2549
+ if isinstance(step, dict)
2550
+ ]
2551
+ },
2552
+ )
2553
+ elif method == "thread/tokenUsage/updated":
2554
+ total = (params.get("tokenUsage") or {}).get("total")
2555
+ if isinstance(total, dict):
2556
+ self._usage_total = total
2557
+ elif method == "turn/completed":
2558
+ # **`turn/failed` 라는 알림은 codex 에 없다.** 실패도 이 알림으로
2559
+ # 오고 `turn.status` 가 가른다. 예전에는 무조건 `ok` 로 적재해서
2560
+ # 실패한 턴이 성공으로 집계됐다 (이슈 #96).
2561
+ turn = params.get("turn") or {}
2562
+ status = str(turn.get("status") or "completed")
2563
+ if status == "failed":
2564
+ error = turn.get("error")
2565
+ detail = str((error or {}).get("message") or "턴이 실패했습니다.")
2566
+ self._record_turn(f"error: {detail}")
2567
+ yield self._error_event(detail)
2568
+ yield self._complete_event()
2569
+ await self.close()
2570
+ return
2571
+ if status == "interrupted":
2572
+ self._cancelled = True
2573
+ if status == "completed" and self._wants_answer_follow_up():
2574
+ # 답 없이 끝났다 — `run()` 이 후속 턴을 얹고, 그 턴이 이 행을 닫는다
2575
+ pass
2576
+ else:
2577
+ self._record_turn("ok" if status == "completed" else status)
2578
+ yield self._complete_event()
2579
+ # 소켓을 열어 둔다 — 다음 턴이 같은 스레드에 실려야 codex 가 앞
2580
+ # 턴을 기억한다 (이슈 #93). 회수는 http_handlers 의
2581
+ # `_sweep_codex_sessions` 가 유휴 TTL 로 한다. 실패 경로는
2582
+ # 위처럼 그대로 닫는다 — 끊긴 소켓은 재사용할 수 없다.
2583
+ return
2584
+ elif method == "error":
2585
+ # 본문은 `params.error.message` 다. `params.message` 를 읽던 탓에
2586
+ # 모든 오류가 "알 수 없는 오류" 로 보였다 (이슈 #96).
2587
+ info = params.get("error")
2588
+ raw = (info or {}).get("message") if isinstance(info, dict) else info
2589
+ # `str()` 을 바깥에 두면 `str(None)` 이 `"None"` 이라 폴백 문구에
2590
+ # 영영 안 닿는다 — 본문 없는 알림이 화면에 `None` 으로 나갔다.
2591
+ detail = str(raw) if raw else "알 수 없는 오류"
2592
+ if params.get("willRetry"):
2593
+ # codex 가 스스로 다시 시도한다 — 턴은 아직 안 끝났다.
2594
+ yield (None, {"status": f"codex 재시도: {detail}"})
2595
+ continue
2596
+ self._record_turn(f"error: {detail}")
2597
+ yield self._error_event(detail)
2598
+ yield self._complete_event()
2599
+ await self.close()
2600
+ return
2601
+ elif message.get("id") is not None:
2602
+ # id 가 있으면 **요청**이다. 답을 안 하면 codex 턴이 영원히
2603
+ # 매달려 스트림이 종료 이벤트 없이 끊긴다 (이슈 #88).
2604
+ # 회신 모양은 메서드마다 다르다 — 도구 결과 봉투로 다 답하면
2605
+ # codex 가 역직렬화에서 죽는다 (이슈 #96).
2606
+ decline = _DECLINE_RESPONSE.get(method)
2607
+ if decline is not None:
2608
+ logger.info("codex 의 네이티브 승인 요청을 거절합니다: %s", method)
2609
+ await self._respond(message.get("id"), dict(decline))
2610
+ else:
2611
+ await self._respond_error(
2612
+ message.get("id"),
2613
+ f"코드버프가 지원하지 않는 요청입니다: {method}",
2614
+ )
2615
+ # 그 밖의 알림(thread/started, turn/started, 명령 출력 delta 등)은
2616
+ # 흘려보낸다. 명령 출력은 답변이 아니라서 본문에 섞지 않는다.
2617
+
2618
+ async def _handle_tool_call(
2619
+ self, message: Dict[str, Any]
2620
+ ) -> AsyncIterator[SseEvent]:
2621
+ params = message.get("params") or {}
2622
+ tool = params.get("tool", "")
2623
+ args = params.get("arguments") or {}
2624
+ if tool not in self._allowed_tools:
2625
+ self._count_tool_call(tool, params.get("callId"))
2626
+ error = f"{self._mode} 모드에서 허용되지 않은 도구입니다: {tool}"
2627
+ yield (None, {"status": f"⛔ {error}"})
2628
+ await self._respond(
2629
+ message.get("id"),
2630
+ _tool_result({"success": False, "error": error}),
2631
+ )
2632
+ return
2633
+ if self._mode == "qa":
2634
+ # 같은 callId 는 한 번만 센다 — `item/started` 가 먼저 셌어도 그대로다
2635
+ self._count_tool_call(tool, params.get("callId"))
2636
+ if self._tool_calls > QA_MAX_TOOL_CALLS:
2637
+ # LangChain 은 여기서 스트림을 끊어 답이 없었다. codex 는 턴을 살려
2638
+ # 모은 결과로 답하게 한다 (이슈 #155)
2639
+ yield (
2640
+ None,
2641
+ {
2642
+ "status": f"도구 호출 횟수 제한({QA_MAX_TOOL_CALLS}회)에 "
2643
+ "도달하여 중단합니다."
2644
+ },
2645
+ )
2646
+ await self._respond(
2647
+ message.get("id"),
2648
+ _tool_result(
2649
+ {
2650
+ "success": False,
2651
+ "error": f"도구 호출 한도({QA_MAX_TOOL_CALLS}회)에 "
2652
+ "도달했습니다. 도구를 더 부르지 말고 지금까지의 결과로 답하세요.",
2653
+ }
2654
+ ),
2655
+ )
2656
+ return
2657
+ # 사내에서 boto3 를 직접 쓰는 코드는 사람에게 묻기 전에 돌려보낸다 — 어차피 실패할
2658
+ # 것을 승인받지 않는다. 모델이 오류를 읽고 bdpengine 으로 고쳐 다시 부른다 (이슈 #120).
2659
+ violation = tool_call_violation(tool, args)
2660
+ if violation:
2661
+ self._count_tool_call(tool, params.get("callId"))
2662
+ yield (
2663
+ None,
2664
+ {"status": "⛔ boto3 직접 사용 — bdpengine 으로 다시 쓰게 했습니다"},
2665
+ )
2666
+ yield (
2667
+ "tool_call",
2668
+ {
2669
+ "id": str(message.get("id") or ""),
2670
+ "tool": tool,
2671
+ "name": tool,
2672
+ "arguments": args,
2673
+ },
2674
+ )
2675
+ await self._respond(
2676
+ message.get("id"), _tool_result({"success": False, "error": violation})
2677
+ )
2678
+ return
2679
+ needs_browser = tool in BROWSER_TOOLS
2680
+ needs_approval = self._permission_mode == "ask" and tool in GUARDED_POD_TOOLS
2681
+
2682
+ if needs_browser or needs_approval:
2683
+ self._pending_call = {
2684
+ "id": message.get("id"),
2685
+ "call_id": params.get("callId"),
2686
+ "tool": tool,
2687
+ "args": args,
2688
+ }
2689
+ yield (
2690
+ "hitl_request",
2691
+ {
2692
+ "thread_id": self._thread_id,
2693
+ "action": _ACTION_NAME_MAP.get(tool, tool + "_tool"),
2694
+ "args": args,
2695
+ "description": params.get("description")
2696
+ or _hitl_description(tool, args),
2697
+ "actionCount": 1,
2698
+ "autoApprove": self._permission_mode == "bypass",
2699
+ },
2700
+ )
2701
+ return
2702
+
2703
+ async for event in self._run_and_respond(
2704
+ message.get("id"), tool, args, params.get("callId")
2705
+ ):
2706
+ yield event
2707
+
2708
+ async def _run_and_respond(
2709
+ self,
2710
+ call_id: Any,
2711
+ tool: str,
2712
+ args: Dict[str, Any],
2713
+ codex_call_id: Optional[str] = None,
2714
+ ) -> AsyncIterator[SseEvent]:
2715
+ """pod 도구를 실행하고 결과를 codex 에 회신한다.
2716
+
2717
+ 셸 명령은 출력을 한 줄씩(`│ `) 패널에 흘리고, 파일 변경은 diff 를 보인다 —
2718
+ 터미널에서 codex CLI 를 쓸 때 보이는 것을 사이드패널에서도 본다.
2719
+ """
2720
+ before = _read_for_diff(tool, args, self._workspace_root)
2721
+ # 빈 기존 파일도 `before` 가 "" 라 존재 여부는 따로 본다 — hook 의 Add/Update 헤더
2722
+ target = _resolve_in_workspace(args.get("path") or "", self._workspace_root)
2723
+ is_new_file = before == "" and not (target and os.path.isfile(target))
2724
+ result_success: Optional[bool] = None
2725
+ try:
2726
+ if tool == "write_todos":
2727
+ todos = _todos_from_args(args)
2728
+ if todos is None:
2729
+ # LangChain 은 pydantic 검증이 실행 전에 거부한다 — 모델이 고쳐 다시 부른다
2730
+ result = {"success": False, "error": _TODOS_ARG_ERROR}
2731
+ else:
2732
+ if todos:
2733
+ yield ("todos", {"todos": todos})
2734
+ # langchain `TodoListMiddleware`(deepagents 가 쓰는 것)의 회신 문구와 같다
2735
+ result = {
2736
+ "success": True,
2737
+ "result": f"Updated todo list to {todos}",
2738
+ }
2739
+ elif tool == "execute_command" and not args.get("background"):
2740
+ result = None
2741
+ async for kind, payload in _stream_command(
2742
+ args.get("command", ""),
2743
+ self._workspace_root,
2744
+ _idle_timeout(args),
2745
+ ):
2746
+ if kind == "output":
2747
+ yield (None, {"status": payload})
2748
+ else:
2749
+ result = payload
2750
+ yield (None, {"status": _command_summary(result)})
2751
+ else:
2752
+ runner = self._qa_tool_runners.get(tool)
2753
+ if runner is not None:
2754
+ result, raw_result = await self._qa_postprocessor.invoke(
2755
+ tool,
2756
+ args,
2757
+ str(codex_call_id or call_id or ""),
2758
+ runner.ainvoke,
2759
+ )
2760
+ result_success = _succeeded(raw_result)
2761
+ from codebuff_core.services.rag_chat_agent import (
2762
+ qa_tool_end_status,
2763
+ )
2764
+
2765
+ done = qa_tool_end_status(tool, raw_result, args)
2766
+ if done:
2767
+ yield (None, done)
2768
+ if not self._tool_processing:
2769
+ # 결과를 읽는 동안 패널이 "분석 중" 을 띄운다 (LangChain 과 같다)
2770
+ self._tool_processing = True
2771
+ yield (None, {"tool_processing": True})
2772
+ else:
2773
+ result = await run_pod_tool(
2774
+ tool, args, self._workspace_root, owner=self._thread_id
2775
+ )
2776
+ except Exception as e:
2777
+ logger.error("codex 도구 실행 실패 (%s): %s", tool, e, exc_info=True)
2778
+ result = {"success": False, "error": str(e)}
2779
+ succeeded = result_success if result_success is not None else _succeeded(result)
2780
+ process = _process_event(tool, args, result)
2781
+ if process is not None:
2782
+ # 패널의 프로세스 스트립이 이 이벤트로 행을 넣고 뺀다 (이슈 #130)
2783
+ yield ("process", process)
2784
+ outcome = codex_hooks.Outcome()
2785
+ if self._mode == "agent" and succeeded:
2786
+ # hook 이 무엇을 던져도 회신은 나가야 한다 — 안 나가면 codex 턴이 매달린다 (이슈 #88)
2787
+ try:
2788
+ async for event in self._post_tool_use(
2789
+ codex_call_id or call_id, tool, args, is_new_file, result, outcome
2790
+ ):
2791
+ yield event
2792
+ except Exception as e:
2793
+ logger.error("codex hook 실행 실패 (%s): %s", tool, e, exc_info=True)
2794
+ outcome.messages.append(f"hook 을 돌리지 못했습니다: {e}")
2795
+ yield (None, {"status": outcome.messages[-1]})
2796
+ if before is not None and succeeded:
2797
+ diff = _diff_lines(args.get("path", ""), before, self._workspace_root)
2798
+ if diff:
2799
+ yield (None, {"status": diff})
2800
+ self._count_tool_call(tool, codex_call_id)
2801
+ # `tool` 키는 프론트(`ApiService`)가 이 이벤트를 알아보는 조건이다.
2802
+ yield (
2803
+ "tool_call",
2804
+ {
2805
+ "id": str(call_id or ""),
2806
+ "tool": tool,
2807
+ "name": tool,
2808
+ "arguments": args,
2809
+ },
2810
+ )
2811
+ await self._respond(
2812
+ call_id,
2813
+ _with_hook_outcome(_tool_result(result, success=succeeded), outcome),
2814
+ )
2815
+
2816
+ async def _post_tool_use(
2817
+ self,
2818
+ tool_use_id: Any,
2819
+ tool: str,
2820
+ args: Dict[str, Any],
2821
+ is_new_file: bool,
2822
+ result: Any,
2823
+ outcome: "codex_hooks.Outcome",
2824
+ ) -> AsyncIterator[SseEvent]:
2825
+ """codex `PostToolUse` hook 을 pod 에서 돌린다 (이슈 #145, `codex_hooks`).
2826
+
2827
+ 도구가 성공했을 때만 부른다. 결과는 `outcome` 에 모이고 회신 봉투에 반영된다.
2828
+ diff 보다 먼저 돌아서, autofix 가 고친 것까지 패널 diff 에 보인다.
2829
+ """
2830
+ errors: List[str] = []
2831
+ hooks = codex_hooks.discover(self._workspace_root, errors)
2832
+ name = codex_hooks.codex_tool_name(tool)
2833
+ matched = [h for h in hooks if codex_hooks.matches(h.matcher, name)]
2834
+ if self._permission_mode == "ask":
2835
+ skipped = [h for h in matched if h.scope == "repo"]
2836
+ matched = [h for h in matched if h.scope != "repo"]
2837
+ if skipped:
2838
+ sources = ", ".join(sorted({h.source for h in skipped}))
2839
+ errors.append(
2840
+ f"ask 모드라 저장소 hook {len(skipped)}개를 건너뜀 ({sources}) — "
2841
+ "승인 없이 명령이 돌면 안 되므로 bypass 모드에서만 돈다"
2842
+ )
2843
+ if matched:
2844
+ payload = codex_hooks.build_payload(
2845
+ tool=tool,
2846
+ args=args,
2847
+ is_new_file=is_new_file,
2848
+ tool_response=result
2849
+ if isinstance(result, str)
2850
+ else json.dumps(result, ensure_ascii=False, default=str),
2851
+ tool_use_id=str(tool_use_id if tool_use_id is not None else ""),
2852
+ cwd=self._workspace_root,
2853
+ session_id=self._codex_thread_id,
2854
+ turn_id=self._codex_turn_id,
2855
+ model=self._model_override or str(self._thread_info.get("model") or ""),
2856
+ permission_mode=self._permission_mode,
2857
+ )
2858
+ env = _command_env()
2859
+ for hook in matched:
2860
+ if hook.status_message:
2861
+ yield (None, {"status": hook.status_message})
2862
+ try:
2863
+ run = await codex_hooks.run_hook(
2864
+ hook, payload, self._workspace_root, env
2865
+ )
2866
+ except OSError as e:
2867
+ outcome.messages.append(f"hook 을 실행하지 못했습니다: {e}")
2868
+ continue
2869
+ codex_hooks.interpret(hook, run, outcome)
2870
+ # 실패·시간 초과는 매번, 설정 오류·건너뜀·systemMessage 는 세션당 한 번 띄운다
2871
+ for message in outcome.messages:
2872
+ yield (None, {"status": message})
2873
+ for message in errors + outcome.notices:
2874
+ if message not in self._hook_notices:
2875
+ self._hook_notices.add(message)
2876
+ yield (None, {"status": message})
2877
+
2878
+
2879
+ def _with_hook_outcome(
2880
+ reply: Dict[str, Any], outcome: "codex_hooks.Outcome"
2881
+ ) -> Dict[str, Any]:
2882
+ """hook 결과를 회신 봉투에 싣는다 — codex 가 모델에 넘기는 방식과 같다.
2883
+
2884
+ `block` 은 실행을 되돌리지 않고 결과 텍스트를 이유로 바꾸고,
2885
+ `additionalContext` 는 결과 뒤에 덧붙는다.
2886
+ """
2887
+ item = reply["contentItems"][0]
2888
+ if outcome.block_reason is not None:
2889
+ item["text"] = _mask(outcome.block_reason)[0]
2890
+ if outcome.context:
2891
+ item["text"] += "\n\n" + _mask("\n".join(outcome.context))[0]
2892
+ return reply
2893
+
2894
+
2895
+ def _succeeded(result: Any) -> bool:
2896
+ """도구 결과가 성공인지 본다.
2897
+
2898
+ pod 도구(`langchain_tools`)는 성공도 실패도 **JSON 문자열**로 돌려주고,
2899
+ 프론트가 실행한 결과는 `execution_result` 안에 성패가 들어 있다. 겉 타입만
2900
+ 보면 "파일 없음" 도 성공으로 회신된다 (이슈 #88).
2901
+ """
2902
+ if isinstance(result, str):
2903
+ try:
2904
+ parsed = json.loads(result)
2905
+ except (ValueError, TypeError):
2906
+ return True
2907
+ return _succeeded(parsed) if isinstance(parsed, dict) else True
2908
+ if not isinstance(result, dict):
2909
+ return True
2910
+ if "success" in result:
2911
+ return bool(result["success"])
2912
+ if "error" in result:
2913
+ return not result["error"]
2914
+ inner = result.get("execution_result")
2915
+ return _succeeded(inner) if isinstance(inner, (dict, str)) else True
2916
+
2917
+
2918
+ def _tool_result(
2919
+ result: Any, success: Optional[bool] = None, tool_output: bool = True
2920
+ ) -> Dict[str, Any]:
2921
+ """codex 가 기대하는 도구 응답 봉투로 감싼다.
2922
+
2923
+ `tool_output=False` 는 사람이 친 글(거부 사유)을 싣는 회신이다 — 프롬프트처럼 가린다.
2924
+ """
2925
+ if success is None:
2926
+ success = _succeeded(result)
2927
+ return {
2928
+ "contentItems": [
2929
+ {"type": "inputText", "text": mask_tool_output(result, tool_output)}
2930
+ ],
2931
+ "success": success,
2932
+ }
2933
+
2934
+
2935
+ def mask_tool_output(result: Any, tool_output: bool = True) -> str:
2936
+ """도구 결과를 가려 모델에 보낼 문자열로 만든다.
2937
+
2938
+ LangChain Agent 경로(`PiiMaskMiddleware`)도 같은 규칙을 쓴다 (이슈 #155).
2939
+ """
2940
+ payload = result
2941
+ # JSON 객체·배열 문자열(read_file 결과)만 연다 — `1e5` 같은 텍스트가 숫자로 바뀌면 안 된다
2942
+ if isinstance(result, str) and result.lstrip()[:1] in ("{", "["):
2943
+ try:
2944
+ payload = json.loads(result)
2945
+ except ValueError:
2946
+ payload = result
2947
+ # 도구 결과는 파일 내용·명령 출력이라 그대로 pod 밖으로 나간다. 식별자 숫자가
2948
+ # 흔해서 카드·건강보험에는 문맥 조건을 더한 모드로 가린다 (이슈 #155).
2949
+ # 직렬화한 뒤에 가리면 줄바꿈이 `\n` 두 글자가 되어 줄머리 번호를 못 잡으므로,
2950
+ # 문자열 값마다 먼저 가리고 직렬화한다.
2951
+ masked = _mask_leaves(payload, tool_output)
2952
+ if not isinstance(masked, str):
2953
+ masked = json.dumps(masked, ensure_ascii=False, default=str)
2954
+ return masked
2955
+
2956
+
2957
+ def _mask_leaves(value: Any, tool_output: bool, key: Any = None) -> Any:
2958
+ """JSON 값의 문자열마다 `mask_pii` 를 건다.
2959
+
2960
+ 값 앞에 키를 붙여 가린다 — `card_no`·`snapshot_id` 같은 키가 문맥 판정의 근거라서다.
2961
+ 마스킹은 같은 길이 `*` 로 바꾸므로 붙인 접두만큼 잘라 내면 값이 남는다.
2962
+ """
2963
+ if isinstance(value, str):
2964
+ prefix = f"{key}: " if isinstance(key, str) else ""
2965
+ masked = mask_pii(prefix + value, tool_output=tool_output).masked_text
2966
+ return masked[len(prefix) :]
2967
+ if isinstance(value, dict):
2968
+ return {
2969
+ _mask_leaves(k, tool_output) if isinstance(k, str) else k: _mask_leaves(
2970
+ v, tool_output, k
2971
+ )
2972
+ for k, v in value.items()
2973
+ }
2974
+ if isinstance(value, (list, tuple)):
2975
+ return [_mask_leaves(v, tool_output, key) for v in value]
2976
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
2977
+ # 숫자로 온 번호도 직렬화 전처럼 가린다. 가려지면 문자열로 바뀐다
2978
+ masked = _mask_leaves(str(value), tool_output, key)
2979
+ return value if masked == str(value) else masked
2980
+ if value is None or isinstance(value, bool):
2981
+ return value
2982
+ # 그 밖의 객체는 직렬화가 `default=str` 로 문자열이 되므로 그 문자열을 가린다
2983
+ return _mask_leaves(str(value), tool_output, key)
2984
+
2985
+
2986
+ def _process_event(tool: str, args: Dict[str, Any], result: Any) -> Optional[Dict]:
2987
+ """백그라운드 시작·종료 결과를 SSE `process` 이벤트 데이터로."""
2988
+ if not isinstance(result, dict) or not result.get("success"):
2989
+ return None
2990
+ if tool == "kill_process" or (tool == "execute_command" and args.get("background")):
2991
+ keys = ("id", "pid", "command", "state", "exit_code", "ports", "elapsed_s")
2992
+ return {k: result.get(k) for k in keys}
2993
+ return None
2994
+
2995
+
2996
+ async def run_pod_tool(
2997
+ tool: str, args: Dict[str, Any], workspace_root: str, owner: str = ""
2998
+ ) -> Any:
2999
+ """pod 안에서 끝나는 도구를 실행한다. 기존 LangChain 도구를 그대로 쓴다.
3000
+
3001
+ `owner` 는 백그라운드 프로세스를 띄운 대화(codebuff thread_id)이고 표시용이다.
3002
+ 레지스트리는 대화와 무관한 전역이라 대화를 리셋해도 프로세스는 남는다
3003
+ (`background_procs` 모듈 설명).
3004
+ """
3005
+ bg = background_procs
3006
+ from . import langchain_tools as lt
3007
+
3008
+ if tool == "execute_command" and args.get("background"):
3009
+ return await asyncio.to_thread(
3010
+ bg.start,
3011
+ args.get("command", ""),
3012
+ workspace_root,
3013
+ _command_env(),
3014
+ owner,
3015
+ )
3016
+ if tool == "list_processes":
3017
+ return await asyncio.to_thread(bg.list_processes)
3018
+ if tool == "read_process_output":
3019
+ return await asyncio.to_thread(
3020
+ bg.read_output, str(args.get("process_id", "")), args.get("tail")
3021
+ )
3022
+ if tool == "kill_process":
3023
+ return await asyncio.to_thread(bg.kill, str(args.get("process_id", "")))
3024
+ if tool == "execute_command":
3025
+ result: Dict[str, Any] = {}
3026
+ async for kind, payload in _stream_command(
3027
+ args.get("command", ""), workspace_root, _idle_timeout(args)
3028
+ ):
3029
+ if kind == "done":
3030
+ result = payload
3031
+ return result
3032
+ if tool == "edit_file":
3033
+ return await asyncio.to_thread(_edit_file, args, workspace_root)
3034
+ if tool == "load_skill":
3035
+ return await asyncio.to_thread(_load_skill, args, workspace_root)
3036
+
3037
+ factories = {
3038
+ "read_file": lt.create_file_read_tool,
3039
+ "write_file": lt.create_file_write_tool,
3040
+ "list_files": lt.create_file_list_tool,
3041
+ "search_code": lt.create_search_code_tool,
3042
+ }
3043
+ if tool not in factories:
3044
+ return {"success": False, "error": f"알 수 없는 도구: {tool}"}
3045
+ factory = factories[tool]
3046
+ payload = {k: v for k, v in args.items() if k in POD_TOOL_ARGS[tool]}
3047
+ result = await asyncio.to_thread(factory(workspace_root).invoke, payload)
3048
+ if tool == "read_file":
3049
+ result = _cap_read(result)
3050
+ return result
3051
+
3052
+
3053
+ _TODOS_ARG_ERROR = "todos 는 {content: 문자열, status: pending|in_progress|completed} 객체의 목록이어야 합니다."
3054
+
3055
+
3056
+ def _todos_from_args(args: Dict[str, Any]) -> Optional[List[Dict[str, str]]]:
3057
+ """`write_todos` 인자를 todo 패널 꼴로. 스키마에 안 맞으면 None — 고쳐 부르게 한다."""
3058
+ todos = args.get("todos")
3059
+ if not isinstance(todos, list):
3060
+ return None
3061
+ out = []
3062
+ for item in todos:
3063
+ if not isinstance(item, dict):
3064
+ return None
3065
+ content, status = item.get("content"), item.get("status")
3066
+ if not isinstance(content, str) or not isinstance(status, str):
3067
+ return None
3068
+ if status not in _TODO_STATUSES:
3069
+ return None
3070
+ out.append({"content": content, "status": status})
3071
+ return out
3072
+
3073
+
3074
+ def _load_skill(args: Dict[str, Any], workspace_root: str) -> Dict[str, Any]:
3075
+ """호출 때마다 다시 찾는다 — 대화 중에 사용자가 스킬을 더해도 보인다.
3076
+
3077
+ 같은 이름이면 커스텀(포털) 스킬이 내장 스킬을 이긴다 (이슈 #202, #219).
3078
+ """
3079
+ skills = custom_skills.with_builtin(custom_skills.discover(workspace_root))
3080
+ body = custom_skills.load_body(str(args.get("name") or ""), skills)
3081
+ # 오류 문장은 전부 "Error: " 로 시작한다. "Error handling …" 같은 본문을 실패로 읽지 않는다
3082
+ if body.startswith("Error: "):
3083
+ return {"success": False, "error": body}
3084
+ return {"success": True, "content": body}
3085
+
3086
+
3087
+ def _cap_read(result: str) -> str:
3088
+ """큰 파일은 앞부분만 돌려주고 나머지를 읽는 법을 적는다."""
3089
+ data = json.loads(result)
3090
+ content = (data.get("data") or {}).get("content")
3091
+ if not isinstance(content, str) or len(content) <= _READ_FILE_MAX_CHARS:
3092
+ return result
3093
+ data["data"]["content"] = (
3094
+ content[:_READ_FILE_MAX_CHARS]
3095
+ + f"\n… (전체 {len(content)}자 중 앞 {_READ_FILE_MAX_CHARS}자에서 잘랐다. "
3096
+ "나머지는 search_code 나 execute_command 의 `sed -n '시작,끝p'` 로 읽는다) …"
3097
+ )
3098
+ return json.dumps(data, ensure_ascii=False)
3099
+
3100
+
3101
+ def _idle_timeout(args: Dict[str, Any]) -> int:
3102
+ # 0·음수·못 읽는 값은 "한도 없음" 이 아니라 기본값이다 — `0` 과 `"0"` 이 갈리지 않게 (#100)
3103
+ try:
3104
+ value = int(args.get("timeout") or 0)
3105
+ except (TypeError, ValueError):
3106
+ value = 0
3107
+ if value <= 0:
3108
+ return _COMMAND_IDLE_TIMEOUT
3109
+ return min(value, _COMMAND_HARD_LIMIT)
3110
+
3111
+
3112
+ def _command_env() -> Dict[str, str]:
3113
+ """셸 명령의 환경. 사람이 이미 정한 인덱스는 덮지 않는다.
3114
+
3115
+ uv 는 `PIP_INDEX_URL` 을 안 보고 `UV_DEFAULT_INDEX`(옛 `UV_INDEX_URL`)를 본다.
3116
+ """
3117
+ env = dict(os.environ)
3118
+ url = read_pip_index_url(_PIP_INDEX_FILE)
3119
+ if url:
3120
+ env.setdefault("PIP_INDEX_URL", url)
3121
+ if "UV_INDEX_URL" not in env:
3122
+ env.setdefault("UV_DEFAULT_INDEX", url)
3123
+ return env
3124
+
3125
+
3126
+ async def _stream_command(
3127
+ command: str, workspace_root: str, idle_timeout: int
3128
+ ) -> AsyncIterator[Tuple[str, Any]]:
3129
+ """셸 명령을 돌리며 출력을 `("output", 줄들)` 로 흘리고 끝에 `("done", 결과)` 를 낸다.
3130
+
3131
+ 한도는 **출력이 없는 시간**이다. mvn 첫 빌드처럼 오래 걸려도 출력이 이어지면
3132
+ 살아 있는 것이므로 끊지 않는다. 절대 한도(`_COMMAND_HARD_LIMIT`)만 따로 건다.
3133
+ """
3134
+ if not command:
3135
+ yield ("done", {"success": False, "error": "명령이 비어 있습니다."})
3136
+ return
3137
+ started = time.monotonic()
3138
+ proc = await asyncio.create_subprocess_shell(
3139
+ command,
3140
+ cwd=workspace_root if os.path.isdir(workspace_root) else None,
3141
+ # 서버의 stdin(소켓)을 물려받으면 대화형 명령이 무출력 한도까지 매달린다 (#100)
3142
+ stdin=asyncio.subprocess.DEVNULL,
3143
+ stdout=asyncio.subprocess.PIPE,
3144
+ stderr=asyncio.subprocess.STDOUT,
3145
+ # 셸이 띄운 자식(java 등)까지 한 번에 끊으려고 프로세스 그룹을 따로 둔다
3146
+ start_new_session=True,
3147
+ env=_command_env(),
3148
+ )
3149
+ try:
3150
+ async for event in _pump_command(proc, command, started, idle_timeout):
3151
+ yield event
3152
+ finally:
3153
+ # 턴 취소·SSE 끊김·세션 회수로 제너레이터가 닫히면 여기로 온다. 셸이 아직
3154
+ # 살아 있으면 그 그룹(셸이 띄운 자식 포함)을 거둔다 — 안 그러면 고아가 된다 (#130)
3155
+ if proc.returncode is None:
3156
+ _kill_group(proc)
3157
+ await proc.wait()
3158
+
3159
+
3160
+ async def _pump_command(
3161
+ proc: Any, command: str, started: float, idle_timeout: int
3162
+ ) -> AsyncIterator[Tuple[str, Any]]:
3163
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
3164
+ collected: List[str] = []
3165
+ pending = ""
3166
+ error = ""
3167
+ while True:
3168
+ remaining = _COMMAND_HARD_LIMIT - (time.monotonic() - started)
3169
+ wait = min(idle_timeout, remaining)
3170
+ try:
3171
+ if wait <= 0:
3172
+ raise asyncio.TimeoutError
3173
+ chunk = await asyncio.wait_for(proc.stdout.read(4096), timeout=wait)
3174
+ except asyncio.TimeoutError:
3175
+ if remaining <= idle_timeout:
3176
+ error = f"{_COMMAND_HARD_LIMIT}초를 넘겨 멈췄습니다"
3177
+ else:
3178
+ error = f"{idle_timeout}초 동안 출력이 없어 멈췄습니다"
3179
+ break
3180
+ if not chunk:
3181
+ break
3182
+ text = decoder.decode(chunk)
3183
+ collected.append(text)
3184
+ pending += text
3185
+ lines = pending.split("\n")
3186
+ pending = lines.pop()
3187
+ if lines:
3188
+ yield ("output", "\n".join("│ " + line for line in lines))
3189
+ pending += decoder.decode(b"", final=True)
3190
+ if pending:
3191
+ yield ("output", "│ " + pending)
3192
+ output = _truncate_output("".join(collected))
3193
+ elapsed = round(time.monotonic() - started, 1)
3194
+ if error:
3195
+ _kill_group(proc)
3196
+ await proc.wait()
3197
+ yield (
3198
+ "done",
3199
+ {
3200
+ "success": False,
3201
+ "error": f"{error}: {shlex.quote(command)}",
3202
+ "output": output,
3203
+ "elapsed_s": elapsed,
3204
+ },
3205
+ )
3206
+ return
3207
+ await proc.wait()
3208
+ yield (
3209
+ "done",
3210
+ {
3211
+ "success": proc.returncode == 0,
3212
+ "exit_code": proc.returncode,
3213
+ "output": output,
3214
+ "elapsed_s": elapsed,
3215
+ },
3216
+ )
3217
+
3218
+
3219
+ def _kill_group(proc: Any) -> None:
3220
+ try:
3221
+ os.killpg(proc.pid, 9)
3222
+ except (ProcessLookupError, PermissionError):
3223
+ try:
3224
+ proc.kill()
3225
+ except ProcessLookupError:
3226
+ pass
3227
+
3228
+
3229
+ def _truncate_output(text: str) -> str:
3230
+ """모델에 돌려줄 출력을 앞뒤만 남기고 자른다 (codex CLI 와 같은 방식).
3231
+
3232
+ 빌드 로그는 앞(무엇을 했나)과 뒤(어떻게 끝났나)가 중요하고 가운데는 반복이다.
3233
+ """
3234
+ lines = text.splitlines()
3235
+ keep = _OUTPUT_HEAD_LINES + _OUTPUT_TAIL_LINES
3236
+ if len(lines) > keep:
3237
+ omitted = len(lines) - keep
3238
+ lines = (
3239
+ lines[:_OUTPUT_HEAD_LINES]
3240
+ + [f"… ({omitted}줄 생략) …"]
3241
+ + lines[-_OUTPUT_TAIL_LINES:]
3242
+ )
3243
+ result = "\n".join(lines)
3244
+ if len(result) > _OUTPUT_MAX_CHARS:
3245
+ head = _OUTPUT_MAX_CHARS // 4
3246
+ tail = _OUTPUT_MAX_CHARS - head
3247
+ omitted = len(result) - _OUTPUT_MAX_CHARS
3248
+ result = f"{result[:head]}\n… ({omitted}자 생략) …\n{result[-tail:]}"
3249
+ return result
3250
+
3251
+
3252
+ def _command_summary(result: Any) -> str:
3253
+ if not isinstance(result, dict):
3254
+ return "명령 종료"
3255
+ elapsed = result.get("elapsed_s")
3256
+ took = f" · {elapsed}초" if elapsed is not None else ""
3257
+ if elapsed is not None and elapsed < 0.1:
3258
+ # 반올림한 "0.0초" 는 시간을 못 잰 것처럼 읽힌다
3259
+ took = " · 0.1초 미만"
3260
+ if "exit_code" in result:
3261
+ return f"종료 코드 {result['exit_code']}{took}"
3262
+ return _one_line(f"명령 실패: {result.get('error') or ''}{took}")
3263
+
3264
+
3265
+ def _resolve_in_workspace(path: str, workspace_root: str) -> Optional[str]:
3266
+ """작업공간 안의 실제 경로. 밖을 가리키면 None."""
3267
+ root = os.path.realpath(workspace_root)
3268
+ target = os.path.realpath(os.path.join(root, path))
3269
+ if target != root and not target.startswith(root + os.sep):
3270
+ return None
3271
+ return target
3272
+
3273
+
3274
+ def _edit_file(args: Dict[str, Any], workspace_root: str) -> Dict[str, Any]:
3275
+ """파일에서 old_string 을 new_string 으로 바꾼다. 물리 파일을 직접 고친다."""
3276
+ path = args.get("path") or ""
3277
+ old = args.get("old_string") or ""
3278
+ new = args.get("new_string") or ""
3279
+ replace_all = bool(args.get("replace_all"))
3280
+ target = _resolve_in_workspace(path, workspace_root)
3281
+ if target is None:
3282
+ return {"success": False, "error": f"작업공간 밖의 경로입니다: {path}"}
3283
+ if not os.path.isfile(target):
3284
+ return {"success": False, "error": f"파일을 찾을 수 없음: {path}"}
3285
+ if not old:
3286
+ return {"success": False, "error": "old_string 이 비어 있습니다."}
3287
+ with open(target, encoding="utf-8", newline="") as f:
3288
+ text = f.read()
3289
+ count = text.count(old)
3290
+ if count == 0:
3291
+ return {"success": False, "error": f"old_string 을 찾지 못했습니다: {path}"}
3292
+ if count > 1 and not replace_all:
3293
+ return {
3294
+ "success": False,
3295
+ "error": (
3296
+ f"old_string 이 {count}곳과 일치합니다. 앞뒤 줄을 더 넣어 한 곳으로 "
3297
+ "좁히거나 replace_all 을 켜세요."
3298
+ ),
3299
+ }
3300
+ updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
3301
+ with open(target, "w", encoding="utf-8", newline="") as f:
3302
+ f.write(updated)
3303
+ return {"success": True, "path": path, "replacements": count if replace_all else 1}
3304
+
3305
+
3306
+ def _read_for_diff(
3307
+ tool: str, args: Dict[str, Any], workspace_root: str
3308
+ ) -> Optional[str]:
3309
+ """파일을 바꾸는 도구면 바꾸기 전 내용을 돌려준다 (새 파일이면 빈 문자열)."""
3310
+ if tool not in ("write_file", "edit_file"):
3311
+ return None
3312
+ target = _resolve_in_workspace(args.get("path") or "", workspace_root)
3313
+ if target is None:
3314
+ return None
3315
+ if not os.path.isfile(target):
3316
+ return ""
3317
+ try:
3318
+ with open(target, encoding="utf-8", errors="replace") as f:
3319
+ return f.read()
3320
+ except OSError:
3321
+ return None
3322
+
3323
+
3324
+ def _diff_lines(path: str, before: str, workspace_root: str) -> str:
3325
+ """바뀐 내용을 CLI 처럼 diff 로 보인다. 너무 길면 자른다."""
3326
+ target = _resolve_in_workspace(path, workspace_root)
3327
+ if target is None or not os.path.isfile(target):
3328
+ return ""
3329
+ with open(target, encoding="utf-8", errors="replace") as f:
3330
+ after = f.read()
3331
+ diff = list(
3332
+ difflib.unified_diff(before.splitlines(), after.splitlines(), lineterm="", n=2)
3333
+ )[2:]
3334
+ if not diff:
3335
+ return ""
3336
+ if len(diff) > _DIFF_MAX_LINES:
3337
+ more = len(diff) - _DIFF_MAX_LINES
3338
+ diff = diff[:_DIFF_MAX_LINES] + [f"… ({more}줄 더)"]
3339
+ return "\n".join("│ " + line for line in diff)