Skip to content

Commit 64a805c

Browse files
author
Mateusz
committed
Inline large tool artifacts into conversation previews
1 parent ced58ee commit 64a805c

File tree

1 file changed

+178
-49
lines changed

1 file changed

+178
-49
lines changed

src/core/services/request_processor_service.py

Lines changed: 178 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import logging
1111
import re
12+
from pathlib import Path
1213
from typing import Any
1314

1415
from src.core.domain.chat import ChatRequest
@@ -25,6 +26,11 @@
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+
2834
logger = 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

Comments
 (0)