hdsp-jupyter-extension 2.0.27__py3-none-any.whl → 2.0.29__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 (76) hide show
  1. agent_server/config/__init__.py +5 -0
  2. agent_server/config/server_config.py +213 -0
  3. agent_server/context_providers/__init__.py +4 -2
  4. agent_server/context_providers/actions.py +73 -7
  5. agent_server/context_providers/file.py +23 -23
  6. agent_server/core/__init__.py +2 -2
  7. agent_server/core/llm_service.py +2 -3
  8. agent_server/langchain/__init__.py +2 -2
  9. agent_server/langchain/agent.py +18 -251
  10. agent_server/langchain/agent_factory.py +26 -4
  11. agent_server/langchain/agent_prompts/planner_prompt.py +22 -31
  12. agent_server/langchain/custom_middleware.py +268 -43
  13. agent_server/langchain/llm_factory.py +102 -54
  14. agent_server/langchain/logging_utils.py +1 -1
  15. agent_server/langchain/middleware/__init__.py +5 -0
  16. agent_server/langchain/middleware/content_injection_middleware.py +110 -0
  17. agent_server/langchain/middleware/subagent_events.py +88 -9
  18. agent_server/langchain/middleware/subagent_middleware.py +501 -245
  19. agent_server/langchain/prompts.py +5 -22
  20. agent_server/langchain/state_schema.py +44 -0
  21. agent_server/langchain/tools/jupyter_tools.py +4 -5
  22. agent_server/langchain/tools/tool_registry.py +6 -0
  23. agent_server/main.py +4 -4
  24. agent_server/routers/agent.py +2 -2
  25. agent_server/routers/chat.py +334 -28
  26. agent_server/routers/config.py +197 -11
  27. agent_server/routers/config_schema.py +254 -0
  28. agent_server/routers/context.py +31 -8
  29. agent_server/routers/langchain_agent.py +348 -209
  30. hdsp_agent_core/managers/config_manager.py +60 -11
  31. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/share/jupyter/labextensions/hdsp-agent/build_log.json +1 -1
  32. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/share/jupyter/labextensions/hdsp-agent/package.json +2 -2
  33. hdsp_jupyter_extension-2.0.27.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js → hdsp_jupyter_extension-2.0.29.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.f2eca2f8fa682eb21f72.js +488 -25
  34. hdsp_jupyter_extension-2.0.29.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.f2eca2f8fa682eb21f72.js.map +1 -0
  35. jupyter_ext/labextension/static/lib_index_js.67505497667f9c0a763d.js → hdsp_jupyter_extension-2.0.29.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.cc0a7158a5e3de7f22f7.js +1327 -1054
  36. hdsp_jupyter_extension-2.0.29.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.cc0a7158a5e3de7f22f7.js.map +1 -0
  37. hdsp_jupyter_extension-2.0.27.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.4ab73bb5068405670214.js → hdsp_jupyter_extension-2.0.29.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.bfff374b5cc6a57e16d2.js +3 -3
  38. jupyter_ext/labextension/static/remoteEntry.4ab73bb5068405670214.js.map → hdsp_jupyter_extension-2.0.29.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.bfff374b5cc6a57e16d2.js.map +1 -1
  39. {hdsp_jupyter_extension-2.0.27.dist-info → hdsp_jupyter_extension-2.0.29.dist-info}/METADATA +1 -1
  40. {hdsp_jupyter_extension-2.0.27.dist-info → hdsp_jupyter_extension-2.0.29.dist-info}/RECORD +71 -67
  41. jupyter_ext/_version.py +1 -1
  42. jupyter_ext/handlers.py +41 -0
  43. jupyter_ext/labextension/build_log.json +1 -1
  44. jupyter_ext/labextension/package.json +2 -2
  45. jupyter_ext/labextension/static/{frontend_styles_index_js.b5e4416b4e07ec087aad.js → frontend_styles_index_js.f2eca2f8fa682eb21f72.js} +488 -25
  46. jupyter_ext/labextension/static/frontend_styles_index_js.f2eca2f8fa682eb21f72.js.map +1 -0
  47. hdsp_jupyter_extension-2.0.27.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.67505497667f9c0a763d.js → jupyter_ext/labextension/static/lib_index_js.cc0a7158a5e3de7f22f7.js +1327 -1054
  48. jupyter_ext/labextension/static/lib_index_js.cc0a7158a5e3de7f22f7.js.map +1 -0
  49. jupyter_ext/labextension/static/{remoteEntry.4ab73bb5068405670214.js → remoteEntry.bfff374b5cc6a57e16d2.js} +3 -3
  50. hdsp_jupyter_extension-2.0.27.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.4ab73bb5068405670214.js.map → jupyter_ext/labextension/static/remoteEntry.bfff374b5cc6a57e16d2.js.map +1 -1
  51. agent_server/langchain/middleware/description_injector.py +0 -150
  52. hdsp_jupyter_extension-2.0.27.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js.map +0 -1
  53. hdsp_jupyter_extension-2.0.27.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.67505497667f9c0a763d.js.map +0 -1
  54. jupyter_ext/labextension/static/frontend_styles_index_js.b5e4416b4e07ec087aad.js.map +0 -1
  55. jupyter_ext/labextension/static/lib_index_js.67505497667f9c0a763d.js.map +0 -1
  56. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +0 -0
  57. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/share/jupyter/labextensions/hdsp-agent/install.json +0 -0
  58. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.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
  59. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.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
  60. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.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
  61. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.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
  62. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/share/jupyter/labextensions/hdsp-agent/static/style.js +0 -0
  63. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.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
  64. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.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
  65. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js.24edcc52a1c014a8a5f0.js +0 -0
  66. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.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
  67. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.19ecf6babe00caff6b8a.js +0 -0
  68. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.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
  69. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +0 -0
  70. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.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
  71. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +0 -0
  72. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +0 -0
  73. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js +0 -0
  74. {hdsp_jupyter_extension-2.0.27.data → hdsp_jupyter_extension-2.0.29.data}/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.1f5038488cdfd8b3a85d.js.map +0 -0
  75. {hdsp_jupyter_extension-2.0.27.dist-info → hdsp_jupyter_extension-2.0.29.dist-info}/WHEEL +0 -0
  76. {hdsp_jupyter_extension-2.0.27.dist-info → hdsp_jupyter_extension-2.0.29.dist-info}/licenses/LICENSE +0 -0
