99
1010import logging
1111import re
12+ from pathlib import Path
1213from typing import Any
1314
1415from src .core .domain .chat import ChatRequest
2526 ProjectDirectoryResolutionService ,
2627)
2728
29+ _TRUNCATED_ARTIFACT_PREFIX = "<system-reminder> CRITICAL: This output was truncated."
30+ _TRUNCATED_ARTIFACT_PATH_RE = re .compile (r"saved to ([A-Za-z]:\\[^\s]+)" , re .IGNORECASE )
31+ _ARTIFACT_MAX_LINES = 120
32+ _ARTIFACT_MAX_CHARS = 6000
33+
2834logger = logging .getLogger (__name__ )
2935
3036
@@ -114,6 +120,8 @@ async def process_request(
114120 f"command_results={ len (command_result .command_results ) if hasattr (command_result .command_results , '__len__' ) else 0 } "
115121 )
116122
123+ self ._expand_truncated_tool_outputs (command_result )
124+
117125 # Special handling: Cline agent expects tool_calls for proxy commands
118126 try :
119127 if (
@@ -139,9 +147,9 @@ async def process_request(
139147 await self ._session_manager .record_command_in_session (
140148 request_data , session_id
141149 )
142- return await self ._response_manager .process_command_result (
143- command_result , session
144- )
150+ return await self ._response_manager .process_command_result (
151+ command_result , session
152+ )
145153
146154 # Prepare backend request
147155 backend_request = await self ._backend_request_manager .prepare_backend_request (
@@ -625,20 +633,20 @@ async def process_request(
625633 )
626634 if isinstance (hybrid_disabled_map , dict ):
627635 hybrid_disabled_map = dict (hybrid_disabled_map )
628- if session_id in hybrid_disabled_map :
629- hybrid_reasoning_disabled = True
630- # Remove the flag so it's only used for this request
631- del hybrid_disabled_map [session_id ]
632- if self ._app_state is not None :
633- self ._app_state .set_setting (
634- "edit_precision_hybrid_reasoning_disabled" ,
635- hybrid_disabled_map ,
636- )
637- self ._clear_active_hybrid_disable_flag (session_id )
638- logger .info (
639- f"Hybrid reasoning disabled for session { session_id } due to edit failure" ,
640- extra = {"session_id" : session_id },
641- )
636+ if session_id in hybrid_disabled_map :
637+ hybrid_reasoning_disabled = True
638+ # Remove the flag so it's only used for this request
639+ del hybrid_disabled_map [session_id ]
640+ if self ._app_state is not None :
641+ self ._app_state .set_setting (
642+ "edit_precision_hybrid_reasoning_disabled" ,
643+ hybrid_disabled_map ,
644+ )
645+ self ._clear_active_hybrid_disable_flag (session_id )
646+ logger .info (
647+ f"Hybrid reasoning disabled for session { session_id } due to edit failure" ,
648+ extra = {"session_id" : session_id },
649+ )
642650 except (AttributeError , TypeError , ValueError ) as e :
643651 logger .debug (
644652 "Could not resolve hybrid reasoning disabled flag: %s" ,
@@ -836,38 +844,38 @@ async def process_request(
836844 f"Failed to update session fingerprint: { e } " , exc_info = True
837845 )
838846
839- return backend_response
840-
841- def _clear_active_hybrid_disable_flag (self , session_id : str ) -> None :
842- """Remove the active hybrid disable marker for the given session if present."""
843- if self ._app_state is None :
844- return
845-
846- try :
847- active_map = self ._app_state .get_setting (
848- "edit_precision_hybrid_reasoning_active" , {}
849- )
850- if not isinstance (active_map , dict ) or session_id not in active_map :
851- return
852-
853- updated_map = dict (active_map )
854- updated_map .pop (session_id , None )
855- self ._app_state .set_setting (
856- "edit_precision_hybrid_reasoning_active" , updated_map
857- )
858- except Exception as exc :
859- logger .debug (
860- "Failed to clear active hybrid disable marker for session %s: %s" ,
861- session_id ,
862- exc ,
863- exc_info = True ,
864- )
865-
866- def _apply_hybrid_reasoning_override (
867- self ,
868- backend_request : ChatRequest ,
869- session_id : str ,
870- app_config : Any | None ,
847+ return backend_response
848+
849+ def _clear_active_hybrid_disable_flag (self , session_id : str ) -> None :
850+ """Remove the active hybrid disable marker for the given session if present."""
851+ if self ._app_state is None :
852+ return
853+
854+ try :
855+ active_map = self ._app_state .get_setting (
856+ "edit_precision_hybrid_reasoning_active" , {}
857+ )
858+ if not isinstance (active_map , dict ) or session_id not in active_map :
859+ return
860+
861+ updated_map = dict (active_map )
862+ updated_map .pop (session_id , None )
863+ self ._app_state .set_setting (
864+ "edit_precision_hybrid_reasoning_active" , updated_map
865+ )
866+ except Exception as exc :
867+ logger .debug (
868+ "Failed to clear active hybrid disable marker for session %s: %s" ,
869+ session_id ,
870+ exc ,
871+ exc_info = True ,
872+ )
873+
874+ def _apply_hybrid_reasoning_override (
875+ self ,
876+ backend_request : ChatRequest ,
877+ session_id : str ,
878+ app_config : Any | None ,
871879 ) -> ChatRequest :
872880 """Temporarily disable hybrid reasoning for the given request if applicable."""
873881
@@ -986,3 +994,124 @@ async def _handle_command_processing(
986994 return await self ._command_processor .process_messages (
987995 request_data .messages , session_id , context
988996 )
997+
998+ def _expand_truncated_tool_outputs (self , command_result : ProcessedResult ) -> None :
999+ """Expand truncated tool outputs recorded as artifact references."""
1000+ messages = getattr (command_result , "modified_messages" , None )
1001+ if not messages :
1002+ return
1003+
1004+ normalized_messages : list [Any ] = []
1005+ changed = False
1006+
1007+ for raw_message in messages :
1008+ message , altered = self ._normalize_tool_message (raw_message )
1009+ normalized_messages .append (message )
1010+ changed = changed or altered
1011+
1012+ if changed :
1013+ command_result .modified_messages = normalized_messages
1014+
1015+ def _normalize_tool_message (self , raw_message : Any ) -> tuple [Any , bool ]:
1016+ """Return tool message with expanded artifact content when possible."""
1017+ role = None
1018+ content = None
1019+
1020+ if isinstance (raw_message , dict ):
1021+ role = raw_message .get ("role" )
1022+ content = raw_message .get ("content" )
1023+ else :
1024+ role = getattr (raw_message , "role" , None )
1025+ content = getattr (raw_message , "content" , None )
1026+
1027+ if role != "tool" :
1028+ return raw_message , False
1029+
1030+ replacement = self ._extract_truncated_artifact_preview (content )
1031+ if replacement is None :
1032+ return raw_message , False
1033+
1034+ if isinstance (raw_message , dict ):
1035+ updated = dict (raw_message )
1036+ updated ["content" ] = replacement
1037+ return updated , True
1038+
1039+ if hasattr (raw_message , "model_copy" ):
1040+ return raw_message .model_copy (update = {"content" : replacement }), True
1041+
1042+ # Fallback: attempt in-place assignment
1043+ try :
1044+ raw_message .content = replacement # type: ignore[attr-defined]
1045+ return raw_message , True
1046+ except Exception :
1047+ return raw_message , False
1048+
1049+ def _extract_truncated_artifact_preview (self , content : Any ) -> str | None :
1050+ """Extract and truncate the artifact referenced by the tool output."""
1051+ if not isinstance (content , str ):
1052+ return None
1053+ if _TRUNCATED_ARTIFACT_PREFIX not in content :
1054+ return None
1055+
1056+ match = _TRUNCATED_ARTIFACT_PATH_RE .search (content )
1057+ if not match :
1058+ return None
1059+
1060+ raw_path = match .group (1 )
1061+ artifact_path = self ._convert_artifact_path (raw_path )
1062+ if artifact_path is None or not artifact_path .exists ():
1063+ logger .debug (
1064+ "Artifact path %s could not be resolved or does not exist" , raw_path
1065+ )
1066+ return None
1067+
1068+ try :
1069+ artifact_text = artifact_path .read_text (encoding = "utf-8" , errors = "replace" )
1070+ except OSError as exc :
1071+ logger .warning ("Failed to read tool artifact %s: %s" , artifact_path , exc )
1072+ return None
1073+
1074+ preview = self ._build_artifact_preview (artifact_text )
1075+ note = (
1076+ f"<system-reminder> Extracted artifact from { raw_path } . "
1077+ f"Showing limited preview for the language model.\n \n "
1078+ )
1079+ return note + preview
1080+
1081+ def _convert_artifact_path (self , raw_path : str ) -> Path | None :
1082+ """Convert CLI artifact path to a path accessible from this environment."""
1083+ potential_path = Path (raw_path )
1084+ if potential_path .exists ():
1085+ return potential_path
1086+
1087+ # Handle Windows paths when running under WSL/Linux (e.g., C:\ -> /mnt/c/)
1088+ if len (raw_path ) > 2 and raw_path [1 :3 ] == ":\\ " :
1089+ drive = raw_path [0 ].lower ()
1090+ remainder = raw_path [3 :].replace ("\\ " , "/" )
1091+ candidate = Path (f"/mnt/{ drive } /{ remainder } " )
1092+ if candidate .exists ():
1093+ return candidate
1094+
1095+ return None
1096+
1097+ def _build_artifact_preview (self , artifact_text : str ) -> str :
1098+ """Produce a trimmed preview of artifact contents."""
1099+ lines = artifact_text .splitlines ()
1100+ truncated_lines = False
1101+
1102+ if len (lines ) > _ARTIFACT_MAX_LINES :
1103+ omitted = len (lines ) - _ARTIFACT_MAX_LINES
1104+ lines = lines [:_ARTIFACT_MAX_LINES ]
1105+ lines .append (f"[... { omitted } additional lines omitted ...]" )
1106+ truncated_lines = True
1107+
1108+ preview = "\n " .join (lines )
1109+
1110+ if len (preview ) > _ARTIFACT_MAX_CHARS :
1111+ preview = preview [:_ARTIFACT_MAX_CHARS ] + "\n [... output truncated ...]"
1112+ truncated_lines = True
1113+
1114+ if truncated_lines :
1115+ preview += "\n "
1116+
1117+ return preview
0 commit comments