hdsp-jupyter-extension 2.0.23__py3-none-any.whl → 2.0.25__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 (54) 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/main.py +2 -1
  7. agent_server/routers/chat.py +61 -10
  8. agent_server/routers/context.py +168 -0
  9. agent_server/routers/langchain_agent.py +609 -182
  10. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/build_log.json +1 -1
  11. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/package.json +2 -2
  12. 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.25.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js +245 -121
  13. hdsp_jupyter_extension-2.0.25.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js.map +1 -0
  14. jupyter_ext/labextension/static/lib_index_js.2d5ea542350862f7c531.js → hdsp_jupyter_extension-2.0.25.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.67505497667f9c0a763d.js +583 -39
  15. hdsp_jupyter_extension-2.0.25.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.67505497667f9c0a763d.js.map +1 -0
  16. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.f0127d8744730f2092c1.js → hdsp_jupyter_extension-2.0.25.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.ffc2b4bc8e6cb300e1e1.js +3 -3
  17. jupyter_ext/labextension/static/remoteEntry.f0127d8744730f2092c1.js.map → hdsp_jupyter_extension-2.0.25.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.ffc2b4bc8e6cb300e1e1.js.map +1 -1
  18. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.25.dist-info}/METADATA +1 -1
  19. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.25.dist-info}/RECORD +50 -44
  20. jupyter_ext/_version.py +1 -1
  21. jupyter_ext/handlers.py +29 -0
  22. jupyter_ext/labextension/build_log.json +1 -1
  23. jupyter_ext/labextension/package.json +2 -2
  24. jupyter_ext/labextension/static/{frontend_styles_index_js.96745acc14125453fba8.js → frontend_styles_index_js.b5e4416b4e07ec087aad.js} +245 -121
  25. jupyter_ext/labextension/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js.map +1 -0
  26. 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
  27. jupyter_ext/labextension/static/lib_index_js.67505497667f9c0a763d.js.map +1 -0
  28. jupyter_ext/labextension/static/{remoteEntry.f0127d8744730f2092c1.js → remoteEntry.ffc2b4bc8e6cb300e1e1.js} +3 -3
  29. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.f0127d8744730f2092c1.js.map → jupyter_ext/labextension/static/remoteEntry.ffc2b4bc8e6cb300e1e1.js.map +1 -1
  30. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.96745acc14125453fba8.js.map +0 -1
  31. hdsp_jupyter_extension-2.0.23.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.2d5ea542350862f7c531.js.map +0 -1
  32. jupyter_ext/labextension/static/frontend_styles_index_js.96745acc14125453fba8.js.map +0 -1
  33. jupyter_ext/labextension/static/lib_index_js.2d5ea542350862f7c531.js.map +0 -1
  34. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +0 -0
  35. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/install.json +0 -0
  36. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.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
  37. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.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
  38. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.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
  39. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.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
  40. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/style.js +0 -0
  41. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.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
  42. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.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
  43. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js +0 -0
  44. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.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
  45. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js +0 -0
  46. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.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
  47. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +0 -0
  48. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.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
  49. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +0 -0
  50. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +0 -0
  51. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js +0 -0
  52. {hdsp_jupyter_extension-2.0.23.data → hdsp_jupyter_extension-2.0.25.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +0 -0
  53. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.25.dist-info}/WHEEL +0 -0
  54. {hdsp_jupyter_extension-2.0.23.dist-info → hdsp_jupyter_extension-2.0.25.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:
@@ -856,7 +869,11 @@ async def stream_agent(request: AgentRequest):
856
869
  }
857
870
  # Build previous todos context for LLM
858
871
  if existing_todos:
859
- completed_items = [t.get("content", "") for t in existing_todos if t.get("status") == "completed"]
872
+ completed_items = [
873
+ t.get("content", "")
874
+ for t in existing_todos
875
+ if t.get("status") == "completed"
876
+ ]
860
877
  if completed_items:
861
878
  items_summary = ", ".join(completed_items[:5])
862
879
  if len(completed_items) > 5:
@@ -865,7 +882,10 @@ async def stream_agent(request: AgentRequest):
865
882
  f"[SYSTEM] 이전 todo list가 완료 혹은 취소되었습니다. 완료된 작업: {items_summary}. "
866
883
  f"새 작업을 시작합니다. 이전 todo list에 신규 작업을 append 하지 말고 새로운 todo list를 생성하세요."
867
884
  )
868
- logger.info("Injecting previous todos context: %s", previous_todos_context[:100])
885
+ logger.info(
886
+ "Injecting previous todos context: %s",
887
+ previous_todos_context[:100],
888
+ )
869
889
  except Exception as e:
870
890
  logger.warning("Could not reset todos in agent state: %s", e)
871
891
 
@@ -895,7 +915,10 @@ async def stream_agent(request: AgentRequest):
895
915
 
896
916
  # Initial status: waiting for LLM
897
917
  logger.info("SSE: Sending initial debug status 'LLM 응답 대기 중'")
898
- yield {"event": "debug", "data": json.dumps({"status": "LLM 응답 대기 중", "icon": "thinking"})}
918
+ yield {
919
+ "event": "debug",
920
+ "data": json.dumps({"status": "LLM 응답 대기 중", "icon": "thinking"}),
921
+ }
899
922
 
900
923
  # Main streaming loop
901
924
  async for step in _async_stream_wrapper(
@@ -903,9 +926,16 @@ async def stream_agent(request: AgentRequest):
903
926
  ):
904
927
  # Check if thread was cancelled by user
905
928
  if is_thread_cancelled(thread_id):
906
- logger.info(f"Thread {thread_id} cancelled by user, stopping stream")
929
+ logger.info(
930
+ f"Thread {thread_id} cancelled by user, stopping stream"
931
+ )
907
932
  clear_cancelled_thread(thread_id)
908
- yield {"event": "cancelled", "data": json.dumps({"message": "작업이 사용자에 의해 중단되었습니다."})}
933
+ yield {
934
+ "event": "cancelled",
935
+ "data": json.dumps(
936
+ {"message": "작업이 사용자에 의해 중단되었습니다."}
937
+ ),
938
+ }
909
939
  return
910
940
 
911
941
  if isinstance(step, dict):
@@ -915,7 +945,10 @@ async def stream_agent(request: AgentRequest):
915
945
  # DEBUG: Check for __interrupt__ in every step
