hdsp-jupyter-extension 2.0.23__py3-none-any.whl → 2.0.26__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 (60) hide show
  1. agent_server/context_providers/__init__.py +22 -0
  2. agent_server/context_providers/actions.py +45 -0
  3. agent_server/context_providers/base.py +231 -0
  4. agent_server/context_providers/file.py +316 -0
  5. agent_server/context_providers/processor.py +150 -0
  6. agent_server/langchain/agent_factory.py +14 -14
  7. agent_server/langchain/agent_prompts/planner_prompt.py +13 -19
  8. agent_server/langchain/custom_middleware.py +73 -17
  9. agent_server/langchain/models/gpt_oss_chat.py +26 -13
  10. agent_server/langchain/prompts.py +11 -8
  11. agent_server/langchain/tools/jupyter_tools.py +43 -0
  12. agent_server/main.py +2 -1
  13. agent_server/routers/chat.py +61 -10
  14. agent_server/routers/context.py +168 -0
  15. agent_server/routers/langchain_agent.py +806 -203
  16. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/build_log.json +1 -1
  17. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/package.json +2 -2
  18. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.96745acc14125453fba8.js → hdsp_jupyter_extension-2.0.26.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js +245 -121
  19. hdsp_jupyter_extension-2.0.26.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js.map +1 -0
  20. jupyter_ext/labextension/static/lib_index_js.2d5ea542350862f7c531.js → hdsp_jupyter_extension-2.0.26.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.67505497667f9c0a763d.js +583 -39
  21. hdsp_jupyter_extension-2.0.26.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.67505497667f9c0a763d.js.map +1 -0
  22. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.f0127d8744730f2092c1.js → hdsp_jupyter_extension-2.0.26.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.0fe2dcbbd176ee0efceb.js +3 -3
  23. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.f0127d8744730f2092c1.js.map → hdsp_jupyter_extension-2.0.26.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.0fe2dcbbd176ee0efceb.js.map +1 -1
  24. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.26.dist-info}/METADATA +1 -1
  25. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.26.dist-info}/RECORD +56 -50
  26. jupyter_ext/_version.py +1 -1
  27. jupyter_ext/handlers.py +29 -0
  28. jupyter_ext/labextension/build_log.json +1 -1
  29. jupyter_ext/labextension/package.json +2 -2
  30. jupyter_ext/labextension/static/{frontend_styles_index_js.96745acc14125453fba8.js → frontend_styles_index_js.b5e4416b4e07ec087aad.js} +245 -121
  31. jupyter_ext/labextension/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js.map +1 -0
  32. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.2d5ea542350862f7c531.js → jupyter_ext/labextension/static/lib_index_js.67505497667f9c0a763d.js +583 -39
  33. jupyter_ext/labextension/static/lib_index_js.67505497667f9c0a763d.js.map +1 -0
  34. jupyter_ext/labextension/static/{remoteEntry.f0127d8744730f2092c1.js → remoteEntry.0fe2dcbbd176ee0efceb.js} +3 -3
  35. jupyter_ext/labextension/static/{remoteEntry.f0127d8744730f2092c1.js.map → remoteEntry.0fe2dcbbd176ee0efceb.js.map} +1 -1
  36. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.96745acc14125453fba8.js.map +0 -1
  37. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.2d5ea542350862f7c531.js.map +0 -1
  38. jupyter_ext/labextension/static/frontend_styles_index_js.96745acc14125453fba8.js.map +0 -1
  39. jupyter_ext/labextension/static/lib_index_js.2d5ea542350862f7c531.js.map +0 -1
  40. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +0 -0
  41. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/install.json +0 -0
  42. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  43. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  44. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  45. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  46. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/style.js +0 -0
  47. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  48. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  49. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js +0 -0
  50. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  51. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js +0 -0
  52. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  53. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +0 -0
  54. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.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
  55. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +0 -0
  56. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +0 -0
  57. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js +0 -0
  58. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.26.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +0 -0
  59. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.26.dist-info}/WHEEL +0 -0
  60. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.26.dist-info}/licenses/LICENSE +0 -0
@@ -24,6 +24,10 @@ from agent_server.langchain.agent import (
24
24
  create_agent_system,
25
25
  )
26
26
  from agent_server.langchain.llm_factory import create_llm
27
+ from agent_server.langchain.logging_utils import (
28
+ LOG_RESPONSE_END,
29
+ LOG_RESPONSE_START,
30
+ )
27
31
  from agent_server.langchain.middleware.code_history_middleware import (
28
32
  track_tool_execution,
29
33
  )
@@ -65,10 +69,12 @@ def get_subagent_debug_events():
65
69
  events = drain_subagent_events()
66
70
  sse_events = []
67
71
  for event in events:
68
- sse_events.append({
69
- "event": "debug",
70
- "data": json.dumps(event.to_status_dict()),
71
- })
72
+ sse_events.append(
73
+ {
74
+ "event": "debug",
75
+ "data": json.dumps(event.to_status_dict()),
76
+ }
77
+ )
72
78
  return sse_events
73
79
 
74
80
 
@@ -94,7 +100,7 @@ def _get_tool_status_message(
94
100
  nb_path = tool_args.get("notebook_path", "all notebooks")
95
101
  return {
96
102
  "status": f"노트북 검색: '{pattern}' in {nb_path or 'all notebooks'}",
97
- "icon": "search"
103
+ "icon": "search",
98
104
  }
99
105
  elif tool_name_normalized in ("task", "task_tool"):
100
106
  # Show subagent delegation details with expand support
@@ -402,17 +408,22 @@ def _normalize_action_request(action: Dict[str, Any]) -> Dict[str, Any]:
402
408
  or {}
403
409
  )
404
410
  # Try to get description from action first, then from args (for jupyter_cell_tool etc)
405
- description = action.get("description", "") or (args.get("description", "") if isinstance(args, dict) else "")
411
+ description = action.get("description", "") or (
412
+ args.get("description", "") if isinstance(args, dict) else ""
413
+ )
406
414
 
407
415
  # Auto-inject description for jupyter_cell_tool from python_developer's response
408
416
  # Only inject into args.description, keep top-level description as HITL default
409
417
  if name == "jupyter_cell_tool":
410
- logger.info(f"[HITL] jupyter_cell_tool detected, current description: '{description[:50] if description else 'None'}'")
418
+ logger.info(
419
+ f"[HITL] jupyter_cell_tool detected, current description: '{description[:50] if description else 'None'}'"
420
+ )
411
421
  try:
412
422
  from agent_server.langchain.middleware.description_injector import (
413
423
  clear_pending_description,
414
424
  get_pending_description,
415
425
  )
426
+
416
427
  pending = get_pending_description()
417
428
  if pending:
418
429
  # Inject into args.description only (for detailed description display)
@@ -421,7 +432,9 @@ def _normalize_action_request(action: Dict[str, Any]) -> Dict[str, Any]:
421
432
  args = dict(args)
422
433
  args["description"] = pending
423
434
  clear_pending_description()
424
- logger.info(f"[HITL] Auto-injected description into args: {pending[:80]}...")
435
+ logger.info(
436
+ f"[HITL] Auto-injected description into args: {pending[:80]}..."
437
+ )
425
438
  else:
426
439
  logger.info("[HITL] No pending description from python_developer")
427
440
  except Exception as e:
@@ -733,20 +746,31 @@ async def stream_agent(request: AgentRequest):
733
746
  logger.info("Agent mode: %s", agent_mode)
734
747
 
735
748
  # Get agent prompts (for multi-agent mode)
736
- # ROOT CAUSE FOUND: Frontend sends both systemPrompt AND agentPrompts
737
- # which creates different cache keys and causes MALFORMED_FUNCTION_CALL
738
- # FIX: Ignore all custom prompts for multi-agent mode until frontend is fixed
739
749
  agent_prompts = None
740
750
  if agent_mode == "multi":
741
- # For multi-agent mode, always use default prompts
751
+ # Multi-agent mode: Use agentPrompts for per-agent customization
752
+ # systemPrompt is for single-agent mode only (DEFAULT_SYSTEM_PROMPT)
742
753
  if request.llmConfig and request.llmConfig.agent_prompts:
