hdsp-jupyter-extension 2.0.6__py3-none-any.whl → 2.0.8__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/embedding_service.py +67 -46
- agent_server/core/rag_manager.py +31 -17
- agent_server/core/reflection_engine.py +0 -1
- agent_server/core/retriever.py +13 -8
- agent_server/core/vllm_embedding_service.py +243 -0
- agent_server/knowledge/watchdog_service.py +1 -1
- agent_server/langchain/ARCHITECTURE.md +1193 -0
- agent_server/langchain/agent.py +82 -588
- agent_server/langchain/custom_middleware.py +663 -0
- agent_server/langchain/executors/__init__.py +2 -7
- agent_server/langchain/executors/notebook_searcher.py +46 -38
- agent_server/langchain/hitl_config.py +71 -0
- agent_server/langchain/llm_factory.py +166 -0
- agent_server/langchain/logging_utils.py +223 -0
- agent_server/langchain/prompts.py +150 -0
- agent_server/langchain/state.py +16 -6
- agent_server/langchain/tools/__init__.py +19 -0
- agent_server/langchain/tools/file_tools.py +354 -114
- agent_server/langchain/tools/file_utils.py +334 -0
- agent_server/langchain/tools/jupyter_tools.py +18 -18
- agent_server/langchain/tools/lsp_tools.py +264 -0
- 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 +11 -1
- agent_server/routers/health.py +1 -1
- agent_server/routers/langchain_agent.py +1040 -289
- agent_server/routers/rag.py +8 -3
- hdsp_agent_core/models/rag.py +15 -1
- hdsp_agent_core/prompts/auto_agent_prompts.py +3 -3
- hdsp_agent_core/services/rag_service.py +6 -1
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/build_log.json +1 -1
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/package.json +3 -2
- hdsp_jupyter_extension-2.0.6.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.02d346171474a0fb2dc1.js → hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.8740a527757068814573.js +470 -7
- hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.8740a527757068814573.js.map +1 -0
- hdsp_jupyter_extension-2.0.6.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.a223ea20056954479ae9.js → hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.e4ff4b5779b5e049f84c.js +3196 -441
- hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.e4ff4b5779b5e049f84c.js.map +1 -0
- hdsp_jupyter_extension-2.0.6.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.addf2fa038fa60304aa2.js → hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.020cdb0b864cfaa4e41e.js +9 -7
- hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.020cdb0b864cfaa4e41e.js.map +1 -0
- {hdsp_jupyter_extension-2.0.6.dist-info → hdsp_jupyter_extension-2.0.8.dist-info}/METADATA +2 -1
- {hdsp_jupyter_extension-2.0.6.dist-info → hdsp_jupyter_extension-2.0.8.dist-info}/RECORD +75 -69
- jupyter_ext/__init__.py +18 -0
- jupyter_ext/_version.py +1 -1
- jupyter_ext/handlers.py +1351 -58
- jupyter_ext/labextension/build_log.json +1 -1
- jupyter_ext/labextension/package.json +3 -2
- jupyter_ext/labextension/static/{frontend_styles_index_js.02d346171474a0fb2dc1.js → frontend_styles_index_js.8740a527757068814573.js} +470 -7
- jupyter_ext/labextension/static/frontend_styles_index_js.8740a527757068814573.js.map +1 -0
- jupyter_ext/labextension/static/{lib_index_js.a223ea20056954479ae9.js → lib_index_js.e4ff4b5779b5e049f84c.js} +3196 -441
- jupyter_ext/labextension/static/lib_index_js.e4ff4b5779b5e049f84c.js.map +1 -0
- jupyter_ext/labextension/static/{remoteEntry.addf2fa038fa60304aa2.js → remoteEntry.020cdb0b864cfaa4e41e.js} +9 -7
- jupyter_ext/labextension/static/remoteEntry.020cdb0b864cfaa4e41e.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.6.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.02d346171474a0fb2dc1.js.map +0 -1
- hdsp_jupyter_extension-2.0.6.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.a223ea20056954479ae9.js.map +0 -1
- hdsp_jupyter_extension-2.0.6.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.addf2fa038fa60304aa2.js.map +0 -1
- jupyter_ext/labextension/static/frontend_styles_index_js.02d346171474a0fb2dc1.js.map +0 -1
- jupyter_ext/labextension/static/lib_index_js.a223ea20056954479ae9.js.map +0 -1
- jupyter_ext/labextension/static/remoteEntry.addf2fa038fa60304aa2.js.map +0 -1
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.data}/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +0 -0
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/install.json +0 -0
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.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.6.data → hdsp_jupyter_extension-2.0.8.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.6.data → hdsp_jupyter_extension-2.0.8.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.6.data → hdsp_jupyter_extension-2.0.8.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.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/static/style.js +0 -0
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.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.6.data → hdsp_jupyter_extension-2.0.8.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.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js +0 -0
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js.map +0 -0
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js +0 -0
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js.map +0 -0
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.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.6.data → hdsp_jupyter_extension-2.0.8.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.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +0 -0
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.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.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js +0 -0
- {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +0 -0
- {hdsp_jupyter_extension-2.0.6.dist-info → hdsp_jupyter_extension-2.0.8.dist-info}/WHEEL +0 -0
- {hdsp_jupyter_extension-2.0.6.dist-info → hdsp_jupyter_extension-2.0.8.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,103 @@ 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
|
+
# Check existing state and reset todos if all completed
|
|
565
|
+
should_reset_todos = False
|
|
566
|
+
try:
|
|
567
|
+
existing_state = checkpointer.get(config)
|
|
568
|
+
if existing_state:
|
|
569
|
+
channel_values = existing_state.get("channel_values", {})
|
|
570
|
+
existing_messages = channel_values.get("messages", [])
|
|
571
|
+
existing_todos = channel_values.get("todos", [])
|
|
572
|
+
logger.info(
|
|
573
|
+
"Existing state for thread %s: %d messages, %d todos found",
|
|
574
|
+
thread_id,
|
|
575
|
+
len(existing_messages),
|
|
576
|
+
len(existing_todos),
|
|
577
|
+
)
|
|
578
|
+
# Check if all todos are completed - if so, reset them
|
|
579
|
+
if existing_todos:
|
|
580
|
+
all_completed = all(
|
|
581
|
+
t.get("status") == "completed" for t in existing_todos
|
|
582
|
+
)
|
|
583
|
+
if all_completed:
|
|
584
|
+
should_reset_todos = True
|
|
585
|
+
logger.info(
|
|
586
|
+
"All %d todos are completed, will reset for new task",
|
|
587
|
+
len(existing_todos),
|
|
588
|
+
)
|
|
589
|
+
else:
|
|
590
|
+
logger.info("No existing state for thread %s", thread_id)
|
|
591
|
+
except Exception as e:
|
|
592
|
+
logger.warning("Could not check existing state: %s", e)
|
|
593
|
+
|
|
594
|
+
# Reset todos in agent state if all were completed
|
|
595
|
+
todos_reset_event = None
|
|
596
|
+
if should_reset_todos:
|
|
597
|
+
try:
|
|
598
|
+
agent.update_state(config, {"todos": []})
|
|
599
|
+
logger.info("Reset todos in agent state for thread %s", thread_id)
|
|
600
|
+
# Prepare event to notify frontend (will be yielded after function setup)
|
|
601
|
+
todos_reset_event = {
|
|
602
|
+
"event": "todos",
|
|
603
|
+
"data": json.dumps({"todos": [], "reset": True}),
|
|
604
|
+
}
|
|
605
|
+
except Exception as e:
|
|
606
|
+
logger.warning("Could not reset todos in agent state: %s", e)
|
|
607
|
+
|
|
391
608
|
# Prepare input
|
|
392
609
|
agent_input = {"messages": [{"role": "user", "content": request.request}]}
|
|
393
610
|
|
|
@@ -399,63 +616,32 @@ async def stream_agent(request: AgentRequest):
|
|
|
399
616
|
last_finish_reason = None
|
|
400
617
|
last_signature = None
|
|
401
618
|
latest_todos: Optional[List[Dict[str, Any]]] = None
|
|
619
|
+
# Initialize emitted contents set for this thread (clear any stale data)
|
|
620
|
+
emitted_contents: set = set()
|
|
621
|
+
_simple_agent_emitted_contents[thread_id] = emitted_contents
|
|
622
|
+
|
|
623
|
+
# Emit todos reset event if needed (before starting the stream)
|
|
624
|
+
if todos_reset_event:
|
|
625
|
+
logger.info("SSE: Emitting todos reset event")
|
|
626
|
+
yield todos_reset_event
|
|
402
627
|
|
|
403
628
|
# Initial status: waiting for LLM
|
|
629
|
+
logger.info("SSE: Sending initial debug status '🤔 LLM 응답 대기 중'")
|
|
404
630
|
yield {
|
|
405
631
|
"event": "debug",
|
|
406
632
|
"data": json.dumps({"status": "🤔 LLM 응답 대기 중"}),
|
|
407
633
|
}
|
|
408
634
|
|
|
409
|
-
for step in
|
|
635
|
+
async for step in _async_stream_wrapper(
|
|
636
|
+
agent, agent_input, config, stream_mode="values"
|
|
637
|
+
):
|
|
410
638
|
if isinstance(step, dict):
|
|
411
639
|
logger.info(
|
|
412
640
|
"SimpleAgent step keys: %s", ",".join(sorted(step.keys()))
|
|
413
641
|
)
|
|
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
642
|
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
457
|
-
# Stop streaming - wait for resume
|
|
458
|
-
return
|
|
643
|
+
# IMPORTANT: Process todos and messages BEFORE checking for interrupt
|
|
644
|
+
# This ensures todos/debug events are emitted even in interrupt steps
|
|
459
645
|
|
|
460
646
|
# Check for todos in state and stream them
|
|
461
647
|
if isinstance(step, dict) and "todos" in step:
|
|
@@ -475,107 +661,145 @@ async def stream_agent(request: AgentRequest):
|
|
|
475
661
|
"data": json.dumps({"todos": todos}),
|
|
476
662
|
}
|
|
477
663
|
|
|
478
|
-
# Process messages
|
|
664
|
+
# Process messages (no continue statements to ensure interrupt check always runs)
|
|
479
665
|
if isinstance(step, dict) and "messages" in step:
|
|
480
666
|
messages = step["messages"]
|
|
667
|
+
should_process_message = False
|
|
481
668
|
if messages:
|
|
482
669
|
last_message = messages[-1]
|
|
483
670
|
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
671
|
logger.info(
|
|
501
|
-
"
|
|
502
|
-
|
|
672
|
+
"Initial: Signature comparison - current: %s, last: %s, match: %s",
|
|
673
|
+
signature[:100] if signature else None,
|
|
674
|
+
last_signature[:100] if last_signature else None,
|
|
675
|
+
signature == last_signature,
|
|
503
676
|
)
|
|
504
|
-
|
|
505
|
-
if
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
}
|
|
511
|
-
tool_name = getattr(last_message, "name", "") or ""
|
|
512
|
-
logger.info(
|
|
513
|
-
"SimpleAgent ToolMessage name attribute: %s", tool_name
|
|
514
|
-
)
|
|
515
|
-
|
|
516
|
-
# Also check content for tool name if name attribute is empty
|
|
517
|
-
if not tool_name:
|
|
518
|
-
try:
|
|
519
|
-
content_json = json.loads(last_message.content)
|
|
520
|
-
tool_name = content_json.get("tool", "")
|
|
677
|
+
# Only process if this is a new message (not duplicate)
|
|
678
|
+
if signature != last_signature:
|
|
679
|
+
last_signature = signature
|
|
680
|
+
# Skip HumanMessage
|
|
681
|
+
if not isinstance(last_message, HumanMessage):
|
|
682
|
+
should_process_message = True
|
|
521
683
|
logger.info(
|
|
522
|
-
"SimpleAgent
|
|
523
|
-
|
|
684
|
+
"SimpleAgent last_message type=%s has_content=%s tool_calls=%s",
|
|
685
|
+
type(last_message).__name__,
|
|
686
|
+
bool(getattr(last_message, "content", None)),
|
|
687
|
+
bool(getattr(last_message, "tool_calls", None)),
|
|
524
688
|
)
|
|
525
|
-
except (json.JSONDecodeError, TypeError):
|
|
526
|
-
pass
|
|
527
689
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
690
|
+
# Process message only if it's new and not HumanMessage
|
|
691
|
+
if should_process_message:
|
|
692
|
+
# Handle ToolMessage - extract final_answer result
|
|
693
|
+
if isinstance(last_message, ToolMessage):
|
|
694
|
+
logger.info(
|
|
695
|
+
"SimpleAgent ToolMessage content: %s",
|
|
696
|
+
last_message.content,
|
|
697
|
+
)
|
|
698
|
+
todos = _extract_todos(last_message.content)
|
|
699
|
+
if todos:
|
|
700
|
+
latest_todos = todos
|
|
701
|
+
yield {
|
|
702
|
+
"event": "todos",
|
|
703
|
+
"data": json.dumps({"todos": todos}),
|
|
704
|
+
}
|
|
705
|
+
tool_name = getattr(last_message, "name", "") or ""
|
|
706
|
+
logger.info(
|
|
707
|
+
"SimpleAgent ToolMessage name attribute: %s", tool_name
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
# Also check content for tool name if name attribute is empty
|
|
711
|
+
if not tool_name:
|
|
712
|
+
try:
|
|
713
|
+
content_json = json.loads(last_message.content)
|
|
714
|
+
tool_name = content_json.get("tool", "")
|
|
715
|
+
logger.info(
|
|
716
|
+
"SimpleAgent ToolMessage tool from content: %s",
|
|
717
|
+
tool_name,
|
|
718
|
+
)
|
|
719
|
+
except (json.JSONDecodeError, TypeError):
|
|
720
|
+
pass
|
|
721
|
+
|
|
722
|
+
if tool_name in ("final_answer_tool", "final_answer"):
|
|
723
|
+
# Extract the final answer from the tool result
|
|
724
|
+
try:
|
|
725
|
+
tool_result = json.loads(last_message.content)
|
|
726
|
+
# Check both direct "answer" and "parameters.answer"
|
|
727
|
+
final_answer = tool_result.get(
|
|
728
|
+
"answer"
|
|
729
|
+
) or tool_result.get("parameters", {}).get("answer")
|
|
730
|
+
|
|
731
|
+
# Check for next_items in answer field (LLM may put JSON here)
|
|
732
|
+
if final_answer:
|
|
733
|
+
try:
|
|
734
|
+
answer_json = json.loads(final_answer)
|
|
735
|
+
if "next_items" in answer_json:
|
|
736
|
+
next_items_block = f"\n\n```json\n{json.dumps(answer_json, ensure_ascii=False, indent=2)}\n```"
|
|
737
|
+
# Get summary for the main text
|
|
738
|
+
summary_text = tool_result.get(
|
|
739
|
+
"summary"
|
|
740
|
+
) or tool_result.get("parameters", {}).get("summary") or ""
|
|
741
|
+
final_answer = summary_text + next_items_block
|
|
742
|
+
logger.info("Extracted next_items from answer field")
|
|
743
|
+
except (json.JSONDecodeError, TypeError):
|
|
744
|
+
pass
|
|
745
|
+
|
|
746
|
+
# Check for next_items in summary field (Gemini puts JSON here)
|
|
747
|
+
summary = tool_result.get(
|
|
748
|
+
"summary"
|
|
749
|
+
) or tool_result.get("parameters", {}).get("summary")
|
|
750
|
+
if summary and "next_items" not in (final_answer or ""):
|
|
751
|
+
try:
|
|
752
|
+
summary_json = json.loads(summary)
|
|
753
|
+
if "next_items" in summary_json:
|
|
754
|
+
next_items_block = f"\n\n```json\n{json.dumps(summary_json, ensure_ascii=False, indent=2)}\n```"
|
|
755
|
+
final_answer = (final_answer or "") + next_items_block
|
|
756
|
+
logger.info("Extracted next_items from summary field")
|
|
757
|
+
except (json.JSONDecodeError, TypeError):
|
|
758
|
+
pass
|
|
759
|
+
if final_answer:
|
|
760
|
+
yield {
|
|
761
|
+
"event": "token",
|
|
762
|
+
"data": json.dumps(
|
|
763
|
+
{"content": final_answer}
|
|
764
|
+
),
|
|
765
|
+
}
|
|
766
|
+
else:
|
|
767
|
+
# Fallback to raw content if no answer found
|
|
768
|
+
yield {
|
|
769
|
+
"event": "token",
|
|
770
|
+
"data": json.dumps(
|
|
771
|
+
{"content": last_message.content}
|
|
772
|
+
),
|
|
773
|
+
}
|
|
774
|
+
except json.JSONDecodeError:
|
|
775
|
+
# If not JSON, use content directly
|
|
776
|
+
if last_message.content:
|
|
777
|
+
yield {
|
|
778
|
+
"event": "token",
|
|
779
|
+
"data": json.dumps(
|
|
780
|
+
{"content": last_message.content}
|
|
781
|
+
),
|
|
782
|
+
}
|
|
783
|
+
if latest_todos:
|
|
552
784
|
yield {
|
|
553
|
-
"event": "
|
|
785
|
+
"event": "todos",
|
|
554
786
|
"data": json.dumps(
|
|
555
|
-
{"
|
|
787
|
+
{"todos": _complete_todos(latest_todos)}
|
|
556
788
|
),
|
|
557
789
|
}
|
|
558
|
-
|
|
790
|
+
# End stream after final answer
|
|
791
|
+
yield {"event": "debug_clear", "data": json.dumps({})}
|
|
559
792
|
yield {
|
|
560
|
-
"event": "
|
|
793
|
+
"event": "complete",
|
|
561
794
|
"data": json.dumps(
|
|
562
|
-
{"
|
|
795
|
+
{"success": True, "thread_id": thread_id}
|
|
563
796
|
),
|
|
564
797
|
}
|
|
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
|
|
798
|
+
return
|
|
799
|
+
# Other ToolMessages: don't skip with continue, just don't process further
|
|
576
800
|
|
|
577
801
|
# Handle AIMessage
|
|
578
|
-
|
|
802
|
+
elif isinstance(last_message, AIMessage):
|
|
579
803
|
logger.info(
|
|
580
804
|
"SimpleAgent AIMessage content: %s",
|
|
581
805
|
last_message.content or "",
|
|
@@ -630,9 +854,19 @@ async def stream_agent(request: AgentRequest):
|
|
|
630
854
|
).get("function_call")
|
|
631
855
|
tool_calls = _normalize_tool_calls(raw_tool_calls)
|
|
632
856
|
|
|
857
|
+
has_final_answer_tool = False
|
|
633
858
|
if tool_calls:
|
|
859
|
+
has_final_answer_tool = any(
|
|
860
|
+
(call.get("name") or call.get("tool") or "")
|
|
861
|
+
in ("final_answer_tool", "final_answer")
|
|
862
|
+
for call in tool_calls
|
|
863
|
+
)
|
|
634
864
|
todos = _emit_todos_from_tool_calls(tool_calls)
|
|
635
865
|
if todos:
|
|
866
|
+
logger.info(
|
|
867
|
+
"SSE: Emitting todos event from AIMessage tool_calls: %d items",
|
|
868
|
+
len(todos),
|
|
869
|
+
)
|
|
636
870
|
latest_todos = todos
|
|
637
871
|
yield {
|
|
638
872
|
"event": "todos",
|
|
@@ -642,11 +876,33 @@ async def stream_agent(request: AgentRequest):
|
|
|
642
876
|
tool_name = tool_call.get("name", "unknown")
|
|
643
877
|
tool_args = tool_call.get("args", {})
|
|
644
878
|
|
|
879
|
+
# Create detailed status message for search tools
|
|
880
|
+
if tool_name in (
|
|
881
|
+
"search_workspace_tool",
|
|
882
|
+
"search_workspace",
|
|
883
|
+
):
|
|
884
|
+
pattern = tool_args.get("pattern", "")
|
|
885
|
+
path = tool_args.get("path", ".")
|
|
886
|
+
status_msg = f"🔍 검색 실행: grep/rg '{pattern}' in {path}"
|
|
887
|
+
elif tool_name in (
|
|
888
|
+
"search_notebook_cells_tool",
|
|
889
|
+
"search_notebook_cells",
|
|
890
|
+
):
|
|
891
|
+
pattern = tool_args.get("pattern", "")
|
|
892
|
+
nb_path = tool_args.get(
|
|
893
|
+
"notebook_path", "all notebooks"
|
|
894
|
+
)
|
|
895
|
+
status_msg = f"🔍 노트북 검색: '{pattern}' in {nb_path or 'all notebooks'}"
|
|
896
|
+
else:
|
|
897
|
+
status_msg = f"🔧 Tool 실행: {tool_name}"
|
|
898
|
+
|
|
899
|
+
logger.info(
|
|
900
|
+
"SSE: Emitting debug event for tool: %s",
|
|
901
|
+
tool_name,
|
|
902
|
+
)
|
|
645
903
|
yield {
|
|
646
904
|
"event": "debug",
|
|
647
|
-
"data": json.dumps(
|
|
648
|
-
{"status": f"🔧 Tool 실행: {tool_name}"}
|
|
649
|
-
),
|
|
905
|
+
"data": json.dumps({"status": status_msg}),
|
|
650
906
|
}
|
|
651
907
|
|
|
652
908
|
# Send tool_call event with details for frontend to execute
|
|
@@ -680,6 +936,77 @@ async def stream_agent(request: AgentRequest):
|
|
|
680
936
|
}
|
|
681
937
|
),
|
|
682
938
|
}
|
|
939
|
+
elif tool_name == "execute_command_tool":
|
|
940
|
+
produced_output = True
|
|
941
|
+
yield {
|
|
942
|
+
"event": "tool_call",
|
|
943
|
+
"data": json.dumps(
|
|
944
|
+
{
|
|
945
|
+
"tool": "execute_command_tool",
|
|
946
|
+
"command": tool_args.get(
|
|
947
|
+
"command", ""
|
|
948
|
+
),
|
|
949
|
+
"timeout": tool_args.get("timeout"),
|
|
950
|
+
}
|
|
951
|
+
),
|
|
952
|
+
}
|
|
953
|
+
elif tool_name in (
|
|
954
|
+
"search_workspace_tool",
|
|
955
|
+
"search_workspace",
|
|
956
|
+
):
|
|
957
|
+
# Search workspace - emit tool_call for client-side execution
|
|
958
|
+
produced_output = True
|
|
959
|
+
yield {
|
|
960
|
+
"event": "tool_call",
|
|
961
|
+
"data": json.dumps(
|
|
962
|
+
{
|
|
963
|
+
"tool": "search_workspace",
|
|
964
|
+
"pattern": tool_args.get(
|
|
965
|
+
"pattern", ""
|
|
966
|
+
),
|
|
967
|
+
"file_types": tool_args.get(
|
|
968
|
+
"file_types",
|
|
969
|
+
["*.py", "*.ipynb"],
|
|
970
|
+
),
|
|
971
|
+
"path": tool_args.get("path", "."),
|
|
972
|
+
"max_results": tool_args.get(
|
|
973
|
+
"max_results", 50
|
|
974
|
+
),
|
|
975
|
+
"case_sensitive": tool_args.get(
|
|
976
|
+
"case_sensitive", False
|
|
977
|
+
),
|
|
978
|
+
}
|
|
979
|
+
),
|
|
980
|
+
}
|
|
981
|
+
elif tool_name in (
|
|
982
|
+
"search_notebook_cells_tool",
|
|
983
|
+
"search_notebook_cells",
|
|
984
|
+
):
|
|
985
|
+
# Search notebook cells - emit tool_call for client-side execution
|
|
986
|
+
produced_output = True
|
|
987
|
+
yield {
|
|
988
|
+
"event": "tool_call",
|
|
989
|
+
"data": json.dumps(
|
|
990
|
+
{
|
|
991
|
+
"tool": "search_notebook_cells",
|
|
992
|
+
"pattern": tool_args.get(
|
|
993
|
+
"pattern", ""
|
|
994
|
+
),
|
|
995
|
+
"notebook_path": tool_args.get(
|
|
996
|
+
"notebook_path"
|
|
997
|
+
),
|
|
998
|
+
"cell_type": tool_args.get(
|
|
999
|
+
"cell_type"
|
|
1000
|
+
),
|
|
1001
|
+
"max_results": tool_args.get(
|
|
1002
|
+
"max_results", 30
|
|
1003
|
+
),
|
|
1004
|
+
"case_sensitive": tool_args.get(
|
|
1005
|
+
"case_sensitive", False
|
|
1006
|
+
),
|
|
1007
|
+
}
|
|
1008
|
+
),
|
|
1009
|
+
}
|
|
683
1010
|
|
|
684
1011
|
# Only display content if it's not empty and not a JSON tool response
|
|
685
1012
|
if (
|
|
@@ -688,23 +1015,120 @@ async def stream_agent(request: AgentRequest):
|
|
|
688
1015
|
):
|
|
689
1016
|
content = last_message.content
|
|
690
1017
|
|
|
1018
|
+
# Handle list content (e.g., multimodal responses)
|
|
1019
|
+
if isinstance(content, list):
|
|
1020
|
+
# Extract text content from list
|
|
1021
|
+
text_parts = []
|
|
1022
|
+
for part in content:
|
|
1023
|
+
if isinstance(part, str):
|
|
1024
|
+
text_parts.append(part)
|
|
1025
|
+
elif (
|
|
1026
|
+
isinstance(part, dict)
|
|
1027
|
+
and part.get("type") == "text"
|
|
1028
|
+
):
|
|
1029
|
+
text_parts.append(part.get("text", ""))
|
|
1030
|
+
content = "\n".join(text_parts)
|
|
1031
|
+
|
|
691
1032
|
# Filter out raw JSON tool responses
|
|
692
|
-
if
|
|
693
|
-
content
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1033
|
+
if (
|
|
1034
|
+
content
|
|
1035
|
+
and isinstance(content, str)
|
|
1036
|
+
and not has_final_answer_tool
|
|
1037
|
+
and not (
|
|
1038
|
+
content.strip().startswith('{"tool":')
|
|
1039
|
+
or content.strip().startswith('{"status":')
|
|
1040
|
+
or '"pending_execution"' in content
|
|
1041
|
+
or '"status": "complete"' in content
|
|
1042
|
+
)
|
|
697
1043
|
):
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
1044
|
+
# Check if we've already emitted this content (prevents duplicates)
|
|
1045
|
+
content_hash = hash(content)
|
|
1046
|
+
if content_hash in emitted_contents:
|
|
1047
|
+
logger.info(
|
|
1048
|
+
"Initial: SKIPPING duplicate content (len=%d): %s",
|
|
1049
|
+
len(content),
|
|
1050
|
+
content[:100],
|
|
1051
|
+
)
|
|
1052
|
+
else:
|
|
1053
|
+
emitted_contents.add(content_hash)
|
|
1054
|
+
logger.info(
|
|
1055
|
+
"Initial: EMITTING token content (len=%d): %s",
|
|
1056
|
+
len(content),
|
|
1057
|
+
content[:100],
|
|
1058
|
+
)
|
|
1059
|
+
produced_output = True
|
|
1060
|
+
yield {
|
|
1061
|
+
"event": "token",
|
|
1062
|
+
"data": json.dumps({"content": content}),
|
|
1063
|
+
}
|
|
703
1064
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1065
|
+
# Check for interrupt AFTER processing todos and messages
|
|
1066
|
+
# This ensures todos/debug events are emitted even in interrupt steps
|
|
1067
|
+
if isinstance(step, dict) and "__interrupt__" in step:
|
|
1068
|
+
interrupts = step["__interrupt__"]
|
|
1069
|
+
|
|
1070
|
+
yield {
|
|
1071
|
+
"event": "debug",
|
|
1072
|
+
"data": json.dumps({"status": "⏸️ 사용자 승인 대기 중"}),
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
# Process interrupts
|
|
1076
|
+
for interrupt in interrupts:
|
|
1077
|
+
interrupt_value = (
|
|
1078
|
+
interrupt.value
|
|
1079
|
+
if hasattr(interrupt, "value")
|
|
1080
|
+
else interrupt
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
# Extract action requests
|
|
1084
|
+
action_requests = interrupt_value.get("action_requests", [])
|
|
1085
|
+
normalized_actions = [
|
|
1086
|
+
_normalize_action_request(a) for a in action_requests
|
|
1087
|
+
]
|
|
1088
|
+
if normalized_actions:
|
|
1089
|
+
_simple_agent_pending_actions[thread_id] = (
|
|
1090
|
+
normalized_actions
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
total_actions = len(normalized_actions)
|
|
1094
|
+
for idx, action in enumerate(normalized_actions):
|
|
1095
|
+
yield {
|
|
1096
|
+
"event": "interrupt",
|
|
1097
|
+
"data": json.dumps(
|
|
1098
|
+
{
|
|
1099
|
+
"thread_id": thread_id,
|
|
1100
|
+
"action": action.get("name", "unknown"),
|
|
1101
|
+
"args": action.get("arguments", {}),
|
|
1102
|
+
"description": action.get("description", ""),
|
|
1103
|
+
"action_index": idx,
|
|
1104
|
+
"total_actions": total_actions,
|
|
1105
|
+
}
|
|
1106
|
+
),
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
# Save last signature for resume to avoid duplicate content
|
|
1110
|
+
if last_signature:
|
|
1111
|
+
_simple_agent_last_signatures[thread_id] = last_signature
|
|
1112
|
+
logger.info(
|
|
1113
|
+
"Interrupt: Saved signature for thread %s: %s",
|
|
1114
|
+
thread_id,
|
|
1115
|
+
last_signature[:100] if last_signature else None,
|
|
1116
|
+
)
|
|
1117
|
+
# Save emitted contents for resume
|
|
1118
|
+
_simple_agent_emitted_contents[thread_id] = emitted_contents
|
|
1119
|
+
logger.info(
|
|
1120
|
+
"Interrupt: Saved %d emitted content hashes for thread %s",
|
|
1121
|
+
len(emitted_contents),
|
|
1122
|
+
thread_id,
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
# Stop streaming - wait for resume
|
|
1126
|
+
return
|
|
1127
|
+
|
|
1128
|
+
if not produced_output and last_finish_reason == "MALFORMED_FUNCTION_CALL":
|
|
1129
|
+
logger.info(
|
|
1130
|
+
"SimpleAgent fallback: retrying tool call generation after malformed function call"
|
|
1131
|
+
)
|
|
708
1132
|
try:
|
|
709
1133
|
fallback_config = json.loads(json.dumps(config_dict))
|
|
710
1134
|
if fallback_config.get(
|
|
@@ -719,7 +1143,7 @@ async def stream_agent(request: AgentRequest):
|
|
|
719
1143
|
"SimpleAgent fallback: switching model to gemini-2.5-pro"
|
|
720
1144
|
)
|
|
721
1145
|
|
|
722
|
-
llm =
|
|
1146
|
+
llm = create_llm(fallback_config)
|
|
723
1147
|
tools = _get_all_tools()
|
|
724
1148
|
# Force tool calling - use tool_config for Gemini, tool_choice for others
|
|
725
1149
|
provider = config_dict.get("provider", "gemini")
|
|
@@ -838,6 +1262,24 @@ async def stream_agent(request: AgentRequest):
|
|
|
838
1262
|
}
|
|
839
1263
|
),
|
|
840
1264
|
}
|
|
1265
|
+
elif tool_name == "execute_command_tool":
|
|
1266
|
+
produced_output = True
|
|
1267
|
+
yield {
|
|
1268
|
+
"event": "debug",
|
|
1269
|
+
"data": json.dumps(
|
|
1270
|
+
{"status": f"🔧 Tool 실행: {tool_name}"}
|
|
1271
|
+
),
|
|
1272
|
+
}
|
|
1273
|
+
yield {
|
|
1274
|
+
"event": "tool_call",
|
|
1275
|
+
"data": json.dumps(
|
|
1276
|
+
{
|
|
1277
|
+
"tool": "execute_command_tool",
|
|
1278
|
+
"command": tool_args.get("command", ""),
|
|
1279
|
+
"timeout": tool_args.get("timeout"),
|
|
1280
|
+
}
|
|
1281
|
+
),
|
|
1282
|
+
}
|
|
841
1283
|
elif tool_name in (
|
|
842
1284
|
"read_file_tool",
|
|
843
1285
|
"list_files_tool",
|
|
@@ -987,21 +1429,63 @@ async def resume_agent(request: ResumeRequest):
|
|
|
987
1429
|
config_dict["openai"] = request.llmConfig.openai
|
|
988
1430
|
if request.llmConfig.vllm:
|
|
989
1431
|
config_dict["vllm"] = request.llmConfig.vllm
|
|
1432
|
+
if request.llmConfig.resource_context:
|
|
1433
|
+
config_dict["resource_context"] = request.llmConfig.resource_context
|
|
990
1434
|
system_prompt_override = (
|
|
991
1435
|
request.llmConfig.system_prompt if request.llmConfig else None
|
|
992
1436
|
)
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1437
|
+
# Get or create cached agent
|
|
1438
|
+
resolved_workspace_root = _resolve_workspace_root(request.workspaceRoot)
|
|
1439
|
+
|
|
1440
|
+
# CRITICAL: Validate checkpoint exists before resume
|
|
1441
|
+
# InMemorySaver is volatile - server restart loses all checkpoints
|
|
1442
|
+
if request.threadId not in _simple_agent_checkpointers:
|
|
1443
|
+
logger.warning(
|
|
1444
|
+
"Resume failed: No checkpoint found for thread %s. "
|
|
1445
|
+
"Server may have restarted or session expired.",
|
|
1446
|
+
request.threadId,
|
|
1447
|
+
)
|
|
1448
|
+
yield {
|
|
1449
|
+
"event": "error",
|
|
1450
|
+
"data": json.dumps({
|
|
1451
|
+
"error": "Session expired or not found",
|
|
1452
|
+
"code": "CHECKPOINT_NOT_FOUND",
|
|
1453
|
+
"message": "이전 세션을 찾을 수 없습니다. 서버가 재시작되었거나 세션이 만료되었습니다. 새로운 대화를 시작해주세요.",
|
|
1454
|
+
}),
|
|
1455
|
+
}
|
|
1456
|
+
return
|
|
1457
|
+
|
|
1458
|
+
checkpointer = _simple_agent_checkpointers.get(request.threadId)
|
|
1459
|
+
|
|
1460
|
+
agent_cache_key = _get_agent_cache_key(
|
|
996
1461
|
llm_config=config_dict,
|
|
997
|
-
workspace_root=
|
|
998
|
-
enable_hitl=True,
|
|
999
|
-
checkpointer=_simple_agent_checkpointers.setdefault(
|
|
1000
|
-
request.threadId, InMemorySaver()
|
|
1001
|
-
),
|
|
1462
|
+
workspace_root=resolved_workspace_root,
|
|
1002
1463
|
system_prompt_override=system_prompt_override,
|
|
1003
1464
|
)
|
|
1004
1465
|
|
|
1466
|
+
if agent_cache_key in _simple_agent_instances:
|
|
1467
|
+
agent = _simple_agent_instances[agent_cache_key]
|
|
1468
|
+
logger.info(
|
|
1469
|
+
"Resume: Using cached agent for key %s (total cached: %d)",
|
|
1470
|
+
agent_cache_key[:8],
|
|
1471
|
+
len(_simple_agent_instances),
|
|
1472
|
+
)
|
|
1473
|
+
else:
|
|
1474
|
+
logger.info("Resume: Creating new agent for key %s", agent_cache_key[:8])
|
|
1475
|
+
agent = create_simple_chat_agent(
|
|
1476
|
+
llm_config=config_dict,
|
|
1477
|
+
workspace_root=resolved_workspace_root,
|
|
1478
|
+
enable_hitl=True,
|
|
1479
|
+
checkpointer=checkpointer,
|
|
1480
|
+
system_prompt_override=system_prompt_override,
|
|
1481
|
+
)
|
|
1482
|
+
_simple_agent_instances[agent_cache_key] = agent
|
|
1483
|
+
logger.info(
|
|
1484
|
+
"Resume: Agent cached for key %s (total cached: %d)",
|
|
1485
|
+
agent_cache_key[:8],
|
|
1486
|
+
len(_simple_agent_instances),
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1005
1489
|
# Prepare config with thread_id
|
|
1006
1490
|
config = {"configurable": {"thread_id": request.threadId}}
|
|
1007
1491
|
|
|
@@ -1030,8 +1514,8 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1030
1514
|
langgraph_decisions.append(
|
|
1031
1515
|
{
|
|
1032
1516
|
"type": "reject",
|
|
1033
|
-
|
|
1034
|
-
or "User rejected this action",
|
|
1517
|
+
# LangChain HITL middleware expects 'message' key for reject feedback
|
|
1518
|
+
"message": decision.feedback or "User rejected this action",
|
|
1035
1519
|
}
|
|
1036
1520
|
)
|
|
1037
1521
|
|
|
@@ -1047,8 +1531,22 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1047
1531
|
processed_tool_call_ids: set[str] = set()
|
|
1048
1532
|
latest_todos: Optional[List[Dict[str, Any]]] = None
|
|
1049
1533
|
|
|
1050
|
-
# Resume with Command
|
|
1051
|
-
last_signature =
|
|
1534
|
+
# Resume with Command - use saved signature to avoid duplicate content
|
|
1535
|
+
last_signature = _simple_agent_last_signatures.get(request.threadId)
|
|
1536
|
+
logger.info(
|
|
1537
|
+
"Resume: Restored signature for thread %s: %s",
|
|
1538
|
+
request.threadId,
|
|
1539
|
+
last_signature[:100] if last_signature else None,
|
|
1540
|
+
)
|
|
1541
|
+
# Restore emitted contents set to prevent duplicate content emission
|
|
1542
|
+
emitted_contents = _simple_agent_emitted_contents.get(
|
|
1543
|
+
request.threadId, set()
|
|
1544
|
+
)
|
|
1545
|
+
logger.info(
|
|
1546
|
+
"Resume: Restored %d emitted content hashes for thread %s",
|
|
1547
|
+
len(emitted_contents),
|
|
1548
|
+
request.threadId,
|
|
1549
|
+
)
|
|
1052
1550
|
|
|
1053
1551
|
# Status: waiting for LLM response
|
|
1054
1552
|
yield {
|
|
@@ -1057,7 +1555,9 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1057
1555
|
}
|
|
1058
1556
|
|
|
1059
1557
|
step_count = 0
|
|
1060
|
-
|
|
1558
|
+
|
|
1559
|
+
async for step in _async_stream_wrapper(
|
|
1560
|
+
agent,
|
|
1061
1561
|
Command(resume={"decisions": langgraph_decisions}),
|
|
1062
1562
|
config,
|
|
1063
1563
|
stream_mode="values",
|
|
@@ -1071,47 +1571,8 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1071
1571
|
step_keys,
|
|
1072
1572
|
)
|
|
1073
1573
|
|
|
1074
|
-
#
|
|
1075
|
-
|
|
1076
|
-
interrupts = step["__interrupt__"]
|
|
1077
|
-
|
|
1078
|
-
yield {
|
|
1079
|
-
"event": "debug",
|
|
1080
|
-
"data": json.dumps({"status": "⏸️ 사용자 승인 대기 중"}),
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
for interrupt in interrupts:
|
|
1084
|
-
interrupt_value = (
|
|
1085
|
-
interrupt.value
|
|
1086
|
-
if hasattr(interrupt, "value")
|
|
1087
|
-
else interrupt
|
|
1088
|
-
)
|
|
1089
|
-
action_requests = interrupt_value.get("action_requests", [])
|
|
1090
|
-
normalized_actions = [
|
|
1091
|
-
_normalize_action_request(a) for a in action_requests
|
|
1092
|
-
]
|
|
1093
|
-
if normalized_actions:
|
|
1094
|
-
_simple_agent_pending_actions[request.threadId] = (
|
|
1095
|
-
normalized_actions
|
|
1096
|
-
)
|
|
1097
|
-
|
|
1098
|
-
total_actions = len(normalized_actions)
|
|
1099
|
-
for idx, action in enumerate(normalized_actions):
|
|
1100
|
-
yield {
|
|
1101
|
-
"event": "interrupt",
|
|
1102
|
-
"data": json.dumps(
|
|
1103
|
-
{
|
|
1104
|
-
"thread_id": request.threadId,
|
|
1105
|
-
"action": action.get("name", "unknown"),
|
|
1106
|
-
"args": action.get("arguments", {}),
|
|
1107
|
-
"description": action.get("description", ""),
|
|
1108
|
-
"action_index": idx,
|
|
1109
|
-
"total_actions": total_actions,
|
|
1110
|
-
}
|
|
1111
|
-
),
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
return
|
|
1574
|
+
# IMPORTANT: Process todos and messages BEFORE checking for interrupt
|
|
1575
|
+
# This ensures todos/debug events are emitted even in interrupt steps
|
|
1115
1576
|
|
|
1116
1577
|
# Check for todos in state and stream them
|
|
1117
1578
|
if isinstance(step, dict) and "todos" in step:
|
|
@@ -1125,16 +1586,63 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1125
1586
|
latest_todos = todos
|
|
1126
1587
|
yield {"event": "todos", "data": json.dumps({"todos": todos})}
|
|
1127
1588
|
|
|
1128
|
-
# Process messages
|
|
1589
|
+
# Process messages (no continue statements to ensure interrupt check always runs)
|
|
1129
1590
|
if isinstance(step, dict) and "messages" in step:
|
|
1130
1591
|
messages = step["messages"]
|
|
1592
|
+
should_process_message = False
|
|
1131
1593
|
if messages:
|
|
1132
1594
|
last_message = messages[-1]
|
|
1133
1595
|
signature = _message_signature(last_message)
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1596
|
+
# Debug: Show full signature details when mismatch occurs
|
|
1597
|
+
if signature != last_signature and last_signature:
|
|
1598
|
+
logger.info(
|
|
1599
|
+
"Resume: Signature MISMATCH - len(current)=%d, len(last)=%d",
|
|
1600
|
+
len(signature),
|
|
1601
|
+
len(last_signature) if last_signature else 0,
|
|
1602
|
+
)
|
|
1603
|
+
# Find first difference position
|
|
1604
|
+
min_len = min(len(signature), len(last_signature))
|
|
1605
|
+
diff_pos = next(
|
|
1606
|
+
(
|
|
1607
|
+
i
|
|
1608
|
+
for i in range(min_len)
|
|
1609
|
+
if signature[i] != last_signature[i]
|
|
1610
|
+
),
|
|
1611
|
+
min_len,
|
|
1612
|
+
)
|
|
1613
|
+
logger.info(
|
|
1614
|
+
"Resume: First diff at pos %d: current[%d:%d]='%s', last[%d:%d]='%s'",
|
|
1615
|
+
diff_pos,
|
|
1616
|
+
max(0, diff_pos - 20),
|
|
1617
|
+
min(len(signature), diff_pos + 30),
|
|
1618
|
+
signature[
|
|
1619
|
+
max(0, diff_pos - 20) : min(
|
|
1620
|
+
len(signature), diff_pos + 30
|
|
1621
|
+
)
|
|
1622
|
+
],
|
|
1623
|
+
max(0, diff_pos - 20),
|
|
1624
|
+
min(len(last_signature), diff_pos + 30),
|
|
1625
|
+
last_signature[
|
|
1626
|
+
max(0, diff_pos - 20) : min(
|
|
1627
|
+
len(last_signature), diff_pos + 30
|
|
1628
|
+
)
|
|
1629
|
+
]
|
|
1630
|
+
if last_signature
|
|
1631
|
+
else "",
|
|
1632
|
+
)
|
|
1633
|
+
logger.info(
|
|
1634
|
+
"Resume: Signature comparison - current: %s, last: %s, match: %s",
|
|
1635
|
+
signature[:100] if signature else None,
|
|
1636
|
+
last_signature[:100] if last_signature else None,
|
|
1637
|
+
signature == last_signature,
|
|
1638
|
+
)
|
|
1639
|
+
# Only process if this is a new message (not duplicate)
|
|
1640
|
+
if signature != last_signature:
|
|
1641
|
+
last_signature = signature
|
|
1642
|
+
should_process_message = True
|
|
1137
1643
|
|
|
1644
|
+
# Process message only if it's new
|
|
1645
|
+
if should_process_message:
|
|
1138
1646
|
if isinstance(last_message, ToolMessage):
|
|
1139
1647
|
logger.info(
|
|
1140
1648
|
"Resume ToolMessage content: %s", last_message.content
|
|
@@ -1169,6 +1677,35 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1169
1677
|
final_answer = tool_result.get(
|
|
1170
1678
|
"answer"
|
|
1171
1679
|
) or tool_result.get("parameters", {}).get("answer")
|
|
1680
|
+
|
|
1681
|
+
# Check for next_items in answer field (LLM may put JSON here)
|
|
1682
|
+
if final_answer:
|
|
1683
|
+
try:
|
|
1684
|
+
answer_json = json.loads(final_answer)
|
|
1685
|
+
if "next_items" in answer_json:
|
|
1686
|
+
next_items_block = f"\n\n```json\n{json.dumps(answer_json, ensure_ascii=False, indent=2)}\n```"
|
|
1687
|
+
# Get summary for the main text
|
|
1688
|
+
summary_text = tool_result.get(
|
|
1689
|
+
"summary"
|
|
1690
|
+
) or tool_result.get("parameters", {}).get("summary") or ""
|
|
1691
|
+
final_answer = summary_text + next_items_block
|
|
1692
|
+
logger.info("Resume: Extracted next_items from answer field")
|
|
1693
|
+
except (json.JSONDecodeError, TypeError):
|
|
1694
|
+
pass
|
|
1695
|
+
|
|
1696
|
+
# Check for next_items in summary field (Gemini puts JSON here)
|
|
1697
|
+
summary = tool_result.get(
|
|
1698
|
+
"summary"
|
|
1699
|
+
) or tool_result.get("parameters", {}).get("summary")
|
|
1700
|
+
if summary and "next_items" not in (final_answer or ""):
|
|
1701
|
+
try:
|
|
1702
|
+
summary_json = json.loads(summary)
|
|
1703
|
+
if "next_items" in summary_json:
|
|
1704
|
+
next_items_block = f"\n\n```json\n{json.dumps(summary_json, ensure_ascii=False, indent=2)}\n```"
|
|
1705
|
+
final_answer = (final_answer or "") + next_items_block
|
|
1706
|
+
logger.info("Resume: Extracted next_items from summary field")
|
|
1707
|
+
except (json.JSONDecodeError, TypeError):
|
|
1708
|
+
pass
|
|
1172
1709
|
if final_answer:
|
|
1173
1710
|
yield {
|
|
1174
1711
|
"event": "token",
|
|
@@ -1205,23 +1742,69 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1205
1742
|
),
|
|
1206
1743
|
}
|
|
1207
1744
|
return
|
|
1208
|
-
#
|
|
1209
|
-
continue
|
|
1745
|
+
# Other ToolMessages: don't process further (no continue to ensure interrupt check runs)
|
|
1210
1746
|
|
|
1211
|
-
|
|
1747
|
+
# Handle AIMessage (use elif to avoid processing after ToolMessage)
|
|
1748
|
+
elif hasattr(last_message, "content") and last_message.content:
|
|
1749
|
+
message_tool_calls = (
|
|
1750
|
+
last_message.tool_calls
|
|
1751
|
+
if hasattr(last_message, "tool_calls")
|
|
1752
|
+
and last_message.tool_calls
|
|
1753
|
+
else []
|
|
1754
|
+
)
|
|
1755
|
+
has_final_answer_tool = any(
|
|
1756
|
+
(call.get("name") or call.get("tool") or "")
|
|
1757
|
+
in ("final_answer_tool", "final_answer")
|
|
1758
|
+
for call in message_tool_calls
|
|
1759
|
+
if isinstance(call, dict)
|
|
1760
|
+
)
|
|
1212
1761
|
content = last_message.content
|
|
1213
1762
|
|
|
1763
|
+
# Handle list content (e.g., multimodal responses)
|
|
1764
|
+
if isinstance(content, list):
|
|
1765
|
+
# Extract text content from list
|
|
1766
|
+
text_parts = []
|
|
1767
|
+
for part in content:
|
|
1768
|
+
if isinstance(part, str):
|
|
1769
|
+
text_parts.append(part)
|
|
1770
|
+
elif (
|
|
1771
|
+
isinstance(part, dict)
|
|
1772
|
+
and part.get("type") == "text"
|
|
1773
|
+
):
|
|
1774
|
+
text_parts.append(part.get("text", ""))
|
|
1775
|
+
content = "\n".join(text_parts)
|
|
1776
|
+
|
|
1214
1777
|
# Filter out raw JSON tool responses
|
|
1215
|
-
if
|
|
1216
|
-
content
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1778
|
+
if (
|
|
1779
|
+
content
|
|
1780
|
+
and isinstance(content, str)
|
|
1781
|
+
and not has_final_answer_tool
|
|
1782
|
+
and not (
|
|
1783
|
+
content.strip().startswith('{"tool":')
|
|
1784
|
+
or content.strip().startswith('{"status":')
|
|
1785
|
+
or '"pending_execution"' in content
|
|
1786
|
+
or '"status": "complete"' in content
|
|
1787
|
+
)
|
|
1220
1788
|
):
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1789
|
+
# Check if we've already emitted this content (prevents duplicates)
|
|
1790
|
+
content_hash = hash(content)
|
|
1791
|
+
if content_hash in emitted_contents:
|
|
1792
|
+
logger.info(
|
|
1793
|
+
"Resume: SKIPPING duplicate content (len=%d): %s",
|
|
1794
|
+
len(content),
|
|
1795
|
+
content[:100],
|
|
1796
|
+
)
|
|
1797
|
+
else:
|
|
1798
|
+
emitted_contents.add(content_hash)
|
|
1799
|
+
logger.info(
|
|
1800
|
+
"Resume: EMITTING token content (len=%d): %s",
|
|
1801
|
+
len(content),
|
|
1802
|
+
content[:100],
|
|
1803
|
+
)
|
|
1804
|
+
yield {
|
|
1805
|
+
"event": "token",
|
|
1806
|
+
"data": json.dumps({"content": content}),
|
|
1807
|
+
}
|
|
1225
1808
|
|
|
1226
1809
|
if (
|
|
1227
1810
|
hasattr(last_message, "tool_calls")
|
|
@@ -1234,66 +1817,218 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1234
1817
|
if tc.get("id") not in processed_tool_call_ids
|
|
1235
1818
|
]
|
|
1236
1819
|
|
|
1237
|
-
if
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
if tc.get("id"):
|
|
1244
|
-
processed_tool_call_ids.add(tc["id"])
|
|
1820
|
+
# Only process if there are new tool calls (no continue to ensure interrupt check runs)
|
|
1821
|
+
if new_tool_calls:
|
|
1822
|
+
# Mark these tool calls as processed
|
|
1823
|
+
for tc in new_tool_calls:
|
|
1824
|
+
if tc.get("id"):
|
|
1825
|
+
processed_tool_call_ids.add(tc["id"])
|
|
1245
1826
|
|
|
1246
|
-
logger.info(
|
|
1247
|
-
"Resume AIMessage tool_calls: %s",
|
|
1248
|
-
json.dumps(new_tool_calls, ensure_ascii=False),
|
|
1249
|
-
)
|
|
1250
|
-
todos = _emit_todos_from_tool_calls(new_tool_calls)
|
|
1251
|
-
if todos:
|
|
1252
|
-
latest_todos = todos
|
|
1253
|
-
yield {
|
|
1254
|
-
"event": "todos",
|
|
1255
|
-
"data": json.dumps({"todos": todos}),
|
|
1256
|
-
}
|
|
1257
|
-
for tool_call in new_tool_calls:
|
|
1258
|
-
tool_name = tool_call.get("name", "unknown")
|
|
1259
|
-
tool_args = tool_call.get("args", {})
|
|
1260
|
-
if tool_args.get("execution_result"):
|
|
1261
1827
|
logger.info(
|
|
1262
|
-
"Resume
|
|
1263
|
-
|
|
1828
|
+
"Resume AIMessage tool_calls: %s",
|
|
1829
|
+
json.dumps(new_tool_calls, ensure_ascii=False),
|
|
1264
1830
|
)
|
|
1265
|
-
|
|
1831
|
+
todos = _emit_todos_from_tool_calls(new_tool_calls)
|
|
1832
|
+
if todos:
|
|
1833
|
+
latest_todos = todos
|
|
1834
|
+
yield {
|
|
1835
|
+
"event": "todos",
|
|
1836
|
+
"data": json.dumps({"todos": todos}),
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
# Process tool calls
|
|
1840
|
+
for tool_call in new_tool_calls:
|
|
1841
|
+
tool_name = tool_call.get("name", "unknown")
|
|
1842
|
+
tool_args = tool_call.get("args", {})
|
|
1843
|
+
# Skip tool calls with execution_result (continue is OK here - inner loop)
|
|
1844
|
+
if tool_args.get("execution_result"):
|
|
1845
|
+
logger.info(
|
|
1846
|
+
"Resume tool_call includes execution_result; skipping client execution for %s",
|
|
1847
|
+
tool_name,
|
|
1848
|
+
)
|
|
1849
|
+
continue
|
|
1850
|
+
|
|
1851
|
+
# Create detailed status message for search tools
|
|
1852
|
+
if tool_name in (
|
|
1853
|
+
"search_workspace_tool",
|
|
1854
|
+
"search_workspace",
|
|
1855
|
+
):
|
|
1856
|
+
pattern = tool_args.get("pattern", "")
|
|
1857
|
+
path = tool_args.get("path", ".")
|
|
1858
|
+
status_msg = f"🔍 검색 실행: grep/rg '{pattern}' in {path}"
|
|
1859
|
+
elif tool_name in (
|
|
1860
|
+
"search_notebook_cells_tool",
|
|
1861
|
+
"search_notebook_cells",
|
|
1862
|
+
):
|
|
1863
|
+
pattern = tool_args.get("pattern", "")
|
|
1864
|
+
nb_path = tool_args.get(
|
|
1865
|
+
"notebook_path", "all notebooks"
|
|
1866
|
+
)
|
|
1867
|
+
status_msg = f"🔍 노트북 검색: '{pattern}' in {nb_path or 'all notebooks'}"
|
|
1868
|
+
else:
|
|
1869
|
+
status_msg = f"🔧 Tool 실행: {tool_name}"
|
|
1870
|
+
|
|
1871
|
+
yield {
|
|
1872
|
+
"event": "debug",
|
|
1873
|
+
"data": json.dumps({"status": status_msg}),
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
if tool_name in (
|
|
1877
|
+
"jupyter_cell_tool",
|
|
1878
|
+
"jupyter_cell",
|
|
1879
|
+
):
|
|
1880
|
+
yield {
|
|
1881
|
+
"event": "tool_call",
|
|
1882
|
+
"data": json.dumps(
|
|
1883
|
+
{
|
|
1884
|
+
"tool": "jupyter_cell",
|
|
1885
|
+
"code": tool_args.get("code", ""),
|
|
1886
|
+
"description": tool_args.get(
|
|
1887
|
+
"description", ""
|
|
1888
|
+
),
|
|
1889
|
+
}
|
|
1890
|
+
),
|
|
1891
|
+
}
|
|
1892
|
+
elif tool_name in ("markdown_tool", "markdown"):
|
|
1893
|
+
yield {
|
|
1894
|
+
"event": "tool_call",
|
|
1895
|
+
"data": json.dumps(
|
|
1896
|
+
{
|
|
1897
|
+
"tool": "markdown",
|
|
1898
|
+
"content": tool_args.get(
|
|
1899
|
+
"content", ""
|
|
1900
|
+
),
|
|
1901
|
+
}
|
|
1902
|
+
),
|
|
1903
|
+
}
|
|
1904
|
+
elif tool_name == "execute_command_tool":
|
|
1905
|
+
yield {
|
|
1906
|
+
"event": "tool_call",
|
|
1907
|
+
"data": json.dumps(
|
|
1908
|
+
{
|
|
1909
|
+
"tool": "execute_command_tool",
|
|
1910
|
+
"command": tool_args.get(
|
|
1911
|
+
"command", ""
|
|
1912
|
+
),
|
|
1913
|
+
"timeout": tool_args.get("timeout"),
|
|
1914
|
+
}
|
|
1915
|
+
),
|
|
1916
|
+
}
|
|
1917
|
+
elif tool_name in (
|
|
1918
|
+
"search_workspace_tool",
|
|
1919
|
+
"search_workspace",
|
|
1920
|
+
):
|
|
1921
|
+
# Search workspace - emit tool_call for client-side execution
|
|
1922
|
+
yield {
|
|
1923
|
+
"event": "tool_call",
|
|
1924
|
+
"data": json.dumps(
|
|
1925
|
+
{
|
|
1926
|
+
"tool": "search_workspace",
|
|
1927
|
+
"pattern": tool_args.get(
|
|
1928
|
+
"pattern", ""
|
|
1929
|
+
),
|
|
1930
|
+
"file_types": tool_args.get(
|
|
1931
|
+
"file_types",
|
|
1932
|
+
["*.py", "*.ipynb"],
|
|
1933
|
+
),
|
|
1934
|
+
"path": tool_args.get("path", "."),
|
|
1935
|
+
"max_results": tool_args.get(
|
|
1936
|
+
"max_results", 50
|
|
1937
|
+
),
|
|
1938
|
+
"case_sensitive": tool_args.get(
|
|
1939
|
+
"case_sensitive", False
|
|
1940
|
+
),
|
|
1941
|
+
}
|
|
1942
|
+
),
|
|
1943
|
+
}
|
|
1944
|
+
elif tool_name in (
|
|
1945
|
+
"search_notebook_cells_tool",
|
|
1946
|
+
"search_notebook_cells",
|
|
1947
|
+
):
|
|
1948
|
+
# Search notebook cells - emit tool_call for client-side execution
|
|
1949
|
+
yield {
|
|
1950
|
+
"event": "tool_call",
|
|
1951
|
+
"data": json.dumps(
|
|
1952
|
+
{
|
|
1953
|
+
"tool": "search_notebook_cells",
|
|
1954
|
+
"pattern": tool_args.get(
|
|
1955
|
+
"pattern", ""
|
|
1956
|
+
),
|
|
1957
|
+
"notebook_path": tool_args.get(
|
|
1958
|
+
"notebook_path"
|
|
1959
|
+
),
|
|
1960
|
+
"cell_type": tool_args.get(
|
|
1961
|
+
"cell_type"
|
|
1962
|
+
),
|
|
1963
|
+
"max_results": tool_args.get(
|
|
1964
|
+
"max_results", 30
|
|
1965
|
+
),
|
|
1966
|
+
"case_sensitive": tool_args.get(
|
|
1967
|
+
"case_sensitive", False
|
|
1968
|
+
),
|
|
1969
|
+
}
|
|
1970
|
+
),
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
# Check for interrupt AFTER processing todos and messages
|
|
1974
|
+
# This ensures todos/debug events are emitted even in interrupt steps
|
|
1975
|
+
if isinstance(step, dict) and "__interrupt__" in step:
|
|
1976
|
+
interrupts = step["__interrupt__"]
|
|
1977
|
+
|
|
1978
|
+
yield {
|
|
1979
|
+
"event": "debug",
|
|
1980
|
+
"data": json.dumps({"status": "⏸️ 사용자 승인 대기 중"}),
|
|
1981
|
+
}
|
|
1266
1982
|
|
|
1983
|
+
for interrupt in interrupts:
|
|
1984
|
+
interrupt_value = (
|
|
1985
|
+
interrupt.value
|
|
1986
|
+
if hasattr(interrupt, "value")
|
|
1987
|
+
else interrupt
|
|
1988
|
+
)
|
|
1989
|
+
action_requests = interrupt_value.get("action_requests", [])
|
|
1990
|
+
normalized_actions = [
|
|
1991
|
+
_normalize_action_request(a) for a in action_requests
|
|
1992
|
+
]
|
|
1993
|
+
if normalized_actions:
|
|
1994
|
+
_simple_agent_pending_actions[request.threadId] = (
|
|
1995
|
+
normalized_actions
|
|
1996
|
+
)
|
|
1997
|
+
|
|
1998
|
+
total_actions = len(normalized_actions)
|
|
1999
|
+
for idx, action in enumerate(normalized_actions):
|
|
1267
2000
|
yield {
|
|
1268
|
-
"event": "
|
|
2001
|
+
"event": "interrupt",
|
|
1269
2002
|
"data": json.dumps(
|
|
1270
|
-
{
|
|
2003
|
+
{
|
|
2004
|
+
"thread_id": request.threadId,
|
|
2005
|
+
"action": action.get("name", "unknown"),
|
|
2006
|
+
"args": action.get("arguments", {}),
|
|
2007
|
+
"description": action.get("description", ""),
|
|
2008
|
+
"action_index": idx,
|
|
2009
|
+
"total_actions": total_actions,
|
|
2010
|
+
}
|
|
1271
2011
|
),
|
|
1272
2012
|
}
|
|
1273
2013
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
"tool": "markdown",
|
|
1293
|
-
"content": tool_args.get("content", ""),
|
|
1294
|
-
}
|
|
1295
|
-
),
|
|
1296
|
-
}
|
|
2014
|
+
# Save last signature for next resume to avoid duplicate content
|
|
2015
|
+
if last_signature:
|
|
2016
|
+
_simple_agent_last_signatures[request.threadId] = last_signature
|
|
2017
|
+
logger.info(
|
|
2018
|
+
"Resume Interrupt: Saved signature for thread %s: %s",
|
|
2019
|
+
request.threadId,
|
|
2020
|
+
last_signature[:100] if last_signature else None,
|
|
2021
|
+
)
|
|
2022
|
+
# Save emitted contents for next resume
|
|
2023
|
+
_simple_agent_emitted_contents[request.threadId] = emitted_contents
|
|
2024
|
+
logger.info(
|
|
2025
|
+
"Resume Interrupt: Saved %d emitted content hashes for thread %s",
|
|
2026
|
+
len(emitted_contents),
|
|
2027
|
+
request.threadId,
|
|
2028
|
+
)
|
|
2029
|
+
|
|
2030
|
+
# Stop streaming - wait for resume
|
|
2031
|
+
return
|
|
1297
2032
|
|
|
1298
2033
|
# Clear debug status before completion
|
|
1299
2034
|
yield {"event": "debug_clear", "data": json.dumps({})}
|
|
@@ -1312,16 +2047,31 @@ async def resume_agent(request: ResumeRequest):
|
|
|
1312
2047
|
}
|
|
1313
2048
|
|
|
1314
2049
|
except Exception as e:
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
2050
|
+
error_msg = str(e)
|
|
2051
|
+
logger.error(f"Resume error: {error_msg}", exc_info=True)
|
|
2052
|
+
|
|
2053
|
+
# Detect specific Gemini error for empty contents
|
|
2054
|
+
if "contents is not specified" in error_msg.lower():
|
|
2055
|
+
logger.warning(
|
|
2056
|
+
"Detected 'contents is not specified' error - likely session state loss"
|
|
2057
|
+
)
|
|
2058
|
+
yield {
|
|
2059
|
+
"event": "error",
|
|
2060
|
+
"data": json.dumps({
|
|
2061
|
+
"error": "Session state lost",
|
|
2062
|
+
"code": "CONTENTS_NOT_SPECIFIED",
|
|
1321
2063
|
"error_type": type(e).__name__,
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
2064
|
+
"message": "세션 상태가 손실되었습니다. 서버가 재시작되었거나 세션이 만료되었습니다. 새로운 대화를 시작해주세요.",
|
|
2065
|
+
}),
|
|
2066
|
+
}
|
|
2067
|
+
else:
|
|
2068
|
+
yield {
|
|
2069
|
+
"event": "error",
|
|
2070
|
+
"data": json.dumps({
|
|
2071
|
+
"error": error_msg,
|
|
2072
|
+
"error_type": type(e).__name__,
|
|
2073
|
+
}),
|
|
2074
|
+
}
|
|
1325
2075
|
|
|
1326
2076
|
return EventSourceResponse(event_generator())
|
|
1327
2077
|
|
|
@@ -1346,7 +2096,8 @@ async def search_workspace(
|
|
|
1346
2096
|
"""
|
|
1347
2097
|
from agent_server.langchain.executors.notebook_searcher import NotebookSearcher
|
|
1348
2098
|
|
|
1349
|
-
|
|
2099
|
+
resolved_workspace_root = _resolve_workspace_root(workspace_root)
|
|
2100
|
+
searcher = NotebookSearcher(resolved_workspace_root)
|
|
1350
2101
|
|
|
1351
2102
|
if notebook_path:
|
|
1352
2103
|
results = searcher.search_notebook(
|
|
@@ -1378,9 +2129,9 @@ async def health_check() -> Dict[str, Any]:
|
|
|
1378
2129
|
@router.delete("/cache")
|
|
1379
2130
|
async def clear_agent_cache() -> Dict[str, Any]:
|
|
1380
2131
|
"""Clear the agent instance cache"""
|
|
1381
|
-
global
|
|
1382
|
-
count = len(
|
|
1383
|
-
|
|
2132
|
+
global _simple_agent_instances
|
|
2133
|
+
count = len(_simple_agent_instances)
|
|
2134
|
+
_simple_agent_instances.clear()
|
|
1384
2135
|
|
|
1385
2136
|
return {
|
|
1386
2137
|
"status": "ok",
|