Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/providers/gemini_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ def _convert_messages(
)
else:
raw_text = str(raw)
# FunctionResponse has no error field — prefix the
# text like the OpenAI-wire converters (mirrors TS
# convertToolResultContent, openaiShim.ts:309) so
# failed tools keep a failure signal on the wire.
if block.get("is_error"):
raw_text = f"Error: {raw_text}"
# Gemini FunctionResponse needs a name; tool_use_id is
# an Anthropic-only correlation id. Use it as the name
# if no better signal is available.
Expand Down
11 changes: 11 additions & 0 deletions src/providers/openai_compatible.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,17 @@ def _convert_anthropic_messages_to_openai(
# requirement is honoured.
flat_content = "[empty tool result]"

# OpenAI's ``role=tool`` message has no ``is_error`` field,
# so error-ness must ride the text: prefix ``Error: `` like
# TS convertToolResultContent (openaiShim.ts:309/:349/:356
# — every text-shaped emission applies it). Load-bearing
# since thrown tool errors are no longer wrapped in
# ``<tool_use_error>`` tags (_format_error parity port):
# without the prefix an OpenAI-wire model would see a bare
# message with no failure signal at all.
if tr.get("is_error"):
flat_content = f"Error: {flat_content}"

result.append({
"role": "tool",
"tool_call_id": tool_use_id,
Expand Down
6 changes: 6 additions & 0 deletions src/providers/openai_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,12 @@ def convert_messages_to_responses_input(
})
if not flat:
flat = "[empty tool result]"
# ``function_call_output`` is text-only with no error
# field — prefix ``Error: `` on is_error, same policy
# as the Chat Completions converter (which mirrors TS
# convertToolResultContent, openaiShim.ts:309).
if block.get("is_error"):
flat = f"Error: {flat}"
input_items.append({
"type": "function_call_output",
"call_id": call_id,
Expand Down
49 changes: 44 additions & 5 deletions src/services/tool_execution/tool_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from src.types.messages import (
CANCEL_MESSAGE,
INTERRUPT_MESSAGE_FOR_TOOL_USE,
AssistantMessage,
Message,
create_progress_message,
Expand Down Expand Up @@ -667,13 +668,51 @@ def _create_tool_result_stop(tool_use_id: str) -> dict[str, Any]:
}


# TS formatError caps thrown-error content at 40KB with middle truncation
# (toolErrors.ts:16) — "enough for most command error logs".
_FORMAT_ERROR_MAX_LENGTH = 40000


def _format_error(error: Exception) -> str:
"""Format a thrown tool error — port of formatError (utils/toolErrors.ts:5).

Thrown ``call()`` errors go on the wire RAW: ``is_error: True`` on the
tool_result carries the error-ness (TS toolExecution.ts:1633+1674 sends
``content: formatError(error)`` with no ``<tool_use_error>`` wrapping).
The tags are reserved for pre-execution failures — no-such-tool,
InputValidationError, validate_input — which TS also tags. Wrapping
thrown errors too (the old behavior here) leaked the tags into the TUI
transcript (`Error: <tool_use_error>path is not a file: …`) and diverged
the model-facing bytes from the original.

getErrorParts (toolErrors.ts:26) appends ``stderr``/``stdout`` string
attributes when present (subprocess.CalledProcessError carries both).
The TS ShellError branch has no Python parallel — our bash tool formats
shell failures into the result body itself, so only the generic branch
is ported.
"""
if isinstance(error, AbortError):
return CANCEL_MESSAGE
msg = str(error)
if not msg:
msg = type(error).__name__
return f"<tool_use_error>{msg}</tool_use_error>"
# TS: error.message || INTERRUPT_MESSAGE_FOR_TOOL_USE. Effectively
# dead here — AbortError is caught by the dedicated except at the
# call-tool layer before Step 14 — kept as a belt for direct callers.
return str(error) or INTERRUPT_MESSAGE_FOR_TOOL_USE
parts = [str(error)]
for attr in ("stderr", "stdout"):
value = getattr(error, attr, None)
if isinstance(value, str):
parts.append(value)
full_message = "\n".join(p for p in parts if p).strip()
if not full_message:
full_message = "Command failed with no output"
if len(full_message) <= _FORMAT_ERROR_MAX_LENGTH:
return full_message
half_length = _FORMAT_ERROR_MAX_LENGTH // 2
start = full_message[:half_length]
end = full_message[-half_length:]
return (
f"{start}\n\n... [{len(full_message) - _FORMAT_ERROR_MAX_LENGTH} "
f"characters truncated] ...\n\n{end}"
)


def classify_tool_error(error: Exception) -> str:
Expand Down
8 changes: 8 additions & 0 deletions src/tool_system/tools/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,14 @@ def _read_call(tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
raise ToolInputError(message)

if not path.is_file():
# Directory: exact TS message (readFileInRange.ts:89-92) — models
# know EISDIR from original-CC transcripts and recover by listing.
# Other non-files (sockets, FIFOs, devices) keep the generic message:
# TS streams those; we refuse (deliberate pre-existing divergence).
if path.is_dir():
raise ToolInputError(
f"EISDIR: illegal operation on a directory, read '{path}'"
)
raise ToolInputError(f"path is not a file: {path}")

offset = tool_input.get("offset")
Expand Down
Loading
Loading