743
- logger.warning(
744
- "Multi-agent mode: Ignoring frontend agentPrompts to ensure consistent behavior"
754
+ agent_prompts = {
755
+ "planner": request.llmConfig.agent_prompts.planner,
756
+ "python_developer": (
757
+ request.llmConfig.agent_prompts.python_developer
758
+ ),
759
+ "researcher": request.llmConfig.agent_prompts.researcher,
760
+ "athena_query": request.llmConfig.agent_prompts.athena_query,
761
+ }
762
+ agent_prompts = {k: v for k, v in agent_prompts.items() if v}
763
+ logger.info(
764
+ "Multi-agent mode: Using agentPrompts (%s)",
765
+ list(agent_prompts.keys()),
745
766
  )
746
- # Also ignore system_prompt_override for multi-agent mode
767
+ # In multi-agent mode, DON'T use systemPrompt as override
768
+ # (systemPrompt = single-agent prompt, not planner prompt)
769
+ # Use agentPrompts.planner instead (handled by agent_factory)
747
770
  if system_prompt_override:
748
- logger.warning(
749
- "Multi-agent mode: Ignoring systemPrompt override (len=%d)",
771
+ logger.info(
772
+ "Multi-agent mode: Ignoring systemPrompt override (len=%d) - "
773
+ "use agentPrompts.planner instead",
750
774
  len(system_prompt_override),
751
775
  )
752
776
  system_prompt_override = None
@@ -856,7 +880,11 @@ async def stream_agent(request: AgentRequest):
856
880
  }
857
881
  # Build previous todos context for LLM
858
882
  if existing_todos:
859
- completed_items = [t.get("content", "") for t in existing_todos if t.get("status") == "completed"]
883
+ completed_items = [
884
+ t.get("content", "")
885
+ for t in existing_todos
886
+ if t.get("status") == "completed"
887
+ ]
860
888
  if completed_items:
861
889
  items_summary = ", ".join(completed_items[:5])
862
890
  if len(completed_items) > 5:
@@ -865,7 +893,10 @@ async def stream_agent(request: AgentRequest):
865
893
  f"[SYSTEM] 이전 todo list가 완료 혹은 취소되었습니다. 완료된 작업: {items_summary}. "
866
894
  f"새 작업을 시작합니다. 이전 todo list에 신규 작업을 append 하지 말고 새로운 todo list를 생성하세요."
867
895
  )
868
- logger.info("Injecting previous todos context: %s", previous_todos_context[:100])
896
+ logger.info(
897
+ "Injecting previous todos context: %s",
898
+ previous_todos_context[:100],
899
+ )
869
900
  except Exception as e:
870
901
  logger.warning("Could not reset todos in agent state: %s", e)
871
902
 
@@ -883,7 +914,6 @@ async def stream_agent(request: AgentRequest):
883
914
  produced_output = False
884
915
  last_finish_reason = None
885
916
  last_signature = None
886
- latest_todos: Optional[List[Dict[str, Any]]] = None
887
917
  # Initialize emitted contents set for this thread (clear any stale data)
888
918
  emitted_contents: set = set()
889
919
  _simple_agent_emitted_contents[thread_id] = emitted_contents
@@ -895,7 +925,10 @@ async def stream_agent(request: AgentRequest):
895
925
 
896
926
  # Initial status: waiting for LLM
897
927
  logger.info("SSE: Sending initial debug status 'LLM 응답 대기 중'")
898
- yield {"event": "debug", "data": json.dumps({"status": "LLM 응답 대기 중", "icon": "thinking"})}
928
+ yield {
929
+ "event": "debug",
930
+ "data": json.dumps({"status": "LLM 응답 대기 중", "icon": "thinking"}),
931
+ }
899
932
 
900
933
  # Main streaming loop
901
934
  async for step in _async_stream_wrapper(
@@ -903,9 +936,16 @@ async def stream_agent(request: AgentRequest):
903
936
  ):
904
937
  # Check if thread was cancelled by user
905
938
  if is_thread_cancelled(thread_id):
906
- logger.info(f"Thread {thread_id} cancelled by user, stopping stream")
939
+ logger.info(
940
+ f"Thread {thread_id} cancelled by user, stopping stream"
941
+ )
907
942
  clear_cancelled_thread(thread_id)
908
- yield {"event": "cancelled", "data": json.dumps({"message": "작업이 사용자에 의해 중단되었습니다."})}
943
+ yield {
944
+ "event": "cancelled",
945
+ "data": json.dumps(
946
+ {"message": "작업이 사용자에 의해 중단되었습니다."}
947
+ ),
948
+ }
909
949
  return
910
950
 
911
951
  if isinstance(step, dict):
@@ -915,7 +955,10 @@ async def stream_agent(request: AgentRequest):
915
955
  # DEBUG: Check for __interrupt__ in every step
916
956
  if "__interrupt__" in step:
917
957
  logger.info("[DEBUG-INTERRUPT] Found __interrupt__ in step!")
918
- logger.info("[DEBUG-INTERRUPT] interrupt value: %s", str(step["__interrupt__"])[:500])
958
+ logger.info(
959
+ "[DEBUG-INTERRUPT] interrupt value: %s",
960
+ str(step["__interrupt__"])[:500],
961
+ )
919
962
 
920
963
  # IMPORTANT: Process todos and messages BEFORE checking for interrupt
921
964
  # This ensures todos/debug events are emitted even in interrupt steps
@@ -924,12 +967,10 @@ async def stream_agent(request: AgentRequest):
924
967
  if isinstance(step, dict) and "todos" in step:
925
968
  todos = step["todos"]
926
969
  if todos:
927
- latest_todos = todos
928
970
  yield {"event": "todos", "data": json.dumps({"todos": todos})}
929
971
  elif isinstance(step, dict):
930
972
  todos = _extract_todos(step)
931
973
  if todos:
932
- latest_todos = todos
933
974
  yield {"event": "todos", "data": json.dumps({"todos": todos})}
934
975
 
935
976
  # Process messages (no continue statements to ensure interrupt check always runs)
@@ -979,8 +1020,10 @@ async def stream_agent(request: AgentRequest):
979
1020
  )
980
1021
  todos = _extract_todos(last_message.content)
981
1022
  if todos:
982
- latest_todos = todos
983
- yield {"event": "todos", "data": json.dumps({"todos": todos})}
1023
+ yield {
1024
+ "event": "todos",
1025
+ "data": json.dumps({"todos": todos}),
1026
+ }
984
1027
  # Check if all todos are completed - auto terminate only if summary exists