916
946
  if "__interrupt__" in step:
917
947
  logger.info("[DEBUG-INTERRUPT] Found __interrupt__ in step!")
918
- logger.info("[DEBUG-INTERRUPT] interrupt value: %s", str(step["__interrupt__"])[:500])
948
+ logger.info(
949
+ "[DEBUG-INTERRUPT] interrupt value: %s",
950
+ str(step["__interrupt__"])[:500],
951
+ )
919
952
 
920
953
  # IMPORTANT: Process todos and messages BEFORE checking for interrupt
921
954
  # This ensures todos/debug events are emitted even in interrupt steps
@@ -980,7 +1013,10 @@ async def stream_agent(request: AgentRequest):
980
1013
  todos = _extract_todos(last_message.content)
981
1014
  if todos:
982
1015
  latest_todos = todos
983
- yield {"event": "todos", "data": json.dumps({"todos": todos})}
1016
+ yield {
1017
+ "event": "todos",
1018
+ "data": json.dumps({"todos": todos}),
1019
+ }
984
1020
  # Check if all todos are completed - auto terminate only if summary exists
985
1021
  all_completed = all(
986
1022
  t.get("status") == "completed" for t in todos
@@ -989,16 +1025,31 @@ async def stream_agent(request: AgentRequest):
989
1025
  # Check if summary JSON exists in the CURRENT step's AIMessage
990
1026
  # (not in history, to avoid false positives from previous tasks)
991
1027
  summary_exists = False
992
- step_messages = step.get("messages", []) if isinstance(step, dict) else []
1028
+ step_messages = (
1029
+ step.get("messages", [])
1030
+ if isinstance(step, dict)
1031
+ else []
1032
+ )
993
1033
  # 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
1034
+ for recent_msg in step_messages[
1035
+ -3:
1036
+ ]: # Check only the most recent few messages
995
1037
  if isinstance(recent_msg, AIMessage):
996
- recent_content = getattr(recent_msg, "content", "") or ""
1038
+ recent_content = (
1039
+ getattr(recent_msg, "content", "") or ""
1040
+ )
997
1041
  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:
1042
+ recent_content = " ".join(
1043
+ str(p) for p in recent_content
1044
+ )
1045
+ if (
1046
+ '"summary"' in recent_content
1047
+ and '"next_items"' in recent_content
1048
+ ):
1000
1049
  summary_exists = True
1001
- logger.info("Found summary in current AIMessage content")
1050
+ logger.info(
1051
+ "Found summary in current AIMessage content"
1052
+ )
1002
1053
  break
1003
1054
 
1004
1055
  if summary_exists:
@@ -1010,24 +1061,54 @@ async def stream_agent(request: AgentRequest):
1010
1061
  # Find and emit the AIMessage content with summary
1011
1062
  for recent_msg in step_messages[-3:]:
1012
1063
  if isinstance(recent_msg, AIMessage):
1013
- step_content = getattr(recent_msg, "content", "") or ""
1064
+ step_content = (
1065
+ getattr(recent_msg, "content", "")
1066
+ or ""
1067
+ )
1014
1068
  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:
1069
+ step_content = " ".join(
1070
+ str(p) for p in step_content
1071
+ )
1072
+ if (
1073
+ '"summary"' in step_content
1074
+ and '"next_items"' in step_content
1075
+ ):
1017
1076
  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)
1077
+ if (
1078
+ content_hash
1079
+ not in emitted_contents
1080
+ ):
1081
+ emitted_contents.add(
1082
+ content_hash
1083
+ )
1084
+ repaired_content = _repair_summary_json_content(
1085
+ step_content
1086
+ )
1021
1087
  logger.info(
1022
1088
  "Step auto-terminate: EMITTING summary content (len=%d): %s",
1023
1089
  len(repaired_content),
1024
1090
  repaired_content[:100],
1025
1091
  )
1026
1092
  produced_output = True
1027
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
1093
+ yield {
1094
+ "event": "token",
1095
+ "data": json.dumps(
1096
+ {
1097
+ "content": repaired_content
1098
+ }
1099
+ ),
1100
+ }
1028
1101
  break
1029
- yield {"event": "debug_clear", "data": json.dumps({})}
1030
- yield {"event": "done", "data": json.dumps({"reason": "all_todos_completed"})}
1102
+ yield {
1103
+ "event": "debug_clear",
1104
+ "data": json.dumps({}),
1105
+ }
1106
+ yield {
1107
+ "event": "done",
1108
+ "data": json.dumps(
1109
+ {"reason": "all_todos_completed"}
1110
+ ),
1111
+ }
1031
1112
  return # Exit the generator
1032
1113
  else:
1033
1114
  logger.warning(
@@ -1056,49 +1137,37 @@ async def stream_agent(request: AgentRequest):
1056
1137
 
1057
1138
  # Handle AIMessage
1058
1139
  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)
1140
+ # LLM Response - structured JSON format
1141
+ print(LOG_RESPONSE_START, flush=True)
1142
+ response_data = {
1143
+ "type": "AIMessage",
1144
+ "content": last_message.content or "",
1145
+ "tool_calls": last_message.tool_calls
1071
1146
  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",
1147
+ else [],
1148
+ "additional_kwargs": getattr(
1149
+ last_message, "additional_kwargs", {}
1150
+ )
1151
+ or {},
1152
+ "response_metadata": getattr(
1153
+ last_message, "response_metadata", {}
1154
+ )
1155
+ or {},
1156
+ "usage_metadata": getattr(
1157
+ last_message, "usage_metadata", {}
1158
+ )
1159
+ or {},
1160
+ }
1161
+ print(
1092
1162
  json.dumps(
1093
- getattr(last_message, "usage_metadata", {}) or {},
1163
+ response_data,
1164
+ indent=2,
1094
1165
  ensure_ascii=False,
1166
+ default=str,
1095
1167
  ),
1168
+ flush=True,
1096
1169
  )
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)
1170
+ print(LOG_RESPONSE_END, flush=True)
1102
1171
  last_finish_reason = (
1103
1172
  getattr(last_message, "response_metadata", {}) or {}
1104
1173
  ).get("finish_reason")
