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.
Files changed (88) hide show
  1. agent_server/core/embedding_service.py +67 -46
  2. agent_server/core/rag_manager.py +31 -17
  3. agent_server/core/reflection_engine.py +0 -1
  4. agent_server/core/retriever.py +13 -8
  5. agent_server/core/vllm_embedding_service.py +243 -0
  6. agent_server/knowledge/watchdog_service.py +1 -1
  7. agent_server/langchain/ARCHITECTURE.md +1193 -0
  8. agent_server/langchain/agent.py +82 -588
  9. agent_server/langchain/custom_middleware.py +663 -0
  10. agent_server/langchain/executors/__init__.py +2 -7
  11. agent_server/langchain/executors/notebook_searcher.py +46 -38
  12. agent_server/langchain/hitl_config.py +71 -0
  13. agent_server/langchain/llm_factory.py +166 -0
  14. agent_server/langchain/logging_utils.py +223 -0
  15. agent_server/langchain/prompts.py +150 -0
  16. agent_server/langchain/state.py +16 -6
  17. agent_server/langchain/tools/__init__.py +19 -0
  18. agent_server/langchain/tools/file_tools.py +354 -114
  19. agent_server/langchain/tools/file_utils.py +334 -0
  20. agent_server/langchain/tools/jupyter_tools.py +18 -18
  21. agent_server/langchain/tools/lsp_tools.py +264 -0
  22. agent_server/langchain/tools/resource_tools.py +161 -0
  23. agent_server/langchain/tools/search_tools.py +198 -216
  24. agent_server/langchain/tools/shell_tools.py +54 -0
  25. agent_server/main.py +11 -1
  26. agent_server/routers/health.py +1 -1
  27. agent_server/routers/langchain_agent.py +1040 -289
  28. agent_server/routers/rag.py +8 -3
  29. hdsp_agent_core/models/rag.py +15 -1
  30. hdsp_agent_core/prompts/auto_agent_prompts.py +3 -3
  31. hdsp_agent_core/services/rag_service.py +6 -1
  32. {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
  33. {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/package.json +3 -2
  34. 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
  35. hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.8740a527757068814573.js.map +1 -0
  36. 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
  37. hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.e4ff4b5779b5e049f84c.js.map +1 -0
  38. 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
  39. hdsp_jupyter_extension-2.0.8.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.020cdb0b864cfaa4e41e.js.map +1 -0
  40. {hdsp_jupyter_extension-2.0.6.dist-info → hdsp_jupyter_extension-2.0.8.dist-info}/METADATA +2 -1
  41. {hdsp_jupyter_extension-2.0.6.dist-info → hdsp_jupyter_extension-2.0.8.dist-info}/RECORD +75 -69
  42. jupyter_ext/__init__.py +18 -0
  43. jupyter_ext/_version.py +1 -1
  44. jupyter_ext/handlers.py +1351 -58
  45. jupyter_ext/labextension/build_log.json +1 -1
  46. jupyter_ext/labextension/package.json +3 -2
  47. jupyter_ext/labextension/static/{frontend_styles_index_js.02d346171474a0fb2dc1.js → frontend_styles_index_js.8740a527757068814573.js} +470 -7
  48. jupyter_ext/labextension/static/frontend_styles_index_js.8740a527757068814573.js.map +1 -0
  49. jupyter_ext/labextension/static/{lib_index_js.a223ea20056954479ae9.js → lib_index_js.e4ff4b5779b5e049f84c.js} +3196 -441
  50. jupyter_ext/labextension/static/lib_index_js.e4ff4b5779b5e049f84c.js.map +1 -0
  51. jupyter_ext/labextension/static/{remoteEntry.addf2fa038fa60304aa2.js → remoteEntry.020cdb0b864cfaa4e41e.js} +9 -7
  52. jupyter_ext/labextension/static/remoteEntry.020cdb0b864cfaa4e41e.js.map +1 -0
  53. jupyter_ext/resource_usage.py +180 -0
  54. jupyter_ext/tests/test_handlers.py +58 -0
  55. agent_server/langchain/executors/jupyter_executor.py +0 -429
  56. agent_server/langchain/middleware/__init__.py +0 -36
  57. agent_server/langchain/middleware/code_search_middleware.py +0 -278
  58. agent_server/langchain/middleware/error_handling_middleware.py +0 -338
  59. agent_server/langchain/middleware/jupyter_execution_middleware.py +0 -301
  60. agent_server/langchain/middleware/rag_middleware.py +0 -227
  61. agent_server/langchain/middleware/validation_middleware.py +0 -240
  62. hdsp_jupyter_extension-2.0.6.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.02d346171474a0fb2dc1.js.map +0 -1
  63. hdsp_jupyter_extension-2.0.6.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.a223ea20056954479ae9.js.map +0 -1
  64. hdsp_jupyter_extension-2.0.6.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.addf2fa038fa60304aa2.js.map +0 -1
  65. jupyter_ext/labextension/static/frontend_styles_index_js.02d346171474a0fb2dc1.js.map +0 -1
  66. jupyter_ext/labextension/static/lib_index_js.a223ea20056954479ae9.js.map +0 -1
  67. jupyter_ext/labextension/static/remoteEntry.addf2fa038fa60304aa2.js.map +0 -1
  68. {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
  69. {hdsp_jupyter_extension-2.0.6.data → hdsp_jupyter_extension-2.0.8.data}/data/share/jupyter/labextensions/hdsp-agent/install.json +0 -0
  70. {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
  71. {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
  72. {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
  73. {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
  74. {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
  75. {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
  76. {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
  77. {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
  78. {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
  79. {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
  80. {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
  81. {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
  82. {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
  83. {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
  84. {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
  85. {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
  86. {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
  87. {hdsp_jupyter_extension-2.0.6.dist-info → hdsp_jupyter_extension-2.0.8.dist-info}/WHEEL +0 -0
  88. {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
- tool_calls = json.dumps(tool_calls, ensure_ascii=False, sort_keys=True)
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(f"Agent stream request: {request.request[:100]}...")
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
- agent = create_simple_chat_agent(
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=request.workspaceRoot or ".",
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 agent.stream(agent_input, config, stream_mode="values"):
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
- # 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
-
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
- "SimpleAgent ToolMessage content: %s",
502
- last_message.content,
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
- 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
- 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 ToolMessage tool from content: %s",
523
- tool_name,
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
- if tool_name in ("final_answer_tool", "final_answer"):
529
- # Extract the final answer from the tool result
530
- try:
531
- tool_result = json.loads(last_message.content)
532
- # Check both direct "answer" and "parameters.answer"
533
- final_answer = tool_result.get(
534
- "answer"
535
- ) or tool_result.get("parameters", {}).get("answer")
536
- if final_answer:
537
- yield {
538
- "event": "token",
539
- "data": json.dumps({"content": final_answer}),
540
- }
541
- else:
542
- # Fallback to raw content if no answer found
543
- yield {
544
- "event": "token",
545
- "data": json.dumps(
546
- {"content": last_message.content}
547
- ),
548
- }
549
- except json.JSONDecodeError:
550
- # If not JSON, use content directly
551
- if last_message.content:
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": "token",
785
+ "event": "todos",
554
786
  "data": json.dumps(
555
- {"content": last_message.content}
787
+ {"todos": _complete_todos(latest_todos)}
556
788
  ),
557
789
  }
558
- if latest_todos:
790
+ # End stream after final answer
791
+ yield {"event": "debug_clear", "data": json.dumps({})}
559
792
  yield {
560
- "event": "todos",
793
+ "event": "complete",
561
794
  "data": json.dumps(
562
- {"todos": _complete_todos(latest_todos)}
795
+ {"success": True, "thread_id": thread_id}
563
796
  ),
564
797
  }
565
- # End stream after final answer
566
- yield {"event": "debug_clear", "data": json.dumps({})}
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
- if isinstance(last_message, AIMessage):
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 not (
693
- content.strip().startswith('{"tool":')
694
- or content.strip().startswith('{"status":')
695
- or '"pending_execution"' in content
696
- or '"status": "complete"' in content
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
- produced_output = True
699
- yield {
700
- "event": "token",
701
- "data": json.dumps({"content": content}),
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
- if not produced_output and last_finish_reason == "MALFORMED_FUNCTION_CALL":
705
- logger.info(
706
- "SimpleAgent fallback: retrying tool call generation after malformed function call"
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 = _create_llm(fallback_config)
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
- # Create agent (will use same checkpointer)
995
- agent = create_simple_chat_agent(
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=request.workspaceRoot or ".",
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
- "feedback": decision.feedback
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 = None
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
- for step in agent.stream(
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
- # Check for another interrupt
1075
- if isinstance(step, dict) and "__interrupt__" in step:
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
- if signature == last_signature:
1135
- continue
1136
- last_signature = signature
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
- # Skip other ToolMessages (jupyter_cell, markdown, etc.) - don't emit their content
1209
- continue
1745
+ # Other ToolMessages: don't process further (no continue to ensure interrupt check runs)
1210
1746
 
1211
- if hasattr(last_message, "content") and last_message.content:
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 not (
1216
- content.strip().startswith('{"tool":')
1217
- or content.strip().startswith('{"status":')
1218
- or '"pending_execution"' in content
1219
- or '"status": "complete"' in content
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
- yield {
1222
- "event": "token",
1223
- "data": json.dumps({"content": content}),
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 not new_tool_calls:
1238
- # All tool calls already processed, skip
1239
- continue
1240
-
1241
- # Mark these tool calls as processed
1242
- for tc in new_tool_calls:
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 tool_call includes execution_result; skipping client execution for %s",
1263
- tool_name,
1828
+ "Resume AIMessage tool_calls: %s",
1829
+ json.dumps(new_tool_calls, ensure_ascii=False),
1264
1830
  )
1265
- continue
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": "debug",
2001
+ "event": "interrupt",
1269
2002
  "data": json.dumps(
1270
- {"status": f"🔧 Tool 실행: {tool_name}"}
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
- if tool_name in ("jupyter_cell_tool", "jupyter_cell"):
1275
- yield {
1276
- "event": "tool_call",
1277
- "data": json.dumps(
1278
- {
1279
- "tool": "jupyter_cell",
1280
- "code": tool_args.get("code", ""),
1281
- "description": tool_args.get(
1282
- "description", ""
1283
- ),
1284
- }
1285
- ),
1286
- }
1287
- elif tool_name in ("markdown_tool", "markdown"):
1288
- yield {
1289
- "event": "tool_call",
1290
- "data": json.dumps(
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
- logger.error(f"Resume error: {e}", exc_info=True)
1316
- yield {
1317
- "event": "error",
1318
- "data": json.dumps(
1319
- {
1320
- "error": str(e),
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
- searcher = NotebookSearcher(workspace_root)
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 _agent_cache
1382
- count = len(_agent_cache)
1383
- _agent_cache.clear()
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",