985
1028
  all_completed = all(
986
1029
  t.get("status") == "completed" for t in todos
@@ -989,16 +1032,31 @@ async def stream_agent(request: AgentRequest):
989
1032
  # Check if summary JSON exists in the CURRENT step's AIMessage
990
1033
  # (not in history, to avoid false positives from previous tasks)
991
1034
  summary_exists = False
992
- step_messages = step.get("messages", []) if isinstance(step, dict) else []
1035
+ step_messages = (
1036
+ step.get("messages", [])
1037
+ if isinstance(step, dict)
1038
+ else []
1039
+ )
993
1040
  # Only check the AIMessage that called write_todos (should be right before this ToolMessage)
994
- for recent_msg in step_messages[-3:]: # Check only the most recent few messages
1041
+ for recent_msg in step_messages[
1042
+ -3:
1043
+ ]: # Check only the most recent few messages
995
1044
  if isinstance(recent_msg, AIMessage):
996
- recent_content = getattr(recent_msg, "content", "") or ""
1045
+ recent_content = (
1046
+ getattr(recent_msg, "content", "") or ""
1047
+ )
997
1048
  if isinstance(recent_content, list):
998
- recent_content = " ".join(str(p) for p in recent_content)
999
- if '"summary"' in recent_content and '"next_items"' in recent_content:
1049
+ recent_content = " ".join(
1050
+ str(p) for p in recent_content
1051
+ )
1052
+ if (
1053
+ '"summary"' in recent_content
1054
+ and '"next_items"' in recent_content
1055
+ ):
1000
1056
  summary_exists = True
1001
- logger.info("Found summary in current AIMessage content")
1057
+ logger.info(
1058
+ "Found summary in current AIMessage content"
1059
+ )
1002
1060
  break
1003
1061
 
1004
1062
  if summary_exists:
@@ -1010,24 +1068,54 @@ async def stream_agent(request: AgentRequest):
1010
1068
  # Find and emit the AIMessage content with summary
1011
1069
  for recent_msg in step_messages[-3:]:
1012
1070
  if isinstance(recent_msg, AIMessage):
1013
- step_content = getattr(recent_msg, "content", "") or ""
1071
+ step_content = (
1072
+ getattr(recent_msg, "content", "")
1073
+ or ""
1074
+ )
1014
1075
  if isinstance(step_content, list):
1015
- step_content = " ".join(str(p) for p in step_content)
1016
- if '"summary"' in step_content and '"next_items"' in step_content:
1076
+ step_content = " ".join(
1077
+ str(p) for p in step_content
1078
+ )
1079
+ if (
1080
+ '"summary"' in step_content
1081
+ and '"next_items"' in step_content
1082
+ ):
1017
1083
  content_hash = hash(step_content)
1018
- if content_hash not in emitted_contents:
1019
- emitted_contents.add(content_hash)
1020
- repaired_content = _repair_summary_json_content(step_content)
1084
+ if (
1085
+ content_hash
1086
+ not in emitted_contents
1087
+ ):
1088
+ emitted_contents.add(
1089
+ content_hash
1090
+ )
1091
+ repaired_content = _repair_summary_json_content(
1092
+ step_content
1093
+ )
1021
1094
  logger.info(
1022
1095
  "Step auto-terminate: EMITTING summary content (len=%d): %s",
1023
1096
  len(repaired_content),
1024
1097
  repaired_content[:100],
1025
1098
  )
1026
1099
  produced_output = True
1027
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
1100
+ yield {
1101
+ "event": "token",
1102
+ "data": json.dumps(
1103
+ {
1104
+ "content": repaired_content
1105
+ }
1106
+ ),
1107
+ }
1028
1108
  break
1029
- yield {"event": "debug_clear", "data": json.dumps({})}
1030
- yield {"event": "done", "data": json.dumps({"reason": "all_todos_completed"})}
1109
+ yield {
1110
+ "event": "debug_clear",
1111
+ "data": json.dumps({}),
1112
+ }
1113
+ yield {
1114
+ "event": "done",
1115
+ "data": json.dumps(
1116
+ {"reason": "all_todos_completed"}
1117
+ ),
1118
+ }
1031
1119
  return # Exit the generator
1032
1120
  else:
1033
1121
  logger.warning(
@@ -1056,49 +1144,37 @@ async def stream_agent(request: AgentRequest):
1056
1144
 
1057
1145
  # Handle AIMessage
1058
1146
  elif isinstance(last_message, AIMessage):
1059
- # LLM Response separator for easy log reading
1060
- print("\n" + "🔵" * 48, flush=True)
1061
- print("=" * 96, flush=True)
1062
- print(" LLM RESPONSE", flush=True)
1063
- print("=" * 96, flush=True)
1064
- logger.info(
1065
- "SimpleAgent AIMessage content: %s",
1066
- last_message.content or "",
1067
- )
1068
- logger.info(
1069
- "SimpleAgent AIMessage tool_calls: %s",
1070
- json.dumps(last_message.tool_calls, ensure_ascii=False)
1147
+ # LLM Response - structured JSON format
1148
+ print(LOG_RESPONSE_START, flush=True)
1149
+ response_data = {
1150
+ "type": "AIMessage",
1151
+ "content": last_message.content or "",
1152
+ "tool_calls": last_message.tool_calls
1071
1153
  if hasattr(last_message, "tool_calls")
1072
- else "[]",
1073
- )
1074
- logger.info(
1075
- "SimpleAgent AIMessage additional_kwargs: %s",
1076
- json.dumps(
1077
- getattr(last_message, "additional_kwargs", {})
1078
- or {},
1079
- ensure_ascii=False,
1080
- ),
1081
- )
1082
- logger.info(
1083
- "SimpleAgent AIMessage response_metadata: %s",
1084
- json.dumps(
1085
- getattr(last_message, "response_metadata", {})
1086
- or {},
1087
- ensure_ascii=False,
1088
- ),
1089
- )
1090
- logger.info(
1091
- "SimpleAgent AIMessage usage_metadata: %s",
1154
+ else [],
1155
+ "additional_kwargs": getattr(
1156
+ last_message, "additional_kwargs", {}
1157
+ )
1158
+ or {},
1159
+ "response_metadata": getattr(
1160
+ last_message, "response_metadata", {}
1161
+ )
1162
+ or {},
1163
+ "usage_metadata": getattr(
1164
+ last_message, "usage_metadata", {}
1165
+ )
1166
+ or {},
1167
+ }
1168
+ print(
1092
1169
  json.dumps(
1093
- getattr(last_message, "usage_metadata", {}) or {},
1170
+ response_data,
1171
+ indent=2,
1094
1172
  ensure_ascii=False,
1173
+ default=str,
1095
1174
  ),
1175
+ flush=True,
1096
1176
  )
1097
- # LLM Response end separator
1098
- print("=" * 96, flush=True)
1099
- print(" ✅ LLM RESPONSE END", flush=True)
1100
- print("=" * 96, flush=True)
1101
- print("🔵" * 48 + "\n", flush=True)
1177
+ print(LOG_RESPONSE_END, flush=True)
1102
1178
  last_finish_reason = (
1103
1179
  getattr(last_message, "response_metadata", {}) or {}
1104
1180
  ).get("finish_reason")
@@ -1157,10 +1233,22 @@ async def stream_agent(request: AgentRequest):
1157
1233
  logger.warning(
1158
1234
  "MALFORMED_FUNCTION_CALL with empty response - sending error to client"
1159
1235
  )
1160
- yield {"event": "token", "data": json.dumps({
1236
+ yield {
1237
+ "event": "token",
1238
+ "data": json.dumps(
1239
+ {
1161
1240
  "content": "\n\n[경고] LLM이 잘못된 응답을 반환했습니다. 다시 시도해주세요.\n"
1162
- })}
1163
- yield {"event": "debug", "data": json.dumps({"status": "[경고] MALFORMED_FUNCTION_CALL 에러"})}
1241
+ }
1242
+ ),
1243
+ }
1244
+ yield {
1245
+ "event": "debug",
1246
+ "data": json.dumps(
1247
+ {
1248
+ "status": "[경고] MALFORMED_FUNCTION_CALL 에러"
1249
+ }
1250
+ ),
1251
+ }
1164
1252
  produced_output = True
1165
1253
  # Continue to let agent retry on next iteration
1166
1254
 
@@ -1189,8 +1277,10 @@ async def stream_agent(request: AgentRequest):
1189
1277
  "SSE: Emitting todos event from AIMessage tool_calls: %d items",
1190
1278
  len(todos),
1191
1279
  )
1192
- latest_todos = todos
1193
- yield {"event": "todos", "data": json.dumps({"todos": todos})}
1280
+ yield {
1281
+ "event": "todos",
1282
+ "data": json.dumps({"todos": todos}),
1283
+ }
1194
1284
  # Check if all todos are completed - terminate early
1195
1285
  all_completed = all(
1196
1286
  t.get("status") == "completed" for t in todos
@@ -1202,7 +1292,7 @@ async def stream_agent(request: AgentRequest):
1202
1292
  "다음단계",
1203
1293
  "다음 단계",
1204
1294
  ]
1205
- has_summary_todo = any(
1295
+ any(
1206
1296
  any(
1207
1297
  kw in t.get("content", "")
1208
1298
  for kw in summary_keywords
@@ -1232,7 +1322,9 @@ async def stream_agent(request: AgentRequest):
1232
1322
  "## 다음 단계",
1233
1323
  ]
1234
1324
  )
1235
- has_summary = has_summary_json or has_markdown_summary
1325
+ has_summary = (
1326
+ has_summary_json or has_markdown_summary
1327
+ )
1236
1328
 
1237
1329
  # Only check current AIMessage for summary (not history, to avoid false positives)
1238
1330
  if not has_summary:
@@ -1247,20 +1339,41 @@ async def stream_agent(request: AgentRequest):
1247
1339
  )
1248
1340
  # IMPORTANT: Emit the summary content BEFORE terminating
1249
1341
  # so the UI can display the summary JSON
1250
- if msg_content and isinstance(msg_content, str):
1342
+ if msg_content and isinstance(
1343
+ msg_content, str
1344
+ ):
1251
1345
  content_hash = hash(msg_content)
1252
1346
  if content_hash not in emitted_contents:
1253
1347
  emitted_contents.add(content_hash)
1254
- repaired_content = _repair_summary_json_content(msg_content)
1348
+ repaired_content = (
1349
+ _repair_summary_json_content(
1350
+ msg_content
1351
+ )
1352
+ )
1255
1353
  logger.info(
1256
1354
  "Auto-terminate: EMITTING summary content (len=%d): %s",
1257
1355
  len(repaired_content),
1258
1356
  repaired_content[:100],
1259
1357
  )
1260
1358
  produced_output = True
1261
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
1262
- yield {"event": "debug_clear", "data": json.dumps({})}
1263
- yield {"event": "done", "data": json.dumps({"reason": "all_todos_completed"})}
1359
+ yield {
1360
+ "event": "token",
1361
+ "data": json.dumps(
1362
+ {
1363
+ "content": repaired_content
1364
+ }
1365
+ ),
1366
+ }
1367
+ yield {
1368
+ "event": "debug_clear",
1369
+ "data": json.dumps({}),
1370
+ }
1371
+ yield {
1372
+ "event": "done",
1373
+ "data": json.dumps(
1374
+ {"reason": "all_todos_completed"}
1375
+ ),
1376
+ }
1264
1377
  return # Exit before executing more tool calls
1265
1378
  for tool_call in tool_calls:
1266
1379
  tool_name = tool_call.get("name", "unknown")
@@ -1275,7 +1388,10 @@ async def stream_agent(request: AgentRequest):
1275
1388
  "SSE: Emitting debug event for tool: %s",
1276
1389
  tool_name,
1277
1390
  )
1278
- yield {"event": "debug", "data": json.dumps(status_msg)}
1391
+ yield {
1392
+ "event": "debug",
1393
+ "data": json.dumps(status_msg),
1394
+ }
1279
1395
 
1280
1396
  # Send tool_call event with details for frontend to execute
1281
1397
  if tool_name in (
@@ -1283,37 +1399,55 @@ async def stream_agent(request: AgentRequest):
1283
1399
  "jupyter_cell",
1284
1400
  ):
1285
1401
  produced_output = True
1286
- yield {"event": "tool_call", "data": json.dumps({
1402
+ yield {
1403
+ "event": "tool_call",
1404
+ "data": json.dumps(
1405
+ {
1287
1406
  "tool": "jupyter_cell",
1288
1407
  "code": tool_args.get("code", ""),
1289
1408
  "description": tool_args.get(
1290
1409
  "description", ""
1291
1410
  ),
1292
- })}
1411
+ }
1412
+ ),
1413
+ }
1293
1414
  elif tool_name in ("markdown_tool", "markdown"):
1294
1415
  produced_output = True
1295
- yield {"event": "tool_call", "data": json.dumps({
1416
+ yield {
1417
+ "event": "tool_call",
1418
+ "data": json.dumps(
1419
+ {
1296
1420
  "tool": "markdown",
1297
1421
  "content": tool_args.get(
1298
1422
  "content", ""
1299
1423
  ),
1300
- })}
1424
+ }
1425
+ ),
1426
+ }
1301
1427
  elif tool_name == "execute_command_tool":
1302
1428
  produced_output = True
1303
- yield {"event": "tool_call", "data": json.dumps({
1429
+ yield {
1430
+ "event": "tool_call",
1431
+ "data": json.dumps(
1432
+ {
1304
1433
  "tool": "execute_command_tool",
1305
1434
  "command": tool_args.get(
1306
1435
  "command", ""
1307
1436
  ),
1308
1437
  "timeout": tool_args.get("timeout"),
1309
- })}
1438
+ }
1439
+ ),
1440
+ }
1310
1441
  elif tool_name in (
1311
1442
  "search_notebook_cells_tool",
1312
1443
  "search_notebook_cells",
1313
1444
  ):
1314
1445
  # Search notebook cells - emit tool_call for client-side execution
1315
1446
  produced_output = True
1316
- yield {"event": "tool_call", "data": json.dumps({
1447
+ yield {
1448
+ "event": "tool_call",
1449
+ "data": json.dumps(
1450
+ {
1317
1451
  "tool": "search_notebook_cells",
1318
1452
  "pattern": tool_args.get(
1319
1453
  "pattern", ""
@@ -1330,7 +1464,25 @@ async def stream_agent(request: AgentRequest):
1330
1464
  "case_sensitive": tool_args.get(
1331
1465
  "case_sensitive", False
1332
1466
  ),
1333
- })}
1467
+ }
1468
+ ),
1469
+ }
1470
+ elif tool_name in (
1471
+ "final_summary_tool",
1472
+ "final_summary",
1473
+ ):
1474
+ # Final summary - emit summary event for frontend
1475
+ produced_output = True
1476
+ summary_data = {
1477
+ "summary": tool_args.get("summary", ""),
1478
+ "next_items": tool_args.get(
1479
+ "next_items", []
1480
+ ),
1481
+ }
1482
+ yield {
1483
+ "event": "summary",
1484
+ "data": json.dumps(summary_data),
1485
+ }
1334
1486
 
1335
1487
  # Only display content if it's not empty and not a JSON tool response
1336
1488
  if (
@@ -1374,6 +1526,31 @@ async def stream_agent(request: AgentRequest):
1374
1526
  and content_stripped.startswith("{")
1375
1527
  )
1376
1528
  )
1529
+
1530
+ # Check if this is summary/next_items with tool_calls (premature summary)
1531
+ has_summary_pattern = (
1532
+ '"summary"' in content or "'summary'" in content
1533
+ ) and (
1534
+ '"next_items"' in content
1535
+ or "'next_items'" in content
1536
+ )
1537
+ tool_calls = (
1538
+ getattr(last_message, "tool_calls", []) or []
1539
+ )
1540
+ # Check if any tool_call is NOT write_todos (work still in progress)
1541
+ has_non_todo_tool_calls = any(
1542
+ tc.get("name")
1543
+ not in ("write_todos", "write_todos_tool")
1544
+ for tc in tool_calls
1545
+ )
1546
+ # Skip summary if non-write_todos tool_calls exist (work still in progress)
1547
+ if has_summary_pattern and has_non_todo_tool_calls:
1548
+ logger.info(
1549
+ "Initial: SKIPPING premature summary (has non-todo tool_calls): %s",
1550
+ content[:100],
1551
+ )
1552
+ content = None # Skip this content
1553
+
1377
1554
  if (
1378
1555
  content
1379
1556
  and isinstance(content, str)
@@ -1399,7 +1576,12 @@ async def stream_agent(request: AgentRequest):
1399
1576
  repaired_content[:100],
1400
1577
  )
1401
1578
  produced_output = True
1402
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
1579
+ yield {
1580
+ "event": "token",
1581
+ "data": json.dumps(
1582
+ {"content": repaired_content}
1583
+ ),
1584
+ }
1403
1585
 
1404
1586
  # Drain and emit any subagent events (tool calls from subagents)
1405
1587
  for subagent_event in get_subagent_debug_events():
@@ -1418,7 +1600,12 @@ async def stream_agent(request: AgentRequest):
1418
1600
  else interrupt
1419
1601
  )