@@ -1157,10 +1226,22 @@ async def stream_agent(request: AgentRequest):
1157
1226
  logger.warning(
1158
1227
  "MALFORMED_FUNCTION_CALL with empty response - sending error to client"
1159
1228
  )
1160
- yield {"event": "token", "data": json.dumps({
1229
+ yield {
1230
+ "event": "token",
1231
+ "data": json.dumps(
1232
+ {
1161
1233
  "content": "\n\n[경고] LLM이 잘못된 응답을 반환했습니다. 다시 시도해주세요.\n"
1162
- })}
1163
- yield {"event": "debug", "data": json.dumps({"status": "[경고] MALFORMED_FUNCTION_CALL 에러"})}
1234
+ }
1235
+ ),
1236
+ }
1237
+ yield {
1238
+ "event": "debug",
1239
+ "data": json.dumps(
1240
+ {
1241
+ "status": "[경고] MALFORMED_FUNCTION_CALL 에러"
1242
+ }
1243
+ ),
1244
+ }
1164
1245
  produced_output = True
1165
1246
  # Continue to let agent retry on next iteration
1166
1247
 
@@ -1190,7 +1271,10 @@ async def stream_agent(request: AgentRequest):
1190
1271
  len(todos),
1191
1272
  )
1192
1273
  latest_todos = todos
1193
- yield {"event": "todos", "data": json.dumps({"todos": todos})}
1274
+ yield {
1275
+ "event": "todos",
1276
+ "data": json.dumps({"todos": todos}),
1277
+ }
1194
1278
  # Check if all todos are completed - terminate early
1195
1279
  all_completed = all(
1196
1280
  t.get("status") == "completed" for t in todos
@@ -1232,7 +1316,9 @@ async def stream_agent(request: AgentRequest):
1232
1316
  "## 다음 단계",
1233
1317
  ]
1234
1318
  )
1235
- has_summary = has_summary_json or has_markdown_summary
1319
+ has_summary = (
1320
+ has_summary_json or has_markdown_summary
1321
+ )
1236
1322
 
1237
1323
  # Only check current AIMessage for summary (not history, to avoid false positives)
1238
1324
  if not has_summary:
@@ -1247,20 +1333,41 @@ async def stream_agent(request: AgentRequest):
1247
1333
  )
1248
1334
  # IMPORTANT: Emit the summary content BEFORE terminating
1249
1335
  # so the UI can display the summary JSON
1250
- if msg_content and isinstance(msg_content, str):
1336
+ if msg_content and isinstance(
1337
+ msg_content, str
1338
+ ):
1251
1339
  content_hash = hash(msg_content)
1252
1340
  if content_hash not in emitted_contents:
1253
1341
  emitted_contents.add(content_hash)
1254
- repaired_content = _repair_summary_json_content(msg_content)
1342
+ repaired_content = (
1343
+ _repair_summary_json_content(
1344
+ msg_content
1345
+ )
1346
+ )
1255
1347
  logger.info(
1256
1348
  "Auto-terminate: EMITTING summary content (len=%d): %s",
1257
1349
  len(repaired_content),
1258
1350
  repaired_content[:100],
1259
1351
  )
1260
1352
  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"})}
1353
+ yield {
1354
+ "event": "token",
1355
+ "data": json.dumps(
1356
+ {
1357
+ "content": repaired_content
1358
+ }
1359
+ ),
1360
+ }
1361
+ yield {
1362
+ "event": "debug_clear",
1363
+ "data": json.dumps({}),
1364
+ }
1365
+ yield {
1366
+ "event": "done",
1367
+ "data": json.dumps(
1368
+ {"reason": "all_todos_completed"}
1369
+ ),
1370
+ }
1264
1371
  return # Exit before executing more tool calls
1265
1372
  for tool_call in tool_calls:
1266
1373
  tool_name = tool_call.get("name", "unknown")
@@ -1275,7 +1382,10 @@ async def stream_agent(request: AgentRequest):
1275
1382
  "SSE: Emitting debug event for tool: %s",
1276
1383
  tool_name,
1277
1384
  )
1278
- yield {"event": "debug", "data": json.dumps(status_msg)}
1385
+ yield {
1386
+ "event": "debug",
1387
+ "data": json.dumps(status_msg),
1388
+ }
1279
1389
 
1280
1390
  # Send tool_call event with details for frontend to execute
1281
1391
  if tool_name in (
@@ -1283,37 +1393,55 @@ async def stream_agent(request: AgentRequest):
1283
1393
  "jupyter_cell",
1284
1394
  ):
1285
1395
  produced_output = True
1286
- yield {"event": "tool_call", "data": json.dumps({
1396
+ yield {
1397
+ "event": "tool_call",
1398
+ "data": json.dumps(
1399
+ {
1287
1400
  "tool": "jupyter_cell",
1288
1401
  "code": tool_args.get("code", ""),
1289
1402
  "description": tool_args.get(
1290
1403
  "description", ""
1291
1404
  ),
1292
- })}
1405
+ }
1406
+ ),
1407
+ }
1293
1408
  elif tool_name in ("markdown_tool", "markdown"):
1294
1409
  produced_output = True
1295
- yield {"event": "tool_call", "data": json.dumps({
1410
+ yield {
1411
+ "event": "tool_call",
1412
+ "data": json.dumps(
1413
+ {
1296
1414
  "tool": "markdown",
1297
1415
  "content": tool_args.get(
1298
1416
  "content", ""
1299
1417
  ),
1300
- })}
1418
+ }
1419
+ ),
1420
+ }
1301
1421
  elif tool_name == "execute_command_tool":
1302
1422
  produced_output = True
1303
- yield {"event": "tool_call", "data": json.dumps({
1423
+ yield {
1424
+ "event": "tool_call",
1425
+ "data": json.dumps(
1426
+ {
1304
1427
  "tool": "execute_command_tool",
1305
1428
  "command": tool_args.get(
1306
1429
  "command", ""
1307
1430
  ),
1308
1431
  "timeout": tool_args.get("timeout"),
1309
- })}
1432
+ }
1433
+ ),
1434
+ }
1310
1435
  elif tool_name in (
1311
1436
  "search_notebook_cells_tool",
1312
1437
  "search_notebook_cells",
1313
1438
  ):
1314
1439
  # Search notebook cells - emit tool_call for client-side execution
1315
1440
  produced_output = True
