Skip to content

Commit e54ea69

Browse files
fix(langchain): capture retriever steps in sync handler and tool-call outputs
- Wire on_retriever_start/end/error into the sync OpenlayerHandler (previously only AsyncOpenlayerHandler exposed them), so synchronous RAG pipelines produce a RETRIEVER step and populate the context column. - Fall back to serializing AIMessage.tool_calls in _extract_output when the generation text is empty, so tool-only agent turns no longer record empty output; preserve tool_calls in _message_to_dict for assistant messages. - Fix context metadata being dropped when a sync root-level retriever has no external trace context (external trace still takes priority). - Add offline unit tests for the sync retriever path and tool-call capture. Implements the high-priority items of OPEN-11315; medium/low items tracked separately on the issue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent de3eb27 commit e54ea69

4 files changed

Lines changed: 311 additions & 5 deletions

File tree

src/openlayer/lib/integrations/langchain_callback.py

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ def _convert_langchain_objects(self, obj: Any) -> Any:
364364

365365
def _message_to_dict(
366366
self, message: "langchain_schema.BaseMessage"
367-
) -> Dict[str, str]:
367+
) -> Dict[str, Any]:
368368
"""Convert a LangChain message to a JSON-serializable dictionary."""
369369
message_type = getattr(message, "type", "user")
370370

