Skip to content

Commit 40bf75c

Browse files
ncybulKyle-Verhoog
andauthored
feat(llmobs): [MLOB-2662] [MLOB-3100] add agent manifest (#13311)
This PR adds instrumentation for the agent manifest for our LLM Obs agentic integrations (minus LangGraph which will be covered in @sabrenner's [PR](#13904)). I have been using [this doc](https://docs.google.com/document/d/1k-vHYQZfCdckvoH29Mi_TzXuNGBLf3KpypZDpm4vFio/edit?pli=1&tab=t.0) to track what information is available in the Agent Manifest for each integration, but a summary is below: ## Agent Manifest Schema ### Crew AI - Framework - Name - Goal - Backstory - Model - Model Settings - Tools - Handoffs - Code Execution Permissions - Max Iterations <img width="1744" height="918" alt="image" src="https://github.com/user-attachments/assets/d85ac5a7-bbf9-4098-a4a2-93116be6d007" /> ### Open AI Agents - Framework - Name - Instructions - Handoff Description - Model - Model Settings - Tools - Handoffs - Guardrails <img width="1650" height="938" alt="image" src="https://github.com/user-attachments/assets/88f5034c-1846-441d-9495-49ddf7b075ad" /> ### PydanticAI - Framework - Name - Instructions - System Prompts - Model - Model Settings - Tools - Dependencies <img width="548" height="830" alt="image" src="https://github.com/user-attachments/assets/4cbf3466-9de0-475f-808a-43c86e3c45cf" /> ## Next Steps - Make the current Agent Manifest UI work for the new fields that have been added ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --------- Co-authored-by: Kyle Verhoog <[email protected]>
1 parent c2fb069 commit 40bf75c

File tree

15 files changed

+730
-108
lines changed

15 files changed

+730
-108
lines changed

ddtrace/contrib/internal/openai_agents/patch.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55

66
from ddtrace import config
77
from ddtrace.contrib.internal.openai_agents.processor import LLMObsTraceProcessor
8+
from ddtrace.contrib.trace_utils import unwrap
9+
from ddtrace.contrib.trace_utils import with_traced_module_async
10+
from ddtrace.contrib.trace_utils import wrap
11+
from ddtrace.internal.utils.version import parse_version
812
from ddtrace.llmobs._integrations.openai_agents import OpenAIAgentsIntegration
913
from ddtrace.trace import Pin
1014

@@ -22,6 +26,29 @@ def _supported_versions() -> Dict[str, str]:
2226
return {"agents": ">=0.0.2"}
2327

2428

29+
OPENAI_AGENTS_VERSION = parse_version(get_version())
30+
31+
32+
@with_traced_module_async
33+
async def patched_run_single_turn(agents, pin, func, instance, args, kwargs):
34+
return await _patched_run_single_turn(agents, pin, func, instance, args, kwargs, agent_index=0)
35+
36+
37+
@with_traced_module_async
38+
async def patched_run_single_turn_streamed(agents, pin, func, instance, args, kwargs):
39+
return await _patched_run_single_turn(agents, pin, func, instance, args, kwargs, agent_index=1)
40+
41+
42+
async def _patched_run_single_turn(agents, pin, func, instance, args, kwargs, agent_index=0):
43+
current_span = pin.tracer.current_span()
44+
result = await func(*args, **kwargs)
45+
46+
integration = agents._datadog_integration
47+
integration.tag_agent_manifest(current_span, args, kwargs, agent_index)
48+
49+
return result
50+
51+
2552
def patch():
2653
"""
2754
Patch the instrumented methods
@@ -33,7 +60,16 @@ def patch():
3360

3461
Pin().onto(agents)
3562

36-
add_trace_processor(LLMObsTraceProcessor(OpenAIAgentsIntegration(integration_config=config.openai_agents)))
63+
integration = OpenAIAgentsIntegration(integration_config=config.openai_agents)
64+
add_trace_processor(LLMObsTraceProcessor(integration))
65+
agents._datadog_integration = integration
66+
67+
if OPENAI_AGENTS_VERSION >= (0, 0, 19):
68+
wrap(agents.run.AgentRunner, "_run_single_turn", patched_run_single_turn(agents))
69+
wrap(agents.run.AgentRunner, "_run_single_turn_streamed", patched_run_single_turn_streamed(agents))
70+
else:
71+
wrap(agents.run.Runner, "_run_single_turn", patched_run_single_turn(agents))
72+
wrap(agents.run.Runner, "_run_single_turn_streamed", patched_run_single_turn_streamed(agents))
3773

3874

3975
def unpatch():
@@ -44,3 +80,10 @@ def unpatch():
4480
return
4581

4682
agents._datadog_patch = False
83+
84+
if OPENAI_AGENTS_VERSION >= (0, 0, 19):
85+
unwrap(agents.run.AgentRunner, "_run_single_turn")
86+
unwrap(agents.run.AgentRunner, "_run_single_turn_streamed")
87+
else:
88+
unwrap(agents.run.Runner, "_run_single_turn")
89+
unwrap(agents.run.Runner, "_run_single_turn_streamed")

ddtrace/llmobs/_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
PROPAGATED_LLMOBS_TRACE_ID_KEY = "_dd.p.llmobs_trace_id"
1010
LLMOBS_TRACE_ID = "_ml_obs.llmobs_trace_id"
1111
TAGS = "_ml_obs.tags"
12+
AGENT_MANIFEST = "_ml_obs.meta.agent_manifest"
1213

1314
MODEL_NAME = "_ml_obs.meta.model_name"
1415
MODEL_PROVIDER = "_ml_obs.meta.model_provider"

ddtrace/llmobs/_integrations/crewai.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ddtrace.internal.logger import get_logger
99
from ddtrace.internal.utils import get_argument_value
1010
from ddtrace.internal.utils.formats import format_trace_id
11+
from ddtrace.llmobs._constants import AGENT_MANIFEST
1112
from ddtrace.llmobs._constants import INPUT_VALUE
1213
from ddtrace.llmobs._constants import METADATA
1314
from ddtrace.llmobs._constants import NAME
@@ -151,9 +152,8 @@ def _llmobs_set_tags_agent(self, span, args, kwargs, response):
151152
Agent spans are 1:1 with its parent (task/tool) span, so we link them directly here, even on the parent itself.
152153
"""
153154
agent_instance = kwargs.get("instance")
155+
self._tag_agent_manifest(span, agent_instance)
154156
agent_role = getattr(agent_instance, "role", "")
155-
agent_goal = getattr(agent_instance, "goal", "")
156-
agent_backstory = getattr(agent_instance, "backstory", "")
157157
task_description = getattr(kwargs.get("task"), "description", "")
158158
context = get_argument_value(args, kwargs, 1, "context", optional=True) or ""
159159

@@ -174,7 +174,6 @@ def _llmobs_set_tags_agent(self, span, args, kwargs, response):
174174
span._set_ctx_items(
175175
{
176176
NAME: agent_role if agent_role else "CrewAI Agent",
177-
METADATA: {"description": agent_goal, "backstory": agent_backstory},
178177
INPUT_VALUE: {"context": context, "input": task_description},
179178
SPAN_LINKS: curr_span_links + [span_link],
180179
}
@@ -198,6 +197,56 @@ def _llmobs_set_tags_tool(self, span, args, kwargs, response):
198197
return
199198
span._set_ctx_item(OUTPUT_VALUE, response)
200199

200+
def _tag_agent_manifest(self, span, agent):
201+
if not agent:
202+
return
203+
204+
manifest = {}
205+
manifest["framework"] = "CrewAI"
206+
manifest["name"] = agent.role if hasattr(agent, "role") and agent.role else "CrewAI Agent"
207+
if hasattr(agent, "goal"):
208+
manifest["goal"] = agent.goal
209+
if hasattr(agent, "backstory"):
210+
manifest["backstory"] = agent.backstory
211+
if hasattr(agent, "llm"):
212+
if hasattr(agent.llm, "model"):
213+
manifest["model"] = agent.llm.model
214+
model_settings = {}
215+
if hasattr(agent.llm, "max_tokens"):
216+
model_settings["max_tokens"] = agent.llm.max_tokens
217+
if hasattr(agent.llm, "temperature"):
218+
model_settings["temperature"] = agent.llm.temperature
219+
if model_settings:
220+
manifest["model_settings"] = model_settings
221+
if hasattr(agent, "allow_delegation"):
222+
manifest["handoffs"] = {"allow_delegation": agent.allow_delegation}
223+
code_execution_permissions = {}
224+
if hasattr(agent, "allow_code_execution"):
225+
manifest["code_execution_permissions"] = {"allow_code_execution": agent.allow_code_execution}
226+
if hasattr(agent, "code_execution_mode"):
227+
manifest["code_execution_permissions"] = {"code_execution_mode": agent.code_execution_mode}
228+
if code_execution_permissions:
229+
manifest["code_execution_permissions"] = code_execution_permissions
230+
if hasattr(agent, "max_iter"):
231+
manifest["max_iterations"] = agent.max_iter
232+
if hasattr(agent, "tools"):
233+
manifest["tools"] = self._get_agent_tools(agent.tools)
234+
235+
span._set_ctx_item(AGENT_MANIFEST, manifest)
236+
237+
def _get_agent_tools(self, tools):
238+
if not tools or not isinstance(tools, list):
239+
return []
240+
formatted_tools = []
241+
for tool in tools:
242+
tool_dict = {}
243+
if hasattr(tool, "name"):
244+
tool_dict["name"] = tool.name
245+
if hasattr(tool, "description"):
246+
tool_dict["description"] = tool.description
247+
formatted_tools.append(tool_dict)
248+
return formatted_tools
249+
201250
def _llmobs_set_span_link_on_task(self, span, args, kwargs):
202251
"""Set span links for the next queued task in a CrewAI workflow.
203252
This happens between task executions, (the current span is the crew span and the task span hasn't started yet)

ddtrace/llmobs/_integrations/openai_agents.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
from ddtrace.internal import core
1010
from ddtrace.internal.logger import get_logger
11+
from ddtrace.internal.utils import get_argument_value
1112
from ddtrace.internal.utils.formats import format_trace_id
13+
from ddtrace.llmobs._constants import AGENT_MANIFEST
1214
from ddtrace.llmobs._constants import DISPATCH_ON_LLM_TOOL_CHOICE
1315
from ddtrace.llmobs._constants import DISPATCH_ON_TOOL_CALL
1416
from ddtrace.llmobs._constants import DISPATCH_ON_TOOL_CALL_OUTPUT_USED
@@ -31,6 +33,7 @@
3133
from ddtrace.llmobs._integrations.utils import OaiTraceAdapter
3234
from ddtrace.llmobs._utils import _get_nearest_llmobs_ancestor
3335
from ddtrace.llmobs._utils import _get_span_name
36+
from ddtrace.llmobs._utils import load_data_value
3437
from ddtrace.trace import Pin
3538
from ddtrace.trace import Span
3639

@@ -296,3 +299,145 @@ def _llmobs_get_trace_info(
296299
def clear_state(self) -> None:
297300
self.oai_to_llmobs_span.clear()
298301
self.llmobs_traces.clear()
302+
303+
def tag_agent_manifest(self, span: Span, args: List[Any], kwargs: Dict[str, Any], agent_index: int) -> None:
304+
agent = get_argument_value(args, kwargs, agent_index, "agent", True)
305+
if not agent or not self.llmobs_enabled:
306+
return
307+
308+
manifest = {}
309+
manifest["framework"] = "OpenAI"
310+
if hasattr(agent, "name"):
311+
manifest["name"] = agent.name
312+
if hasattr(agent, "instructions"):
313+
manifest["instructions"] = agent.instructions
314+
if hasattr(agent, "handoff_description"):
315+
manifest["handoff_description"] = agent.handoff_description
316+
if hasattr(agent, "model"):
317+
model = agent.model
318+
manifest["model"] = model if isinstance(model, str) else getattr(model, "model", "")
319+
320+
model_settings = self._extract_model_settings_from_agent(agent)
321+
if model_settings:
322+
manifest["model_settings"] = model_settings
323+
324+
tools = self._extract_tools_from_agent(agent)
325+
if tools:
326+
manifest["tools"] = tools
327+
328+
handoffs = self._extract_handoffs_from_agent(agent)
329+
if handoffs:
330+
manifest["handoffs"] = handoffs
331+
332+
guardrails = self._extract_guardrails_from_agent(agent)
333+
if guardrails:
334+
manifest["guardrails"] = guardrails
335+
336+
span._set_ctx_item(AGENT_MANIFEST, manifest)
337+
338+
def _extract_model_settings_from_agent(self, agent):
339+
if not hasattr(agent, "model_settings"):
340+
return None
341+
342+
# convert model_settings to dict if it's not already
343+
model_settings = agent.model_settings
344+
if type(model_settings) != dict:
345+
model_settings = getattr(model_settings, "__dict__", None)
346+
347+
return load_data_value(model_settings)
348+
349+
def _extract_tools_from_agent(self, agent):
350+
if not hasattr(agent, "tools") or not agent.tools:
351+
return None
352+
353+
tools = []
354+
for tool in agent.tools:
355+
tool_dict = {}
356+
tool_name = getattr(tool, "name", None)
357+
if tool_name:
358+
tool_dict["name"] = tool_name
359+
if tool_name == "web_search_preview":
360+
if hasattr(tool, "user_location"):
361+
tool_dict["user_location"] = tool.user_location
362+
if hasattr(tool, "search_context_size"):
363+
tool_dict["search_context_size"] = tool.search_context_size
364+
elif tool_name == "file_search":
365+
if hasattr(tool, "vector_store_ids"):
366+
tool_dict["vector_store_ids"] = tool.vector_store_ids
367+
if hasattr(tool, "max_num_results"):
368+
tool_dict["max_num_results"] = tool.max_num_results
369+
if hasattr(tool, "include_search_results"):
370+
tool_dict["include_search_results"] = tool.include_search_results
371+
if hasattr(tool, "ranking_options"):
372+
tool_dict["ranking_options"] = tool.ranking_options
373+
if hasattr(tool, "filters"):
374+
tool_dict["filters"] = tool.filters
375+
elif tool_name == "computer_use_preview":
376+
if hasattr(tool, "computer"):
377+
tool_dict["computer"] = tool.computer
378+
if hasattr(tool, "on_safety_check"):
379+
tool_dict["on_safety_check"] = tool.on_safety_check
380+
elif tool_name == "code_interpreter":
381+
if hasattr(tool, "tool_config"):
382+
tool_dict["tool_config"] = tool.tool_config
383+
elif tool_name == "hosted_mcp":
384+
if hasattr(tool, "tool_config"):
385+
tool_dict["tool_config"] = tool.tool_config
386+
if hasattr(tool, "on_approval_request"):
387+
tool_dict["on_approval_request"] = tool.on_approval_request
388+
elif tool_name == "image_generation":
389+
if hasattr(tool, "tool_config"):
390+
tool_dict["tool_config"] = tool.tool_config
391+
elif tool_name == "local_shell":
392+
if hasattr(tool, "executor"):
393+
tool_dict["executor"] = tool.executor
394+
else:
395+
if hasattr(tool, "description"):
396+
tool_dict["description"] = tool.description
397+
if hasattr(tool, "strict_json_schema"):
398+
tool_dict["strict_json_schema"] = tool.strict_json_schema
399+
if hasattr(tool, "params_json_schema"):
400+
parameter_schema = tool.params_json_schema
401+
required_params = {param: True for param in parameter_schema.get("required", [])}
402+
parameters = {}
403+
for param, schema in parameter_schema.get("properties", {}).items():
404+
param_dict = {}
405+
if "type" in schema:
406+
param_dict["type"] = schema["type"]
407+
if "title" in schema:
408+
param_dict["title"] = schema["title"]
409+
if param in required_params:
410+
param_dict["required"] = True
411+
parameters[param] = param_dict
412+
tool_dict["parameters"] = parameters
413+
tools.append(tool_dict)
414+
415+
return tools
416+
417+
def _extract_handoffs_from_agent(self, agent):
418+
if not hasattr(agent, "handoffs") or not agent.handoffs:
419+
return None
420+
421+
handoffs = []
422+
for handoff in agent.handoffs:
423+
handoff_dict = {}
424+
if hasattr(handoff, "handoff_description") or hasattr(handoff, "tool_description"):
425+
handoff_dict["handoff_description"] = getattr(handoff, "handoff_description", None) or getattr(
426+
handoff, "tool_description", None
427+
)
428+
if hasattr(handoff, "name") or hasattr(handoff, "agent_name"):
429+
handoff_dict["agent_name"] = getattr(handoff, "name", None) or getattr(handoff, "agent_name", None)
430+
if hasattr(handoff, "tool_name"):
431+
handoff_dict["tool_name"] = handoff.tool_name
432+
if handoff_dict:
433+
handoffs.append(handoff_dict)
434+
435+
return handoffs
436+
437+
def _extract_guardrails_from_agent(self, agent):
438+
guardrails = []
439+
if hasattr(agent, "input_guardrails"):
440+
guardrails.extend([getattr(guardrail, "name", "") for guardrail in agent.input_guardrails])
441+
if hasattr(agent, "output_guardrails"):
442+
guardrails.extend([getattr(guardrail, "name", "") for guardrail in agent.output_guardrails])
443+
return guardrails

0 commit comments

Comments
 (0)