1316
- yield {"event": "tool_call", "data": json.dumps({
1441
+ yield {
1442
+ "event": "tool_call",
1443
+ "data": json.dumps(
1444
+ {
1317
1445
  "tool": "search_notebook_cells",
1318
1446
  "pattern": tool_args.get(
1319
1447
  "pattern", ""
@@ -1330,7 +1458,9 @@ async def stream_agent(request: AgentRequest):
1330
1458
  "case_sensitive": tool_args.get(
1331
1459
  "case_sensitive", False
1332
1460
  ),
1333
- })}
1461
+ }
1462
+ ),
1463
+ }
1334
1464
 
1335
1465
  # Only display content if it's not empty and not a JSON tool response
1336
1466
  if (
@@ -1399,7 +1529,12 @@ async def stream_agent(request: AgentRequest):
1399
1529
  repaired_content[:100],
1400
1530
  )
1401
1531
  produced_output = True
1402
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
1532
+ yield {
1533
+ "event": "token",
1534
+ "data": json.dumps(
1535
+ {"content": repaired_content}
1536
+ ),
1537
+ }
1403
1538
 
1404
1539
  # Drain and emit any subagent events (tool calls from subagents)
1405
1540
  for subagent_event in get_subagent_debug_events():
@@ -1418,7 +1553,12 @@ async def stream_agent(request: AgentRequest):
1418
1553
  else interrupt
1419
1554
  )
1420
1555
 
1421
- yield {"event": "debug", "data": json.dumps({"status": "사용자 승인 대기 중", "icon": "pause"})}
1556
+ yield {
1557
+ "event": "debug",
1558
+ "data": json.dumps(
1559
+ {"status": "사용자 승인 대기 중", "icon": "pause"}
1560
+ ),
1561
+ }
1422
1562
 
1423
1563
  # Process regular HITL interrupts (non-subagent)
1424
1564
  for interrupt in interrupts:
@@ -1430,7 +1570,9 @@ async def stream_agent(request: AgentRequest):
1430
1570
 
1431
1571
  # Extract action requests
1432
1572
  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'}")
1573
+ logger.info(
1574
+ f"[INTERRUPT] action_requests count: {len(action_requests)}, first: {str(action_requests[0])[:200] if action_requests else 'none'}"
1575
+ )
1434
1576
  normalized_actions = [
1435
1577
  _normalize_action_request(a) for a in action_requests
1436
1578
  ]
@@ -1443,14 +1585,16 @@ async def stream_agent(request: AgentRequest):
1443
1585
  for idx, action in enumerate(normalized_actions):
1444
1586
  yield {
1445
1587
  "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
- }),
1588
+ "data": json.dumps(
1589
+ {
1590
+ "thread_id": thread_id,
1591
+ "action": action.get("name", "unknown"),
1592
+ "args": action.get("arguments", {}),
1593
+ "description": action.get("description", ""),
1594
+ "action_index": idx,
1595
+ "total_actions": total_actions,
1596
+ }
1597
+ ),
1454
1598
  }
1455
1599
 
1456
1600
  # Save last signature for resume to avoid duplicate content
@@ -1541,9 +1685,14 @@ async def stream_agent(request: AgentRequest):
1541
1685
  )
1542
1686
  except asyncio.TimeoutError:
1543
1687
  logger.error("SimpleAgent fallback timed out after 30s")
1544
- yield {"event": "token", "data": json.dumps({
1688
+ yield {
1689
+ "event": "token",
1690
+ "data": json.dumps(
1691
+ {
1545
1692
  "content": "모델이 도구 호출을 생성하지 못했습니다. 다시 시도해주세요."
1546
- })}
1693
+ }
1694
+ ),
1695
+ }
1547
1696
  produced_output = True
1548
1697
  fallback_response = None
1549
1698
  except Exception as fallback_error:
@@ -1552,7 +1701,12 @@ async def stream_agent(request: AgentRequest):
1552
1701
  fallback_error,
1553
1702
  exc_info=True,
1554
1703
  )
1555
- yield {"event": "token", "data": json.dumps({"content": f"오류가 발생했습니다: {str(fallback_error)}"})}
1704
+ yield {
1705
+ "event": "token",
1706
+ "data": json.dumps(
1707
+ {"content": f"오류가 발생했습니다: {str(fallback_error)}"}
1708
+ ),
1709
+ }
1556
1710
  produced_output = True
1557
1711
  fallback_response = None
1558
1712
  if isinstance(fallback_response, AIMessage) and getattr(
@@ -1566,27 +1720,57 @@ async def stream_agent(request: AgentRequest):
1566
1720
 
1567
1721
  if tool_name in ("jupyter_cell_tool", "jupyter_cell"):
1568
1722
  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({
1723
+ yield {
1724
+ "event": "debug",
1725
+ "data": json.dumps(
1726
+ _get_tool_status_message(tool_name, tool_args)
1727
+ ),
1728
+ }
1729
+ yield {
1730
+ "event": "tool_call",
1731
+ "data": json.dumps(
1732
+ {
1571
1733
  "tool": "jupyter_cell",
1572
1734
  "code": tool_args.get("code", ""),
1573
1735
  "description": tool_args.get("description", ""),
1574
- })}
1736
+ }
1737
+ ),
1738
+ }
1575
1739
  elif tool_name in ("markdown_tool", "markdown"):
1576
1740
  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({
1741
+ yield {
1742
+ "event": "debug",
1743
+ "data": json.dumps(
1744
+ _get_tool_status_message(tool_name, tool_args)
1745
+ ),
1746
+ }
1747
+ yield {
1748
+ "event": "tool_call",
1749
+ "data": json.dumps(
1750
+ {
1579
1751
  "tool": "markdown",
1580
1752
  "content": tool_args.get("content", ""),
1581
- })}
1753
+ }
1754
+ ),
1755
+ }
1582
1756
  elif tool_name == "execute_command_tool":
1583
1757
  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({
1758
+ yield {
1759
+ "event": "debug",
1760
+ "data": json.dumps(
1761
+ _get_tool_status_message(tool_name, tool_args)
1762
+ ),
1763
+ }
1764
+ yield {
1765
+ "event": "tool_call",
1766
+ "data": json.dumps(
1767
+ {
1586
1768
  "tool": "execute_command_tool",
1587
1769
  "command": tool_args.get("command", ""),
1588
1770
  "timeout": tool_args.get("timeout"),
1589
- })}
1771
+ }
1772
+ ),
1773
+ }
1590
1774
  elif tool_name == "read_file_tool":
