hdsp-jupyter-extension 2.0.5__py3-none-any.whl → 2.0.7__py3-none-any.whl

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