1420
1602
 
1421
- yield {"event": "debug", "data": json.dumps({"status": "사용자 승인 대기 중", "icon": "pause"})}
1603
+ yield {
1604
+ "event": "debug",
1605
+ "data": json.dumps(
1606
+ {"status": "사용자 승인 대기 중", "icon": "pause"}
1607
+ ),
1608
+ }
1422
1609
 
1423
1610
  # Process regular HITL interrupts (non-subagent)
1424
1611
  for interrupt in interrupts:
@@ -1430,7 +1617,9 @@ async def stream_agent(request: AgentRequest):
1430
1617
 
1431
1618
  # Extract action requests
1432
1619
  action_requests = interrupt_value.get("action_requests", [])
1433
- logger.info(f"[INTERRUPT] action_requests count: {len(action_requests)}, first: {str(action_requests[0])[:200] if action_requests else 'none'}")
1620
+ logger.info(
1621
+ f"[INTERRUPT] action_requests count: {len(action_requests)}, first: {str(action_requests[0])[:200] if action_requests else 'none'}"
1622
+ )
1434
1623
  normalized_actions = [
1435
1624
  _normalize_action_request(a) for a in action_requests
1436
1625
  ]
@@ -1443,14 +1632,16 @@ async def stream_agent(request: AgentRequest):
1443
1632
  for idx, action in enumerate(normalized_actions):
1444
1633
  yield {
1445
1634
  "event": "interrupt",
1446
- "data": json.dumps({
1447
- "thread_id": thread_id,
1448
- "action": action.get("name", "unknown"),
1449
- "args": action.get("arguments", {}),
1450
- "description": action.get("description", ""),
1451
- "action_index": idx,
1452
- "total_actions": total_actions,
1453
- }),
1635
+ "data": json.dumps(
1636
+ {
1637
+ "thread_id": thread_id,
1638
+ "action": action.get("name", "unknown"),
1639
+ "args": action.get("arguments", {}),
1640
+ "description": action.get("description", ""),
1641
+ "action_index": idx,
1642
+ "total_actions": total_actions,
1643
+ }
1644
+ ),
1454
1645
  }
1455
1646
 
1456
1647
  # Save last signature for resume to avoid duplicate content
@@ -1541,9 +1732,14 @@ async def stream_agent(request: AgentRequest):
1541
1732
  )
1542
1733
  except asyncio.TimeoutError:
1543
1734
  logger.error("SimpleAgent fallback timed out after 30s")
1544
- yield {"event": "token", "data": json.dumps({
1735
+ yield {
1736
+ "event": "token",
1737
+ "data": json.dumps(
1738
+ {
1545
1739
  "content": "모델이 도구 호출을 생성하지 못했습니다. 다시 시도해주세요."
1546
- })}
1740
+ }
1741
+ ),
1742
+ }
1547
1743
  produced_output = True
1548
1744
  fallback_response = None
1549
1745
  except Exception as fallback_error:
@@ -1552,9 +1748,47 @@ async def stream_agent(request: AgentRequest):
1552
1748
  fallback_error,
1553
1749
  exc_info=True,
1554
1750
  )
1555
- yield {"event": "token", "data": json.dumps({"content": f"오류가 발생했습니다: {str(fallback_error)}"})}
1751
+ yield {
1752
+ "event": "token",
1753
+ "data": json.dumps(
1754
+ {"content": f"오류가 발생했습니다: {str(fallback_error)}"}
1755
+ ),
1756
+ }
1556
1757
  produced_output = True
1557
1758
  fallback_response = None
