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