Skip to content

Commit 50c7425

Browse files
Autogen Agent Instrumentation (#1412)
* Add autogen agent instrumentation. * Address review feedback. * [MegaLinter] Apply linters fixes --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent 921a750 commit 50c7425

File tree

8 files changed

+1019
-15
lines changed

8 files changed

+1019
-15
lines changed

newrelic/config.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2867,7 +2867,11 @@ def _process_module_builtin_defaults():
28672867
_process_module_definition(
28682868
"autogen_ext.tools.mcp._base", "newrelic.hooks.mlmodel_autogen", "instrument_autogen_ext_tools_mcp__base"
28692869
)
2870-
2870+
_process_module_definition(
2871+
"autogen_agentchat.agents._assistant_agent",
2872+
"newrelic.hooks.mlmodel_autogen",
2873+
"instrument_autogen_agentchat_agents__assistant_agent",
2874+
)
28712875
_process_module_definition("mcp.client.session", "newrelic.hooks.adapter_mcp", "instrument_mcp_client_session")
28722876

28732877
_process_module_definition("structlog._base", "newrelic.hooks.logger_structlog", "instrument_structlog__base")

newrelic/hooks/mlmodel_autogen.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,33 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
16+
import logging
17+
import sys
18+
import uuid
19+
1520
from newrelic.api.function_trace import FunctionTrace
21+
from newrelic.api.time_trace import get_trace_linking_metadata
1622
from newrelic.api.transaction import current_transaction
1723
from newrelic.common.object_names import callable_name
1824
from newrelic.common.object_wrapper import wrap_function_wrapper
25+
from newrelic.common.package_version_utils import get_package_version
1926
from newrelic.common.signature import bind_args
27+
from newrelic.core.config import global_settings
28+
29+
# Check for the presence of the autogen-core, autogen-agentchat, or autogen-ext package as they should all have the
30+
# same version and one or multiple could be installed
31+
AUTOGEN_VERSION = (
32+
get_package_version("autogen-core")
33+
or get_package_version("autogen-agentchat")
34+
or get_package_version("autogen-ext")
35+
)
36+
37+
38+
RECORD_EVENTS_FAILURE_LOG_MESSAGE = "Exception occurred in Autogen instrumentation: Failed to record LLM events. Please report this issue to New Relic Support.\n%s"
39+
40+
41+
_logger = logging.getLogger(__name__)
2042

2143

2244
async def wrap_from_server_params(wrapped, instance, args, kwargs):
@@ -32,6 +54,123 @@ async def wrap_from_server_params(wrapped, instance, args, kwargs):
3254
return await wrapped(*args, **kwargs)
3355

3456

57+
def wrap_on_messages_stream(wrapped, instance, args, kwargs):
58+
transaction = current_transaction()
59+
if not transaction:
60+
return wrapped(*args, **kwargs)
61+
62+
agent_name = getattr(instance, "name", "agent")
63+
func_name = callable_name(wrapped)
64+
function_trace_name = f"{func_name}/{agent_name}"
65+
with FunctionTrace(name=function_trace_name, group="Llm", source=wrapped):
66+
return wrapped(*args, **kwargs)
67+
68+
69+
def _get_llm_metadata(transaction):
70+
# Grab LLM-related custom attributes off of the transaction to store as metadata on LLM events
71+
custom_attrs_dict = transaction._custom_params
72+
llm_metadata_dict = {key: value for key, value in custom_attrs_dict.items() if key.startswith("llm.")}
73+
llm_context_attrs = getattr(transaction, "_llm_context_attrs", None)
74+
if llm_context_attrs:
75+
llm_metadata_dict.update(llm_context_attrs)
76+
77+
return llm_metadata_dict
78+
79+
80+
def _extract_tool_output(return_val, tool_name):
81+
try:
82+
output = getattr(return_val[1], "content", None)
83+
return output
84+
except Exception:
85+
_logger.warning("Unable to parse tool output value from %s. Omitting output from LlmTool event.", tool_name)
86+
return None
87+
88+
89+
def _construct_base_tool_event_dict(bound_args, tool_call_data, tool_id, transaction, settings):
90+
try:
91+
_input = getattr(tool_call_data, "arguments", None)
92+
tool_input = str(_input) if _input else None
93+
run_id = getattr(tool_call_data, "id", None)
94+
tool_name = getattr(tool_call_data, "name", "tool")
95+
agent_name = bound_args.get("agent_name")
96+
linking_metadata = get_trace_linking_metadata()
97+
98+
tool_event_dict = {
99+
"id": tool_id,
100+
"run_id": run_id,
101+
"name": tool_name,
102+
"span_id": linking_metadata.get("span.id"),
103+
"trace_id": linking_metadata.get("trace.id"),
104+
"agent_name": agent_name,
105+
"vendor": "autogen",
106+
"ingest_source": "Python",
107+
}
108+
if settings.ai_monitoring.record_content.enabled:
109+
tool_event_dict.update({"input": tool_input})
110+
tool_event_dict.update(_get_llm_metadata(transaction))
111+
except Exception:
112+
tool_event_dict = {}
113+
_logger.warning(RECORD_EVENTS_FAILURE_LOG_MESSAGE, exc_info=True)
114+
115+
return tool_event_dict
116+
117+
118+
async def wrap__execute_tool_call(wrapped, instance, args, kwargs):
119+
transaction = current_transaction()
120+
if not transaction:
121+
return await wrapped(*args, **kwargs)
122+
123+
settings = transaction.settings or global_settings()
124+
if not settings.ai_monitoring.enabled:
125+
return await wrapped(*args, **kwargs)
126+
127+
# Framework metric also used for entity tagging in the UI
128+
transaction.add_ml_model_info("Autogen", AUTOGEN_VERSION)
129+
transaction._add_agent_attribute("llm", True)
130+
131+
tool_id = str(uuid.uuid4())
132+
bound_args = bind_args(wrapped, args, kwargs)
133+
tool_call_data = bound_args.get("tool_call")
134+
tool_event_dict = _construct_base_tool_event_dict(bound_args, tool_call_data, tool_id, transaction, settings)
135+
136+
tool_name = getattr(tool_call_data, "name", "tool")
137+
138+
func_name = callable_name(wrapped)
139+
ft = FunctionTrace(name=f"{func_name}/{tool_name}", group="Llm/tool/Autogen")
140+
ft.__enter__()
141+
142+
try:
143+
return_val = await wrapped(*args, **kwargs)
144+
except Exception:
145+
ft.notice_error(attributes={"tool_id": tool_id})
146+
ft.__exit__(*sys.exc_info())
147+
# If we hit an exception, append the error attribute and duration from the exited function trace
148+
tool_event_dict.update({"duration": ft.duration * 1000, "error": True})
149+
transaction.record_custom_event("LlmTool", tool_event_dict)
150+
raise
151+
152+
ft.__exit__(None, None, None)
153+
154+
tool_event_dict.update({"duration": ft.duration * 1000})
155+
156+
# If the tool was executed successfully, we can grab the tool output from the result
157+
tool_output = _extract_tool_output(return_val, tool_name)
158+
if settings.ai_monitoring.record_content.enabled:
159+
tool_event_dict.update({"output": tool_output})
160+
161+
transaction.record_custom_event("LlmTool", tool_event_dict)
162+
163+
return return_val
164+
165+
166+
def instrument_autogen_agentchat_agents__assistant_agent(module):
167+
if hasattr(module, "AssistantAgent"):
168+
if hasattr(module.AssistantAgent, "on_messages_stream"):
169+
wrap_function_wrapper(module, "AssistantAgent.on_messages_stream", wrap_on_messages_stream)
170+
if hasattr(module.AssistantAgent, "_execute_tool_call"):
171+
wrap_function_wrapper(module, "AssistantAgent._execute_tool_call", wrap__execute_tool_call)
172+
173+
35174
def instrument_autogen_ext_tools_mcp__base(module):
36175
if hasattr(module, "McpToolAdapter"):
37176
if hasattr(module.McpToolAdapter, "from_server_params"):

tests/mlmodel_autogen/conftest.py

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,24 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import json
16+
17+
import pytest
18+
from autogen_agentchat.agents import AssistantAgent
19+
from autogen_agentchat.base import TaskResult
20+
from autogen_agentchat.teams import RoundRobinGroupChat
21+
from autogen_core import ComponentModel, FunctionCall, Image
22+
from autogen_core.models import CreateResult, RequestUsage
23+
from autogen_core.models._model_client import ModelFamily
24+
from autogen_ext.models.replay import ReplayChatCompletionClient
25+
from pydantic import BaseModel, ValidationError
1526
from testing_support.fixture.event_loop import event_loop as loop
1627
from testing_support.fixtures import collector_agent_registration_fixture, collector_available_fixture
1728

29+
from newrelic.common.object_names import callable_name
30+
1831
_default_settings = {
19-
"package_reporting.enabled": False,
32+
"package_reporting.enabled": False, # Turn off package reporting for testing as it causes slowdowns.
2033
"transaction_tracer.explain_threshold": 0.0,
2134
"transaction_tracer.transaction_threshold": 0.0,
2235
"transaction_tracer.stack_trace_threshold": 0.0,
@@ -28,3 +41,133 @@
2841
collector_agent_registration = collector_agent_registration_fixture(
2942
app_name="Python Agent Test (mlmodel_autogen)", default_settings=_default_settings
3043
)
44+
45+
46+
@pytest.fixture
47+
def single_tool_model_client():
48+
model_client = ReplayChatCompletionClient(
49+
[
50+
CreateResult(
51+
finish_reason="function_calls",
52+
content=[FunctionCall(id="1", arguments=json.dumps({"message": "Hello"}), name="add_exclamation")],
53+
usage=RequestUsage(prompt_tokens=10, completion_tokens=5),
54+
cached=False,
55+
),
56+
"Hello",
57+
"TERMINATE",
58+
],
59+
model_info={
60+
"function_calling": True,
61+
"vision": True,
62+
"json_output": True,
63+
"family": "gpt-4.1-nano",
64+
"structured_output": True,
65+
},
66+
)
67+
return model_client
68+
69+
70+
@pytest.fixture
71+
def single_tool_model_client_error():
72+
model_client = ReplayChatCompletionClient(
73+
[
74+
CreateResult(
75+
finish_reason="function_calls",
76+
# Set arguments to an invalid type to trigger error in tool
77+
content=[FunctionCall(id="1", arguments=12, name="add_exclamation")],
78+
usage=RequestUsage(prompt_tokens=10, completion_tokens=5),
79+
cached=False,
80+
),
81+
"Hello",
82+
"TERMINATE",
83+
],
84+
model_info={
85+
"function_calling": True,
86+
"vision": True,
87+
"json_output": True,
88+
"family": "gpt-4.1-nano",
89+
"structured_output": True,
90+
},
91+
)
92+
return model_client
93+
94+
95+
@pytest.fixture
96+
def multi_tool_model_client():
97+
model_client = ReplayChatCompletionClient(
98+
chat_completions=[
99+
CreateResult(
100+
finish_reason="function_calls",
101+
content=[FunctionCall(id="1", name="add_exclamation", arguments=json.dumps({"message": "Hello"}))],
102+
usage=RequestUsage(prompt_tokens=10, completion_tokens=5),
103+
cached=False,
104+
),
105+
CreateResult(
106+
finish_reason="function_calls",
107+
content=[FunctionCall(id="2", name="add_exclamation", arguments=json.dumps({"message": "Goodbye"}))],
108+
usage=RequestUsage(prompt_tokens=10, completion_tokens=5),
109+
cached=False,
110+
),
111+
CreateResult(
112+
finish_reason="function_calls",
113+
content=[FunctionCall(id="3", name="compute_sum", arguments=json.dumps({"a": 5, "b": 3}))],
114+
usage=RequestUsage(prompt_tokens=10, completion_tokens=5),
115+
cached=False,
116+
),
117+
CreateResult(
118+
finish_reason="function_calls",
119+
content=[FunctionCall(id="4", name="compute_sum", arguments=json.dumps({"a": 123, "b": 2}))],
120+
usage=RequestUsage(prompt_tokens=10, completion_tokens=5),
121+
cached=False,
122+
),
123+
],
124+
model_info={
125+
"family": "gpt-4.1-nano",
126+
"function_calling": True,
127+
"json_output": True,
128+
"vision": True,
129+
"structured_output": True,
130+
},
131+
)
132+
return model_client
133+
134+
135+
@pytest.fixture
136+
def multi_tool_model_client_error():
137+
model_client = ReplayChatCompletionClient(
138+
chat_completions=[
139+
CreateResult(
140+
finish_reason="function_calls",
141+
content=[FunctionCall(id="1", name="add_exclamation", arguments=json.dumps({"message": "Hello"}))],
142+
usage=RequestUsage(prompt_tokens=10, completion_tokens=5),
143+
cached=False,
144+
),
145+
CreateResult(
146+
finish_reason="function_calls",
147+
content=[FunctionCall(id="2", name="add_exclamation", arguments=json.dumps({"message": "Goodbye"}))],
148+
usage=RequestUsage(prompt_tokens=10, completion_tokens=5),
149+
cached=False,
150+
),
151+
CreateResult(
152+
finish_reason="function_calls",
153+
content=[FunctionCall(id="3", name="compute_sum", arguments=json.dumps({"a": 5, "b": 3}))],
154+
usage=RequestUsage(prompt_tokens=10, completion_tokens=5),
155+
cached=False,
156+
),
157+
CreateResult(
158+
finish_reason="function_calls",
159+
# Set arguments to an invalid type to trigger error in tool
160+
content=[FunctionCall(id="4", name="compute_sum", arguments=12)],
161+
usage=RequestUsage(prompt_tokens=10, completion_tokens=5),
162+
cached=False,
163+
),
164+
],
165+
model_info={
166+
"family": "gpt-4.1-nano",
167+
"function_calling": True,
168+
"json_output": True,
169+
"vision": True,
170+
"structured_output": True,
171+
},
172+
)
173+
return model_client

0 commit comments

Comments
 (0)