1759
+ if isinstance(fallback_response, AIMessage):
1760
+ # LLM Response - structured JSON format
1761
+ print(LOG_RESPONSE_START, flush=True)
1762
+ response_data = {
1763
+ "type": "AIMessage",
1764
+ "content": fallback_response.content or "",
1765
+ "tool_calls": fallback_response.tool_calls
1766
+ if hasattr(fallback_response, "tool_calls")
1767
+ else [],
1768
+ "additional_kwargs": getattr(
1769
+ fallback_response, "additional_kwargs", {}
1770
+ )
1771
+ or {},
1772
+ "response_metadata": getattr(
1773
+ fallback_response, "response_metadata", {}
1774
+ )
1775
+ or {},
1776
+ "usage_metadata": getattr(
1777
+ fallback_response, "usage_metadata", {}
1778
+ )
1779
+ or {},
1780
+ }
1781
+ print(
1782
+ json.dumps(
1783
+ response_data,
1784
+ indent=2,
1785
+ ensure_ascii=False,
1786
+ default=str,
1787
+ ),
1788
+ flush=True,
1789
+ )
1790
+ print(LOG_RESPONSE_END, flush=True)
1791
+
1558
1792
  if isinstance(fallback_response, AIMessage) and getattr(
1559
1793
  fallback_response, "tool_calls", None
1560
1794
  ):
@@ -1566,27 +1800,68 @@ async def stream_agent(request: AgentRequest):
1566
1800
 
1567
1801
  if tool_name in ("jupyter_cell_tool", "jupyter_cell"):
1568
1802
  produced_output = True
1569
- yield {"event": "debug", "data": json.dumps(_get_tool_status_message(tool_name, tool_args))}
1570
- yield {"event": "tool_call", "data": json.dumps({
1803
+ yield {
1804
+ "event": "debug",
1805
+ "data": json.dumps(
1806
+ _get_tool_status_message(tool_name, tool_args)
1807
+ ),
1808
+ }
1809
+ yield {
1810
+ "event": "tool_call",
1811
+ "data": json.dumps(
1812
+ {
1571
1813
  "tool": "jupyter_cell",
1572
1814
  "code": tool_args.get("code", ""),
1573
1815
  "description": tool_args.get("description", ""),
1574
- })}
1816
+ }
1817
+ ),
1818
+ }
1575
1819
  elif tool_name in ("markdown_tool", "markdown"):
1576
1820
  produced_output = True
1577
- yield {"event": "debug", "data": json.dumps(_get_tool_status_message(tool_name, tool_args))}
1578
- yield {"event": "tool_call", "data": json.dumps({
1821
+ yield {
1822
+ "event": "debug",
1823
+ "data": json.dumps(
1824
+ _get_tool_status_message(tool_name, tool_args)
1825
+ ),
1826
+ }
1827
+ yield {
1828
+ "event": "tool_call",
1829
+ "data": json.dumps(
1830
+ {
1579
1831
  "tool": "markdown",
1580
1832
  "content": tool_args.get("content", ""),
1581
- })}
1833
+ }
1834
+ ),
1835
+ }
1582
1836
  elif tool_name == "execute_command_tool":
1583
1837
  produced_output = True
1584
- yield {"event": "debug", "data": json.dumps(_get_tool_status_message(tool_name, tool_args))}
1585
- yield {"event": "tool_call", "data": json.dumps({
1838
+ yield {
1839
+ "event": "debug",
1840
+ "data": json.dumps(
1841
+ _get_tool_status_message(tool_name, tool_args)
1842
+ ),
1843
+ }
1844
+ yield {
1845
+ "event": "tool_call",
1846
+ "data": json.dumps(
1847
+ {
1586
1848
  "tool": "execute_command_tool",
1587
1849
  "command": tool_args.get("command", ""),
1588
1850
  "timeout": tool_args.get("timeout"),
1589
- })}
1851
+ }
1852
+ ),
1853
+ }
1854
+ elif tool_name in ("final_summary_tool", "final_summary"):
1855
+ # Final summary - emit summary event for frontend
1856
+ produced_output = True
1857
+ summary_data = {
1858
+ "summary": tool_args.get("summary", ""),
1859
+ "next_items": tool_args.get("next_items", []),
1860
+ }
1861
+ yield {
1862
+ "event": "summary",
1863
+ "data": json.dumps(summary_data),
1864
+ }
1590
1865
  elif tool_name == "read_file_tool":
1591
1866
  # For file operations, generate code with the LLM
1592
1867
  logger.info(
@@ -1618,20 +1893,32 @@ async def stream_agent(request: AgentRequest):
1618
1893
  )
1619
1894
 
1620
1895
  if not code:
1621
- yield {"event": "token", "data": json.dumps({
1896
+ yield {
1897
+ "event": "token",
1898
+ "data": json.dumps(
1899
+ {
1622
1900
  "content": "도구 실행을 위한 코드를 생성하지 못했습니다. 다시 시도해주세요."
1623
- })}
1901
+ }
1902
+ ),
1903
+ }
1624
1904
  produced_output = True
1625
1905
  continue
1626
1906
 
1627
- yield {"event": "debug", "data": json.dumps({"status": "[변환] Jupyter Cell로 변환 중"})}
1907
+ yield {
1908
+ "event": "debug",
1909
+ "data": json.dumps(
1910
+ {"status": "[변환] Jupyter Cell로 변환 중"}
1911
+ ),
1912
+ }
1628
1913
  yield {
1629
1914
  "event": "tool_call",
1630
- "data": json.dumps({
1631
- "tool": "jupyter_cell",
1632
- "code": code,
1633
- "description": f"Converted from {tool_name}",
1634
- }),
1915
+ "data": json.dumps(
1916
+ {
1917
+ "tool": "jupyter_cell",
1918
+ "code": code,
1919
+ "description": f"Converted from {tool_name}",
1920
+ }
1921
+ ),
1635
1922
  }
1636
1923
  else:
1637
1924
  # Unknown tool - skip and show message
@@ -1640,9 +1927,11 @@ async def stream_agent(request: AgentRequest):
1640
1927
  )
1641
1928
  yield {
1642
1929
  "event": "token",
1643
- "data": json.dumps({
1644
- "content": f"알 수 없는 도구 '{tool_name}'입니다. jupyter_cell_tool을 사용해주세요."
1645
- }),
1930
+ "data": json.dumps(
1931
+ {
1932
+ "content": f"알 수 없는 도구 '{tool_name}'입니다. jupyter_cell_tool을 사용해주세요."
1933
+ }
1934
+ ),
1646
1935
  }
1647
1936
  produced_output = True
1648
1937
  elif (
@@ -1654,25 +1943,41 @@ async def stream_agent(request: AgentRequest):
1654
1943
  repaired_content = _repair_summary_json_content(
1655
1944
  fallback_response.content
1656
1945
  )
1657
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
1946
+ yield {
1947
+ "event": "token",
1948
+ "data": json.dumps({"content": repaired_content}),
1949
+ }
1658
1950
  elif fallback_response is not None and not produced_output:
1659
- yield {"event": "token", "data": json.dumps({
1951
+ yield {
1952
+ "event": "token",
1953
+ "data": json.dumps(
1954
+ {
1660
1955
  "content": "모델이 도구 호출을 생성하지 못했습니다. 다시 시도해주세요."
1661
- })}
1956
+ }
1957
+ ),
1958
+ }
1662
1959
  produced_output = True
1663
1960
 
1664
1961
  # Clear debug status before completion
1665
1962
  yield {"event": "debug_clear", "data": json.dumps({})}
1666
1963
 
1667
1964
  # No interrupt - execution completed
1668
- yield {"event": "complete", "data": json.dumps({"success": True, "thread_id": thread_id})}
1965
+ yield {
1966
+ "event": "complete",
1967
+ "data": json.dumps({"success": True, "thread_id": thread_id}),
1968
+ }
1669
1969
 
1670
1970
  except Exception as e:
1671
1971
  logger.error(f"Stream error: {e}", exc_info=True)
1672
- yield {"event": "error", "data": json.dumps({
1972
+ yield {
1973
+ "event": "error",
1974
+ "data": json.dumps(
1975
+ {
1673
1976
  "error": str(e),
1674
1977
  "error_type": type(e).__name__,
1675
- })}
1978
+ }
1979
+ ),
1980
+ }
1676
1981
 
1677
1982
  return EventSourceResponse(event_generator())
1678
1983
 
@@ -1726,11 +2031,16 @@ async def resume_agent(request: ResumeRequest):
1726
2031
  "Server may have restarted or session expired.",
1727
2032
  request.threadId,
1728
2033
  )
1729
- yield {"event": "error", "data": json.dumps({
2034
+ yield {
2035
+ "event": "error",
2036
+ "data": json.dumps(
2037
+ {
1730
2038
  "error": "Session expired or not found",
1731
2039
  "code": "CHECKPOINT_NOT_FOUND",
1732
2040
  "message": "이전 세션을 찾을 수 없습니다. 서버가 재시작되었거나 세션이 만료되었습니다. 새로운 대화를 시작해주세요.",
1733
- })}
2041
+ }
2042
+ ),
2043
+ }
1734
2044
  return
