Skip to content

Commit 8aa32ec

Browse files
committed
fix(deepagents): capture root agent input messages
1 parent 8c70103 commit 8aa32ec

6 files changed

Lines changed: 255 additions & 7 deletions

File tree

instrumentation-loongsuite/loongsuite-instrumentation-deepagents/src/opentelemetry/instrumentation/deepagents/internal/patch.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from collections.abc import AsyncIterator, Iterator
3131
from contextlib import suppress
3232
from typing import Any, Callable
33+
from weakref import WeakSet
3334

3435
from wrapt import ObjectProxy, wrap_function_wrapper
3536

@@ -48,6 +49,8 @@
4849
_MISSING = object()
4950
_top_level_original: Any = _MISSING
5051
_top_level_patched = False
52+
_wrapped_graphs = WeakSet()
53+
_strong_wrapped_graphs: list[Any] = []
5154

5255

5356
def _create_deep_agent_wrapper(
@@ -109,6 +112,7 @@ def uninstrument_create_deep_agent() -> None:
109112
with suppress(Exception):
110113
module = importlib.import_module(CREATE_DEEP_AGENT_MODULE)
111114
unwrap(module, CREATE_DEEP_AGENT_NAME)
115+
_restore_wrapped_graph_methods()
112116
_restore_top_level_create_deep_agent()
113117

114118

@@ -186,7 +190,7 @@ def _wrap_graph_methods(graph: Any) -> None:
186190
return
187191

188192
originals: dict[str, Callable[..., Any]] = {}
189-
for method_name in ("invoke", "ainvoke", "stream", "astream"):
193+
for method_name in ("invoke", "ainvoke", "stream", "astream", "with_config"):
190194
original = getattr(graph, method_name, None)
191195
if original is None:
192196
continue
@@ -204,6 +208,38 @@ def _wrap_graph_methods(graph: Any) -> None:
204208
with suppress(Exception):
205209
setattr(graph, GRAPH_ORIGINAL_METHODS_ATTR, originals)
206210
setattr(graph, GRAPH_METHODS_WRAPPED_ATTR, True)
211+
_track_wrapped_graph(graph)
212+
213+
214+
def _track_wrapped_graph(graph: Any) -> None:
215+
try:
216+
_wrapped_graphs.add(graph)
217+
except TypeError:
218+
_strong_wrapped_graphs.append(graph)
219+
220+
221+
def _restore_wrapped_graph_methods() -> None:
222+
for graph in [*list(_wrapped_graphs), *_strong_wrapped_graphs]:
223+
_restore_graph_methods(graph)
224+
_wrapped_graphs.clear()
225+
_strong_wrapped_graphs.clear()
226+
227+
228+
def _restore_graph_methods(graph: Any) -> None:
229+
originals = getattr(graph, GRAPH_ORIGINAL_METHODS_ATTR, None)
230+
if isinstance(originals, dict):
231+
for method_name, original in originals.items():
232+
with suppress(Exception):
233+
setattr(graph, method_name, original)
234+
235+
for attr_name in (
236+
GRAPH_ORIGINAL_METHODS_ATTR,
237+
GRAPH_METHODS_WRAPPED_ATTR,
238+
REACT_AGENT_METADATA_KEY,
239+
DEEPAGENTS_METADATA_KEY,
240+
):
241+
with suppress(Exception):
242+
delattr(graph, attr_name)
207243

208244

209245
def _make_method_wrapper(
@@ -222,21 +258,28 @@ async def ainvoke_wrapper(*args: Any, **kwargs: Any) -> Any:
222258

223259
def stream_wrapper(*args: Any, **kwargs: Any) -> Iterator[Any]:
224260
args, kwargs = _rewrite_config(args, kwargs)
225-
yield from original(*args, **kwargs)
261+
return original(*args, **kwargs)
226262

227263
return stream_wrapper
228264

229265
if method_name == "astream":
230266

231-
async def astream_wrapper(
232-
*args: Any, **kwargs: Any
233-
) -> AsyncIterator[Any]:
267+
def astream_wrapper(*args: Any, **kwargs: Any) -> AsyncIterator[Any]:
234268
args, kwargs = _rewrite_config(args, kwargs)
235-
async for chunk in original(*args, **kwargs):
236-
yield chunk
269+
return original(*args, **kwargs)
237270

238271
return astream_wrapper
239272

273+
if method_name == "with_config":
274+
275+
def with_config_wrapper(*args: Any, **kwargs: Any) -> Any:
276+
graph = original(*args, **kwargs)
277+
_mark_graph(graph)
278+
_wrap_graph_methods(graph)
279+
return graph
280+
281+
return with_config_wrapper
282+
240283
def invoke_wrapper(*args: Any, **kwargs: Any) -> Any:
241284
args, kwargs = _rewrite_config(args, kwargs)
242285
return original(*args, **kwargs)

instrumentation-loongsuite/loongsuite-instrumentation-deepagents/tests/test_deepagents_spans.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from __future__ import annotations
1818

19+
import json
1920
from concurrent.futures import ThreadPoolExecutor
2021
from typing import Any, Optional
2122

@@ -172,6 +173,15 @@ def test_deepagents_root_span_is_agent_and_single_step(
172173

173174
spans = span_exporter.get_finished_spans()
174175
agent_span = _assert_single_agent(spans, "deep_test_agent")
176+
input_raw = agent_span.attributes.get("gen_ai.input.messages")
177+
assert input_raw, "AGENT span missing gen_ai.input.messages"
178+
input_messages = json.loads(input_raw)
179+
assert input_messages == [
180+
{
181+
"role": "user",
182+
"parts": [{"content": "hello", "type": "text"}],
183+
}
184+
]
175185

176186
step_spans = _spans_by_kind(spans, "STEP")
177187
assert len(step_spans) == 1

instrumentation-loongsuite/loongsuite-instrumentation-deepagents/tests/test_instrumentor.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,50 @@
1717
from __future__ import annotations
1818

1919
from opentelemetry.instrumentation.deepagents import DeepAgentsInstrumentor
20+
from opentelemetry.instrumentation.deepagents.internal.patch import (
21+
DEEPAGENTS_METADATA_KEY,
22+
GRAPH_METHODS_WRAPPED_ATTR,
23+
GRAPH_ORIGINAL_METHODS_ATTR,
24+
REACT_AGENT_METADATA_KEY,
25+
_mark_graph,
26+
_wrap_graph_methods,
27+
uninstrument_create_deep_agent,
28+
)
2029
from opentelemetry.instrumentation.langchain import LangChainInstrumentor
2130
from opentelemetry.instrumentation.langgraph import LangGraphInstrumentor
2231

2332

33+
class _DummyGraph:
34+
def __init__(self):
35+
self.seen_stream_config = None
36+
self.bound_config = None
37+
38+
def _effective_config(self, config):
39+
if self.bound_config is None:
40+
return config
41+
if config is None:
42+
return self.bound_config
43+
44+
merged = {**self.bound_config, **config}
45+
metadata = dict(self.bound_config.get("metadata") or {})
46+
metadata.update(config.get("metadata") or {})
47+
merged["metadata"] = metadata
48+
return merged
49+
50+
def invoke(self, _input, config=None):
51+
return self._effective_config(config)
52+
53+
def stream(self, _input, config=None):
54+
effective_config = self._effective_config(config)
55+
self.seen_stream_config = effective_config
56+
return iter([effective_config])
57+
58+
def with_config(self, config=None, **_kwargs):
59+
graph = _DummyGraph()
60+
graph.bound_config = config
61+
return graph
62+
63+
2464
def _cleanup_instrumentors() -> None:
2565
for instrumentor in (
2666
DeepAgentsInstrumentor(),
@@ -50,6 +90,57 @@ def test_uninstrument_cleans_dependencies_it_instrumented():
5090
_cleanup_instrumentors()
5191

5292

93+
def test_uninstrument_restores_wrapped_graph_methods():
94+
_cleanup_instrumentors()
95+
graph = _DummyGraph()
96+
97+
try:
98+
_mark_graph(graph)
99+
_wrap_graph_methods(graph)
100+
101+
assert getattr(graph, GRAPH_METHODS_WRAPPED_ATTR) is True
102+
assert getattr(graph, REACT_AGENT_METADATA_KEY) is True
103+
assert getattr(graph, DEEPAGENTS_METADATA_KEY) is True
104+
105+
invoke_config = graph.invoke("hello")
106+
assert invoke_config["metadata"][DEEPAGENTS_METADATA_KEY] is True
107+
108+
stream_iter = graph.stream("hello")
109+
assert (
110+
graph.seen_stream_config["metadata"][DEEPAGENTS_METADATA_KEY]
111+
is True
112+
)
113+
assert next(stream_iter)["metadata"][DEEPAGENTS_METADATA_KEY] is True
114+
115+
child_graph = graph.with_config({"metadata": {"customer": "kept"}})
116+
assert getattr(child_graph, GRAPH_METHODS_WRAPPED_ATTR) is True
117+
assert getattr(child_graph, DEEPAGENTS_METADATA_KEY) is True
118+
child_config = child_graph.invoke("hello")
119+
assert child_config["metadata"]["customer"] == "kept"
120+
assert child_config["metadata"][DEEPAGENTS_METADATA_KEY] is True
121+
122+
uninstrument_create_deep_agent()
123+
124+
assert not hasattr(graph, GRAPH_METHODS_WRAPPED_ATTR)
125+
assert not hasattr(graph, GRAPH_ORIGINAL_METHODS_ATTR)
126+
assert not hasattr(graph, REACT_AGENT_METADATA_KEY)
127+
assert not hasattr(graph, DEEPAGENTS_METADATA_KEY)
128+
assert not hasattr(child_graph, GRAPH_METHODS_WRAPPED_ATTR)
129+
assert not hasattr(child_graph, GRAPH_ORIGINAL_METHODS_ATTR)
130+
assert not hasattr(child_graph, REACT_AGENT_METADATA_KEY)
131+
assert not hasattr(child_graph, DEEPAGENTS_METADATA_KEY)
132+
assert graph.invoke("hello") is None
133+
assert child_graph.invoke("hello") == {"metadata": {"customer": "kept"}}
134+
135+
graph.seen_stream_config = "not-called"
136+
stream_iter = graph.stream("hello")
137+
assert graph.seen_stream_config is None
138+
assert next(stream_iter) is None
139+
finally:
140+
uninstrument_create_deep_agent()
141+
_cleanup_instrumentors()
142+
143+
53144
def test_uninstrument_preserves_preinstrumented_dependencies():
54145
_cleanup_instrumentors()
55146
langchain_instrumentor = LangChainInstrumentor()

instrumentation-loongsuite/loongsuite-instrumentation-langchain/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
### Changed
1717

18+
- Capture OpenAI-style LangGraph and DeepAgents message dictionaries as
19+
`gen_ai.input.messages` on root `AGENT` spans.
1820
- Preserve structured LangChain tool call arguments when tool inputs do not use
1921
`input` or `query`, which keeps DeepAgents filesystem `read_file` arguments
2022
such as `file_path` available for skill-load telemetry.

instrumentation-loongsuite/loongsuite-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/internal/_tracer.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
LLMInvocation,
9797
OutputMessage,
9898
Text,
99+
ToolCall,
99100
)
100101
from opentelemetry.util.genai.utils import (
101102
ContentCapturingMode,
@@ -957,6 +958,67 @@ def _extract_langgraph_input_message(msg: Any) -> InputMessage | None:
957958
return InputMessage(role=str(role), parts=[Text(content=content)])
958959
return None
959960

961+
# OpenAI-style message dict: {"role": "user", "content": "hello"}.
962+
if isinstance(msg, dict):
963+
content = msg.get("content")
964+
if content is None:
965+
content = msg.get("text")
966+
967+
parts: list[Any] = []
968+
if isinstance(content, str) and content:
969+
parts.append(Text(content=content))
970+
elif isinstance(content, list):
971+
for part in content:
972+
if isinstance(part, str) and part:
973+
parts.append(Text(content=part))
974+
elif isinstance(part, dict):
975+
text = part.get("text")
976+
if text is None:
977+
text = part.get("content")
978+
if isinstance(text, str) and text:
979+
parts.append(Text(content=text))
980+
981+
for tool_call in msg.get("tool_calls") or []:
982+
if isinstance(tool_call, dict):
983+
function = tool_call.get("function") or {}
984+
if not isinstance(function, dict):
985+
function = {}
986+
arguments = tool_call.get("args")
987+
if arguments is None:
988+
arguments = tool_call.get("arguments")
989+
if arguments is None:
990+
arguments = function.get("arguments", {})
991+
parts.append(
992+
ToolCall(
993+
name=tool_call.get("name")
994+
or function.get("name")
995+
or "",
996+
arguments=arguments,
997+
id=tool_call.get("id"),
998+
)
999+
)
1000+
1001+
if parts:
1002+
role = msg.get("role") or msg.get("type") or "user"
1003+
role_name = str(role).lower()
1004+
if role_name.endswith("_message"):
1005+
role_name = role_name[: -len("_message")]
1006+
elif role_name.endswith("message"):
1007+
role_name = role_name[: -len("message")]
1008+
role_map = {
1009+
"human": "user",
1010+
"ai": "assistant",
1011+
"system": "system",
1012+
"function": "tool",
1013+
"tool": "tool",
1014+
"chat": "user",
1015+
}
1016+
return InputMessage(
1017+
role=role_map.get(role_name, role_name),
1018+
parts=parts,
1019+
)
1020+
return None
1021+
9601022
# LangChain message object (HumanMessage, AIMessage, etc.)
9611023
content = getattr(msg, "content", None)
9621024
if content and isinstance(content, str):

instrumentation-loongsuite/loongsuite-instrumentation-langchain/tests/test_agent_spans.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from opentelemetry.instrumentation.langchain.internal._tracer import (
2020
LoongsuiteTracer,
21+
_extract_langgraph_input_message,
2122
)
2223
from opentelemetry.instrumentation.langchain.internal._utils import (
2324
AGENT_RUN_NAMES,
@@ -77,6 +78,45 @@ def test_agent_run_names_immutable(self):
7778
assert isinstance(AGENT_RUN_NAMES, frozenset)
7879

7980

81+
def test_extract_langgraph_input_message_from_openai_style_dict():
82+
message = _extract_langgraph_input_message(
83+
{"role": "user", "content": "hello"}
84+
)
85+
86+
assert message is not None
87+
assert message.role == "user"
88+
assert len(message.parts) == 1
89+
assert message.parts[0].type == "text"
90+
assert message.parts[0].content == "hello"
91+
92+
93+
def test_extract_langgraph_input_message_keeps_empty_content_tool_call():
94+
message = _extract_langgraph_input_message(
95+
{
96+
"role": "assistant",
97+
"content": "",
98+
"tool_calls": [
99+
{
100+
"id": "call_1",
101+
"type": "function",
102+
"function": {
103+
"name": "search",
104+
"arguments": '{"query":"hello"}',
105+
},
106+
}
107+
],
108+
}
109+
)
110+
111+
assert message is not None
112+
assert message.role == "assistant"
113+
assert len(message.parts) == 1
114+
assert message.parts[0].type == "tool_call"
115+
assert message.parts[0].name == "search"
116+
assert message.parts[0].arguments == '{"query":"hello"}'
117+
assert message.parts[0].id == "call_1"
118+
119+
80120
def test_agent_context_colors_child_llm_and_tool_spans(
81121
tracer_provider, span_exporter
82122
):

0 commit comments

Comments
 (0)