1591
1775
  # For file operations, generate code with the LLM
1592
1776
  logger.info(
@@ -1618,20 +1802,32 @@ async def stream_agent(request: AgentRequest):
1618
1802
  )
1619
1803
 
1620
1804
  if not code:
1621
- yield {"event": "token", "data": json.dumps({
1805
+ yield {
1806
+ "event": "token",
1807
+ "data": json.dumps(
1808
+ {
1622
1809
  "content": "도구 실행을 위한 코드를 생성하지 못했습니다. 다시 시도해주세요."
1623
- })}
1810
+ }
1811
+ ),
1812
+ }
1624
1813
  produced_output = True
1625
1814
  continue
1626
1815
 
1627
- yield {"event": "debug", "data": json.dumps({"status": "[변환] Jupyter Cell로 변환 중"})}
1816
+ yield {
1817
+ "event": "debug",
1818
+ "data": json.dumps(
1819
+ {"status": "[변환] Jupyter Cell로 변환 중"}
1820
+ ),
1821
+ }
1628
1822
  yield {
1629
1823
  "event": "tool_call",
1630
- "data": json.dumps({
1631
- "tool": "jupyter_cell",
1632
- "code": code,
1633
- "description": f"Converted from {tool_name}",
1634
- }),
1824
+ "data": json.dumps(
1825
+ {
1826
+ "tool": "jupyter_cell",
1827
+ "code": code,
1828
+ "description": f"Converted from {tool_name}",
1829
+ }
1830
+ ),
1635
1831
  }
1636
1832
  else:
1637
1833
  # Unknown tool - skip and show message
@@ -1640,9 +1836,11 @@ async def stream_agent(request: AgentRequest):
1640
1836
  )
1641
1837
  yield {
1642
1838
  "event": "token",
1643
- "data": json.dumps({
1644
- "content": f"알 수 없는 도구 '{tool_name}'입니다. jupyter_cell_tool을 사용해주세요."
1645
- }),
1839
+ "data": json.dumps(
1840
+ {
1841
+ "content": f"알 수 없는 도구 '{tool_name}'입니다. jupyter_cell_tool을 사용해주세요."
1842
+ }
1843
+ ),
1646
1844
  }
1647
1845
  produced_output = True
1648
1846
  elif (
@@ -1654,25 +1852,41 @@ async def stream_agent(request: AgentRequest):
1654
1852
  repaired_content = _repair_summary_json_content(
1655
1853
  fallback_response.content
1656
1854
  )
1657
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
1855
+ yield {
1856
+ "event": "token",
1857
+ "data": json.dumps({"content": repaired_content}),
1858
+ }
1658
1859
  elif fallback_response is not None and not produced_output:
1659
- yield {"event": "token", "data": json.dumps({
1860
+ yield {
1861
+ "event": "token",
1862
+ "data": json.dumps(
1863
+ {
1660
1864
  "content": "모델이 도구 호출을 생성하지 못했습니다. 다시 시도해주세요."
1661
- })}
1865
+ }
1866
+ ),
1867
+ }
1662
1868
  produced_output = True
1663
1869
 
1664
1870
  # Clear debug status before completion
1665
1871
  yield {"event": "debug_clear", "data": json.dumps({})}
1666
1872
 
1667
1873
  # No interrupt - execution completed
1668
- yield {"event": "complete", "data": json.dumps({"success": True, "thread_id": thread_id})}
1874
+ yield {
1875
+ "event": "complete",
1876
+ "data": json.dumps({"success": True, "thread_id": thread_id}),
1877
+ }
1669
1878
 
1670
1879
  except Exception as e:
1671
1880
  logger.error(f"Stream error: {e}", exc_info=True)
1672
- yield {"event": "error", "data": json.dumps({
1881
+ yield {
1882
+ "event": "error",
1883
+ "data": json.dumps(
1884
+ {
1673
1885
  "error": str(e),
1674
1886
  "error_type": type(e).__name__,
1675
- })}
1887
+ }
1888
+ ),
1889
+ }
1676
1890
 
1677
1891
  return EventSourceResponse(event_generator())
1678
1892
 
@@ -1726,11 +1940,16 @@ async def resume_agent(request: ResumeRequest):
1726
1940
  "Server may have restarted or session expired.",
1727
1941
  request.threadId,
1728
1942
  )
1729
- yield {"event": "error", "data": json.dumps({
1943
+ yield {
1944
+ "event": "error",
1945
+ "data": json.dumps(
1946
+ {
1730
1947
  "error": "Session expired or not found",
1731
1948
  "code": "CHECKPOINT_NOT_FOUND",
1732
1949
  "message": "이전 세션을 찾을 수 없습니다. 서버가 재시작되었거나 세션이 만료되었습니다. 새로운 대화를 시작해주세요.",
1733
- })}
1950
+ }
1951
+ ),
1952
+ }
1734
1953
  return
1735
1954
 
1736
1955
  checkpointer = _simple_agent_checkpointers.get(request.threadId)
@@ -1822,7 +2041,12 @@ async def resume_agent(request: ResumeRequest):
1822
2041
  )
1823
2042
  # Track code execution for history (injected into subagent context)
1824
2043
  tool_name = edited_action.get("name", "")
1825
- if tool_name in ("jupyter_cell_tool", "write_file_tool", "edit_file_tool", "multiedit_file_tool"):
2044
+ if tool_name in (
2045
+ "jupyter_cell_tool",
2046
+ "write_file_tool",
2047
+ "edit_file_tool",
2048
+ "multiedit_file_tool",
2049
+ ):
1826
2050
  track_tool_execution(tool_name, args)
1827
2051
  langgraph_decisions.append(
1828
2052
  {
@@ -1840,7 +2064,10 @@ async def resume_agent(request: ResumeRequest):
1840
2064
  )
1841
2065
 
1842
2066
  # Resume execution
1843
- yield {"event": "debug", "data": json.dumps({"status": "실행 재개 중", "icon": "play"})}
2067
+ yield {
2068
+ "event": "debug",
2069
+ "data": json.dumps({"status": "실행 재개 중", "icon": "play"}),
2070
+ }
1844
2071
 
1845
2072
  _simple_agent_pending_actions.pop(request.threadId, None)
1846
2073
 
@@ -1866,7 +2093,10 @@ async def resume_agent(request: ResumeRequest):
1866
2093
  )
