hdsp-jupyter-extension 2.0.5__py3-none-any.whl → 2.0.7__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.
- agent_server/core/reflection_engine.py +0 -1
- agent_server/knowledge/watchdog_service.py +1 -1
- agent_server/langchain/ARCHITECTURE.md +1193 -0
- agent_server/langchain/agent.py +74 -551
- agent_server/langchain/custom_middleware.py +636 -0
- agent_server/langchain/executors/__init__.py +2 -7
- agent_server/langchain/executors/notebook_searcher.py +46 -38
- agent_server/langchain/hitl_config.py +66 -0
- agent_server/langchain/llm_factory.py +166 -0
- agent_server/langchain/logging_utils.py +184 -0
- agent_server/langchain/prompts.py +119 -0
- agent_server/langchain/state.py +16 -6
- agent_server/langchain/tools/__init__.py +6 -0
- agent_server/langchain/tools/file_tools.py +91 -129
- agent_server/langchain/tools/jupyter_tools.py +18 -18
- agent_server/langchain/tools/resource_tools.py +161 -0
- agent_server/langchain/tools/search_tools.py +198 -216
- agent_server/langchain/tools/shell_tools.py +54 -0
- agent_server/main.py +4 -1
- agent_server/routers/health.py +1 -1
- agent_server/routers/langchain_agent.py +941 -305
- hdsp_agent_core/prompts/auto_agent_prompts.py +3 -3
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/build_log.json +1 -1
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/package.json +2 -2
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.8cc4873c413ed56ff485.js → hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.4770ec0fb2d173b6deb4.js +314 -8
- hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.4770ec0fb2d173b6deb4.js.map +1 -0
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.a223ea20056954479ae9.js → hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.29cf4312af19e86f82af.js +1547 -330
- hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.29cf4312af19e86f82af.js.map +1 -0
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.37299706f55c6d46099d.js → hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.61343eb4cf0577e74b50.js +8 -8
- hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.61343eb4cf0577e74b50.js.map +1 -0
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js → hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js +209 -2
- hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js.map +1 -0
- jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js → hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js +2 -209
- hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js.map +1 -0
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js → hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js +3 -212
- hdsp_jupyter_extension-2.0.7.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js.map +1 -0
- {hdsp_jupyter_extension-2.0.5.dist-info → hdsp_jupyter_extension-2.0.7.dist-info}/METADATA +2 -1
- {hdsp_jupyter_extension-2.0.5.dist-info → hdsp_jupyter_extension-2.0.7.dist-info}/RECORD +71 -68
- jupyter_ext/_version.py +1 -1
- jupyter_ext/handlers.py +1176 -58
- jupyter_ext/labextension/build_log.json +1 -1
- jupyter_ext/labextension/package.json +2 -2
- jupyter_ext/labextension/static/{frontend_styles_index_js.8cc4873c413ed56ff485.js → frontend_styles_index_js.4770ec0fb2d173b6deb4.js} +314 -8
- jupyter_ext/labextension/static/frontend_styles_index_js.4770ec0fb2d173b6deb4.js.map +1 -0
- jupyter_ext/labextension/static/{lib_index_js.a223ea20056954479ae9.js → lib_index_js.29cf4312af19e86f82af.js} +1547 -330
- jupyter_ext/labextension/static/lib_index_js.29cf4312af19e86f82af.js.map +1 -0
- jupyter_ext/labextension/static/{remoteEntry.37299706f55c6d46099d.js → remoteEntry.61343eb4cf0577e74b50.js} +8 -8
- jupyter_ext/labextension/static/remoteEntry.61343eb4cf0577e74b50.js.map +1 -0
- jupyter_ext/labextension/static/{vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js → vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js} +209 -2
- jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js.map +1 -0
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js → jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js +2 -209
- jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js.map +1 -0
- jupyter_ext/labextension/static/{vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js → vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js} +3 -212
- jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js.map +1 -0
- jupyter_ext/resource_usage.py +180 -0
- jupyter_ext/tests/test_handlers.py +58 -0
- agent_server/langchain/executors/jupyter_executor.py +0 -429
- agent_server/langchain/middleware/__init__.py +0 -36
- agent_server/langchain/middleware/code_search_middleware.py +0 -278
- agent_server/langchain/middleware/error_handling_middleware.py +0 -338
- agent_server/langchain/middleware/jupyter_execution_middleware.py +0 -301
- agent_server/langchain/middleware/rag_middleware.py +0 -227
- agent_server/langchain/middleware/validation_middleware.py +0 -240
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.8cc4873c413ed56ff485.js.map +0 -1
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.a223ea20056954479ae9.js.map +0 -1
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.37299706f55c6d46099d.js.map +0 -1
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js.map +0 -1
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js.map +0 -1
- hdsp_jupyter_extension-2.0.5.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +0 -1
- jupyter_ext/labextension/static/frontend_styles_index_js.8cc4873c413ed56ff485.js.map +0 -1
- jupyter_ext/labextension/static/lib_index_js.a223ea20056954479ae9.js.map +0 -1
- jupyter_ext/labextension/static/remoteEntry.37299706f55c6d46099d.js.map +0 -1
- jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js.map +0 -1
- jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js.map +0 -1
- jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +0 -1
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/install.json +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js.map +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js.map +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/static/style.js +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js.map +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js.map +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +0 -0
- {hdsp_jupyter_extension-2.0.5.data → hdsp_jupyter_extension-2.0.7.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +0 -0
- {hdsp_jupyter_extension-2.0.5.dist-info → hdsp_jupyter_extension-2.0.7.dist-info}/WHEEL +0 -0
- {hdsp_jupyter_extension-2.0.5.dist-info → hdsp_jupyter_extension-2.0.7.dist-info}/licenses/LICENSE +0 -0
|
@@ -8,8 +8,9 @@ Provides streaming and non-streaming endpoints for agent execution.
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import json
|
|
10
10
|
import logging
|
|
11
|
+
import os
|
|
11
12
|
import uuid
|
|
12
|
-
from typing import Any, Dict, List, Optional
|
|
13
|
+
from typing import Any, Dict, List, Optional, Union
|
|
13
14
|
|
|
14
15
|
from fastapi import APIRouter, HTTPException
|
|
15
16
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
@@ -18,15 +19,37 @@ from pydantic import BaseModel, ConfigDict, Field
|
|
|
18
19
|
from sse_starlette.sse import EventSourceResponse
|
|
19
20
|
|
|
20
21
|
from agent_server.langchain.agent import (
|
|
21
|
-
_create_llm,
|
|
22
22
|
_get_all_tools,
|
|
23
23
|
create_simple_chat_agent,
|
|
24
24
|
)
|
|
25
|
+
from agent_server.langchain.llm_factory import create_llm
|
|
25
26
|
|
|
26
27
|
logger = logging.getLogger(__name__)
|
|
27
28
|
router = APIRouter(prefix="/langchain", tags=["langchain-agent"])
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _find_project_root(start_path: str) -> str:
|
|
32
|
+
current = os.path.abspath(start_path)
|
|
33
|
+
while True:
|
|
34
|
+
if os.path.isdir(os.path.join(current, "extensions")) and os.path.isdir(
|
|
35
|
+
os.path.join(current, "agent-server")
|
|
36
|
+
):
|
|
37
|
+
return current
|
|
38
|
+
parent = os.path.dirname(current)
|
|
39
|
+
if parent == current:
|
|
40
|
+
return os.path.abspath(start_path)
|
|
41
|
+
current = parent
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_workspace_root(workspace_root: Optional[str]) -> str:
|
|
45
|
+
normalized = os.path.normpath(workspace_root or ".")
|
|
46
|
+
if normalized == ".":
|
|
47
|
+
return _find_project_root(os.getcwd())
|
|
48
|
+
if not os.path.isabs(normalized):
|
|
49
|
+
return os.path.abspath(os.path.join(os.getcwd(), normalized))
|
|
50
|
+
return os.path.abspath(normalized)
|
|
51
|
+
|
|
52
|
+
|
|
30
53
|
# ============ Request/Response Models ============
|
|
31
54
|
|
|
32
55
|
|
|
@@ -44,6 +67,11 @@ class LLMConfig(BaseModel):
|
|
|
44
67
|
alias="systemPrompt",
|
|
45
68
|
description="Override system prompt for LangChain agent",
|
|
46
69
|
)
|
|
70
|
+
resource_context: Optional[Union[Dict[str, Any], str]] = Field(
|
|
71
|
+
default=None,
|
|
72
|
+
alias="resourceContext",
|
|
73
|
+
description="Client resource usage snapshot for prompt injection",
|
|
74
|
+
)
|
|
47
75
|
|
|
48
76
|
|
|
49
77
|
class NotebookContext(BaseModel):
|
|
@@ -140,8 +168,45 @@ class AgentResponse(BaseModel):
|
|
|
140
168
|
# ============ Agent Instance Cache ============
|
|
141
169
|
|
|
142
170
|
|
|
171
|
+
_simple_agent_instances: Dict[str, Any] = {} # Cache agent instances by cache key
|
|
143
172
|
_simple_agent_checkpointers: Dict[str, Any] = {}
|
|
144
173
|
_simple_agent_pending_actions: Dict[str, List[Dict[str, Any]]] = {}
|
|
174
|
+
_simple_agent_last_signatures: Dict[
|
|
175
|
+
str, str
|
|
176
|
+
] = {} # Track last message signature per thread
|
|
177
|
+
_simple_agent_emitted_contents: Dict[
|
|
178
|
+
str, set
|
|
179
|
+
] = {} # Track emitted content hashes per thread to prevent duplicates
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _get_agent_cache_key(
|
|
183
|
+
llm_config: Dict[str, Any],
|
|
184
|
+
workspace_root: str,
|
|
185
|
+
system_prompt_override: Optional[str] = None,
|
|
186
|
+
) -> str:
|
|
187
|
+
"""Generate cache key for agent instance.
|
|
188
|
+
|
|
189
|
+
Agent instances are cached based on LLM config, workspace root, and system prompt.
|
|
190
|
+
Different configurations require different agent instances.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
llm_config: LLM configuration dictionary
|
|
194
|
+
workspace_root: Workspace root directory
|
|
195
|
+
system_prompt_override: Optional custom system prompt
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
MD5 hash of the configuration as cache key
|
|
199
|
+
"""
|
|
200
|
+
import hashlib
|
|
201
|
+
|
|
202
|
+
# Serialize config to deterministic string
|
|
203
|
+
config_str = json.dumps(llm_config, sort_keys=True)
|
|
204
|
+
prompt_str = system_prompt_override or ""
|
|
205
|
+
|
|
206
|
+
cache_data = f"{config_str}|{workspace_root}|{prompt_str}"
|
|
207
|
+
cache_key = hashlib.md5(cache_data.encode()).hexdigest()
|
|
208
|
+
|
|
209
|
+
return cache_key
|
|
145
210
|
|
|
146
211
|
|
|
147
212
|
def _normalize_action_request(action: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -253,12 +318,36 @@ def _normalize_tool_calls(raw_tool_calls: Any) -> List[Dict[str, Any]]:
|
|
|
253
318
|
|
|
254
319
|
|
|
255
320
|
def _message_signature(message: Any) -> str:
|
|
256
|
-
"""Create a stable signature to de-duplicate repeated streamed messages.
|
|
321
|
+
"""Create a stable signature to de-duplicate repeated streamed messages.
|
|
322
|
+
|
|
323
|
+
NOTE: We normalize tool_calls by removing 'execution_result' from args,
|
|
324
|
+
because the same AIMessage can be streamed again with execution results
|
|
325
|
+
added to the tool_calls args after HITL approval.
|
|
326
|
+
"""
|
|
257
327
|
content = getattr(message, "content", "") or ""
|
|
258
328
|
tool_calls = getattr(message, "tool_calls", None)
|
|
259
329
|
if tool_calls:
|
|
260
330
|
try:
|
|
261
|
-
|
|
331
|
+
# Normalize tool_calls: remove execution_result from args to ensure
|
|
332
|
+
# the same logical message has the same signature before and after execution
|
|
333
|
+
normalized_calls = []
|
|
334
|
+
for tc in tool_calls:
|
|
335
|
+
if isinstance(tc, dict):
|
|
336
|
+
normalized_tc = {k: v for k, v in tc.items() if k != "args"}
|
|
337
|
+
args = tc.get("args", {})
|
|
338
|
+
if isinstance(args, dict):
|
|
339
|
+
# Remove execution_result from args
|
|
340
|
+
normalized_tc["args"] = {
|
|
341
|
+
k: v for k, v in args.items() if k != "execution_result"
|
|
342
|
+
}
|
|
343
|
+
else:
|
|
344
|
+
normalized_tc["args"] = args
|
|
345
|
+
normalized_calls.append(normalized_tc)
|
|
346
|
+
else:
|
|
347
|
+
normalized_calls.append(tc)
|
|
348
|
+
tool_calls = json.dumps(
|
|
349
|
+
normalized_calls, ensure_ascii=False, sort_keys=True
|
|
350
|
+
)
|
|
262
351
|
except TypeError:
|
|
263
352
|
tool_calls = str(tool_calls)
|
|
264
353
|
else:
|
|
@@ -274,6 +363,45 @@ def _complete_todos(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
|
274
363
|
]
|
|
275
364
|
|
|
276
365
|
|
|
366
|
+
async def _async_stream_wrapper(agent, input_data, config, stream_mode="values"):
|
|
367
|
+
"""
|
|
368
|
+
Wrap synchronous agent.stream() in an async generator using asyncio.Queue.
|
|
369
|
+
|
|
370
|
+
This prevents blocking the event loop, allowing SSE events to be flushed
|
|
371
|
+
immediately instead of being buffered until the stream completes.
|
|
372
|
+
"""
|
|
373
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
374
|
+
|
|
375
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
376
|
+
loop = asyncio.get_running_loop()
|
|
377
|
+
|
|
378
|
+
def run_stream():
|
|
379
|
+
try:
|
|
380
|
+
for step in agent.stream(input_data, config, stream_mode=stream_mode):
|
|
381
|
+
# Put step into queue from sync thread
|
|
382
|
+
asyncio.run_coroutine_threadsafe(
|
|
383
|
+
queue.put(("step", step)), loop
|
|
384
|
+
).result()
|
|
385
|
+
except Exception as e:
|
|
386
|
+
asyncio.run_coroutine_threadsafe(queue.put(("error", e)), loop).result()
|
|
387
|
+
finally:
|
|
388
|
+
asyncio.run_coroutine_threadsafe(queue.put(("done", None)), loop).result()
|
|
389
|
+
|
|
390
|
+
# Run sync stream in a separate thread
|
|
391
|
+
executor = ThreadPoolExecutor(max_workers=1)
|
|
392
|
+
loop.run_in_executor(executor, run_stream)
|
|
393
|
+
|
|
394
|
+
# Async yield steps from queue
|
|
395
|
+
while True:
|
|
396
|
+
event_type, data = await queue.get()
|
|
397
|
+
if event_type == "done":
|
|
398
|
+
break
|
|
399
|
+
elif event_type == "error":
|
|
400
|
+
raise data
|
|
401
|
+
else:
|
|
402
|
+
yield data
|
|
403
|
+
|
|
404
|
+
|
|
277
405
|
async def _generate_fallback_code(
|
|
278
406
|
llm: Any,
|
|
279
407
|
tool_name: str,
|
|
@@ -337,13 +465,22 @@ async def stream_agent(request: AgentRequest):
|
|
|
337
465
|
- error: Error events
|
|
338
466
|
"""
|
|
339
467
|
|
|
340
|
-
logger.info(
|
|
468
|
+
logger.info(
|
|
469
|
+
"Agent stream request received: length=%d chars, first 100='%s...'",
|
|
470
|
+
len(request.request),
|
|
471
|
+
request.request[:100],
|
|
472
|
+
)
|
|
341
473
|
|
|
342
474
|
if not request.request:
|
|
343
475
|
raise HTTPException(status_code=400, detail="Request is required")
|
|
344
476
|
|
|
345
477
|
# Generate thread_id if not provided
|
|
346
478
|
thread_id = request.threadId or str(uuid.uuid4())
|
|
479
|
+
logger.info(
|
|
480
|
+
"Stream request - threadId from request: %s, using thread_id: %s",
|
|
481
|
+
request.threadId,
|
|
482
|
+
thread_id,
|
|
483
|
+
)
|
|
347
484
|
|
|
348
485
|
async def event_generator():
|
|
349
486
|
try:
|
|
@@ -371,23 +508,76 @@ async def stream_agent(request: AgentRequest):
|
|
|
371
508
|
config_dict["openai"] = request.llmConfig.openai
|
|
372
509
|
if request.llmConfig.vllm:
|
|
373
510
|
config_dict["vllm"] = request.llmConfig.vllm
|
|
511
|
+
if request.llmConfig.resource_context:
|
|
512
|
+
config_dict["resource_context"] = request.llmConfig.resource_context
|
|
374
513
|
system_prompt_override = (
|
|
375
514
|
request.llmConfig.system_prompt if request.llmConfig else None
|
|
376
515
|
)
|
|
377
516
|
|
|
378
|
-
|
|
517
|
+
# Get or create checkpointer for this thread
|
|
518
|
+
is_existing_thread = thread_id in _simple_agent_checkpointers
|
|
519
|
+
checkpointer = _simple_agent_checkpointers.setdefault(
|
|
520
|
+
thread_id, InMemorySaver()
|
|
521
|
+
)
|
|
522
|
+
logger.info(
|
|
523
|
+
"Checkpointer for thread %s: existing=%s, total_threads=%d",
|
|
524
|
+
thread_id,
|
|
525
|
+
is_existing_thread,
|
|
526
|
+
len(_simple_agent_checkpointers),
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
resolved_workspace_root = _resolve_workspace_root(request.workspaceRoot)
|
|
530
|
+
|
|
531
|
+
# Get or create cached agent
|
|
532
|
+
agent_cache_key = _get_agent_cache_key(
|
|
379
533
|
llm_config=config_dict,
|
|
380
|
-
workspace_root=
|
|
381
|
-
enable_hitl=True,
|
|
382
|
-
checkpointer=_simple_agent_checkpointers.setdefault(
|
|
383
|
-
thread_id, InMemorySaver()
|
|
384
|
-
),
|
|
534
|
+
workspace_root=resolved_workspace_root,
|
|
385
535
|
system_prompt_override=system_prompt_override,
|
|
386
536
|
)
|
|
387
537
|
|
|
538
|
+
if agent_cache_key in _simple_agent_instances:
|
|
539
|
+
agent = _simple_agent_instances[agent_cache_key]
|
|
540
|
+
logger.info(
|
|
541
|
+
"Using cached agent for key %s (total cached: %d)",
|
|
542
|
+
agent_cache_key[:8],
|
|
543
|
+
len(_simple_agent_instances),
|
|
544
|
+
)
|
|
545
|
+
else:
|
|
546
|
+
logger.info("Creating new agent for key %s", agent_cache_key[:8])
|
|
547
|
+
agent = create_simple_chat_agent(
|
|
548
|
+
llm_config=config_dict,
|
|
549
|
+
workspace_root=resolved_workspace_root,
|
|
550
|
+
enable_hitl=True,
|
|
551
|
+
checkpointer=checkpointer,
|
|
552
|
+
system_prompt_override=system_prompt_override,
|
|
553
|
+
)
|
|
554
|
+
_simple_agent_instances[agent_cache_key] = agent
|
|
555
|
+
logger.info(
|
|
556
|
+
"Agent cached for key %s (total cached: %d)",
|
|
557
|
+
agent_cache_key[:8],
|
|
558
|
+
len(_simple_agent_instances),
|
|
559
|
+
)
|
|
560
|
+
|
|
388
561
|
# Prepare config with thread_id
|
|
389
562
|
config = {"configurable": {"thread_id": thread_id}}
|
|
390
563
|
|
|
564
|
+
# Debug: Check if there's existing state for this thread
|
|
565
|
+
try:
|
|
566
|
+
existing_state = checkpointer.get(config)
|
|
567
|
+
if existing_state:
|
|
568
|
+
existing_messages = existing_state.get("channel_values", {}).get(
|
|
569
|
+
"messages", []
|
|
570
|
+
)
|
|
571
|
+
logger.info(
|
|
572
|
+
"Existing state for thread %s: %d messages found",
|
|
573
|
+
thread_id,
|
|
574
|
+
len(existing_messages),
|
|
575
|
+
)
|
|
576
|
+
else:
|
|
577
|
+
logger.info("No existing state for thread %s", thread_id)
|
|
578
|
+
except Exception as e:
|
|
579
|
+
logger.warning("Could not check existing state: %s", e)
|
|
580
|
+
|
|
391
581
|
# Prepare input
|
|
392
582
|
agent_input = {"messages": [{"role": "user", "content": request.request}]}
|
|
393
583
|
|
|
@@ -399,63 +589,27 @@ async def stream_agent(request: AgentRequest):
|
|
|
399
589
|
last_finish_reason = None
|
|
400
590
|
last_signature = None
|
|
401
591
|
latest_todos: Optional[List[Dict[str, Any]]] = None
|
|
592
|
+
# Initialize emitted contents set for this thread (clear any stale data)
|
|
593
|
+
emitted_contents: set = set()
|
|
594
|
+
_simple_agent_emitted_contents[thread_id] = emitted_contents
|
|
402
595
|
|
|
403
596
|
# Initial status: waiting for LLM
|
|
597
|
+
logger.info("SSE: Sending initial debug status '🤔 LLM 응답 대기 중'")
|
|
404
598
|
yield {
|
|
405
599
|
"event": "debug",
|
|
406
600
|
"data": json.dumps({"status": "🤔 LLM 응답 대기 중"}),
|
|
407
601
|
}
|
|
408
602
|
|
|
409
|
-
for step in
|
|
603
|
+
async for step in _async_stream_wrapper(
|
|
604
|
+
agent, agent_input, config, stream_mode="values"
|
|
605
|
+
):
|
|
410
606
|
if isinstance(step, dict):
|
|
411
607
|
logger.info(
|
|
412
608
|
"SimpleAgent step keys: %s", ",".join(sorted(step.keys()))
|
|
413
609
|
)
|
|
414
|
-
# Check for interrupt
|
|
415
|
-
if isinstance(step, dict) and "__interrupt__" in step:
|
|
416
|
-
interrupts = step["__interrupt__"]
|
|
417
|
-
|
|
418
|
-
yield {
|
|
419
|
-
"event": "debug",
|
|
420
|
-
"data": json.dumps({"status": "⏸️ 사용자 승인 대기 중"}),
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
# Process interrupts
|
|
424
|
-
for interrupt in interrupts:
|
|
425
|
-
interrupt_value = (
|
|
426
|
-
interrupt.value
|
|
427
|
-
if hasattr(interrupt, "value")
|
|
428
|
-
else interrupt
|
|
429
|
-
)
|
|
430
|
-
|
|
431
|
-
# Extract action requests
|
|
432
|
-
action_requests = interrupt_value.get("action_requests", [])
|
|
433
|
-
normalized_actions = [
|
|
434
|
-
_normalize_action_request(a) for a in action_requests
|
|
435
|
-
]
|
|
436
|
-
if normalized_actions:
|
|
437
|
-
_simple_agent_pending_actions[thread_id] = (
|
|
438
|
-
normalized_actions
|
|
439
|
-
)
|
|
440
|
-
|
|
441
|
-
total_actions = len(normalized_actions)
|
|
442
|
-
for idx, action in enumerate(normalized_actions):
|
|
443
|
-
yield {
|
|
444
|
-
"event": "interrupt",
|
|
445
|
-
"data": json.dumps(
|
|
446
|
-
{
|
|
447
|
-
"thread_id": thread_id,
|
|
448
|
-
"action": action.get("name", "unknown"),
|
|
449
|
-
"args": action.get("arguments", {}),
|
|
450
|
-
"description": action.get("description", ""),
|
|
451
|
-
"action_index": idx,
|
|
452
|
-
"total_actions": total_actions,
|
|
453
|
-
}
|
|
454
|
-
),
|
|
455
|
-
}
|
|
456
610
|
|
|
457
|
-
|
|
458
|
-
|
|
611
|
+
# IMPORTANT: Process todos and messages BEFORE checking for interrupt
|
|
612
|
+
# This ensures todos/debug events are emitted even in interrupt steps
|
|
459
613
|
|
|
460
614
|
# Check for todos in state and stream them
|
|
461
615
|
if isinstance(step, dict) and "todos" in step:
|
|
@@ -475,107 +629,129 @@ async def stream_agent(request: AgentRequest):
|
|
|
475
629
|
"data": json.dumps({"todos": todos}),
|
|
476
630
|
}
|
|
477
631
|
|
|
478
|
-
# Process messages
|
|
632
|
+
# Process messages (no continue statements to ensure interrupt check always runs)
|
|
479
633
|
if isinstance(step, dict) and "messages" in step:
|
|
480
634
|
messages = step["messages"]
|
|
635
|
+
should_process_message = False
|
|
481
636
|
if messages:
|
|
482
637
|
last_message = messages[-1]
|
|
483
638
|
signature = _message_signature(last_message)
|
|
484
|
-
if signature == last_signature:
|
|
485
|
-
continue
|
|
486
|
-
last_signature = signature
|
|
487
|
-
logger.info(
|
|
488
|
-
"SimpleAgent last_message type=%s has_content=%s tool_calls=%s",
|
|
489
|
-
type(last_message).__name__,
|
|
490
|
-
bool(getattr(last_message, "content", None)),
|
|
491
|
-
bool(getattr(last_message, "tool_calls", None)),
|
|
492
|
-
)
|
|
493
|
-
|
|
494
|
-
# Skip HumanMessage - don't echo user's input back
|
|
495
|
-
if isinstance(last_message, HumanMessage):
|
|
496
|
-
continue
|
|
497
|
-
|
|
498
|
-
# Handle ToolMessage - extract final_answer result
|
|
499
|
-
if isinstance(last_message, ToolMessage):
|
|
500
|
-
logger.info(
|
|
501
|
-
"SimpleAgent ToolMessage content: %s",
|
|
502
|
-
last_message.content,
|
|
503
|
-
)
|
|
504
|
-
todos = _extract_todos(last_message.content)
|
|
505
|
-
if todos:
|
|
506
|
-
latest_todos = todos
|
|
507
|
-
yield {
|
|
508
|
-
"event": "todos",
|
|
509
|
-
"data": json.dumps({"todos": todos}),
|
|
510
|
-
}
|
|
511
|
-
tool_name = getattr(last_message, "name", "") or ""
|
|
512
639
|
logger.info(
|
|
513
|
-
"
|
|
640
|
+
"Initial: Signature comparison - current: %s, last: %s, match: %s",
|
|
641
|
+
signature[:100] if signature else None,
|
|
642
|
+
last_signature[:100] if last_signature else None,
|
|
643
|
+
signature == last_signature,
|
|
514
644
|
)
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
645
|
+
# Only process if this is a new message (not duplicate)
|
|
646
|
+
if signature != last_signature:
|
|
647
|
+
last_signature = signature
|
|
648
|
+
# Skip HumanMessage
|
|
649
|
+
if not isinstance(last_message, HumanMessage):
|
|
650
|
+
should_process_message = True
|
|
521
651
|
logger.info(
|
|
522
|
-
"SimpleAgent
|
|
523
|
-
|
|
652
|
+
"SimpleAgent last_message type=%s has_content=%s tool_calls=%s",
|
|
653
|
+
type(last_message).__name__,
|
|
654
|
+
bool(getattr(last_message, "content", None)),
|
|
655
|
+
bool(getattr(last_message, "tool_calls", None)),
|
|
524
656
|
)
|
|
525
|
-
except (json.JSONDecodeError, TypeError):
|
|
526
|
-
pass
|
|
527
657
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
658
|
+
# Process message only if it's new and not HumanMessage
|
|
659
|
+
if should_process_message:
|
|
660
|
+
# Handle ToolMessage - extract final_answer result
|
|
661
|
+
if isinstance(last_message, ToolMessage):
|
|
662
|
+
logger.info(
|
|
663
|
+
"SimpleAgent ToolMessage content: %s",
|
|
664
|
+
last_message.content,
|
|
665
|
+
)
|
|
666
|
+
todos = _extract_todos(last_message.content)
|
|
667
|
+
if todos:
|
|
668
|
+
latest_todos = todos
|
|
669
|
+
yield {
|
|
670
|
+
"event": "todos",
|
|
671
|
+
"data": json.dumps({"todos": todos}),
|
|
672
|
+
}
|
|
673
|
+
tool_name = getattr(last_message, "name", "") or ""
|
|
674
|
+
logger.info(
|
|
675
|
+
"SimpleAgent ToolMessage name attribute: %s", tool_name
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# Also check content for tool name if name attribute is empty
|
|
679
|
+
if not tool_name:
|
|
680
|
+
try:
|
|
681
|
+
content_json = json.loads(last_message.content)
|
|
682
|
+
tool_name = content_json.get("tool", "")
|
|
683
|
+
logger.info(
|
|
684
|
+
"SimpleAgent ToolMessage tool from content: %s",
|
|
685
|
+
tool_name,
|
|
686
|
+
)
|
|
687
|
+
except (json.JSONDecodeError, TypeError):
|
|
688
|
+
pass
|
|
689
|
+
|
|
690
|
+
if tool_name in ("final_answer_tool", "final_answer"):
|
|
691
|
+
# Extract the final answer from the tool result
|
|
692
|
+
try:
|
|
693
|
+
tool_result = json.loads(last_message.content)
|
|
694
|
+
# Check both direct "answer" and "parameters.answer"
|
|
695
|
+
final_answer = tool_result.get(
|
|
696
|
+
"answer"
|
|
697
|
+
) or tool_result.get("parameters", {}).get("answer")
|
|
698
|
+
# Check for next_items in summary field (Gemini puts JSON here)
|
|
699
|
+
summary = tool_result.get(
|
|
700
|
+
"summary"
|
|
701
|
+
) or tool_result.get("parameters", {}).get("summary")
|
|
702
|
+
if summary:
|
|
703
|
+
try:
|
|
704
|
+
summary_json = json.loads(summary)
|
|
705
|
+
if "next_items" in summary_json:
|
|
706
|
+
next_items_block = f"\n\n```json\n{json.dumps(summary_json, ensure_ascii=False, indent=2)}\n```"
|
|
707
|
+
final_answer = (final_answer or "") + next_items_block
|
|
708
|
+
logger.info("Extracted next_items from summary field")
|
|
709
|
+
except (json.JSONDecodeError, TypeError):
|
|
710
|
+
pass
|
|
711
|
+
if final_answer:
|
|
712
|
+
yield {
|
|
713
|
+
"event": "token",
|
|
714
|
+
"data": json.dumps(
|
|
715
|
+
{"content": final_answer}
|
|
716
|
+
),
|
|
717
|
+
}
|
|
718
|
+
else:
|
|
719
|
+
# Fallback to raw content if no answer found
|
|
720
|
+
yield {
|
|
721
|
+
"event": "token",
|
|
722
|
+
"data": json.dumps(
|
|
723
|
+
{"content": last_message.content}
|
|
724
|
+
),
|
|
725
|
+
}
|
|
726
|
+
except json.JSONDecodeError:
|
|
727
|
+
# If not JSON, use content directly
|
|
728
|
+
if last_message.content:
|
|
729
|
+
yield {
|
|
730
|
+
"event": "token",
|
|
731
|
+
"data": json.dumps(
|
|
732
|
+
{"content": last_message.content}
|
|
733
|
+
),
|
|
734
|
+
}
|
|
735
|
+
if latest_todos:
|
|
552
736
|
yield {
|
|
553
|
-
"event": "
|
|
737
|
+
"event": "todos",
|
|
554
738
|
"data": json.dumps(
|
|
555
|
-
{"
|
|
739
|
+
{"todos": _complete_todos(latest_todos)}
|
|
556
740
|
),
|
|
557
741
|
}
|
|
558
|
-
|
|
742
|
+
# End stream after final answer
|
|
743
|
+
yield {"event": "debug_clear", "data": json.dumps({})}
|
|
559
744
|
yield {
|
|
560
|
-
"event": "
|
|
745
|
+
"event": "complete",
|
|
561
746
|
"data": json.dumps(
|
|
562
|
-
{"
|
|
747
|
+
{"success": True, "thread_id": thread_id}
|
|
563
748
|
),
|
|
564
749
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
yield {
|
|
568
|
-
"event": "complete",
|
|
569
|
-
"data": json.dumps(
|
|
570
|
-
{"success": True, "thread_id": thread_id}
|
|
571
|
-
),
|
|
572
|
-
}
|
|
573
|
-
return
|
|
574
|
-
# Skip other tool messages (jupyter_cell, markdown results)
|
|
575
|
-
continue
|
|
750
|
+
return
|
|
751
|
+
# Other ToolMessages: don't skip with continue, just don't process further
|
|
576
752
|
|
|
577
753
|
# Handle AIMessage
|
|
578
|
-
|
|
754
|
+
elif isinstance(last_message, AIMessage):
|
|
579
755
|
logger.info(
|
|
580
756
|
"SimpleAgent AIMessage content: %s",
|
|
581
757
|
last_message.content or "",
|
|
@@ -630,9 +806,19 @@ async def stream_agent(request: AgentRequest):
|
|
|
630
806
|
).get("function_call")
|
|
631
807
|
tool_calls = _normalize_tool_calls(raw_tool_calls)
|
|
632
808
|
|
|
809
|
+
has_final_answer_tool = False
|
|
633
810
|
if tool_calls:
|
|
811
|
+
has_final_answer_tool = any(
|
|
812
|
+
(call.get("name") or call.get("tool") or "")
|
|
813
|
+
in ("final_answer_tool", "final_answer")
|
|
814
|
+
for call in tool_calls
|
|
815
|
+
)
|
|
634
816
|
todos = _emit_todos_from_tool_calls(tool_calls)
|
|
635
817
|
if todos:
|
|
818
|
+
logger.info(
|
|
819
|
+
"SSE: Emitting todos event from AIMessage tool_calls: %d items",
|
|
820
|
+
len(todos),
|
|
821
|
+
)
|
|
636
822
|
latest_todos = todos
|
|
637
823
|
yield {
|
|
638
824
|
"event": "todos",
|
|
@@ -642,11 +828,33 @@ async def stream_agent(request: AgentRequest):
|
|
|
642
828
|
tool_name = tool_call.get("name", "unknown")
|
|
643
829
|
tool_args = tool_call.get("args", {})
|
|
644
830
|
|
|
831
|
+
# Create detailed status message for search tools
|
|
832
|
+
if tool_name in (
|
|
833
|
+
"search_workspace_tool",
|
|
834
|
+
"search_workspace",
|
|
835
|
+
):
|
|
836
|
+
pattern = tool_args.get("pattern", "")
|
|
837
|
+
path = tool_args.get("path", ".")
|
|
838
|
+
status_msg = f"🔍 검색 실행: grep/rg '{pattern}' in {path}"
|
|
839
|
+
elif tool_name in (
|
|
840
|
+
"search_notebook_cells_tool",
|
|
841
|
+
"search_notebook_cells",
|
|
842
|
+
):
|
|
843
|
+
pattern = tool_args.get("pattern", "")
|
|
844
|
+
nb_path = tool_args.get(
|
|
845
|
+
"notebook_path", "all notebooks"
|
|
846
|
+
)
|
|
847
|
+
status_msg = f"🔍 노트북 검색: '{pattern}' in {nb_path or 'all notebooks'}"
|
|
848
|
+
else:
|
|
849
|
+
status_msg = f"🔧 Tool 실행: {tool_name}"
|
|
850
|
+
|
|
851
|
+
logger.info(
|
|
852
|
+
"SSE: Emitting debug event for tool: %s",
|
|
853
|
+
tool_name,
|
|
854
|
+
)
|
|
645
855
|
yield {
|
|
646
856
|
"event": "debug",
|
|
647
|
-
"data": json.dumps(
|
|
648
|
-
{"status": f"🔧 Tool 실행: {tool_name}"}
|
|
649
|
-
),
|
|
857
|
+
"data": json.dumps({"status": status_msg}),
|
|
650
858
|
}
|
|
651
859
|
|
|
652
860
|
# Send tool_call event with details for frontend to execute
|
|
@@ -680,6 +888,77 @@ async def stream_agent(request: AgentRequest):
|
|
|
680
888
|
}
|
|
681
889
|
),
|
|
682
890
|
}
|
|
891
|
+
elif tool_name == "execute_command_tool":
|
|
892
|
+
produced_output = True
|
|
893
|
+
yield {
|
|
894
|
+
"event": "tool_call",
|
|
895
|
+
"data": json.dumps(
|
|
896
|
+
{
|
|
897
|
+
"tool": "execute_command_tool",
|
|
898
|
+
"command": tool_args.get(
|
|
899
|
+
"command", ""
|
|
900
|
+
),
|
|
901
|
+
"timeout": tool_args.get("timeout"),
|
|
902
|
+
}
|
|
903
|
+
),
|
|
904
|
+
}
|
|
905
|
+
elif tool_name in (
|
|
906
|
+
"search_workspace_tool",
|
|
907
|
+
"search_workspace",
|
|
908
|
+
):
|
|
909
|
+
# Search workspace - emit tool_call for client-side execution
|
|
910
|
+
produced_output = True
|
|
911
|
+
yield {
|
|
912
|
+
"event": "tool_call",
|
|
913
|
+
"data": json.dumps(
|
|
914
|
+
{
|
|
915
|
+
"tool": "search_workspace",
|
|
916
|
+
"pattern": tool_args.get(
|
|
917
|
+
"pattern", ""
|
|
918
|
+
),
|
|
919
|
+
"file_types": tool_args.get(
|
|
920
|
+
"file_types",
|
|
921
|
+
["*.py", "*.ipynb"],
|
|
922
|
+
),
|
|
923
|
+
"path": tool_args.get("path", "."),
|
|
924
|
+
"max_results": tool_args.get(
|
|
925
|
+
"max_results", 50
|
|
926
|
+
),
|
|
927
|
+
"case_sensitive": tool_args.get(
|
|
928
|
+
"case_sensitive", False
|
|
929
|
+
),
|
|
930
|
+
}
|
|
931
|
+
),
|
|
932
|
+
}
|
|
933
|
+
elif tool_name in (
|
|
934
|
+
"search_notebook_cells_tool",
|
|
935
|
+
"search_notebook_cells",
|
|
936
|
+
):
|
|
937
|
+
# Search notebook cells - emit tool_call for client-side execution
|
|
938
|
+
produced_output = True
|
|
939
|
+
yield {
|
|
940
|
+
"event": "tool_call",
|
|
941
|
+
"data": json.dumps(
|
|
942
|
+
{
|
|
943
|
+
"tool": "search_notebook_cells",
|
|
944
|
+
"pattern": tool_args.get(
|
|
945
|
+
"pattern", ""
|
|
946
|
+
),
|
|
947
|
+
"notebook_path": tool_args.get(
|
|
948
|
+
"notebook_path"
|
|
949
|
+
),
|
|
950
|
+
"cell_type": tool_args.get(
|
|
951
|
+
"cell_type"
|
|
952
|
+
),
|
|
953
|
+
"max_results": tool_args.get(
|
|
954
|
+
"max_results", 30
|
|
955
|
+
),
|
|
956
|
+
"case_sensitive": tool_args.get(
|
|
957
|
+
"case_sensitive", False
|
|
958
|
+
),
|
|
959
|
+
}
|
|
960
|
+
),
|
|
961
|
+
}
|
|
683
962
|
|
|
684
963
|
# Only display content if it's not empty and not a JSON tool response
|
|
685
964
|
if (
|
|
@@ -688,29 +967,126 @@ async def stream_agent(request: AgentRequest):
|
|
|
688
967
|
):
|
|
689
968
|
content = last_message.content
|
|
690
969
|
|
|
970
|
+
# Handle list content (e.g., multimodal responses)
|
|
971
|
+
if isinstance(content, list):
|
|
972
|
+
# Extract text content from list
|
|
973
|
+
text_parts = []
|
|
974
|
+
for part in content:
|
|
975
|
+
if isinstance(part, str):
|
|
976
|
+
text_parts.append(part)
|
|
977
|
+
elif (
|
|
978
|
+
isinstance(part, dict)
|
|
979
|
+
and part.get("type") == "text"
|
|
980
|
+
):
|
|
981
|
+
text_parts.append(part.get("text", ""))
|
|
982
|
+
content = "\n".join(text_parts)
|
|
983
|
+
|
|
691
984
|
# Filter out raw JSON tool responses
|
|
692
|
-
if
|
|
693
|
-
content
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
985
|
+
if (
|
|
986
|
+
content
|
|
987
|
+
and isinstance(content, str)
|
|
988
|
+
and not has_final_answer_tool
|
|
989
|
+
and not (
|
|
990
|
+
content.strip().startswith('{"tool":')
|
|
991
|
+
or content.strip().startswith('{"status":')
|
|
992
|
+
or '"pending_execution"' in content
|
|
993
|
+
or '"status": "complete"' in content
|
|
994
|
+
)
|
|
697
995
|
):
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
996
|
+
# Check if we've already emitted this content (prevents duplicates)
|
|
997
|
+
content_hash = hash(content)
|
|
998
|
+
if content_hash in emitted_contents:
|
|
999
|
+
logger.info(
|
|
1000
|
+
"Initial: SKIPPING duplicate content (len=%d): %s",
|
|
1001
|
+
len(content),
|
|
1002
|
+
content[:100],
|
|
1003
|
+
)
|
|
1004
|
+
else:
|
|
1005
|
+
emitted_contents.add(content_hash)
|
|
1006
|
+
logger.info(
|
|
1007
|
+
"Initial: EMITTING token content (len=%d): %s",
|
|
1008
|
+
len(content),
|
|
1009
|
+
content[:100],
|
|
1010
|
+
)
|
|
1011
|
+
produced_output = True
|
|
1012
|
+
yield {
|
|
1013
|
+
"event": "token",
|
|
1014
|
+
"data": json.dumps({"content": content}),
|
|
1015
|
+
}
|
|
703
1016
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
"
|
|
712
|
-
|
|
713
|
-
|
|
1017
|
+
# Check for interrupt AFTER processing todos and messages
|
|
1018
|
+
# This ensures todos/debug events are emitted even in interrupt steps
|
|
1019
|
+
if isinstance(step, dict) and "__interrupt__" in step:
|
|
1020
|
+
interrupts = step["__interrupt__"]
|
|
1021
|
+
|
|
1022
|
+
yield {
|
|
1023
|
+
"event": "debug",
|
|
1024
|
+
"data": json.dumps({"status": "⏸️ 사용자 승인 대기 중"}),
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
# Process interrupts
|
|
1028
|
+
for interrupt in interrupts:
|
|
1029
|
+
interrupt_value = (
|
|
1030
|
+
interrupt.value
|
|
1031
|
+
if hasattr(interrupt, "value")
|
|
1032
|
+
else interrupt
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
# Extract action requests
|
|
1036
|
+
action_requests = interrupt_value.get("action_requests", [])
|
|
1037
|
+
normalized_actions = [
|
|
1038
|
+
_normalize_action_request(a) for a in action_requests
|
|
1039
|
+
]
|
|
1040
|
+
if normalized_actions:
|
|
1041
|
+
_simple_agent_pending_actions[thread_id] = (
|
|
1042
|
+
normalized_actions
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
total_actions = len(normalized_actions)
|
|
1046
|
+
for idx, action in enumerate(normalized_actions):
|
|
1047
|
+
yield {
|
|
1048
|
+
"event": "interrupt",
|
|
1049
|
+
"data": json.dumps(
|
|
1050
|
+
{
|
|
1051
|
+
"thread_id": thread_id,
|
|
1052
|
+
"action": action.get("name", "unknown"),
|
|
1053
|
+
"args": action.get("arguments", {}),
|
|
1054
|
+
"description": action.get("description", ""),
|
|
1055
|
+
"action_index": idx,
|
|
1056
|
+
"total_actions": total_actions,
|
|
1057
|
+
}
|
|
1058
|
+
),
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
# Save last signature for resume to avoid duplicate content
|
|
1062
|
+
if last_signature:
|
|
1063
|
+
_simple_agent_last_signatures[thread_id] = last_signature
|
|
1064
|
+
logger.info(
|
|
1065
|
+
"Interrupt: Saved signature for thread %s: %s",
|
|
1066
|
+
thread_id,
|
|
1067
|
+
last_signature[:100] if last_signature else None,
|
|
1068
|
+
)
|
|
1069
|
+
# Save emitted contents for resume
|
|
1070
|
+
_simple_agent_emitted_contents[thread_id] = emitted_contents
|
|
1071
|
+
logger.info(
|
|
1072
|
+
"Interrupt: Saved %d emitted content hashes for thread %s",
|
|
1073
|
+
len(emitted_contents),
|
|
1074
|
+
thread_id,
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
# Stop streaming - wait for resume
|
|
1078
|
+
return
|
|
1079
|
+
|
|
1080
|
+
if not produced_output and last_finish_reason == "MALFORMED_FUNCTION_CALL":
|
|
1081
|
+
logger.info(
|
|
1082
|
+
"SimpleAgent fallback: retrying tool call generation after malformed function call"
|
|
1083
|
+
)
|
|
1084
|
+
try:
|
|
1085
|
+
fallback_config = json.loads(json.dumps(config_dict))
|
|
1086
|
+
if fallback_config.get(
|
|
1087
|
+
"provider"
|
|
1088
|
+
) == "gemini" and fallback_config.get("gemini", {}).get(
|
|
1089
|
+
"model", ""
|
|
714
1090
|
).endswith("flash"):
|
|
715
1091
|
fallback_config.setdefault("gemini", {})["model"] = (
|
|
716
1092
|
"gemini-2.5-pro"
|
|
@@ -719,7 +1095,7 @@ async def stream_agent(request: AgentRequest):
|
|
|
719
1095
|
"SimpleAgent fallback: switching model to gemini-2.5-pro"
|
|
720
1096
|
)
|
|
721
1097
|
|
|
722
|
-
llm =
|
|
1098
|
+
llm = create_llm(fallback_config)
|
|
723
1099
|
tools = _get_all_tools()
|
|
724
1100
|
# Force tool calling - use tool_config for Gemini, tool_choice for others
|
|
725
1101
|
provider = config_dict.get("provider", "gemini")
|
|
@@ -838,6 +1214,24 @@ async def stream_agent(request: AgentRequest):
|
|
|
838
1214
|
}
|
|
839
1215
|
),
|
|
840
1216
|
}
|
|
1217
|
+
elif tool_name == "execute_command_tool":
|
|
1218
|
+
produced_output = True
|
|
1219
|
+
yield {
|
|
1220
|
+
"event": "debug",
|
|
1221
|
+
"data": json.dumps(
|
|
1222
|
+
{"status": f"🔧 Tool 실행: {tool_name}"}
|
|
1223
|
+
),
|
|
1224
|
+
}
|
|
1225
|
+
yield {
|
|
1226
|
+
"event": "tool_call",
|
|
1227
|
+
"data": json.dumps(
|
|
1228
|
+
{
|
|
1229
|
+
"tool": "execute_command_tool",
|
|
1230
|
+
"command": tool_args.get("command", ""),
|
|
1231
|
+
"timeout": tool_args.get("timeout"),
|
|
1232
|
+
}
|
|
1233
|
+
),
|
|
1234
|
+
}
|
|
841
1235
|
elif tool_name in (
|
|
842
1236
|
"read_file_tool",
|
|
843
1237
|
"list_files_tool",
|
|
@@ -987,48 +1381,54 @@ async def resume_agent(request: ResumeRequest):
|
|
|
987
1381
|
config_dict["openai"] = request.llmConfig.openai
|
|
988
1382
|
if request.llmConfig.vllm:
|
|
989
1383
|
config_dict["vllm"] = request.llmConfig.vllm
|
|
1384
|
+
if request.llmConfig.resource_context:
|
|
1385
|
+
config_dict["resource_context"] = request.llmConfig.resource_context
|
|
990
1386
|
system_prompt_override = (
|
|
991
1387
|
request.llmConfig.system_prompt if request.llmConfig else None
|
|
992
1388
|
)
|
|
1389
|
+
# Get or create cached agent
|
|
1390
|
+
resolved_workspace_root = _resolve_workspace_root(request.workspaceRoot)
|
|
1391
|
+
checkpointer = _simple_agent_checkpointers.setdefault(
|
|
1392
|
+
request.threadId, InMemorySaver()
|
|
1393
|
+
)
|
|
993
1394
|
|
|
994
|
-
|
|
995
|
-
agent = create_simple_chat_agent(
|
|
1395
|
+
agent_cache_key = _get_agent_cache_key(
|
|
996
1396
|
llm_config=config_dict,
|
|
997
|
-
workspace_root=
|
|
998
|
-
enable_hitl=True,
|
|
999
|
-
checkpointer=_simple_agent_checkpointers.setdefault(
|
|
1000
|
-
request.threadId, InMemorySaver()
|
|
1001
|
-
),
|
|
1397
|
+
workspace_root=resolved_workspace_root,
|
|
1002
1398
|
system_prompt_override=system_prompt_override,
|
|
1003
1399
|
)
|
|
1004
1400
|
|
|
1401
|
+
if agent_cache_key in _simple_agent_instances:
|
|
1402
|
+
agent = _simple_agent_instances[agent_cache_key]
|
|
1403
|
+
logger.info(
|
|
1404
|
+
"Resume: Using cached agent for key %s (total cached: %d)",
|
|
1405
|
+
agent_cache_key[:8],
|
|
1406
|
+
len(_simple_agent_instances),
|
|
1407
|
+
)
|
|
1408
|
+
else:
|
|
1409
|
+
logger.info("Resume: Creating new agent for key %s", agent_cache_key[:8])
|
|
1410
|
+
agent = create_simple_chat_agent(
|
|
1411
|
+
llm_config=config_dict,
|
|
1412
|
+
workspace_root=resolved_workspace_root,
|
|
1413
|
+
enable_hitl=True,
|
|
1414
|
+
checkpointer=checkpointer,
|
|
1415
|
+
system_prompt_override=system_prompt_override,
|
|
1416
|
+
)
|
|
1417
|
+
_simple_agent_instances[agent_cache_key] = agent
|
|
1418
|
+
logger.info(
|
|
1419
|
+
"Resume: Agent cached for key %s (total cached: %d)",
|
|
1420
|
+
agent_cache_key[:8],
|
|
1421
|
+
len(_simple_agent_instances),
|
|
1422
|
+
)
|
|
1423
|
+
|
|
1005
1424
|
# Prepare config with thread_id
|
|
1006
1425
|
config = {"configurable": {"thread_id": request.threadId}}
|
|
1007
1426
|
|
|
1008
1427
|
pending_actions = _simple_agent_pending_actions.get(request.threadId, [])
|
|
1009
|
-
num_pending = len(pending_actions)
|
|
1010
|
-
num_decisions = len(request.decisions)
|
|
1011
|
-
|
|
1012
|
-
# If user provides fewer decisions than pending actions,
|
|
1013
|
-
# reject remaining actions to avoid multiple state updates per step
|
|
1014
|
-
# This prevents "Can receive only one value per step" errors for todos
|
|
1015
|
-
decisions_to_process = list(request.decisions)
|
|
1016
|
-
if num_decisions < num_pending and num_decisions > 0:
|
|
1017
|
-
logger.info(
|
|
1018
|
-
f"Have {num_decisions} decision(s) but {num_pending} pending action(s). "
|
|
1019
|
-
f"Auto-rejecting remaining {num_pending - num_decisions} action(s)."
|
|
1020
|
-
)
|
|
1021
|
-
# Create reject decisions for remaining actions
|
|
1022
|
-
for i in range(num_pending - num_decisions):
|
|
1023
|
-
reject_decision = ResumeDecision(
|
|
1024
|
-
type="reject",
|
|
1025
|
-
feedback="Auto-rejected: only one action can be processed at a time"
|
|
1026
|
-
)
|
|
1027
|
-
decisions_to_process.append(reject_decision)
|
|
1028
1428
|
|
|
1029
1429
|
# Convert decisions to LangChain format
|
|
1030
1430
|
langgraph_decisions = []
|
|
1031
|
-
for index, decision in enumerate(
|
|
1431
|
+
for index, decision in enumerate(request.decisions):
|
|
1032
1432
|
if decision.type == "approve":
|
|
1033
1433
|
langgraph_decisions.append({"type": "approve"})
|
|
1034
1434
|
elif decision.type == "edit":
|
|
@@ -1049,8 +1449,8 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1049
1449
|
langgraph_decisions.append(
|
|
1050
1450
|
{
|
|
1051
1451
|
"type": "reject",
|
|
1052
|
-
|
|
1053
|
-
or "User rejected this action",
|
|
1452
|
+
# LangChain HITL middleware expects 'message' key for reject feedback
|
|
1453
|
+
"message": decision.feedback or "User rejected this action",
|
|
1054
1454
|
}
|
|
1055
1455
|
)
|
|
1056
1456
|
|
|
@@ -1066,8 +1466,22 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1066
1466
|
processed_tool_call_ids: set[str] = set()
|
|
1067
1467
|
latest_todos: Optional[List[Dict[str, Any]]] = None
|
|
1068
1468
|
|
|
1069
|
-
# Resume with Command
|
|
1070
|
-
last_signature =
|
|
1469
|
+
# Resume with Command - use saved signature to avoid duplicate content
|
|
1470
|
+
last_signature = _simple_agent_last_signatures.get(request.threadId)
|
|
1471
|
+
logger.info(
|
|
1472
|
+
"Resume: Restored signature for thread %s: %s",
|
|
1473
|
+
request.threadId,
|
|
1474
|
+
last_signature[:100] if last_signature else None,
|
|
1475
|
+
)
|
|
1476
|
+
# Restore emitted contents set to prevent duplicate content emission
|
|
1477
|
+
emitted_contents = _simple_agent_emitted_contents.get(
|
|
1478
|
+
request.threadId, set()
|
|
1479
|
+
)
|
|
1480
|
+
logger.info(
|
|
1481
|
+
"Resume: Restored %d emitted content hashes for thread %s",
|
|
1482
|
+
len(emitted_contents),
|
|
1483
|
+
request.threadId,
|
|
1484
|
+
)
|
|
1071
1485
|
|
|
1072
1486
|
# Status: waiting for LLM response
|
|
1073
1487
|
yield {
|
|
@@ -1076,7 +1490,9 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1076
1490
|
}
|
|
1077
1491
|
|
|
1078
1492
|
step_count = 0
|
|
1079
|
-
|
|
1493
|
+
|
|
1494
|
+
async for step in _async_stream_wrapper(
|
|
1495
|
+
agent,
|
|
1080
1496
|
Command(resume={"decisions": langgraph_decisions}),
|
|
1081
1497
|
config,
|
|
1082
1498
|
stream_mode="values",
|
|
@@ -1090,47 +1506,8 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1090
1506
|
step_keys,
|
|
1091
1507
|
)
|
|
1092
1508
|
|
|
1093
|
-
#
|
|
1094
|
-
|
|
1095
|
-
interrupts = step["__interrupt__"]
|
|
1096
|
-
|
|
1097
|
-
yield {
|
|
1098
|
-
"event": "debug",
|
|
1099
|
-
"data": json.dumps({"status": "⏸️ 사용자 승인 대기 중"}),
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
for interrupt in interrupts:
|
|
1103
|
-
interrupt_value = (
|
|
1104
|
-
interrupt.value
|
|
1105
|
-
if hasattr(interrupt, "value")
|
|
1106
|
-
else interrupt
|
|
1107
|
-
)
|
|
1108
|
-
action_requests = interrupt_value.get("action_requests", [])
|
|
1109
|
-
normalized_actions = [
|
|
1110
|
-
_normalize_action_request(a) for a in action_requests
|
|
1111
|
-
]
|
|
1112
|
-
if normalized_actions:
|
|
1113
|
-
_simple_agent_pending_actions[request.threadId] = (
|
|
1114
|
-
normalized_actions
|
|
1115
|
-
)
|
|
1116
|
-
|
|
1117
|
-
total_actions = len(normalized_actions)
|
|
1118
|
-
for idx, action in enumerate(normalized_actions):
|
|
1119
|
-
yield {
|
|
1120
|
-
"event": "interrupt",
|
|
1121
|
-
"data": json.dumps(
|
|
1122
|
-
{
|
|
1123
|
-
"thread_id": request.threadId,
|
|
1124
|
-
"action": action.get("name", "unknown"),
|
|
1125
|
-
"args": action.get("arguments", {}),
|
|
1126
|
-
"description": action.get("description", ""),
|
|
1127
|
-
"action_index": idx,
|
|
1128
|
-
"total_actions": total_actions,
|
|
1129
|
-
}
|
|
1130
|
-
),
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
return
|
|
1509
|
+
# IMPORTANT: Process todos and messages BEFORE checking for interrupt
|
|
1510
|
+
# This ensures todos/debug events are emitted even in interrupt steps
|
|
1134
1511
|
|
|
1135
1512
|
# Check for todos in state and stream them
|
|
1136
1513
|
if isinstance(step, dict) and "todos" in step:
|
|
@@ -1144,16 +1521,63 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1144
1521
|
latest_todos = todos
|
|
1145
1522
|
yield {"event": "todos", "data": json.dumps({"todos": todos})}
|
|
1146
1523
|
|
|
1147
|
-
# Process messages
|
|
1524
|
+
# Process messages (no continue statements to ensure interrupt check always runs)
|
|
1148
1525
|
if isinstance(step, dict) and "messages" in step:
|
|
1149
1526
|
messages = step["messages"]
|
|
1527
|
+
should_process_message = False
|
|
1150
1528
|
if messages:
|
|
1151
1529
|
last_message = messages[-1]
|
|
1152
1530
|
signature = _message_signature(last_message)
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1531
|
+
# Debug: Show full signature details when mismatch occurs
|
|
1532
|
+
if signature != last_signature and last_signature:
|
|
1533
|
+
logger.info(
|
|
1534
|
+
"Resume: Signature MISMATCH - len(current)=%d, len(last)=%d",
|
|
1535
|
+
len(signature),
|
|
1536
|
+
len(last_signature) if last_signature else 0,
|
|
1537
|
+
)
|
|
1538
|
+
# Find first difference position
|
|
1539
|
+
min_len = min(len(signature), len(last_signature))
|
|
1540
|
+
diff_pos = next(
|
|
1541
|
+
(
|
|
1542
|
+
i
|
|
1543
|
+
for i in range(min_len)
|
|
1544
|
+
if signature[i] != last_signature[i]
|
|
1545
|
+
),
|
|
1546
|
+
min_len,
|
|
1547
|
+
)
|
|
1548
|
+
logger.info(
|
|
1549
|
+
"Resume: First diff at pos %d: current[%d:%d]='%s', last[%d:%d]='%s'",
|
|
1550
|
+
diff_pos,
|
|
1551
|
+
max(0, diff_pos - 20),
|
|
1552
|
+
min(len(signature), diff_pos + 30),
|
|
1553
|
+
signature[
|
|
1554
|
+
max(0, diff_pos - 20) : min(
|
|
1555
|
+
len(signature), diff_pos + 30
|
|
1556
|
+
)
|
|
1557
|
+
],
|
|
1558
|
+
max(0, diff_pos - 20),
|
|
1559
|
+
min(len(last_signature), diff_pos + 30),
|
|
1560
|
+
last_signature[
|
|
1561
|
+
max(0, diff_pos - 20) : min(
|
|
1562
|
+
len(last_signature), diff_pos + 30
|
|
1563
|
+
)
|
|
1564
|
+
]
|
|
1565
|
+
if last_signature
|
|
1566
|
+
else "",
|
|
1567
|
+
)
|
|
1568
|
+
logger.info(
|
|
1569
|
+
"Resume: Signature comparison - current: %s, last: %s, match: %s",
|
|
1570
|
+
signature[:100] if signature else None,
|
|
1571
|
+
last_signature[:100] if last_signature else None,
|
|
1572
|
+
signature == last_signature,
|
|
1573
|
+
)
|
|
1574
|
+
# Only process if this is a new message (not duplicate)
|
|
1575
|
+
if signature != last_signature:
|
|
1576
|
+
last_signature = signature
|
|
1577
|
+
should_process_message = True
|
|
1156
1578
|
|
|
1579
|
+
# Process message only if it's new
|
|
1580
|
+
if should_process_message:
|
|
1157
1581
|
if isinstance(last_message, ToolMessage):
|
|
1158
1582
|
logger.info(
|
|
1159
1583
|
"Resume ToolMessage content: %s", last_message.content
|
|
@@ -1188,6 +1612,19 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1188
1612
|
final_answer = tool_result.get(
|
|
1189
1613
|
"answer"
|
|
1190
1614
|
) or tool_result.get("parameters", {}).get("answer")
|
|
1615
|
+
# Check for next_items in summary field (Gemini puts JSON here)
|
|
1616
|
+
summary = tool_result.get(
|
|
1617
|
+
"summary"
|
|
1618
|
+
) or tool_result.get("parameters", {}).get("summary")
|
|
1619
|
+
if summary:
|
|
1620
|
+
try:
|
|
1621
|
+
summary_json = json.loads(summary)
|
|
1622
|
+
if "next_items" in summary_json:
|
|
1623
|
+
next_items_block = f"\n\n```json\n{json.dumps(summary_json, ensure_ascii=False, indent=2)}\n```"
|
|
1624
|
+
final_answer = (final_answer or "") + next_items_block
|
|
1625
|
+
logger.info("Resume: Extracted next_items from summary field")
|
|
1626
|
+
except (json.JSONDecodeError, TypeError):
|
|
1627
|
+
pass
|
|
1191
1628
|
if final_answer:
|
|
1192
1629
|
yield {
|
|
1193
1630
|
"event": "token",
|
|
@@ -1224,23 +1661,69 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1224
1661
|
),
|
|
1225
1662
|
}
|
|
1226
1663
|
return
|
|
1227
|
-
#
|
|
1228
|
-
continue
|
|
1664
|
+
# Other ToolMessages: don't process further (no continue to ensure interrupt check runs)
|
|
1229
1665
|
|
|
1230
|
-
|
|
1666
|
+
# Handle AIMessage (use elif to avoid processing after ToolMessage)
|
|
1667
|
+
elif hasattr(last_message, "content") and last_message.content:
|
|
1668
|
+
message_tool_calls = (
|
|
1669
|
+
last_message.tool_calls
|
|
1670
|
+
if hasattr(last_message, "tool_calls")
|
|
1671
|
+
and last_message.tool_calls
|
|
1672
|
+
else []
|
|
1673
|
+
)
|
|
1674
|
+
has_final_answer_tool = any(
|
|
1675
|
+
(call.get("name") or call.get("tool") or "")
|
|
1676
|
+
in ("final_answer_tool", "final_answer")
|
|
1677
|
+
for call in message_tool_calls
|
|
1678
|
+
if isinstance(call, dict)
|
|
1679
|
+
)
|
|
1231
1680
|
content = last_message.content
|
|
1232
1681
|
|
|
1682
|
+
# Handle list content (e.g., multimodal responses)
|
|
1683
|
+
if isinstance(content, list):
|
|
1684
|
+
# Extract text content from list
|
|
1685
|
+
text_parts = []
|
|
1686
|
+
for part in content:
|
|
1687
|
+
if isinstance(part, str):
|
|
1688
|
+
text_parts.append(part)
|
|
1689
|
+
elif (
|
|
1690
|
+
isinstance(part, dict)
|
|
1691
|
+
and part.get("type") == "text"
|
|
1692
|
+
):
|
|
1693
|
+
text_parts.append(part.get("text", ""))
|
|
1694
|
+
content = "\n".join(text_parts)
|
|
1695
|
+
|
|
1233
1696
|
# Filter out raw JSON tool responses
|
|
1234
|
-
if
|
|
1235
|
-
content
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1697
|
+
if (
|
|
1698
|
+
content
|
|
1699
|
+
and isinstance(content, str)
|
|
1700
|
+
and not has_final_answer_tool
|
|
1701
|
+
and not (
|
|
1702
|
+
content.strip().startswith('{"tool":')
|
|
1703
|
+
or content.strip().startswith('{"status":')
|
|
1704
|
+
or '"pending_execution"' in content
|
|
1705
|
+
or '"status": "complete"' in content
|
|
1706
|
+
)
|
|
1239
1707
|
):
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1708
|
+
# Check if we've already emitted this content (prevents duplicates)
|
|
1709
|
+
content_hash = hash(content)
|
|
1710
|
+
if content_hash in emitted_contents:
|
|
1711
|
+
logger.info(
|
|
1712
|
+
"Resume: SKIPPING duplicate content (len=%d): %s",
|
|
1713
|
+
len(content),
|
|
1714
|
+
content[:100],
|
|
1715
|
+
)
|
|
1716
|
+
else:
|
|
1717
|
+
emitted_contents.add(content_hash)
|
|
1718
|
+
logger.info(
|
|
1719
|
+
"Resume: EMITTING token content (len=%d): %s",
|
|
1720
|
+
len(content),
|
|
1721
|
+
content[:100],
|
|
1722
|
+
)
|
|
1723
|
+
yield {
|
|
1724
|
+
"event": "token",
|
|
1725
|
+
"data": json.dumps({"content": content}),
|
|
1726
|
+
}
|
|
1244
1727
|
|
|
1245
1728
|
if (
|
|
1246
1729
|
hasattr(last_message, "tool_calls")
|
|
@@ -1253,66 +1736,218 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1253
1736
|
if tc.get("id") not in processed_tool_call_ids
|
|
1254
1737
|
]
|
|
1255
1738
|
|
|
1256
|
-
if
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
if tc.get("id"):
|
|
1263
|
-
processed_tool_call_ids.add(tc["id"])
|
|
1739
|
+
# Only process if there are new tool calls (no continue to ensure interrupt check runs)
|
|
1740
|
+
if new_tool_calls:
|
|
1741
|
+
# Mark these tool calls as processed
|
|
1742
|
+
for tc in new_tool_calls:
|
|
1743
|
+
if tc.get("id"):
|
|
1744
|
+
processed_tool_call_ids.add(tc["id"])
|
|
1264
1745
|
|
|
1265
|
-
logger.info(
|
|
1266
|
-
"Resume AIMessage tool_calls: %s",
|
|
1267
|
-
json.dumps(new_tool_calls, ensure_ascii=False),
|
|
1268
|
-
)
|
|
1269
|
-
todos = _emit_todos_from_tool_calls(new_tool_calls)
|
|
1270
|
-
if todos:
|
|
1271
|
-
latest_todos = todos
|
|
1272
|
-
yield {
|
|
1273
|
-
"event": "todos",
|
|
1274
|
-
"data": json.dumps({"todos": todos}),
|
|
1275
|
-
}
|
|
1276
|
-
for tool_call in new_tool_calls:
|
|
1277
|
-
tool_name = tool_call.get("name", "unknown")
|
|
1278
|
-
tool_args = tool_call.get("args", {})
|
|
1279
|
-
if tool_args.get("execution_result"):
|
|
1280
1746
|
logger.info(
|
|
1281
|
-
"Resume
|
|
1282
|
-
|
|
1747
|
+
"Resume AIMessage tool_calls: %s",
|
|
1748
|
+
json.dumps(new_tool_calls, ensure_ascii=False),
|
|
1283
1749
|
)
|
|
1284
|
-
|
|
1750
|
+
todos = _emit_todos_from_tool_calls(new_tool_calls)
|
|
1751
|
+
if todos:
|
|
1752
|
+
latest_todos = todos
|
|
1753
|
+
yield {
|
|
1754
|
+
"event": "todos",
|
|
1755
|
+
"data": json.dumps({"todos": todos}),
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
# Process tool calls
|
|
1759
|
+
for tool_call in new_tool_calls:
|
|
1760
|
+
tool_name = tool_call.get("name", "unknown")
|
|
1761
|
+
tool_args = tool_call.get("args", {})
|
|
1762
|
+
# Skip tool calls with execution_result (continue is OK here - inner loop)
|
|
1763
|
+
if tool_args.get("execution_result"):
|
|
1764
|
+
logger.info(
|
|
1765
|
+
"Resume tool_call includes execution_result; skipping client execution for %s",
|
|
1766
|
+
tool_name,
|
|
1767
|
+
)
|
|
1768
|
+
continue
|
|
1769
|
+
|
|
1770
|
+
# Create detailed status message for search tools
|
|
1771
|
+
if tool_name in (
|
|
1772
|
+
"search_workspace_tool",
|
|
1773
|
+
"search_workspace",
|
|
1774
|
+
):
|
|
1775
|
+
pattern = tool_args.get("pattern", "")
|
|
1776
|
+
path = tool_args.get("path", ".")
|
|
1777
|
+
status_msg = f"🔍 검색 실행: grep/rg '{pattern}' in {path}"
|
|
1778
|
+
elif tool_name in (
|
|
1779
|
+
"search_notebook_cells_tool",
|
|
1780
|
+
"search_notebook_cells",
|
|
1781
|
+
):
|
|
1782
|
+
pattern = tool_args.get("pattern", "")
|
|
1783
|
+
nb_path = tool_args.get(
|
|
1784
|
+
"notebook_path", "all notebooks"
|
|
1785
|
+
)
|
|
1786
|
+
status_msg = f"🔍 노트북 검색: '{pattern}' in {nb_path or 'all notebooks'}"
|
|
1787
|
+
else:
|
|
1788
|
+
status_msg = f"🔧 Tool 실행: {tool_name}"
|
|
1789
|
+
|
|
1790
|
+
yield {
|
|
1791
|
+
"event": "debug",
|
|
1792
|
+
"data": json.dumps({"status": status_msg}),
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
if tool_name in (
|
|
1796
|
+
"jupyter_cell_tool",
|
|
1797
|
+
"jupyter_cell",
|
|
1798
|
+
):
|
|
1799
|
+
yield {
|
|
1800
|
+
"event": "tool_call",
|
|
1801
|
+
"data": json.dumps(
|
|
1802
|
+
{
|
|
1803
|
+
"tool": "jupyter_cell",
|
|
1804
|
+
"code": tool_args.get("code", ""),
|
|
1805
|
+
"description": tool_args.get(
|
|
1806
|
+
"description", ""
|
|
1807
|
+
),
|
|
1808
|
+
}
|
|
1809
|
+
),
|
|
1810
|
+
}
|
|
1811
|
+
elif tool_name in ("markdown_tool", "markdown"):
|
|
1812
|
+
yield {
|
|
1813
|
+
"event": "tool_call",
|
|
1814
|
+
"data": json.dumps(
|
|
1815
|
+
{
|
|
1816
|
+
"tool": "markdown",
|
|
1817
|
+
"content": tool_args.get(
|
|
1818
|
+
"content", ""
|
|
1819
|
+
),
|
|
1820
|
+
}
|
|
1821
|
+
),
|
|
1822
|
+
}
|
|
1823
|
+
elif tool_name == "execute_command_tool":
|
|
1824
|
+
yield {
|
|
1825
|
+
"event": "tool_call",
|
|
1826
|
+
"data": json.dumps(
|
|
1827
|
+
{
|
|
1828
|
+
"tool": "execute_command_tool",
|
|
1829
|
+
"command": tool_args.get(
|
|
1830
|
+
"command", ""
|
|
1831
|
+
),
|
|
1832
|
+
"timeout": tool_args.get("timeout"),
|
|
1833
|
+
}
|
|
1834
|
+
),
|
|
1835
|
+
}
|
|
1836
|
+
elif tool_name in (
|
|
1837
|
+
"search_workspace_tool",
|
|
1838
|
+
"search_workspace",
|
|
1839
|
+
):
|
|
1840
|
+
# Search workspace - emit tool_call for client-side execution
|
|
1841
|
+
yield {
|
|
1842
|
+
"event": "tool_call",
|
|
1843
|
+
"data": json.dumps(
|
|
1844
|
+
{
|
|
1845
|
+
"tool": "search_workspace",
|
|
1846
|
+
"pattern": tool_args.get(
|
|
1847
|
+
"pattern", ""
|
|
1848
|
+
),
|
|
1849
|
+
"file_types": tool_args.get(
|
|
1850
|
+
"file_types",
|
|
1851
|
+
["*.py", "*.ipynb"],
|
|
1852
|
+
),
|
|
1853
|
+
"path": tool_args.get("path", "."),
|
|
1854
|
+
"max_results": tool_args.get(
|
|
1855
|
+
"max_results", 50
|
|
1856
|
+
),
|
|
1857
|
+
"case_sensitive": tool_args.get(
|
|
1858
|
+
"case_sensitive", False
|
|
1859
|
+
),
|
|
1860
|
+
}
|
|
1861
|
+
),
|
|
1862
|
+
}
|
|
1863
|
+
elif tool_name in (
|
|
1864
|
+
"search_notebook_cells_tool",
|
|
1865
|
+
"search_notebook_cells",
|
|
1866
|
+
):
|
|
1867
|
+
# Search notebook cells - emit tool_call for client-side execution
|
|
1868
|
+
yield {
|
|
1869
|
+
"event": "tool_call",
|
|
1870
|
+
"data": json.dumps(
|
|
1871
|
+
{
|
|
1872
|
+
"tool": "search_notebook_cells",
|
|
1873
|
+
"pattern": tool_args.get(
|
|
1874
|
+
"pattern", ""
|
|
1875
|
+
),
|
|
1876
|
+
"notebook_path": tool_args.get(
|
|
1877
|
+
"notebook_path"
|
|
1878
|
+
),
|
|
1879
|
+
"cell_type": tool_args.get(
|
|
1880
|
+
"cell_type"
|
|
1881
|
+
),
|
|
1882
|
+
"max_results": tool_args.get(
|
|
1883
|
+
"max_results", 30
|
|
1884
|
+
),
|
|
1885
|
+
"case_sensitive": tool_args.get(
|
|
1886
|
+
"case_sensitive", False
|
|
1887
|
+
),
|
|
1888
|
+
}
|
|
1889
|
+
),
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
# Check for interrupt AFTER processing todos and messages
|
|
1893
|
+
# This ensures todos/debug events are emitted even in interrupt steps
|
|
1894
|
+
if isinstance(step, dict) and "__interrupt__" in step:
|
|
1895
|
+
interrupts = step["__interrupt__"]
|
|
1896
|
+
|
|
1897
|
+
yield {
|
|
1898
|
+
"event": "debug",
|
|
1899
|
+
"data": json.dumps({"status": "⏸️ 사용자 승인 대기 중"}),
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
for interrupt in interrupts:
|
|
1903
|
+
interrupt_value = (
|
|
1904
|
+
interrupt.value
|
|
1905
|
+
if hasattr(interrupt, "value")
|
|
1906
|
+
else interrupt
|
|
1907
|
+
)
|
|
1908
|
+
action_requests = interrupt_value.get("action_requests", [])
|
|
1909
|
+
normalized_actions = [
|
|
1910
|
+
_normalize_action_request(a) for a in action_requests
|
|
1911
|
+
]
|
|
1912
|
+
if normalized_actions:
|
|
1913
|
+
_simple_agent_pending_actions[request.threadId] = (
|
|
1914
|
+
normalized_actions
|
|
1915
|
+
)
|
|
1285
1916
|
|
|
1917
|
+
total_actions = len(normalized_actions)
|
|
1918
|
+
for idx, action in enumerate(normalized_actions):
|
|
1286
1919
|
yield {
|
|
1287
|
-
"event": "
|
|
1920
|
+
"event": "interrupt",
|
|
1288
1921
|
"data": json.dumps(
|
|
1289
|
-
{
|
|
1922
|
+
{
|
|
1923
|
+
"thread_id": request.threadId,
|
|
1924
|
+
"action": action.get("name", "unknown"),
|
|
1925
|
+
"args": action.get("arguments", {}),
|
|
1926
|
+
"description": action.get("description", ""),
|
|
1927
|
+
"action_index": idx,
|
|
1928
|
+
"total_actions": total_actions,
|
|
1929
|
+
}
|
|
1290
1930
|
),
|
|
1291
1931
|
}
|
|
1292
1932
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
"tool": "markdown",
|
|
1312
|
-
"content": tool_args.get("content", ""),
|
|
1313
|
-
}
|
|
1314
|
-
),
|
|
1315
|
-
}
|
|
1933
|
+
# Save last signature for next resume to avoid duplicate content
|
|
1934
|
+
if last_signature:
|
|
1935
|
+
_simple_agent_last_signatures[request.threadId] = last_signature
|
|
1936
|
+
logger.info(
|
|
1937
|
+
"Resume Interrupt: Saved signature for thread %s: %s",
|
|
1938
|
+
request.threadId,
|
|
1939
|
+
last_signature[:100] if last_signature else None,
|
|
1940
|
+
)
|
|
1941
|
+
# Save emitted contents for next resume
|
|
1942
|
+
_simple_agent_emitted_contents[request.threadId] = emitted_contents
|
|
1943
|
+
logger.info(
|
|
1944
|
+
"Resume Interrupt: Saved %d emitted content hashes for thread %s",
|
|
1945
|
+
len(emitted_contents),
|
|
1946
|
+
request.threadId,
|
|
1947
|
+
)
|
|
1948
|
+
|
|
1949
|
+
# Stop streaming - wait for resume
|
|
1950
|
+
return
|
|
1316
1951
|
|
|
1317
1952
|
# Clear debug status before completion
|
|
1318
1953
|
yield {"event": "debug_clear", "data": json.dumps({})}
|
|
@@ -1365,7 +2000,8 @@ async def search_workspace(
|
|
|
1365
2000
|
"""
|
|
1366
2001
|
from agent_server.langchain.executors.notebook_searcher import NotebookSearcher
|
|
1367
2002
|
|
|
1368
|
-
|
|
2003
|
+
resolved_workspace_root = _resolve_workspace_root(workspace_root)
|
|
2004
|
+
searcher = NotebookSearcher(resolved_workspace_root)
|
|
1369
2005
|
|
|
1370
2006
|
if notebook_path:
|
|
1371
2007
|
results = searcher.search_notebook(
|
|
@@ -1397,9 +2033,9 @@ async def health_check() -> Dict[str, Any]:
|
|
|
1397
2033
|
@router.delete("/cache")
|
|
1398
2034
|
async def clear_agent_cache() -> Dict[str, Any]:
|
|
1399
2035
|
"""Clear the agent instance cache"""
|
|
1400
|
-
global
|
|
1401
|
-
count = len(
|
|
1402
|
-
|
|
2036
|
+
global _simple_agent_instances
|
|
2037
|
+
count = len(_simple_agent_instances)
|
|
2038
|
+
_simple_agent_instances.clear()
|
|
1403
2039
|
|
|
1404
2040
|
return {
|
|
1405
2041
|
"status": "ok",
|