Skip to content
Open
Changes from 2 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
35 changes: 29 additions & 6 deletions langfuse/langchain/CallbackHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,20 @@ def on_retriever_error(
self._log_debug_event(
"on_retriever_error", run_id, parent_run_id, error=error
)
if any(isinstance(error, t) for t in CONTROL_FLOW_EXCEPTION_TYPES):
level = None
else:
level = "ERROR"
Comment on lines +256 to +259
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Control-flow errors lose status

When error is a LangGraph control-flow exception, this sets level=None and also clears status_message (status_message=str(error) if level else None). That removes any indication in the observation that the run ended due to an interrupt/command, which makes these observations look “successfully ended” but empty. If the goal is only to avoid ERROR, consider keeping a non-error status message (or setting a neutral level like "DEFAULT") so users can still see why the run stopped.

Also appears in on_tool_error and on_llm_error (CallbackHandler.py:814-830, CallbackHandler.py:1016-1032).

Copy link
Copy Markdown
Author

@vishnumishra vishnumishra Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A user looking at traces can't distinguish between "tool completed normally" and tool was interrupted for human-in-the-loop

However, this is the exact same pattern on_chain_error already uses (line 583-596), which has been shipping and working. So the behavior is consistent across all four handlers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that this is a real UX problem. When we set level=None and status_message=None, control-flow interruptions become invisible in traces.

Looking at the code, I can see that on_chain_error (lines ~583-605) sets both level=None AND clears the status message for control-flow exceptions:

status_message=str(error) if level else None  # ← This clears the message when level=None!

This PR applies the same pattern to on_tool_error, on_retriever_error, and on_llm_error, which means control-flow interruptions will have no visible status indication in traces.

The problem: Users can't distinguish between:

  1. Tool completed successfully → level=None, status_message=None
  2. Tool interrupted for human-in-the-loop → level=None, status_message=None (same!)

Suggested fix: Keep the status message but use a neutral level like "DEFAULT" or "WARNING" instead of None, so the interruption reason is visible but not marked as an error:

if any(isinstance(error, t) for t in CONTROL_FLOW_EXCEPTION_TYPES):
    level = "DEFAULT"
    status_message = f"Control flow: {str(error)}"
else:
    level = "ERROR"
    status_message = str(error)

observation = self._detach_observation(run_id)

if observation is not None:
    observation.update(
        level=cast(
            Optional[Literal["DEBUG", "DEFAULT", "WARNING", "ERROR"]],
            level,
        ),
        status_message=status_message,
        ...
    )

This preserves visibility of interrupts/handoffs while avoiding the red ERROR badge. The same pattern should be applied to on_chain_error for consistency across all error handlers.

Copy link
Copy Markdown
Author

@vishnumishra vishnumishra Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have updated the PR, with the required changes.


observation = self._detach_observation(run_id)

if observation is not None:
observation.update(
level="ERROR",
status_message=str(error),
level=cast(
Optional[Literal["DEBUG", "DEFAULT", "WARNING", "ERROR"]],
level,
),
status_message=str(error) if level else None,
input=kwargs.get("inputs"),
cost_details={"total": 0},
).end()
Expand Down Expand Up @@ -803,12 +811,20 @@ def on_tool_error(
) -> Any:
try:
self._log_debug_event("on_tool_error", run_id, parent_run_id, error=error)
if any(isinstance(error, t) for t in CONTROL_FLOW_EXCEPTION_TYPES):
level = None
else:
level = "ERROR"

observation = self._detach_observation(run_id)

if observation is not None:
observation.update(
status_message=str(error),
level="ERROR",
level=cast(
Optional[Literal["DEBUG", "DEFAULT", "WARNING", "ERROR"]],
level,
),
status_message=str(error) if level else None,
input=kwargs.get("inputs"),
cost_details={"total": 0},
).end()
Expand Down Expand Up @@ -997,13 +1013,20 @@ def on_llm_error(
) -> Any:
try:
self._log_debug_event("on_llm_error", run_id, parent_run_id, error=error)
if any(isinstance(error, t) for t in CONTROL_FLOW_EXCEPTION_TYPES):
level = None
else:
level = "ERROR"

generation = self._detach_observation(run_id)

if generation is not None:
generation.update(
status_message=str(error),
level="ERROR",
level=cast(
Optional[Literal["DEBUG", "DEFAULT", "WARNING", "ERROR"]],
level,
),
status_message=str(error) if level else None,
input=kwargs.get("inputs"),
cost_details={"total": 0},
).end()
Expand Down