1867
2094
 
1868
2095
  # Status: waiting for LLM response
1869
- yield {"event": "debug", "data": json.dumps({"status": "LLM 응답 대기 중", "icon": "thinking"})}
2096
+ yield {
2097
+ "event": "debug",
2098
+ "data": json.dumps({"status": "LLM 응답 대기 중", "icon": "thinking"}),
2099
+ }
1870
2100
 
1871
2101
  step_count = 0
1872
2102
 
@@ -1878,9 +2108,16 @@ async def resume_agent(request: ResumeRequest):
1878
2108
  ):
1879
2109
  # Check if thread was cancelled by user
1880
2110
  if is_thread_cancelled(request.threadId):
1881
- logger.info(f"Thread {request.threadId} cancelled by user, stopping resume stream")
2111
+ logger.info(
2112
+ f"Thread {request.threadId} cancelled by user, stopping resume stream"
2113
+ )
1882
2114
  clear_cancelled_thread(request.threadId)
1883
- yield {"event": "cancelled", "data": json.dumps({"message": "작업이 사용자에 의해 중단되었습니다."})}
2115
+ yield {
2116
+ "event": "cancelled",
2117
+ "data": json.dumps(
2118
+ {"message": "작업이 사용자에 의해 중단되었습니다."}
2119
+ ),
2120
+ }
1884
2121
  return
1885
2122
 
1886
2123
  step_count += 1
@@ -1982,7 +2219,10 @@ async def resume_agent(request: ResumeRequest):
1982
2219
  todos = _extract_todos(last_message.content)
1983
2220
  if todos:
1984
2221
  latest_todos = todos
1985
- yield {"event": "todos", "data": json.dumps({"todos": todos})}
2222
+ yield {
2223
+ "event": "todos",
2224
+ "data": json.dumps({"todos": todos}),
2225
+ }
1986
2226
  # Check if all todos are completed - auto terminate only if summary exists
1987
2227
  all_completed = all(
1988
2228
  t.get("status") == "completed" for t in todos
@@ -1991,16 +2231,31 @@ async def resume_agent(request: ResumeRequest):
1991
2231
  # Check if summary JSON exists in the CURRENT step's AIMessage
1992
2232
  # (not in history, to avoid false positives from previous tasks)
1993
2233
  summary_exists = False
1994
- step_messages = step.get("messages", []) if isinstance(step, dict) else []
2234
+ step_messages = (
2235
+ step.get("messages", [])
2236
+ if isinstance(step, dict)
2237
+ else []
2238
+ )
1995
2239
  # 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
2240
+ for recent_msg in step_messages[
2241
+ -3:
2242
+ ]: # Check only the most recent few messages
1997
2243
  if isinstance(recent_msg, AIMessage):
1998
- recent_content = getattr(recent_msg, "content", "") or ""
2244
+ recent_content = (
2245
+ getattr(recent_msg, "content", "") or ""
2246
+ )
1999
2247
  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:
2248
+ recent_content = " ".join(
2249
+ str(p) for p in recent_content
2250
+ )
2251
+ if (
2252
+ '"summary"' in recent_content
2253
+ and '"next_items"' in recent_content
2254
+ ):
2002
2255
  summary_exists = True
2003
- logger.info("Resume: Found summary in current AIMessage content")
2256
+ logger.info(
2257
+ "Resume: Found summary in current AIMessage content"
2258
+ )
2004
2259
  break
2005
2260
 
2006
2261
  if summary_exists:
@@ -2012,23 +2267,53 @@ async def resume_agent(request: ResumeRequest):
2012
2267
  # Find and emit the AIMessage content with summary
2013
2268
  for recent_msg in step_messages[-3:]:
2014
2269
  if isinstance(recent_msg, AIMessage):
2015
- step_content = getattr(recent_msg, "content", "") or ""
2270
+ step_content = (
2271
+ getattr(recent_msg, "content", "")
2272
+ or ""
2273
+ )
2016
2274
  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:
2275
+ step_content = " ".join(
2276
+ str(p) for p in step_content
2277
+ )
2278
+ if (
2279
+ '"summary"' in step_content
2280
+ and '"next_items"' in step_content
2281
+ ):
2019
2282
  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)
2283
+ if (
2284
+ content_hash
2285
+ not in emitted_contents
2286
+ ):
2287
+ emitted_contents.add(
2288
+ content_hash
2289
+ )
2290
+ repaired_content = _repair_summary_json_content(
2291
+ step_content
2292
+ )
2023
2293
  logger.info(
2024
2294
  "Resume step auto-terminate: EMITTING summary content (len=%d): %s",
2025
2295
  len(repaired_content),
2026
2296
  repaired_content[:100],
2027
2297
  )
2028
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
2298
+ yield {
2299
+ "event": "token",
2300
+ "data": json.dumps(
2301
+ {
2302
+ "content": repaired_content
2303
+ }
2304
+ ),
2305
+ }
2029
2306
  break
2030
- yield {"event": "debug_clear", "data": json.dumps({})}
2031
- yield {"event": "done", "data": json.dumps({"reason": "all_todos_completed"})}
2307
+ yield {
2308
+ "event": "debug_clear",
2309
+ "data": json.dumps({}),
2310
+ }
2311
+ yield {
2312
+ "event": "done",
2313
+ "data": json.dumps(
2314
+ {"reason": "all_todos_completed"}
2315
+ ),
2316
+ }
2032
2317
  return # Exit the generator
2033
2318
  else:
2034
2319
  logger.warning(
@@ -2120,7 +2405,12 @@ async def resume_agent(request: ResumeRequest):
2120
2405
  len(repaired_content),
2121
2406
  log_preview,
2122
2407
  )
2123
- yield {"event": "token", "data": json.dumps({"content": repaired_content})}
2408
+ yield {
2409
+ "event": "token",
2410
+ "data": json.dumps(
2411
+ {"content": repaired_content}
2412
+ ),
2413
+ }
2124
2414
 
