Skip to content

Commit fb292d0

Browse files
authored
fix(sdk): @agent decorator no longer overwrites enclosing @workflow name (#4288)
1 parent fc33f1c commit fb292d0

2 files changed

Lines changed: 130 additions & 6 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Regression tests for the @agent decorator's effect on workflow_name context.
2+
3+
Before the fix in `_setup_span`, @agent unconditionally wrote
4+
`traceloop.workflow.name = <agent_name>` into the OTel context, clobbering
5+
the name set by an enclosing @workflow. Any child span (LLM or manual)
6+
created inside the agent then inherited the wrong workflow name, breaking
7+
downstream aggregations that group by (agent_name, workflow_name).
8+
9+
These tests pin the fixed behavior:
10+
- @agent nested inside @workflow inherits workflow_name from the workflow.
11+
- The same agent name running under two different workflows stays distinct.
12+
- A bare @agent (no enclosing @workflow) leaves workflow_name unset.
13+
"""
14+
15+
from opentelemetry import trace
16+
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import (
17+
GEN_AI_AGENT_NAME,
18+
)
19+
from opentelemetry.semconv_ai import SpanAttributes
20+
21+
from traceloop.sdk.decorators import agent, task, workflow
22+
23+
24+
def _make_child_span(name: str) -> None:
25+
"""Create and immediately end a manual span, simulating a child LLM call."""
26+
tracer = trace.get_tracer(__name__)
27+
with tracer.start_as_current_span(name):
28+
pass
29+
30+
31+
def test_agent_inside_workflow_inherits_workflow_name(exporter):
32+
"""@agent nested inside @workflow must NOT overwrite workflow_name."""
33+
34+
@agent(name="planner")
35+
def planner_agent():
36+
_make_child_span("child.llm")
37+
38+
@workflow(name="rag")
39+
def rag_workflow():
40+
planner_agent()
41+
42+
rag_workflow()
43+
44+
spans = exporter.get_finished_spans()
45+
by_name = {span.name: span for span in spans}
46+
47+
workflow_span = by_name["rag.workflow"]
48+
agent_span = by_name["planner.agent"]
49+
child_span = by_name["child.llm"]
50+
51+
assert workflow_span.attributes[SpanAttributes.TRACELOOP_WORKFLOW_NAME] == "rag"
52+
53+
assert agent_span.attributes[SpanAttributes.TRACELOOP_WORKFLOW_NAME] == "rag"
54+
assert agent_span.attributes[GEN_AI_AGENT_NAME] == "planner"
55+
56+
assert child_span.attributes[SpanAttributes.TRACELOOP_WORKFLOW_NAME] == "rag"
57+
assert child_span.attributes[GEN_AI_AGENT_NAME] == "planner"
58+
59+
60+
def test_same_agent_under_two_workflows_stays_distinct(exporter):
61+
"""Two workflows sharing one @agent name keep their own workflow_name on child spans.
62+
63+
This is the scenario that motivated the fix: pre-fix, both rag and summarize
64+
workflows had child spans tagged workflow_name="planner", collapsing the
65+
aggregator's (agent_name, workflow_name) groups into one.
66+
"""
67+
68+
@agent(name="planner")
69+
def planner_for_rag():
70+
_make_child_span("rag.child")
71+
72+
@agent(name="planner")
73+
def planner_for_summarize():
74+
_make_child_span("summarize.child")
75+
76+
@workflow(name="rag")
77+
def rag_workflow():
78+
planner_for_rag()
79+
80+
@workflow(name="summarize")
81+
def summarize_workflow():
82+
planner_for_summarize()
83+
84+
@task(name="outer")
85+
def outer():
86+
rag_workflow()
87+
summarize_workflow()
88+
89+
outer()
90+
91+
spans = exporter.get_finished_spans()
92+
by_name = {span.name: span for span in spans}
93+
94+
rag_child = by_name["rag.child"]
95+
summarize_child = by_name["summarize.child"]
96+
97+
assert rag_child.attributes[SpanAttributes.TRACELOOP_WORKFLOW_NAME] == "rag"
98+
assert rag_child.attributes[GEN_AI_AGENT_NAME] == "planner"
99+
100+
assert summarize_child.attributes[SpanAttributes.TRACELOOP_WORKFLOW_NAME] == "summarize"
101+
assert summarize_child.attributes[GEN_AI_AGENT_NAME] == "planner"
102+
103+
104+
def test_bare_agent_does_not_set_workflow_name(exporter):
105+
"""A bare @agent (no enclosing @workflow) must NOT set workflow_name.
106+
107+
Pins the deliberate Option B semantics: an agent is not a workflow.
108+
Previously the agent's own name was used as workflow_name, which made
109+
`(agent_name, workflow_name)` groupings impossible to disambiguate.
110+
"""
111+
112+
@agent(name="solo")
113+
def solo_agent():
114+
_make_child_span("solo.child")
115+
116+
solo_agent()
117+
118+
spans = exporter.get_finished_spans()
119+
by_name = {span.name: span for span in spans}
120+
121+
agent_span = by_name["solo.agent"]
122+
child_span = by_name["solo.child"]
123+
124+
assert SpanAttributes.TRACELOOP_WORKFLOW_NAME not in agent_span.attributes
125+
assert agent_span.attributes[GEN_AI_AGENT_NAME] == "solo"
126+
127+
assert SpanAttributes.TRACELOOP_WORKFLOW_NAME not in child_span.attributes
128+
assert child_span.attributes[GEN_AI_AGENT_NAME] == "solo"

packages/traceloop-sdk/traceloop/sdk/decorators/base.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,9 @@ def _is_async_method(fn):
137137

138138
def _setup_span(entity_name, tlp_span_kind, version):
139139
"""Sets up the OpenTelemetry span and context"""
140-
if tlp_span_kind in [
141-
TraceloopSpanKindValues.WORKFLOW,
142-
TraceloopSpanKindValues.AGENT,
143-
]:
140+
if tlp_span_kind == TraceloopSpanKindValues.WORKFLOW:
144141
set_workflow_name(entity_name)
145-
146-
if tlp_span_kind == TraceloopSpanKindValues.AGENT:
142+
elif tlp_span_kind == TraceloopSpanKindValues.AGENT:
147143
set_agent_name(entity_name)
148144

149145
span_name = f"{entity_name}.{tlp_span_kind.value}"

0 commit comments

Comments
 (0)