@@ -12,7 +12,9 @@ import uuid
12
12
  from typing import Any, Dict, Optional
13
13
 
14
14
  from json_repair import repair_json
15
- from langchain_core.messages import AIMessage, HumanMessage
15
+ from langchain.agents.middleware import AgentMiddleware
16
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
17
+ from langgraph.types import Command
16
18
 
17
19
  from agent_server.langchain.logging_utils import (
18
20
  _format_middleware_marker,
@@ -25,6 +27,92 @@ from agent_server.langchain.prompts import JSON_TOOL_SCHEMA, NON_HITL_TOOLS
25
27
  logger = logging.getLogger(__name__)
26
28
 
27
29
 
30
+ # ---------------------------------------------------------------------------
31
+ # TodoActiveMiddleware — manages todo_active state field
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ class TodoActiveMiddleware(AgentMiddleware):
36
+ """Middleware that manages the `todo_active` state field.
37
+
38
+ Intercepts write_todos and final_summary_tool calls to set/clear
39
+ the todo_active flag in LangGraph state via Command.
40
+
41
+ - write_todos called → todo_active = True
42
+ - final_summary_tool called → todo_active = False
43
+
44
+ This flag is checked by handle_empty_response and continuation_control
45
+ middlewares to decide whether to force continuation or let the LLM
46
+ terminate naturally (for simple 1-2 step tasks).
47
+ """
48
+
49
+ def wrap_tool_call(self, request, handler):
50
+ """Intercept tool calls to manage todo_active state."""
51
+ result = handler(request)
52
+ tool_name = request.tool_call.get("name", "")
53
+
54
+ if tool_name == "write_todos":
55
+ return self._wrap_with_todo_active(request, result, active=True)
56
+ elif tool_name in ("final_summary_tool", "final_summary"):
57
+ return self._wrap_with_todo_active(request, result, active=False)
58
+
59
+ return result
60
+
61
+ def _wrap_with_todo_active(self, request, result, active: bool):
62
+ """Wrap tool result in a Command that updates todo_active state.
63
+
64
+ Handles two cases:
65
+ 1. Result is already a Command (e.g., from TodoListMiddleware) → merge
66
+ 2. Result is a ToolMessage → wrap in new Command
67
+ """
68
+ try:
69
+ if isinstance(result, Command):
70
+ # Merge todo_active into existing Command's update dict
71
+ existing_update = (
72
+ result.update if hasattr(result, "update") and result.update else {}
73
+ )
74
+ merged_update = {**existing_update, "todo_active": active}
75
+ logger.info(
76
+ "[TodoActive] Merged todo_active=%s into Command for tool '%s'",
77
+ active,
78
+ request.tool_call.get("name", ""),
79
+ )
80
+ return Command(update=merged_update)
81
+ elif isinstance(result, ToolMessage):
82
+ # Wrap ToolMessage in a new Command
83
+ logger.info(
84
+ "[TodoActive] Wrapped ToolMessage in Command with todo_active=%s for tool '%s'",
85
+ active,
86
+ request.tool_call.get("name", ""),
87
+ )
88
+ return Command(
89
+ update={
90
+ "todo_active": active,
91
+ "messages": [result],
92
+ }
93
+ )
94
+ else:
95
+ # Unknown result type — wrap as ToolMessage
96
+ tool_call_id = request.tool_call.get("id", "")
97
+ content = str(result) if result else ""
98
+ logger.info(
99
+ "[TodoActive] Wrapped unknown result type (%s) in Command with todo_active=%s",
100
+ type(result).__name__,
101
+ active,
102
+ )
103
+ return Command(
104
+ update={
105
+ "todo_active": active,
106
+ "messages": [
107
+ ToolMessage(content=content, tool_call_id=tool_call_id)
108
+ ],
109
+ }
110
+ )
111
+ except Exception as e:
112
+ logger.warning("[TodoActive] Failed to set todo_active=%s: %s", active, e)
113
+ return result
114
+
115
+
28
116
  def parse_json_tool_call(text) -> Optional[Dict[str, Any]]:
29
117
  """Parse JSON tool call from text response.
30
118
 
@@ -262,6 +350,31 @@ def create_handle_empty_response_middleware(wrap_model_call):
262
350
  def handle_empty_response(request, handler):
263
351
  max_retries = 2
264
352
 
353
+ # Guard: If final_summary_tool was already called, stop the agent immediately.
354
+ # This is independent of todo status (LLM may call final_summary before
355
+ # marking all todos as completed).
356
+ todo_active = request.state.get("todo_active", False)
357
+ if not todo_active:
358
+ messages = request.messages
359
+ # Find last REAL HumanMessage index
360
+ _last_human = -1
361
+ for _i, _msg in enumerate(messages):
362
+ _mtype = getattr(_msg, "type", "") or type(_msg).__name__
363
+ if _mtype in ("human", "HumanMessage"):
364
+ _mcontent = getattr(_msg, "content", "") or ""
365
+ if not _mcontent.startswith("[SYSTEM]"):
366
+ _last_human = _i
367
+ _msgs_after = (
368
+ messages[_last_human + 1 :] if _last_human >= 0 else messages[-10:]
369
+ )
370
+ for _msg in _msgs_after:
371
+ _name = getattr(_msg, "name", "") or ""
372
+ if _name in ("final_summary_tool", "final_summary"):
373
+ logger.info(
374
+ "final_summary_tool already executed and todo_active=False - stopping agent (no LLM call)"
375
+ )
376
+ return AIMessage(content="", tool_calls=[])
377
+
265
378
  # Check if all todos are completed - if so, return empty response to stop agent
266
379
  # Method 1: Check state.todos
267
380
  todos = request.state.get("todos", [])
@@ -297,8 +410,15 @@ def create_handle_empty_response_middleware(wrap_model_call):
297
410
  else messages[-10:]
298
411
  )
299
412
  for msg in messages_to_check:
413
+ # Check ToolMessage name for final_summary_tool
414
+ msg_name = getattr(msg, "name", "") or ""
415
+ if msg_name in ("final_summary_tool", "final_summary"):
416
+ summary_exists = True
417
+ break
300
418
  content = getattr(msg, "content", "") or ""
301
- if '"summary"' in content and '"next_items"' in content:
419
+ if ('"summary"' in content and '"next_items"' in content) or (
420
+ "'summary'" in content and "'next_items'" in content
421
+ ):
302
422
  summary_exists = True
303
423
  break
304
424
 
@@ -343,8 +463,15 @@ def create_handle_empty_response_middleware(wrap_model_call):
343
463
  messages = request.messages
344
464
  summary_exists = False
345
465
  for msg in messages[-15:]:
466
+ # Check ToolMessage name for final_summary_tool
467
+ msg_name = getattr(msg, "name", "") or ""
468
+ if msg_name in ("final_summary_tool", "final_summary"):
469
+ summary_exists = True
470
+ break
346
471
  msg_content = getattr(msg, "content", "") or ""
347
- if '"summary"' in msg_content and '"next_items"' in msg_content:
472
+ if ('"summary"' in msg_content and '"next_items"' in msg_content) or (
473
+ "'summary'" in msg_content and "'next_items'" in msg_content
474
+ ):
348
475
  summary_exists = True
349
476
  break
350
477
  if any(
@@ -583,6 +710,14 @@ def create_handle_empty_response_middleware(wrap_model_call):
583
710
 
584
711
  # Invalid response - retry with JSON schema prompt
585
712
  if response_message and attempt < max_retries:
713
+ # todo_active=False → LLM can terminate naturally (simple tasks)
714
+ todo_active = request.state.get("todo_active", False)
715
+ if not todo_active:
716
+ logger.info(
717
+ "todo_active=False - skipping retry, allowing LLM natural termination"
718
+ )
719
+ return response
720
+
586
721
  reason = "text-only" if has_content else "empty"
587
722
 
588
723
  json_prompt = _build_json_prompt(request, response_message, has_content)
@@ -776,23 +911,38 @@ def _build_json_prompt(request, response_message, has_content):
776
911
  f"Example: {example_json}"
777
912
  )
778
913
  elif not todos:
779
- # No todos yet = new task starting, LLM must create todos or call a tool
780
- # This happens when LLM returns empty response at the start of a new task
781
- logger.info("No todos exist yet - forcing retry to create todos or call tool")
782
- return (
783
- f"{JSON_TOOL_SCHEMA}\n\n"
784
- f"Your response was empty. You MUST call a tool to proceed.\n"
785
- f"한국어로 응답하고, write_todos로 작업 목록을 만들거나 jupyter_cell_tool/read_file_tool을 호출하세요.\n"
786
- f'Example: {{"tool": "write_todos", "arguments": {{"todos": [{{"content": "데이터 분석", "status": "in_progress"}}]}}}}'
914
+ # No todos simple task (1-2 steps), don't force write_todos creation
915
+ # This was the DIRECT CAUSE of the simple-task infinite loop:
916
+ # LLM completes simple task empty response → forced to create todos loop
917
+ logger.info(
918
+ "No todos exist - simple task, skipping retry (no write_todos forcing)"
787
919
  )
920
+ return None # Signal to skip retry — LLM terminates naturally
788
921
  else:
789
- # Todos exist but all completed - ask for summary
790
- logger.info("All todos completed but response empty - asking for summary")
922
+ # Todos exist but all completed
923
+ # Check if final_summary_tool was already called in message history
924
+ messages = getattr(request, "messages", [])
925
+ final_summary_already_called = any(
926
+ getattr(msg, "name", "") in ("final_summary_tool", "final_summary")
927
+ for msg in messages
928
+ )
929
+ if final_summary_already_called:
930
+ logger.info(
931
+ "All todos completed and final_summary_tool already called - "
932
+ "signaling skip (no more retries needed)"
933
+ )
934
+ return None # Signal to skip retry and synthesize completion
935
+
936
+ logger.info(
937
+ "All todos completed but response empty - asking for final_summary_tool"
938
+ )
791
939
  return (
792
940
  f"{JSON_TOOL_SCHEMA}\n\n"
793
- f"All tasks completed. Call markdown_tool to provide a summary in Korean.\n"
794
- f"한국어로 작업 요약을 작성하세요.\n"
795
- f'Example: {{"tool": "markdown_tool", "arguments": {{"content": "작업이 완료되었습니다."}}}}'
941
+ f"All tasks completed. Call final_summary_tool to provide a summary.\n"
942
+ f"final_summary_tool(summary='완료된 작업 요약', "
943
+ f"next_items=[{{'subject': '제목', 'description': '설명'}}, ...]) "
944
+ f"(next_items 3개 이상 필수).\n"
945
+ f"텍스트로 JSON을 출력하지 말고, 반드시 도구 호출로 실행하세요."
796
946
  )
797
947
 
798
948
 
@@ -1020,8 +1170,31 @@ def create_normalize_tool_args_middleware(wrap_model_call, tools=None):
1020
1170
  tool_call["args"], dict
1021
1171
  ):
1022
1172
  args = tool_call["args"]
1023
- # Normalize list arguments to strings for str-typed params
1173
+ # Normalize non-string arguments for str-typed params
1024
1174
  for key, value in args.items():
1175
+ # Convert dict to string/None for str-typed params
1176
+ # LLM sometimes sends {} instead of null for Optional[str]
1177
+ if key in string_params and isinstance(value, dict):
1178
+ if not value: # Empty dict {}
1179
+ logger.info(
1180
+ "Converted empty dict to None for '%s' in tool '%s'",
1181
+ key,
1182
+ tool_name,
1183
+ )
1184
+ args[key] = None
1185
+ else:
1186
+ # Non-empty dict → JSON string
1187
+ json_str = json.dumps(
1188
+ value, ensure_ascii=False
1189
+ )
1190
+ logger.info(
1191
+ "Converted dict to JSON string for '%s' in tool '%s': %s",
1192
+ key,
1193
+ tool_name,
1194
+ json_str[:100],
1195
+ )
1196
+ args[key] = json_str
1197
+
1025
1198
  if key in string_params and isinstance(value, list):
1026
1199
  # Join list items into a single string
1027
1200
  text_parts = []
@@ -1150,10 +1323,18 @@ def create_continuation_control_middleware(wrap_model_call):
1150
1323
  else messages[-15:]
1151
1324
  )
1152
1325
  for msg in messages_to_check:
1326
+ # Check if this is a ToolMessage from final_summary_tool
1327
+ msg_name = getattr(msg, "name", "") or ""
1328
+ if msg_name in ("final_summary_tool", "final_summary"):
1329
+ return True
1330
+
1153
1331
  msg_content = getattr(msg, "content", "") or ""
1154
- # Check for summary JSON
1332
+ # Check for summary JSON (double quotes)
1155
1333
  if '"summary"' in msg_content and '"next_items"' in msg_content:
1156
1334
  return True
1335
+ # Check for summary Python str (single quotes from tool output)
1336
+ if "'summary'" in msg_content and "'next_items'" in msg_content:
1337
+ return True
1157
1338
  # Check for markdown summary (common patterns)
1158
1339
  if any(
1159
1340
  kw in msg_content
@@ -1203,6 +1384,24 @@ def create_continuation_control_middleware(wrap_model_call):
1203
1384
  pass
1204
1385
 
1205
1386
  if tool_name in NON_HITL_TOOLS:
1387
+ # GUARD: Skip forcing when final_summary_tool already ran
1388
+ if tool_name in ("final_summary_tool", "final_summary"):
1389
+ logger.info(
1390
+ "final_summary_tool already executed - "
1391
+ "skipping continuation (preventing infinite loop)"
1392
+ )
1393
+ return handler(request)
1394
+
1395
+ # GUARD: todo_active=False → simple task, skip continuation
1396
+ todo_active = request.state.get("todo_active", False)
1397
+ if not todo_active:
1398
+ logger.info(
1399
+ "todo_active=False after tool '%s' - "
1400
+ "simple task, skipping continuation",
1401
+ tool_name,
1402
+ )
1403
+ return handler(request)
1404
+
1206
1405
  todos = request.state.get("todos", [])
1207
1406
 
1208
1407
  last_real_human_idx = _find_last_real_human_idx(messages)
@@ -1237,36 +1436,60 @@ def create_continuation_control_middleware(wrap_model_call):
1237
1436
  tool_name,
1238
1437
  )
1239
1438
 
1240
- # Skip continuation injection for write_todos
1241
- # This prevents auto-continuation to next task after completing one
1242
- # Agent will decide next action based on its own reasoning
1243
- if tool_name == "write_todos":
1439
+ # === State-based branching: todos 유무로 분기 ===
1440
+ #
1441
+ # (1) todos 없음 간단한 1~2단계 작업 continuation 불필요
1442
+ # (2) todos 있음 + 미완료 → 다음 작업 유도
1443
+ # (3) todos 있음 + 전부 완료 → final_summary_tool 호출 유도
1444
+ #
1445
+ if not todos:
1446
+ # No todos in state → simple task (1~2 steps)
1447
+ # Don't inject any continuation — LLM finishes naturally.
1244
1448
  logger.info(
1245
- "Skipping continuation prompt after write_todos - "
1246
- "agent decides next action (pending: %d)",
1247
- len(pending_todos) if pending_todos else 0,
1449
+ "No todos in state after tool: %s - "
1450
+ "simple task, skipping continuation",
1451
+ tool_name,
1248
1452
  )
1249
- # Don't inject continuation - let agent naturally continue or stop
1250
1453
  elif pending_todos:
1251
- pending_list = ", ".join(
1252
- t.get("content", "")[:30] for t in pending_todos[:3]
1253
- )
1254
- continuation = (
1255
- f"Tool '{tool_name}' completed. "
1256
- f"Continue with pending tasks: {pending_list}. "
1257
- f"Call jupyter_cell_tool or the next appropriate tool."
1258
- )
1259
- new_messages = list(messages) + [
1260
- HumanMessage(content=f"[SYSTEM] {continuation}")
1261
- ]
1262
- request = request.override(messages=new_messages)
1454
+ # Todos exist with pending items → guide to next task
1455
+ if tool_name == "write_todos":
1456
+ # write_todos with pending items → agent manages its own flow
1457
+ logger.info(
1458
+ "write_todos with %d pending todos - "
1459
+ "agent manages own flow",
1460
+ len(pending_todos),
1461
+ )
1462
+ else:
1463
+ pending_list = ", ".join(
1464
+ t.get("content", "")[:30] for t in pending_todos[:3]
1465
+ )
1466
+ continuation = (
1467
+ f"Tool '{tool_name}' completed. "
1468
+ f"Continue with pending tasks: {pending_list}. "
1469
+ f"Call jupyter_cell_tool or the next appropriate tool."
1470
+ )
1471
+ new_messages = list(messages) + [
1472
+ HumanMessage(content=f"[SYSTEM] {continuation}")
1473
+ ]
1474
+ request = request.override(messages=new_messages)
1263
1475
  else:
1476
+ # All todos completed → prompt for final_summary_tool
1477
+ logger.info(
1478
+ "All %d todos completed after tool: %s - "
1479
+ "prompting for final_summary_tool",
1480
+ len(todos),
1481
+ tool_name,
1482
+ )
1264
1483
  continuation = (
1265
- f"Tool '{tool_name}' completed. "
1266
- f"Create a todo list with write_todos if needed."
1484
+ "[SYSTEM] 모든 작업이 완료되었습니다. "
1485
+ "반드시 final_summary_tool을 호출하여 작업 요약과 다음 단계를 제시하세요. "
1486
+ "final_summary_tool(summary='완료된 작업 요약', "
1487
+ "next_items=[{'subject': '제목', 'description': '설명'}, ...]) "
1488
+ "(next_items 3개 이상 필수). "
1489
+ "텍스트로 JSON을 출력하지 말고, 반드시 도구 호출로 실행하세요."
1267
1490
  )
1268
1491
  new_messages = list(messages) + [
1269
- HumanMessage(content=f"[SYSTEM] {continuation}")
1492
+ HumanMessage(content=continuation)
1270
1493
  ]
1271
1494
  request = request.override(messages=new_messages)
1272
1495
 
@@ -1287,8 +1510,10 @@ def create_continuation_control_middleware(wrap_model_call):
1287
1510
  if isinstance(p, (str, dict))
1288
1511
  )
1289
1512
 
1290
- # Check if content contains summary JSON pattern
1291
- has_summary_json = '"summary"' in content and '"next_items"' in content
1513
+ # Check if content contains summary JSON pattern (double or single quotes)
1514
+ has_summary_json = ('"summary"' in content and '"next_items"' in content) or (
1515
+ "'summary'" in content and "'next_items'" in content
1516
+ )
1292
1517
 
1293
1518
  if has_summary_json:
1294
1519
  tool_calls = getattr(response_message, "tool_calls", []) or []
@@ -93,7 +93,6 @@ def _create_vllm_llm(llm_config: Dict[str, Any], callbacks):
93
93
  from langchain_openai import ChatOpenAI
94
94
 
95
95
  vllm_config = llm_config.get("vllm", {})
96
- # User provides full base URL (e.g., https://openrouter.ai/api/v1)
97
96
  endpoint = vllm_config.get("endpoint", "http://localhost:8000/v1")
98
97
  model = vllm_config.get("model", "default")
99
98
  api_key = vllm_config.get("apiKey", "dummy")
@@ -140,9 +139,11 @@ def _create_vllm_llm(llm_config: Dict[str, Any], callbacks):
140
139
 
141
140
 
142
141
  def create_summarization_llm(llm_config: Dict[str, Any]):
143
- """Create LLM for summarization middleware.
142
+ """Create LLM for summarization middleware and /compact feature.
144
143
 
145
- Uses the same provider as the main LLM but with simpler configuration.
144
+ Priority:
145
+ 1. If llm_config["summarization"]["enabled"] is True, use that config
146
+ 2. Otherwise, fall back to main provider with default summarization model
146
147
 
147
148
  Args:
148
149
  llm_config: Configuration dictionary
@@ -150,60 +151,107 @@ def create_summarization_llm(llm_config: Dict[str, Any]):
150
151
  Returns:
151
152
  LLM instance suitable for summarization, or None if unavailable
152
153
  """
153
- provider = llm_config.get("provider", "gemini")
154
-
155
154
  try:
156
- if provider == "gemini":
157
- from langchain_google_genai import ChatGoogleGenerativeAI
158
-
159
- gemini_config = llm_config.get("gemini", {})
160
- api_key = gemini_config.get("apiKey")
161
- if api_key:
162
- return ChatGoogleGenerativeAI(
163
- model="gemini-2.5-flash",
164
- google_api_key=api_key,
165
- temperature=0.0,
166
- )
167
- elif provider == "openai":
168
- from langchain_openai import ChatOpenAI
169
-
170
- openai_config = llm_config.get("openai", {})
171
- api_key = openai_config.get("apiKey")
172
- if api_key:
173
- return ChatOpenAI(
174
- model="gpt-4o-mini",
175
- api_key=api_key,
176
- temperature=0.0,
177
- )
178
- elif provider == "vllm":
179
- vllm_config = llm_config.get("vllm", {})
180
- # User provides full base URL (e.g., https://openrouter.ai/api/v1)
181
- endpoint = vllm_config.get("endpoint", "http://localhost:8000/v1")
182
- model = vllm_config.get("model", "default")
183
- api_key = vllm_config.get("apiKey", "dummy")
184
-
185
- # Use ChatGPTOSS for gpt-oss models (but not via OpenRouter)
186
- is_openrouter = "openrouter" in endpoint.lower()
187
- if "gpt-oss" in model.lower() and not is_openrouter:
188
- from agent_server.langchain.models import ChatGPTOSS
189
-
190
- return ChatGPTOSS(
191
- model=model,
192
- base_url=endpoint,
193
- api_key=api_key,
194
- temperature=0.0,
195
- )
196
-
197
- from langchain_openai import ChatOpenAI
198
-
199
- return ChatOpenAI(
200
- model=model,
201
- api_key=api_key,
202
- base_url=endpoint, # Use endpoint as-is
203
- temperature=0.0,
155
+ # 1. Check for dedicated summarization config
156
+ summarization_config = llm_config.get("summarization", {})
157
+ if summarization_config.get("enabled"):
158
+ sum_provider = summarization_config.get("provider", "gemini")
159
+ sum_model = summarization_config.get("model")
160
+ logger.info(
161
+ f"Using dedicated summarization LLM: provider={sum_provider}, model={sum_model or 'default'}"
162
+ )
163
+ return _create_llm_for_provider(
164
+ llm_config, sum_provider, sum_model, for_summarization=True
204
165
  )
166
+
167
+ # 2. Fall back to main provider with default summarization model
168
+ provider = llm_config.get("provider", "gemini")
169
+ logger.info(f"Using main provider for summarization: {provider}")
170
+ return _create_llm_for_provider(
171
+ llm_config, provider, None, for_summarization=True
172
+ )
173
+
205
174
  except Exception as e:
206
175
  logger.warning(f"Failed to create summarization LLM: {e}")
207
176
  return None
208
177
 
209
- return None
178
+
179
+ def _create_llm_for_provider(
180
+ llm_config: Dict[str, Any],
181
+ provider: str,
182
+ model_override: str = None,
183
+ for_summarization: bool = False,
184
+ ):
185
+ """Create LLM instance for a specific provider.
186
+
187
+ Args:
188
+ llm_config: Full configuration dictionary (for credentials)
189
+ provider: Provider to use ('gemini', 'openai', 'vllm')
190
+ model_override: Optional model name override
191
+ for_summarization: If True, use lightweight default models
192
+
193
+ Returns:
194
+ LLM instance or None
195
+ """
196
+ if provider == "gemini":
197
+ from langchain_google_genai import ChatGoogleGenerativeAI
198
+
199
+ gemini_config = llm_config.get("gemini", {})
200
+ api_key = gemini_config.get("apiKey")
201
+ if not api_key:
202
+ logger.warning("No Gemini API key found")
203
+ return None
204
+
205
+ model = model_override or ("gemini-2.5-flash" if for_summarization else gemini_config.get("model", "gemini-2.5-flash"))
206
+ return ChatGoogleGenerativeAI(
207
+ model=model,
208
+ google_api_key=api_key,
209
+ temperature=0.0,
210
+ )
211
+
212
+ elif provider == "openai":
213
+ from langchain_openai import ChatOpenAI
214
+
215
+ openai_config = llm_config.get("openai", {})
216
+ api_key = openai_config.get("apiKey")
217
+ if not api_key:
218
+ logger.warning("No OpenAI API key found")
219
+ return None
220
+
221
+ model = model_override or ("gpt-4o-mini" if for_summarization else openai_config.get("model", "gpt-4"))
222
+ return ChatOpenAI(
223
+ model=model,
224
+ api_key=api_key,
225
+ temperature=0.0,
226
+ )
227
+
228
+ elif provider == "vllm":
229
+ vllm_config = llm_config.get("vllm", {})
230
+ endpoint = vllm_config.get("endpoint", "http://localhost:8000/v1")
231
+ api_key = vllm_config.get("apiKey", "dummy")
232
+ model = model_override or vllm_config.get("model", "default")
233
+
234
+ # Use ChatGPTOSS for gpt-oss models (but not via OpenRouter)
235
+ is_openrouter = "openrouter" in endpoint.lower()
236
+ if "gpt-oss" in model.lower() and not is_openrouter:
237
+ from agent_server.langchain.models import ChatGPTOSS
238
+
239
+ return ChatGPTOSS(
240
+ model=model,
241
+ base_url=endpoint,
242
+ api_key=api_key,
243
+ temperature=0.0,
244
+ )
245
+
246
+ from langchain_openai import ChatOpenAI
247
+
248
+ return ChatOpenAI(
249
+ model=model,
250
+ api_key=api_key,
251
+ base_url=endpoint,
252
+ temperature=0.0,
253
+ )
254
+
255
+ else:
256
+ logger.warning(f"Unknown provider: {provider}")
257
+ return None
@@ -37,7 +37,7 @@ def disable_langchain_logging():
37
37
 
38
38
 
39
39
  # Auto-disable on import (comment this line to re-enable all logs)
40
- disable_langchain_logging()
40
+ # disable_langchain_logging() # TEMPORARILY ENABLED FOR DEBUGGING
41
41
 
42
42
  LOG_SEPARATOR = "=" * 96
43
43
  LOG_SUBSECTION = "-" * 96
@@ -3,10 +3,14 @@ Middleware Module
3
3
 
4
4
  Custom middleware for the multi-agent architecture:
5
5
  - SubAgentMiddleware: Handles subagent delegation via task tool
6
+ - ContentInjectionMiddleware: Injects generated code/SQL into tool args
6
7
  - SkillMiddleware: Progressive skill loading for code generation agents
7
8
  - Existing middleware from custom_middleware.py is also available
8
9
  """
9
10
 
11
+ from agent_server.langchain.middleware.content_injection_middleware import (
12
+ ContentInjectionMiddleware,
13
+ )
10
14
  from agent_server.langchain.middleware.skill_middleware import (
11
15
  SkillMiddleware,
12
16
  get_skill_middleware,
@@ -18,6 +22,7 @@ from agent_server.langchain.middleware.subagent_middleware import (
18
22
 
19
23
  __all__ = [
20
24
  "SubAgentMiddleware",
25
+ "ContentInjectionMiddleware",
21
26
  "create_task_tool",
22
27
  "SkillMiddleware",
23
28
  "get_skill_middleware",