2125
2415
  if (
2126
2416
  hasattr(last_message, "tool_calls")
@@ -2147,7 +2437,10 @@ async def resume_agent(request: ResumeRequest):
2147
2437
  todos = _emit_todos_from_tool_calls(new_tool_calls)
2148
2438
  if todos:
2149
2439
  latest_todos = todos
2150
- yield {"event": "todos", "data": json.dumps({"todos": todos})}
2440
+ yield {
2441
+ "event": "todos",
2442
+ "data": json.dumps({"todos": todos}),
2443
+ }
2151
2444
  # Check if all todos are completed - terminate early
2152
2445
  all_completed = all(
2153
2446
  t.get("status") == "completed" for t in todos
@@ -2189,7 +2482,9 @@ async def resume_agent(request: ResumeRequest):
2189
2482
  "## 다음 단계",
2190
2483
  ]
2191
2484
  )
2192
- has_summary = has_summary_json or has_markdown_summary
2485
+ has_summary = (
2486
+ has_summary_json or has_markdown_summary
2487
+ )
2193
2488
 
2194
2489
  # Only check current AIMessage for summary (not history, to avoid false positives)
2195
2490
  if not has_summary:
@@ -2204,19 +2499,40 @@ async def resume_agent(request: ResumeRequest):
2204
2499
  )
2205
2500
  # IMPORTANT: Emit the summary content BEFORE terminating
2206
2501
  # so the UI can display the summary JSON
2207
- if msg_content and isinstance(msg_content, str):
2502
+ if msg_content and isinstance(
2503
+ msg_content, str
2504
+ ):
2208
2505
  content_hash = hash(msg_content)
2209
2506
  if content_hash not in emitted_contents:
2210
2507
  emitted_contents.add(content_hash)
2211
- repaired_content = _repair_summary_json_content(msg_content)
2508
+ repaired_content = (
2509
+ _repair_summary_json_content(
2510
+ msg_content
2511
+ )
2512
+ )
2212
2513
  logger.info(
2213
2514
  "Resume auto-terminate: EMITTING summary content (len=%d): %s",
2214
2515
  len(repaired_content),
2215
2516
  repaired_content[:100],
2216
2517
  )
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"})}
2518
+ yield {
2519
+ "event": "token",
2520
+ "data": json.dumps(
2521
+ {
2522
+ "content": repaired_content
2523
+ }
2524
+ ),
2525
+ }
2526
+ yield {
2527
+ "event": "debug_clear",
2528
+ "data": json.dumps({}),
2529
+ }
2530
+ yield {
2531
+ "event": "done",
2532
+ "data": json.dumps(
2533
+ {"reason": "all_todos_completed"}
2534
+ ),
2535
+ }
2220
2536
  return # Exit before executing more tool calls
2221
2537
 
2222
2538
  # Process tool calls
@@ -2236,40 +2552,61 @@ async def resume_agent(request: ResumeRequest):
2236
2552
  tool_name, tool_args
2237
2553
  )
2238
2554
 
2239
- yield {"event": "debug", "data": json.dumps(status_msg)}
2555
+ yield {
2556
+ "event": "debug",
2557
+ "data": json.dumps(status_msg),
2558
+ }
2240
2559
 
2241
2560
  if tool_name in (
2242
2561
  "jupyter_cell_tool",
2243
2562
  "jupyter_cell",
2244
2563
  ):
2245
- yield {"event": "tool_call", "data": json.dumps({
2564
+ yield {
2565
+ "event": "tool_call",
2566
+ "data": json.dumps(
2567
+ {
2246
2568
  "tool": "jupyter_cell",
2247
2569
  "code": tool_args.get("code", ""),
2248
2570
  "description": tool_args.get(
2249
2571
  "description", ""
2250
2572
  ),
2251
- })}
2573
+ }
2574
+ ),
2575
+ }
2252
2576
  elif tool_name in ("markdown_tool", "markdown"):
2253
- yield {"event": "tool_call", "data": json.dumps({
2577
+ yield {
2578
+ "event": "tool_call",
2579
+ "data": json.dumps(
2580
+ {
2254
2581
  "tool": "markdown",
2255
2582
  "content": tool_args.get(
2256
2583
  "content", ""
2257
2584
  ),
2258
- })}
2585
+ }
2586
+ ),
2587
+ }
2259
2588
  elif tool_name == "execute_command_tool":
2260
- yield {"event": "tool_call", "data": json.dumps({
2589
+ yield {
2590
+ "event": "tool_call",
2591
+ "data": json.dumps(
2592
+ {
2261
2593
  "tool": "execute_command_tool",
2262
2594
  "command": tool_args.get(
2263
2595
  "command", ""
2264
2596
  ),
2265
2597
  "timeout": tool_args.get("timeout"),
2266
- })}
2598
+ }
2599
+ ),
2600
+ }
2267
2601
  elif tool_name in (
2268
2602
  "search_notebook_cells_tool",
2269
2603
  "search_notebook_cells",
2270
2604
  ):
2271
2605
  # Search notebook cells - emit tool_call for client-side execution