@@ -374,7 +374,17 @@ def _message_to_dict(
374374
elif message_type == "system":
375375
role = "system"
376376

377-
return {"role": role, "content": str(message.content)}
377+
result: Dict[str, Any] = {"role": role, "content": str(message.content)}
378+
379+
# Preserve tool calls on assistant messages (e.g. AIMessage.tool_calls).
380+
# Without this, agent turns that only emit tool calls would drop them
381+
# from the recorded inputs. Done defensively so it never raises on
382+
# messages that lack the attribute.
383+
tool_calls = getattr(message, "tool_calls", None)
384+
if tool_calls:
385+
result["tool_calls"] = tool_calls
386+
387+
return result
378388

379389
def _messages_to_prompt_format(
380390
self, messages: List[List["langchain_schema.BaseMessage"]]
@@ -480,13 +490,45 @@ def _extract_token_info(
480490
}
481491

482492
def _extract_output(self, response: "langchain_schema.LLMResult") -> str:
483-
"""Extract output text from LLM response."""
493+
"""Extract output text from LLM response.
494+
495+
When a generation carries no text (e.g. an agent turn whose model
496+
response is *only* tool calls, as in LangGraph / ``create_agent``),
497+
fall back to serializing the message's tool calls so the step output
498+
is not empty.
499+
"""
484500
output = ""
485501
for generations in response.generations:
486502
for generation in generations:
487-
output += generation.text.replace("\n", " ")
503+
text = generation.text or ""
504+
if text:
505+
output += text.replace("\n", " ")
506+
else:
507+
output += self._tool_calls_to_str(generation)
488508
return output
489509

510+
def _tool_calls_to_str(self, generation: Any) -> str:
511+
"""Serialize tool calls from a generation's message, if any.
512+
513+
Done defensively (getattr) so it never raises on generations or
514+
messages that lack a ``message`` / ``tool_calls`` attribute, and works
515+
whether or not langchain uses the v1 message shape.
516+
"""
517+
message = getattr(generation, "message", None)
518+
if message is None:
519+
return ""
520+
521+
tool_calls = getattr(message, "tool_calls", None)
522+
if not tool_calls:
523+
return ""
524+
525+
import json
526+
527+
try:
528+
return json.dumps(tool_calls, default=str)
529+
except (TypeError, ValueError):
530+
return str(tool_calls)
531+
490532
def _safe_parse_json(self, input_str: str) -> Any:
491533
"""Safely parse JSON string, returning the string if parsing fails."""
492534
try:
@@ -822,6 +864,33 @@ def _handle_agent_finish(
822864
output=finish.return_values,
823865
)
824866

867+
def _find_owning_trace(self, run_id: UUID) -> Optional["traces.Trace"]:
868+
"""Find the standalone trace that contains the step for ``run_id``.
869+
870+
Used to attach trace-level data (e.g. retriever ``context``) when no
871+
external trace context is active. Returns ``None`` if the run's step is
872+
not part of any standalone trace tracked by this handler.
873+
"""
874+
step = self.steps.get(run_id)
875+
if step is None:
876+
return None
877+
878+
for trace in self._traces_by_root.values():
879+
if self._trace_contains_step(trace, step):
880+
return trace
881+
return None
882+
883+
@staticmethod
884+
def _trace_contains_step(trace: "traces.Trace", target: steps.Step) -> bool:
885+
"""Return True if ``target`` is anywhere in ``trace`` (including nested)."""
886+
stack = list(trace.steps)
887+
while stack:
888+
step = stack.pop()
889+
if step is target:
890+
return True
891+
stack.extend(step.steps)
892+
return False
893+
825894
def _handle_retriever_start(
826895
self,
827896
serialized: Dict[str, Any],
@@ -875,7 +944,12 @@ def _handle_retriever_end(
875944
else:
876945
doc_contents.append(str(doc))
877946

878-
current_trace = tracer.get_current_trace()
947+
# Populate the trace-level `context` so the reserved column is set.
948+
# Prefer an active (external) trace context; otherwise fall back to the
949+
# standalone trace this handler owns for the run. Without the fallback,
950+
# synchronous RAG pipelines (where the retriever is the root step and no
951+
# external @trace context exists) would silently drop the context.
952+
current_trace = tracer.get_current_trace() or self._find_owning_trace(run_id)
879953
if current_trace:
880954
current_trace.update_metadata(context=doc_contents)
881955

@@ -1062,6 +1136,26 @@ def on_tool_error(
10621136
return
10631137
return self._handle_tool_error(error, **kwargs)
10641138

1139+
def on_retriever_start(
1140+
self, serialized: Dict[str, Any], query: str, **kwargs: Any
1141+
) -> Any:
1142+
"""Run when retriever starts running."""
1143+
if self.ignore_retriever:
1144+
return
1145+
return self._handle_retriever_start(serialized, query, **kwargs)
1146+
1147+
def on_retriever_end(self, documents: List[Any], **kwargs: Any) -> Any:
1148+
"""Run when retriever ends running."""
1149+
if self.ignore_retriever:
1150+
return
1151+
return self._handle_retriever_end(documents, **kwargs)
1152+
1153+
def on_retriever_error(self, error: Exception, **kwargs: Any) -> Any:
1154+
"""Run when retriever errors."""
1155+
if self.ignore_retriever:
1156+
return
1157+
return self._handle_retriever_error(error, **kwargs)
1158+
10651159
def on_text(self, text: str, **kwargs: Any) -> Any:
10661160
"""Run on arbitrary text."""
10671161
pass

tests/lib/__init__.py

Whitespace-only changes.

tests/lib/integrations/__init__.py

Whitespace-only changes.
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""Tests for the Openlayer LangChain callback handler.
2+
3+
Regression coverage for OPEN-11315:
4+
5+
1. The synchronous ``OpenlayerHandler`` must wire up the retriever callbacks
6+
(``on_retriever_start`` / ``on_retriever_end`` / ``on_retriever_error``) so
7+
synchronous RAG pipelines produce a RETRIEVER step and the auto-populated
8+
``context`` column on the trace.
9+
10+
2. A chat-completion turn whose response contains ONLY tool calls (every agent
11+
iteration in LangGraph / ``create_agent``) must still produce a non-empty
12+
step output: ``_extract_output`` falls back to serializing the message's
13+
tool calls, and ``_message_to_dict`` preserves ``tool_calls`` on the
14+
assistant message.
15+
"""
16+
17+
import uuid
18+
19+
import pytest
20+
21+
pytest.importorskip("langchain_core")
22+
23+
from langchain_core.documents import Document
24+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
25+
from langchain_core.outputs import ChatGeneration, LLMResult
26+
27+
from openlayer.lib.integrations.langchain_callback import OpenlayerHandler
28+
from openlayer.lib.tracing import enums, steps
29+
30+
31+
@pytest.fixture(autouse=True)
32+
def _disable_publish(monkeypatch: pytest.MonkeyPatch) -> None:
33+
"""Keep all tracer publish paths off during the test (fully offline)."""
34+
monkeypatch.setenv("OPENLAYER_DISABLE_PUBLISH", "true")
35+
monkeypatch.setenv("OPENLAYER_API_KEY", "fake")
36+
37+
from openlayer.lib.tracing import tracer as _tracer
38+
39+
monkeypatch.setattr(_tracer, "_publish", False, raising=False)
40+
41+
42+
def _tool_call(name: str, args: dict, call_id: str) -> dict:
43+
"""Build a langchain_core tool-call dict."""
44+
return {"name": name, "args": args, "id": call_id, "type": "tool_call"}
45+
46+
47+
# --------------------------------------------------------------------------- #
48+
# Fix 2 (b): _message_to_dict preserves tool_calls
49+
# --------------------------------------------------------------------------- #
50+
class TestMessageToDict:
51+
def test_preserves_tool_calls_on_ai_message(self) -> None:
52+
handler = OpenlayerHandler()
53+
tool_calls = [_tool_call("search", {"query": "openlayer"}, "call_1")]
54+
message = AIMessage(content="", tool_calls=tool_calls)
55+
56+
result = handler._message_to_dict(message)
57+
58+
assert result["role"] == "assistant"
59+
assert "tool_calls" in result, "tool_calls should be preserved"
60+
assert result["tool_calls"] == tool_calls
61+
62+
def test_human_message_without_tool_calls_is_backwards_compatible(self) -> None:
63+
handler = OpenlayerHandler()
64+
message = HumanMessage(content="hello there")
65+
66+
result = handler._message_to_dict(message)
67+
68+
assert result == {"role": "user", "content": "hello there"}
69+
assert "tool_calls" not in result
70+
71+
def test_ai_message_without_tool_calls_omits_key(self) -> None:
72+
handler = OpenlayerHandler()
73+
message = AIMessage(content="plain answer")
74+
75+
result = handler._message_to_dict(message)
76+
77+
assert result == {"role": "assistant", "content": "plain answer"}
78+
assert "tool_calls" not in result
79+
80+
def test_tool_message_does_not_raise(self) -> None:
81+
handler = OpenlayerHandler()
82+
message = ToolMessage(content="42", tool_call_id="call_1")
83+
84+
result = handler._message_to_dict(message)
85+
86+
assert result["content"] == "42"
87+
assert "tool_calls" not in result
88+
89+
90+
# --------------------------------------------------------------------------- #
91+
# Fix 2 (a): _extract_output falls back to tool calls
92+
# --------------------------------------------------------------------------- #
93+
class TestExtractOutput:
94+
def test_text_generation_returns_text(self) -> None:
95+
handler = OpenlayerHandler()
96+
gen = ChatGeneration(message=AIMessage(content="Hello world"))
97+
response = LLMResult(generations=[[gen]])
98+
99+
assert handler._extract_output(response) == "Hello world"
100+
101+
def test_tool_only_generation_returns_tool_calls(self) -> None:
102+
handler = OpenlayerHandler()
103+
tool_calls = [_tool_call("get_weather", {"city": "Rio"}, "call_42")]
104+
message = AIMessage(content="", tool_calls=tool_calls)
105+
gen = ChatGeneration(message=message)
106+
response = LLMResult(generations=[[gen]])
107+
108+
output = handler._extract_output(response)
109+
110+
assert output, "tool-only output must not be empty"
111+
assert "get_weather" in output
112+
113+
def test_tool_only_generation_via_callbacks(self) -> None:
114+
"""Drive on_chat_model_start / on_llm_end and assert step output is set."""
115+
handler = OpenlayerHandler()
116+
run_id = uuid.uuid4()
117+
118+
handler.on_chat_model_start(
119+
serialized={"name": "gpt-4o"},
120+
messages=[[HumanMessage(content="What is the weather in Rio?")]],
121+
run_id=run_id,
122+
invocation_params={"model_name": "gpt-4o", "_type": "openai-chat"},
123+
)
124+
125+
# Capture the standalone trace before the root step is ended/popped.
126+
trace = handler._traces_by_root[run_id]
127+
step = handler.steps[run_id]
128+
assert isinstance(step, steps.ChatCompletionStep)
129+
130+
tool_calls = [_tool_call("get_weather", {"city": "Rio"}, "call_42")]
131+
message = AIMessage(content="", tool_calls=tool_calls)
132+
response = LLMResult(generations=[[ChatGeneration(message=message)]])
133+
134+
handler.on_llm_end(response, run_id=run_id)
135+
136+
assert step.step_type == enums.StepType.CHAT_COMPLETION
137+
assert step.output, "tool-only chat completion output must not be empty"
138+
assert "get_weather" in step.output
139+
assert trace.steps[0] is step
140+
141+
142+
# --------------------------------------------------------------------------- #
143+
# Fix 1: sync handler wires retriever callbacks
144+
# --------------------------------------------------------------------------- #
145+
class TestSyncRetrieverCallbacks:
146+
def test_handler_exposes_retriever_callbacks(self) -> None:
147+
handler = OpenlayerHandler()
148+
assert hasattr(handler, "on_retriever_start")
149+
assert hasattr(handler, "on_retriever_end")
150+
assert hasattr(handler, "on_retriever_error")
151+
152+
def test_sync_retriever_run_produces_step_and_context(self) -> None:
153+
handler = OpenlayerHandler()
154+
run_id = uuid.uuid4()
155+
156+
handler.on_retriever_start(
157+
serialized={"id": ["langchain", "retrievers", "VectorStoreRetriever"]},
158+
query="what is openlayer?",
159+
run_id=run_id,
160+
)
161+
162+
trace = handler._traces_by_root[run_id]
163+
step = handler.steps[run_id]
164+
assert isinstance(step, steps.RetrieverStep)
165+
assert step.step_type == enums.StepType.RETRIEVER
166+
assert step.inputs == {"query": "what is openlayer?"}
167+
168+
documents = [
169+
Document(page_content="Openlayer is an evaluation platform."),
170+
Document(page_content="It supports LangChain tracing."),
171+
]
172+
handler.on_retriever_end(documents, run_id=run_id)
173+
174+
# The retriever step captured the documents...
175+
assert step.documents == [
176+
"Openlayer is an evaluation platform.",
177+
"It supports LangChain tracing.",
178+
]
179+
# ...and the trace gained the auto-populated `context` metadata.
180+
assert trace.metadata is not None
181+
assert trace.metadata.get("context") == [
182+
"Openlayer is an evaluation platform.",
183+
"It supports LangChain tracing.",
184+
]
185+
186+
def test_sync_retriever_respects_ignore_flag(self) -> None:
187+
handler = OpenlayerHandler(ignore_retriever=True)
188+
run_id = uuid.uuid4()
189+
190+
handler.on_retriever_start(
191+
serialized={"id": ["VectorStoreRetriever"]},
192+
query="ignored",
193+
run_id=run_id,
194+
)
195+
196+
assert run_id not in handler.steps
197+
198+
def test_sync_retriever_error(self) -> None:
199+
handler = OpenlayerHandler()
200+
run_id = uuid.uuid4()
201+
202+
handler.on_retriever_start(
203+
serialized={"id": ["VectorStoreRetriever"]},
204+
query="boom",
205+
run_id=run_id,
206+
)
207+
step = handler.steps[run_id]
208+
209+
handler.on_retriever_error(ValueError("retrieval failed"), run_id=run_id)
210+
211+
assert step.metadata.get("error") == "retrieval failed"
212+
assert run_id not in handler.steps

0 commit comments

Comments
 (0)