1735
2045
 
1736
2046
  checkpointer = _simple_agent_checkpointers.get(request.threadId)
@@ -1740,14 +2050,28 @@ async def resume_agent(request: ResumeRequest):
1740
2050
  logger.info("Resume: Agent mode: %s", agent_mode)
1741
2051
 
1742
2052
  # Get agent prompts (for multi-agent mode)
1743
- # ROOT CAUSE: Frontend sends both systemPrompt AND agentPrompts
1744
- # FIX: Ignore all custom prompts for multi-agent mode
1745
2053
  agent_prompts = None
1746
2054
  if agent_mode == "multi":
1747
2055
  if request.llmConfig and request.llmConfig.agent_prompts:
1748
- logger.warning("Resume: Multi-agent mode - ignoring agentPrompts")
2056
+ agent_prompts = {
2057
+ "planner": request.llmConfig.agent_prompts.planner,
2058
+ "python_developer": (
2059
+ request.llmConfig.agent_prompts.python_developer
2060
+ ),
2061
+ "researcher": request.llmConfig.agent_prompts.researcher,
2062
+ "athena_query": request.llmConfig.agent_prompts.athena_query,
2063
+ }
2064
+ agent_prompts = {k: v for k, v in agent_prompts.items() if v}
2065
+ logger.info(
2066
+ "Resume: Multi-agent mode - using agentPrompts (%s)",
2067
+ list(agent_prompts.keys()),
2068
+ )
2069
+ # In multi-agent mode, DON'T use systemPrompt as override
1749
2070
  if system_prompt_override:
1750
- logger.warning("Resume: Multi-agent mode - ignoring systemPrompt")
2071
+ logger.info(
2072
+ "Resume: Multi-agent mode - ignoring systemPrompt (len=%d)",
2073
+ len(system_prompt_override),
2074
+ )
1751
2075
  system_prompt_override = None
1752
2076
 
1753
2077
  agent_cache_key = _get_agent_cache_key(
@@ -1822,7 +2146,12 @@ async def resume_agent(request: ResumeRequest):
1822
2146
  )
1823
2147
  # Track code execution for history (injected into subagent context)
1824
2148
  tool_name = edited_action.get("name", "")
1825
- if tool_name in ("jupyter_cell_tool", "write_file_tool", "edit_file_tool", "multiedit_file_tool"):
2149
+ if tool_name in (
2150
+ "jupyter_cell_tool",
2151
+ "write_file_tool",
2152
+ "edit_file_tool",
2153
+ "multiedit_file_tool",
2154
+ ):
1826
2155
  track_tool_execution(tool_name, args)
1827
2156
  langgraph_decisions.append(
1828
2157
  {
@@ -1840,7 +2169,10 @@ async def resume_agent(request: ResumeRequest):
1840
2169
  )
1841
2170
 
1842
2171
  # Resume execution
1843
- yield {"event": "debug", "data": json.dumps({"status": "실행 재개 중", "icon": "play"})}
2172
+ yield {
2173
+ "event": "debug",
2174
+ "data": json.dumps({"status": "실행 재개 중", "icon": "play"}),
2175
+ }
1844
2176
 
1845
2177
  _simple_agent_pending_actions.pop(request.threadId, None)
1846
2178
 
@@ -1866,7 +2198,10 @@ async def resume_agent(request: ResumeRequest):
1866
2198
  )
1867
2199
 
1868
2200
  # Status: waiting for LLM response
1869
- yield {"event": "debug", "data": json.dumps({"status": "LLM 응답 대기 중", "icon": "thinking"})}
2201
+ yield {
2202
+ "event": "debug",
2203
+ "data": json.dumps({"status": "LLM 응답 대기 중", "icon": "thinking"}),
2204
+ }
1870
2205
 
1871
2206
  step_count = 0
1872
2207
 
@@ -1878,9 +2213,16 @@ async def resume_agent(request: ResumeRequest):
1878
2213
  ):
1879
2214
  # Check if thread was cancelled by user
1880
2215
  if is_thread_cancelled(request.threadId):
1881
- logger.info(f"Thread {request.threadId} cancelled by user, stopping resume stream")
2216
+ logger.info(
2217
+ f"Thread {request.threadId} cancelled by user, stopping resume stream"
2218
+ )
1882
2219
  clear_cancelled_thread(request.threadId)
1883
- yield {"event": "cancelled", "data": json.dumps({"message": "작업이 사용자에 의해 중단되었습니다."})}
2220
+ yield {
2221
+ "event": "cancelled",
2222
+ "data": json.dumps(
2223
+ {"message": "작업이 사용자에 의해 중단되었습니다."}
2224
+ ),
2225
+ }
1884
2226
  return
1885
2227
 
1886
2228
  step_count += 1
@@ -1982,7 +2324,10 @@ async def resume_agent(request: ResumeRequest):
1982
2324
  todos = _extract_todos(last_message.content)
1983
2325
  if todos:
1984
2326
  latest_todos = todos
1985
- yield {"event": "todos", "data": json.dumps({"todos": todos})}
2327
+ yield {
2328
+ "event": "todos",
2329
+ "data": json.dumps({"todos": todos}),
2330
+ }
1986
2331
  # Check if all todos are completed - auto terminate only if summary exists
1987
2332
  all_completed = all(
1988
2333
  t.get("status") == "completed" for t in todos
@@ -1991,16 +2336,31 @@ async def resume_agent(request: ResumeRequest):
1991
2336
  # Check if summary JSON exists in the CURRENT step's AIMessage
1992
2337
  # (not in history, to avoid false positives from previous tasks)
1993
2338
  summary_exists = False
1994
- step_messages = step.get("messages", []) if isinstance(step, dict) else []
2339
+ step_messages = (
2340
+ step.get("messages", [])
2341
+ if isinstance(step, dict)
2342
+ else []
2343
+ )
1995
2344
  # Only check the AIMessage that called write_todos (should be right before this ToolMessage)
1996
- for recent_msg in step_messages[-3:]: # Check only the most recent few messages
2345
+ for recent_msg in step_messages[
2346
+ -3:
2347
+ ]: # Check only the most recent few messages
1997
2348
  if isinstance(recent_msg, AIMessage):
1998
- recent_content = getattr(recent_msg, "content", "") or ""
2349
+ recent_content = (
2350
+ getattr(recent_msg, "content", "") or ""
2351
+ )
1999
2352
  if isinstance(recent_content, list):
2000
- recent_content = " ".join(str(p) for p in recent_content)
2001
- if '"summary"' in recent_content and '"next_items"' in recent_content:
2353
+ recent_content = " ".join(
2354
+ str(p) for p in recent_content
2355
+ )
2356
+ if (
2357
+ '"summary"' in recent_content
2358
+ and '"next_items"' in recent_content
2359
+ ):
2002
2360
  summary_exists = True
2003
- logger.info("Resume: Found summary in current AIMessage content")
2361
+ logger.info(
2362
+ "Resume: Found summary in current AIMessage content"
2363
+ )
2004
2364
  break
2005
2365
 
2006
2366
  if summary_exists:
@@ -2012,23 +2372,53 @@ async def resume_agent(request: ResumeRequest):
2012
2372
  # Find and emit the AIMessage content with summary
2013
2373
  for recent_msg in step_messages[-3:]:
2014
2374
  if isinstance(recent_msg, AIMessage):
2015
- step_content = getattr(recent_msg, "content", "") or ""
2375
+ step_content = (
2376
+ getattr(recent_msg, "content", "")
2377
+ or ""
2378
+ )
2016
2379
  if isinstance(step_content, list):
2017
- step_content = " ".join(str(p) for p in step_content)
2018
- if '"summary"' in step_content and '"next_items"' in step_content:
2380
+ step_content = " ".join(
2381
+ str(p) for p in step_content
2382
+ )
2383
+ if (
2384
+ '"summary"' in step_content
2385
+ and '"next_items"' in step_content
2386
+ ):
2019
2387
  content_hash = hash(step_content)
2020
- if content_hash not in emitted_contents:
2021
- emitted_contents.add(content_hash)
2022
- repaired_content = _repair_summary_json_content(step_content)
2388
+ if (
2389
+ content_hash
2390
+ not in emitted_contents
2391
+ ):
2392
+ emitted_contents.add(
2393
+ content_hash
2394
+ )
2395
+ repaired_content = _repair_summary_json_content(
2396
+ step_content
2397
+ )
2023
2398
  logger.info(
2024
2399
  "Resume step auto-terminate: EMITTING summary content (len=%d): %s",
2025
2400
  len(repaired_content),
2026
2401
  repaired_content[:100],
2027
2402
  )
2028
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
2403
+ yield {
2404
+ "event": "token",
2405
+ "data": json.dumps(
2406
+ {
2407
+ "content": repaired_content
2408
+ }
2409
+ ),
2410
+ }
2029
2411
  break
2030
- yield {"event": "debug_clear", "data": json.dumps({})}
2031
- yield {"event": "done", "data": json.dumps({"reason": "all_todos_completed"})}
2412
+ yield {
2413
+ "event": "debug_clear",
2414
+ "data": json.dumps({}),
2415
+ }
2416
+ yield {
2417
+ "event": "done",
2418
+ "data": json.dumps(
2419
+ {"reason": "all_todos_completed"}
2420
+ ),
2421
+ }
2032
2422
  return # Exit the generator
2033
2423
  else:
2034
2424
  logger.warning(
@@ -2056,7 +2446,39 @@ async def resume_agent(request: ResumeRequest):
2056
2446
  # ToolMessage processing continues (no final_answer_tool)
2057
2447
 
2058
2448
  # Handle AIMessage (use elif to avoid processing after ToolMessage)
2059
- elif hasattr(last_message, "content") and last_message.content:
2449
+ elif isinstance(last_message, AIMessage):
2450
+ # LLM Response - structured JSON format
2451
+ print(LOG_RESPONSE_START, flush=True)
2452
+ response_data = {
2453
+ "type": "AIMessage",
2454
+ "content": last_message.content or "",
2455
+ "tool_calls": last_message.tool_calls
2456
+ if hasattr(last_message, "tool_calls")
2457
+ else [],
2458
+ "additional_kwargs": getattr(
2459
+ last_message, "additional_kwargs", {}
2460
+ )
2461
+ or {},
2462
+ "response_metadata": getattr(
2463
+ last_message, "response_metadata", {}
2464
+ )
2465
+ or {},
2466
+ "usage_metadata": getattr(
2467
+ last_message, "usage_metadata", {}
2468
+ )
2469
+ or {},
2470
+ }
2471
+ print(
2472
+ json.dumps(
2473
+ response_data,
2474
+ indent=2,
2475
+ ensure_ascii=False,
2476
+ default=str,
2477
+ ),
2478
+ flush=True,
2479
+ )
2480
+ print(LOG_RESPONSE_END, flush=True)
2481
+
2060
2482
  content = last_message.content
2061
2483
 
2062
2484
  # Handle list content (e.g., multimodal responses)
@@ -2090,6 +2512,28 @@ async def resume_agent(request: ResumeRequest):
2090
2512
  and content_stripped.startswith("{")
2091
2513
  )
2092
2514
  )