2272
- yield {"event": "tool_call", "data": json.dumps({
2606
+ yield {
2607
+ "event": "tool_call",
2608
+ "data": json.dumps(
2609
+ {
2273
2610
  "tool": "search_notebook_cells",
2274
2611
  "pattern": tool_args.get(
2275
2612
  "pattern", ""
@@ -2286,7 +2623,9 @@ async def resume_agent(request: ResumeRequest):
2286
2623
  "case_sensitive": tool_args.get(
2287
2624
  "case_sensitive", False
2288
2625
  ),
2289
- })}
2626
+ }
2627
+ ),
2628
+ }
2290
2629
 
2291
2630
  # Drain and emit any subagent events (tool calls from subagents)
2292
2631
  for subagent_event in get_subagent_debug_events():
@@ -2297,7 +2636,12 @@ async def resume_agent(request: ResumeRequest):
2297
2636
  if isinstance(step, dict) and "__interrupt__" in step:
2298
2637
  interrupts = step["__interrupt__"]
2299
2638
 
2300
- yield {"event": "debug", "data": json.dumps({"status": "사용자 승인 대기 중", "icon": "pause"})}
2639
+ yield {
2640
+ "event": "debug",
2641
+ "data": json.dumps(
2642
+ {"status": "사용자 승인 대기 중", "icon": "pause"}
2643
+ ),
2644
+ }
2301
2645
 
2302
2646
  for interrupt in interrupts:
2303
2647
  interrupt_value = (
@@ -2306,7 +2650,9 @@ async def resume_agent(request: ResumeRequest):
2306
2650
  else interrupt
2307
2651
  )
2308
2652
  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'}")
2653
+ logger.info(
2654
+ f"[RESUME INTERRUPT] action_requests count: {len(action_requests)}, first: {str(action_requests[0])[:200] if action_requests else 'none'}"
2655
+ )
2310
2656
  normalized_actions = [
2311
2657
  _normalize_action_request(a) for a in action_requests
2312
2658
  ]
@@ -2319,14 +2665,16 @@ async def resume_agent(request: ResumeRequest):
2319
2665
  for idx, action in enumerate(normalized_actions):
2320
2666
  yield {
2321
2667
  "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
- }),
2668
+ "data": json.dumps(
2669
+ {
2670
+ "thread_id": request.threadId,
2671
+ "action": action.get("name", "unknown"),
2672
+ "args": action.get("arguments", {}),
2673
+ "description": action.get("description", ""),
2674
+ "action_index": idx,
2675
+ "total_actions": total_actions,
2676
+ }
2677
+ ),
2330
2678
  }
2331
2679
 
2332
2680
  # Save last signature for next resume to avoid duplicate content
@@ -2359,7 +2707,10 @@ async def resume_agent(request: ResumeRequest):
2359
2707
  last_signature,
2360
2708
  latest_todos,
2361
2709
  )
2362
- yield {"event": "complete", "data": json.dumps({"success": True, "thread_id": request.threadId})}
2710
+ yield {
2711
+ "event": "complete",
2712
+ "data": json.dumps({"success": True, "thread_id": request.threadId}),
2713
+ }
2363
2714
 
2364
2715
  except Exception as e:
2365
2716
  error_msg = str(e)
@@ -2370,17 +2721,27 @@ async def resume_agent(request: ResumeRequest):
2370
2721
  logger.warning(
2371
2722
  "Detected 'contents is not specified' error - likely session state loss"
2372
2723
  )
2373
- yield {"event": "error", "data": json.dumps({
2724
+ yield {
2725
+ "event": "error",
2726
+ "data": json.dumps(
2727
+ {
2374
2728
  "error": "Session state lost",
2375
2729
  "code": "CONTENTS_NOT_SPECIFIED",
2376
2730
  "error_type": type(e).__name__,
2377
2731
  "message": "세션 상태가 손실되었습니다. 서버가 재시작되었거나 세션이 만료되었습니다. 새로운 대화를 시작해주세요.",
2378
- })}
2732
+ }
2733
+ ),
2734
+ }
2379
2735
  else:
2380
- yield {"event": "error", "data": json.dumps({
2736
+ yield {
2737
+ "event": "error",
2738
+ "data": json.dumps(
2739
+ {
2381
2740
  "error": error_msg,
2382
2741
  "error_type": type(e).__name__,
2383
- })}
2742
+ }
2743
+ ),
2744
+ }
2384
2745
 
2385
2746
  return EventSourceResponse(event_generator())
2386
2747
 
@@ -2437,6 +2798,7 @@ async def health_check() -> Dict[str, Any]:
2437
2798
 
2438
2799
  class CancelRequest(BaseModel):
2439
2800
  """Request to cancel a running agent thread"""
2801
+
2440
2802
  thread_id: str
2441
2803
 
2442
2804
 
@@ -2463,3 +2825,68 @@ async def clear_agent_cache() -> Dict[str, Any]:
2463
2825
  "cleared": count,
2464
2826
  "message": f"Cleared {count} cached agent instances",
2465
2827
  }
2828
+
2829
+
2830
+ class ResetRequest(BaseModel):
2831
+ """Request to reset a thread (clear session and recreate agent)"""
2832
+
2833
+ thread_id: str
2834
+
2835
+
2836
+ @router.post("/reset")
2837
+ async def reset_agent_thread(request: ResetRequest) -> Dict[str, Any]:
2838
+ """
2839
+ Reset an agent thread by clearing all session state.
2840
+
2841
+ This will:
2842
+ - Clear the checkpointer (conversation history)
2843
+ - Clear pending actions
2844
+ - Clear emitted contents
2845
+ - Clear last signatures
2846
+ - Remove from cancelled threads set
2847
+
2848
+ The agent instance itself is not cleared (it's shared across threads),
2849
+ but the thread state is completely reset.
2850
+ """
2851
+ thread_id = request.thread_id
2852
+
2853
+ # Track what was cleared
2854
+ cleared = []
2855
+
2856
+ # Clear checkpointer (conversation history)
2857
+ if thread_id in _simple_agent_checkpointers:
2858
+ del _simple_agent_checkpointers[thread_id]
2859
+ cleared.append("checkpointer")
2860
+
2861
+ # Clear pending actions
2862
+ if thread_id in _simple_agent_pending_actions:
2863
+ del _simple_agent_pending_actions[thread_id]
2864
+ cleared.append("pending_actions")
2865
+
2866
+ # Clear last signatures
2867
+ if thread_id in _simple_agent_last_signatures:
2868
+ del _simple_agent_last_signatures[thread_id]
2869
+ cleared.append("last_signatures")
2870
+
2871
+ # Clear emitted contents
2872
+ if thread_id in _simple_agent_emitted_contents:
2873
+ del _simple_agent_emitted_contents[thread_id]
2874
+ cleared.append("emitted_contents")
2875
+
2876
+ # Remove from cancelled threads
2877
+ if thread_id in _cancelled_threads:
2878
+ _cancelled_threads.discard(thread_id)
2879
+ cleared.append("cancelled_flag")
2880
+
2881
+ logger.info(
2882
+ "Reset thread %s: cleared %s",
2883
+ thread_id,
2884
+ ", ".join(cleared) if cleared else "nothing (thread not found)",
2885
+ )
2886
+
2887
+ return {
2888
+ "status": "ok",
2889
+ "thread_id": thread_id,
2890
+ "cleared": cleared,
2891
+ "message": f"Thread {thread_id} has been reset" if cleared else f"Thread {thread_id} had no state to clear",
2892
+ }