2515
+
2516
+ # Check if this is summary/next_items with tool_calls (premature summary)
2517
+ has_summary_pattern = (
2518
+ '"summary"' in content or "'summary'" in content
2519
+ ) and (
2520
+ '"next_items"' in content or "'next_items'" in content
2521
+ )
2522
+ tool_calls = getattr(last_message, "tool_calls", []) or []
2523
+ # Check if any tool_call is NOT write_todos (work still in progress)
2524
+ has_non_todo_tool_calls = any(
2525
+ tc.get("name")
2526
+ not in ("write_todos", "write_todos_tool")
2527
+ for tc in tool_calls
2528
+ )
2529
+ # Skip summary if non-write_todos tool_calls exist (work still in progress)
2530
+ if has_summary_pattern and has_non_todo_tool_calls:
2531
+ logger.info(
2532
+ "Resume: SKIPPING premature summary (has non-todo tool_calls): %s",
2533
+ content[:100],
2534
+ )
2535
+ content = None # Skip this content
2536
+
2093
2537
  if (
2094
2538
  content
2095
2539
  and isinstance(content, str)
@@ -2120,7 +2564,12 @@ async def resume_agent(request: ResumeRequest):
2120
2564
  len(repaired_content),
2121
2565
  log_preview,
2122
2566
  )
2123
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
2567
+ yield {
2568
+ "event": "token",
2569
+ "data": json.dumps(
2570
+ {"content": repaired_content}
2571
+ ),
2572
+ }
2124
2573
 
2125
2574
  if (
2126
2575
  hasattr(last_message, "tool_calls")
@@ -2147,7 +2596,10 @@ async def resume_agent(request: ResumeRequest):
2147
2596
  todos = _emit_todos_from_tool_calls(new_tool_calls)
2148
2597
  if todos:
2149
2598
  latest_todos = todos
2150
- yield {"event": "todos", "data": json.dumps({"todos": todos})}
2599
+ yield {
2600
+ "event": "todos",
2601
+ "data": json.dumps({"todos": todos}),
2602
+ }
2151
2603
  # Check if all todos are completed - terminate early
2152
2604
  all_completed = all(
2153
2605
  t.get("status") == "completed" for t in todos
@@ -2159,7 +2611,7 @@ async def resume_agent(request: ResumeRequest):
2159
2611
  "다음단계",
2160
2612
  "다음 단계",
2161
2613
  ]
2162
- has_summary_todo = any(
2614
+ any(
2163
2615
  any(
2164
2616
  kw in t.get("content", "")
2165
2617
  for kw in summary_keywords
@@ -2189,7 +2641,9 @@ async def resume_agent(request: ResumeRequest):
2189
2641
  "## 다음 단계",
2190
2642
  ]
2191
2643
  )
2192
- has_summary = has_summary_json or has_markdown_summary
2644
+ has_summary = (
2645
+ has_summary_json or has_markdown_summary
2646
+ )
2193
2647
 
2194
2648
  # Only check current AIMessage for summary (not history, to avoid false positives)
2195
2649
  if not has_summary:
@@ -2204,19 +2658,40 @@ async def resume_agent(request: ResumeRequest):
2204
2658
  )
2205
2659
  # IMPORTANT: Emit the summary content BEFORE terminating
2206
2660
  # so the UI can display the summary JSON
2207
- if msg_content and isinstance(msg_content, str):
2661
+ if msg_content and isinstance(
2662
+ msg_content, str
2663
+ ):
2208
2664
  content_hash = hash(msg_content)
2209
2665
  if content_hash not in emitted_contents:
2210
2666
  emitted_contents.add(content_hash)
2211
- repaired_content = _repair_summary_json_content(msg_content)
2667
+ repaired_content = (
2668
+ _repair_summary_json_content(
2669
+ msg_content
2670
+ )
2671
+ )
2212
2672
  logger.info(
2213
2673
  "Resume auto-terminate: EMITTING summary content (len=%d): %s",
2214
2674
  len(repaired_content),
2215
2675
  repaired_content[:100],
2216
2676
  )
2217
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
2218
- yield {"event": "debug_clear", "data": json.dumps({})}
2219
- yield {"event": "done", "data": json.dumps({"reason": "all_todos_completed"})}
2677
+ yield {
2678
+ "event": "token",
2679
+ "data": json.dumps(
2680
+ {
2681
+ "content": repaired_content
2682
+ }
2683
+ ),
2684
+ }
2685
+ yield {
2686
+ "event": "debug_clear",
2687
+ "data": json.dumps({}),
2688
+ }
2689
+ yield {
2690
+ "event": "done",
2691
+ "data": json.dumps(
2692
+ {"reason": "all_todos_completed"}
2693
+ ),
2694
+ }
2220
2695
  return # Exit before executing more tool calls
2221
2696
 
2222
2697
  # Process tool calls
@@ -2236,40 +2711,61 @@ async def resume_agent(request: ResumeRequest):
2236
2711
  tool_name, tool_args
2237
2712
  )
2238
2713
 
2239
- yield {"event": "debug", "data": json.dumps(status_msg)}
2714
+ yield {
2715
+ "event": "debug",
2716
+ "data": json.dumps(status_msg),
2717
+ }
2240
2718
 
2241
2719
  if tool_name in (
2242
2720
  "jupyter_cell_tool",
2243
2721
  "jupyter_cell",
2244
2722
  ):
2245
- yield {"event": "tool_call", "data": json.dumps({
2723
+ yield {
2724
+ "event": "tool_call",
2725
+ "data": json.dumps(
2726
+ {
2246
2727
  "tool": "jupyter_cell",
2247
2728
  "code": tool_args.get("code", ""),
2248
2729
  "description": tool_args.get(
2249
2730
  "description", ""
2250
2731
  ),
2251
- })}
2732
+ }
2733
+ ),
2734
+ }
2252
2735
  elif tool_name in ("markdown_tool", "markdown"):
2253
- yield {"event": "tool_call", "data": json.dumps({
2736
+ yield {
2737
+ "event": "tool_call",
2738
+ "data": json.dumps(
2739
+ {
2254
2740
  "tool": "markdown",
2255
2741
  "content": tool_args.get(
2256
2742
  "content", ""
2257
2743
  ),
2258
- })}
2744
+ }
2745
+ ),
2746
+ }
2259
2747
  elif tool_name == "execute_command_tool":
2260
- yield {"event": "tool_call", "data": json.dumps({
2748
+ yield {
2749
+ "event": "tool_call",
2750
+ "data": json.dumps(
2751
+ {
2261
2752
  "tool": "execute_command_tool",
2262
2753
  "command": tool_args.get(
2263
2754
  "command", ""
2264
2755
  ),
2265
2756
  "timeout": tool_args.get("timeout"),
2266
- })}
2757
+ }
2758
+ ),
2759
+ }
2267
2760
  elif tool_name in (
2268
2761
  "search_notebook_cells_tool",
2269
2762
  "search_notebook_cells",
2270
2763
  ):
2271
2764
  # Search notebook cells - emit tool_call for client-side execution
2272
- yield {"event": "tool_call", "data": json.dumps({
2765
+ yield {
2766
+ "event": "tool_call",
2767
+ "data": json.dumps(
2768
+ {
2273
2769
  "tool": "search_notebook_cells",
2274
2770
  "pattern": tool_args.get(
2275
2771
  "pattern", ""
@@ -2286,7 +2782,24 @@ async def resume_agent(request: ResumeRequest):
2286
2782
  "case_sensitive": tool_args.get(
2287
2783
  "case_sensitive", False
2288
2784
  ),
2289
- })}
2785
+ }
2786
+ ),
2787
+ }
2788
+ elif tool_name in (
2789
+ "final_summary_tool",
2790
+ "final_summary",
2791
+ ):
2792
+ # Final summary - emit summary event for frontend
2793
+ summary_data = {
2794
+ "summary": tool_args.get("summary", ""),
2795
+ "next_items": tool_args.get(
2796
+ "next_items", []
2797
+ ),
2798
+ }
2799
+ yield {
2800
+ "event": "summary",
2801
+ "data": json.dumps(summary_data),
2802
+ }
2290
2803
 
2291
2804
  # Drain and emit any subagent events (tool calls from subagents)
2292
2805
  for subagent_event in get_subagent_debug_events():
@@ -2297,7 +2810,12 @@ async def resume_agent(request: ResumeRequest):
2297
2810
  if isinstance(step, dict) and "__interrupt__" in step:
2298
2811
  interrupts = step["__interrupt__"]
2299
2812
 
2300
- yield {"event": "debug", "data": json.dumps({"status": "사용자 승인 대기 중", "icon": "pause"})}
2813
+ yield {
2814
+ "event": "debug",
2815
+ "data": json.dumps(
2816
+ {"status": "사용자 승인 대기 중", "icon": "pause"}
2817
+ ),
2818
+ }
2301
2819
 
2302
2820
  for interrupt in interrupts:
2303
2821
  interrupt_value = (
@@ -2306,7 +2824,9 @@ async def resume_agent(request: ResumeRequest):
2306
2824
  else interrupt
2307
2825
  )
2308
2826
  action_requests = interrupt_value.get("action_requests", [])
2309
- logger.info(f"[RESUME INTERRUPT] action_requests count: {len(action_requests)}, first: {str(action_requests[0])[:200] if action_requests else 'none'}")
2827
+ logger.info(
2828
+ f"[RESUME INTERRUPT] action_requests count: {len(action_requests)}, first: {str(action_requests[0])[:200] if action_requests else 'none'}"
2829
+ )
2310
2830
  normalized_actions = [
2311
2831
  _normalize_action_request(a) for a in action_requests
2312
2832
  ]
@@ -2319,14 +2839,16 @@ async def resume_agent(request: ResumeRequest):
2319
2839
  for idx, action in enumerate(normalized_actions):
2320
2840
  yield {
2321
2841
  "event": "interrupt",
2322
- "data": json.dumps({
2323
- "thread_id": request.threadId,
2324
- "action": action.get("name", "unknown"),
2325
- "args": action.get("arguments", {}),
2326
- "description": action.get("description", ""),
2327
- "action_index": idx,
2328
- "total_actions": total_actions,
2329
- }),
2842
+ "data": json.dumps(
2843
+ {
2844
+ "thread_id": request.threadId,
2845
+ "action": action.get("name", "unknown"),
2846
+ "args": action.get("arguments", {}),
2847
+ "description": action.get("description", ""),
2848
+ "action_index": idx,
2849
+ "total_actions": total_actions,
2850
+ }
2851
+ ),
2330
2852
  }
2331
2853
 
2332
2854
  # Save last signature for next resume to avoid duplicate content
@@ -2359,7 +2881,10 @@ async def resume_agent(request: ResumeRequest):
2359
2881
  last_signature,
2360
2882
  latest_todos,
2361
2883
  )
2362
- yield {"event": "complete", "data": json.dumps({"success": True, "thread_id": request.threadId})}
2884
+ yield {
2885
+ "event": "complete",
2886
+ "data": json.dumps({"success": True, "thread_id": request.threadId}),
2887
+ }
2363
2888
 
2364
2889
  except Exception as e:
2365
2890
  error_msg = str(e)
@@ -2370,17 +2895,27 @@ async def resume_agent(request: ResumeRequest):
2370
2895
  logger.warning(
2371
2896
  "Detected 'contents is not specified' error - likely session state loss"
2372
2897
  )
2373
- yield {"event": "error", "data": json.dumps({
2898
+ yield {
2899
+ "event": "error",
2900
+ "data": json.dumps(
2901
+ {
2374
2902
  "error": "Session state lost",
2375
2903
  "code": "CONTENTS_NOT_SPECIFIED",
2376
2904
  "error_type": type(e).__name__,
2377
2905
  "message": "세션 상태가 손실되었습니다. 서버가 재시작되었거나 세션이 만료되었습니다. 새로운 대화를 시작해주세요.",
2378
- })}
2906
+ }
2907
+ ),
2908
+ }
2379
2909
  else:
2380
- yield {"event": "error", "data": json.dumps({
2910
+ yield {
2911
+ "event": "error",
2912
+ "data": json.dumps(
2913
+ {
2381
2914
  "error": error_msg,
2382
2915
  "error_type": type(e).__name__,
2383
- })}
2916
+ }
2917
+ ),
2918
+ }
2384
2919
 
2385
2920
  return EventSourceResponse(event_generator())
2386
2921
 
@@ -2437,6 +2972,7 @@ async def health_check() -> Dict[str, Any]:
2437
2972
 
2438
2973
  class CancelRequest(BaseModel):
2439
2974
  """Request to cancel a running agent thread"""
2975
+
2440
2976
  thread_id: str
2441
2977
 
2442
2978
 
@@ -2463,3 +2999,70 @@ async def clear_agent_cache() -> Dict[str, Any]:
2463
2999
  "cleared": count,
2464
3000
  "message": f"Cleared {count} cached agent instances",
2465
3001
  }
3002
+
3003
+
3004
+ class ResetRequest(BaseModel):
3005
+ """Request to reset a thread (clear session and recreate agent)"""
3006
+
3007
+ thread_id: str
3008
+
3009
+
3010
+ @router.post("/reset")
3011
+ async def reset_agent_thread(request: ResetRequest) -> Dict[str, Any]:
3012
+ """
3013
+ Reset an agent thread by clearing all session state.
3014
+
3015
+ This will:
3016
+ - Clear the checkpointer (conversation history)
3017
+ - Clear pending actions
3018
+ - Clear emitted contents
3019
+ - Clear last signatures
3020
+ - Remove from cancelled threads set
3021
+
3022
+ The agent instance itself is not cleared (it's shared across threads),
3023
+ but the thread state is completely reset.
3024
+ """
3025
+ thread_id = request.thread_id
3026
+
3027
+ # Track what was cleared
3028
+ cleared = []
3029
+
3030
+ # Clear checkpointer (conversation history)
3031
+ if thread_id in _simple_agent_checkpointers:
3032
+ del _simple_agent_checkpointers[thread_id]
3033
+ cleared.append("checkpointer")
3034
+
3035
+ # Clear pending actions
3036
+ if thread_id in _simple_agent_pending_actions:
3037
+ del _simple_agent_pending_actions[thread_id]
3038
+ cleared.append("pending_actions")
3039
+
3040
+ # Clear last signatures
3041
+ if thread_id in _simple_agent_last_signatures:
3042
+ del _simple_agent_last_signatures[thread_id]
3043
+ cleared.append("last_signatures")
3044
+
3045
+ # Clear emitted contents
3046
+ if thread_id in _simple_agent_emitted_contents:
3047
+ del _simple_agent_emitted_contents[thread_id]
3048
+ cleared.append("emitted_contents")
3049
+
3050
+ # Remove from cancelled threads
3051
+ if thread_id in _cancelled_threads:
3052
+ _cancelled_threads.discard(thread_id)
3053
+ cleared.append("cancelled_flag")
3054
+
3055
+ logger.info(
3056
+ "Reset thread %s: cleared %s",
3057
+ thread_id,
3058
+ ", ".join(cleared) if cleared else "nothing (thread not found)",
3059
+ )
3060
+
3061
+ return {
3062
+ "status": "ok",
3063
+ "thread_id": thread_id,
3064
+ "cleared": cleared,
3065
+ "message": f"Thread {thread_id} has been reset"
3066
+ if cleared
3067
+ else f"Thread {thread_id} had no state to clear",
3068
+ }