From 88e33c563b1965627f0eed4c5d1d30ba7d468727 Mon Sep 17 00:00:00 2001 From: Prathamesh Sonpatki Date: Mon, 16 Mar 2026 11:47:51 +0530 Subject: [PATCH 1/5] Add first-class agent identity tracking using OTel GenAI semantic conventions Add gen_ai.agent.id, gen_ai.agent.name, gen_ai.agent.description, and gen_ai.agent.version attributes following the OpenTelemetry GenAI semantic conventions for agent spans. This enables tracking agent identity across multi-agent systems with automatic context propagation. - Add agent_context() context manager (same pattern as conversation_context) - Auto-propagate agent attributes via Last9SpanProcessor - Add create_agent/invoke_agent operation names - Add agent_tracking.py example - Update README and context_tracking example Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 33 +++++++++ examples/agent_tracking.py | 137 +++++++++++++++++++++++++++++++++++ examples/context_tracking.py | 28 ++++++- last9_genai/__init__.py | 2 + last9_genai/context.py | 77 ++++++++++++++++++++ last9_genai/core.py | 8 ++ last9_genai/processor.py | 13 ++++ 7 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 examples/agent_tracking.py diff --git a/README.md b/README.md index 2d67a75..362aed7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ **Key Features:** - šŸŽÆ **Conversation Tracking**: Automatic multi-turn conversation tracking with `conversation_context` +- šŸ¤– **Agent Tracking**: First-class agent identity with `agent_context` (OTel `gen_ai.agent.*` semantic conventions) - šŸ”„ **Workflow Management**: Track complex multi-step AI workflows with `workflow_context` - šŸŽØ **Zero-Touch Instrumentation**: `@observe()` decorator for automatic tracking - šŸ“Š **Context Propagation**: Thread-safe attribute tracking across nested operations @@ -25,6 +26,7 @@ ### Core Tracking - šŸŽÆ **Conversation Tracking**: Multi-turn conversations with `gen_ai.conversation.id` and turn numbers +- šŸ¤– **Agent Identity**: Track agents with `gen_ai.agent.id`, `gen_ai.agent.name`, `gen_ai.agent.version` (OTel semantic conventions) - šŸ”„ **Workflow Management**: Track multi-step AI operations across LLM calls, tools, and retrievals - šŸ“Š **Auto-Context Propagation**: Thread-safe context managers that automatically tag all nested operations - šŸŽØ **Decorator Pattern**: `@observe()` for zero-touch instrumentation with full input/output/latency tracking @@ -143,6 +145,31 @@ with conversation_context(conversation_id="support_123"): result = lookup_and_respond() ``` +### Track Agents + +Track agent identity using [OTel GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/) (`gen_ai.agent.*`): + +```python +from last9_genai import agent_context + +# Track agent identity — all child spans get gen_ai.agent.* attributes +with agent_context(agent_id="support_bot_v2", agent_name="Support Bot", agent_version="2.0"): + response = client.chat.completions.create( + model="gpt-4o", + messages=[{"role": "user", "content": "Help me with my order"}] + ) + # Span automatically has gen_ai.agent.id, gen_ai.agent.name, gen_ai.agent.version + +# Nest with conversations for full context +with conversation_context(conversation_id="session_123", user_id="user_456"): + with agent_context(agent_id="router_agent", agent_name="Router"): + route = classify_intent(query) + + with agent_context(agent_id="support_agent", agent_name="Support"): + response = handle_support(query) + # Each agent's spans are tagged separately, both share the conversation +``` + ### Decorator Pattern (Zero-Touch) Use `@observe()` for automatic tracking of everything: @@ -479,6 +506,11 @@ workflow.llm_calls = 3 # Conversation gen_ai.conversation.id = "session_123" gen_ai.conversation.turn_number = 2 + +# Agent (OTel GenAI semantic conventions) +gen_ai.agent.id = "support_bot_v2" +gen_ai.agent.name = "Support Bot" +gen_ai.agent.version = "2.0" ``` ## Model Pricing @@ -569,6 +601,7 @@ See [`examples/`](./examples/) directory: **Advanced:** - [`conversation_tracking.py`](./examples/conversation_tracking.py) - Multi-turn conversations +- [`agent_tracking.py`](./examples/agent_tracking.py) - Agent identity tracking with OTel semantic conventions ## Contributing diff --git a/examples/agent_tracking.py b/examples/agent_tracking.py new file mode 100644 index 0000000..eb69e6f --- /dev/null +++ b/examples/agent_tracking.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Agent identity tracking example + +Demonstrates tracking agent identity using OTel GenAI semantic conventions +(gen_ai.agent.id, gen_ai.agent.name, gen_ai.agent.version). + +This is useful for multi-agent systems where you need to attribute spans +to specific agents and correlate their interactions. +""" + +import sys +import os + +sys.path.append(os.path.dirname(os.path.dirname(__file__))) + +import time +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter +from last9_genai import ( + Last9SpanProcessor, + ModelPricing, + agent_context, + conversation_context, + workflow_context, +) + + +def setup_tracing(): + """Set up OpenTelemetry tracing with Last9 auto-enrichment""" + provider = TracerProvider() + trace.set_tracer_provider(provider) + + console_exporter = ConsoleSpanExporter() + provider.add_span_processor(BatchSpanProcessor(console_exporter)) + + custom_pricing = { + "gpt-4o": ModelPricing(input=2.50, output=10.0), + "gpt-4o-mini": ModelPricing(input=0.15, output=0.60), + } + l9_processor = Last9SpanProcessor(custom_pricing=custom_pricing) + provider.add_span_processor(l9_processor) + + return trace.get_tracer(__name__) + + +def simulate_llm_call(tracer, model: str, prompt: str) -> dict: + """Simulate an LLM API call""" + with tracer.start_span("gen_ai.chat.completions") as span: + time.sleep(0.05) + span.set_attribute("gen_ai.request.model", model) + span.set_attribute("gen_ai.operation.name", "chat") + span.set_attribute("gen_ai.usage.input_tokens", len(prompt.split()) * 2) + span.set_attribute("gen_ai.usage.output_tokens", 50) + return {"response": f"Response to: {prompt[:40]}..."} + + +def single_agent_example(): + """Basic agent context example""" + tracer = setup_tracing() + + print("\n--- Example 1: Single agent tracking ---\n") + + with agent_context(agent_id="support_bot_v2", agent_name="Support Bot", agent_version="2.0"): + result = simulate_llm_call(tracer, "gpt-4o", "Help me with my order") + print(f" Response: {result['response']}") + + print("\n Span attributes:") + print(" gen_ai.agent.id = 'support_bot_v2'") + print(" gen_ai.agent.name = 'Support Bot'") + print(" gen_ai.agent.version = '2.0'") + + +def multi_agent_routing_example(): + """Multi-agent system with routing""" + tracer = setup_tracing() + + print("\n--- Example 2: Multi-agent routing ---\n") + + with conversation_context(conversation_id="session_abc", user_id="user_42"): + # Router agent classifies intent + with agent_context(agent_id="router_v1", agent_name="Router Agent"): + intent = simulate_llm_call(tracer, "gpt-4o-mini", "Classify: refund my order") + print(f" Router: {intent['response']}") + + # Specialist agent handles the request + with agent_context(agent_id="refund_agent_v3", agent_name="Refund Agent", agent_version="3.1"): + response = simulate_llm_call(tracer, "gpt-4o", "Process refund for order #12345") + print(f" Refund Agent: {response['response']}") + + print("\n Router spans: gen_ai.agent.id='router_v1', conversation_id='session_abc'") + print(" Refund spans: gen_ai.agent.id='refund_agent_v3', conversation_id='session_abc'") + + +def agent_with_workflow_example(): + """Agent context nested with workflow context""" + tracer = setup_tracing() + + print("\n--- Example 3: Agent + workflow nesting ---\n") + + with conversation_context(conversation_id="session_xyz"): + with agent_context(agent_id="rag_agent", agent_name="RAG Agent", agent_version="1.0"): + with workflow_context(workflow_id="retrieval_pipeline", workflow_type="rag"): + simulate_llm_call(tracer, "gpt-4o-mini", "Expand query: best restaurants") + simulate_llm_call(tracer, "gpt-4o", "Synthesize answer from documents") + print(" RAG pipeline completed") + + print("\n All spans have:") + print(" gen_ai.conversation.id = 'session_xyz'") + print(" gen_ai.agent.id = 'rag_agent'") + print(" workflow.id = 'retrieval_pipeline'") + + +if __name__ == "__main__": + print("Last9 GenAI - Agent Identity Tracking (OTel Semantic Conventions)") + print("=" * 70) + + try: + single_agent_example() + multi_agent_routing_example() + agent_with_workflow_example() + + trace.get_tracer_provider().force_flush(timeout_millis=5000) + + print("\n" + "=" * 70) + print("All agent tracking examples completed!") + print("\nAttributes follow OTel GenAI semantic conventions:") + print(" gen_ai.agent.id - Unique agent identifier") + print(" gen_ai.agent.name - Human-readable name") + print(" gen_ai.agent.version - Agent version") + print("\nSee: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/") + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() diff --git a/examples/context_tracking.py b/examples/context_tracking.py index 91651ef..3f8ecb8 100644 --- a/examples/context_tracking.py +++ b/examples/context_tracking.py @@ -23,6 +23,7 @@ ModelPricing, conversation_context, workflow_context, + agent_context, propagate_attributes, ) @@ -202,11 +203,35 @@ def simulated_chat_endpoint(session_id: str, user_id: str, message: str): print(" - Zero manual attribute setting!") +def agent_context_example(): + """Agent identity tracking with OTel semantic conventions""" + tracer = setup_tracing() + + print("\nšŸ”„ Example 5: Agent identity tracking\n") + + with conversation_context(conversation_id="multi_agent_session", user_id="user_agent"): + # Router agent + with agent_context(agent_id="router_v1", agent_name="Router Agent"): + simulate_llm_call(tracer, "gpt-3.5-turbo", "Classify user intent") + print(" āœ… Router agent classified intent") + + # Specialist agent + with agent_context(agent_id="support_v2", agent_name="Support Agent", agent_version="2.0"): + simulate_llm_call(tracer, "gpt-4o", "Handle support request") + print(" āœ… Support agent handled request") + + print("\n Agent spans automatically have:") + print(" - gen_ai.agent.id (unique per agent)") + print(" - gen_ai.agent.name (human-readable)") + print(" - gen_ai.agent.version (when provided)") + print(" - gen_ai.conversation.id (from parent context)") + + def multi_turn_conversation_example(): """Example with turn numbers""" tracer = setup_tracing() - print("\nšŸ”„ Example 5: Multi-turn conversation with turn tracking\n") + print("\nšŸ”„ Example 6: Multi-turn conversation with turn tracking\n") conversation_id = "multi_turn_session" messages = ["Hello!", "What's the weather?", "Thank you!"] @@ -236,6 +261,7 @@ def multi_turn_conversation_example(): nested_workflow_example() propagate_attributes_example() fastapi_pattern_example() + agent_context_example() multi_turn_conversation_example() # Force export of spans diff --git a/last9_genai/__init__.py b/last9_genai/__init__.py index 4dc1de1..4bb9e7a 100644 --- a/last9_genai/__init__.py +++ b/last9_genai/__init__.py @@ -59,6 +59,7 @@ propagate_attributes, conversation_context, workflow_context, + agent_context, get_current_context, clear_context, ) @@ -93,6 +94,7 @@ "propagate_attributes", "conversation_context", "workflow_context", + "agent_context", "get_current_context", "clear_context", # Span processor diff --git a/last9_genai/context.py b/last9_genai/context.py index 2007f20..025518f 100644 --- a/last9_genai/context.py +++ b/last9_genai/context.py @@ -15,6 +15,9 @@ _user_id: ContextVar[Optional[str]] = ContextVar("user_id", default=None) _workflow_id: ContextVar[Optional[str]] = ContextVar("workflow_id", default=None) _workflow_type: ContextVar[Optional[str]] = ContextVar("workflow_type", default=None) +_agent_id: ContextVar[Optional[str]] = ContextVar("agent_id", default=None) +_agent_name: ContextVar[Optional[str]] = ContextVar("agent_name", default=None) +_agent_version: ContextVar[Optional[str]] = ContextVar("agent_version", default=None) _custom_attributes: ContextVar[Dict[str, Any]] = ContextVar("custom_attributes", default={}) @@ -77,6 +80,18 @@ def get_current_context() -> Dict[str, Any]: if workflow_type is not None: context["workflow_type"] = workflow_type + agent_id = _agent_id.get() + if agent_id is not None: + context["agent_id"] = agent_id + + agent_name = _agent_name.get() + if agent_name is not None: + context["agent_name"] = agent_name + + agent_version = _agent_version.get() + if agent_version is not None: + context["agent_version"] = agent_version + turn_number = _conversation_turn.get() if turn_number is not None: context["turn_number"] = turn_number @@ -94,6 +109,9 @@ def clear_context() -> None: _user_id.set(None) _workflow_id.set(None) _workflow_type.set(None) + _agent_id.set(None) + _agent_name.set(None) + _agent_version.set(None) _conversation_turn.set(None) _custom_attributes.set({}) @@ -211,3 +229,62 @@ def workflow_context( _workflow_type.set(prev_wf_type) _user_id.set(prev_user_id) _custom_attributes.set(prev_custom) + + +@contextmanager +def agent_context( + agent_id: str, + agent_name: Optional[str] = None, + agent_version: Optional[str] = None, + **custom_attrs, +): + """ + Context manager for agent tracking using OTel GenAI semantic conventions. + + All spans created within this context will automatically have + gen_ai.agent.id, gen_ai.agent.name, and gen_ai.agent.version attributes. + + Args: + agent_id: Unique agent identifier (gen_ai.agent.id) + agent_name: Human-readable agent name (gen_ai.agent.name) + agent_version: Agent version (gen_ai.agent.version) + **custom_attrs: Additional custom attributes + + Example: + ```python + with agent_context(agent_id="agent_123", agent_name="Support Bot", agent_version="2.0"): + # All spans automatically tagged with gen_ai.agent.id + response = client.chat.completions.create(...) + ``` + + # Can be nested with conversation and workflow contexts: + ```python + with conversation_context(conversation_id="session_123"): + with agent_context(agent_id="agent_xyz", agent_name="Router"): + # Both conversation AND agent tracked + result = route_request(query) + ``` + """ + prev_agent_id = _agent_id.get() + prev_agent_name = _agent_name.get() + prev_agent_version = _agent_version.get() + prev_custom = _custom_attributes.get() + + try: + _agent_id.set(agent_id) + if agent_name is not None: + _agent_name.set(agent_name) + if agent_version is not None: + _agent_version.set(agent_version) + if custom_attrs: + merged = prev_custom.copy() if prev_custom else {} + merged.update(custom_attrs) + _custom_attributes.set(merged) + + yield + + finally: + _agent_id.set(prev_agent_id) + _agent_name.set(prev_agent_name) + _agent_version.set(prev_agent_version) + _custom_attributes.set(prev_custom) diff --git a/last9_genai/core.py b/last9_genai/core.py index 3d31cc1..4abb5a0 100644 --- a/last9_genai/core.py +++ b/last9_genai/core.py @@ -88,6 +88,12 @@ class GenAIAttributes: PROMPT_HASH = "gen_ai.prompt.hash" PROMPT_TEMPLATE_ID = "gen_ai.prompt.template_id" + # Agent attributes (OTel GenAI semantic conventions - experimental) + AGENT_ID = "gen_ai.agent.id" + AGENT_NAME = "gen_ai.agent.name" + AGENT_DESCRIPTION = "gen_ai.agent.description" + AGENT_VERSION = "gen_ai.agent.version" + # Tool attributes TOOL_NAME = "gen_ai.tool.name" TOOL_TYPE = "gen_ai.tool.type" @@ -145,6 +151,8 @@ class Operations: EMBEDDINGS = "embeddings" TEXT_COMPLETION = "text.completion" TOOL_CALL = "tool.call" + CREATE_AGENT = "create_agent" + INVOKE_AGENT = "invoke_agent" class Providers: diff --git a/last9_genai/processor.py b/last9_genai/processor.py index 120c670..d4991fb 100644 --- a/last9_genai/processor.py +++ b/last9_genai/processor.py @@ -144,6 +144,16 @@ def _add_context_attributes_on_start(self, span: "Span") -> None: if "workflow_type" in context: span.set_attribute("workflow.type", context["workflow_type"]) + # Add agent attributes (OTel GenAI semantic conventions) + if "agent_id" in context: + span.set_attribute(GenAIAttributes.AGENT_ID, context["agent_id"]) + + if "agent_name" in context: + span.set_attribute(GenAIAttributes.AGENT_NAME, context["agent_name"]) + + if "agent_version" in context: + span.set_attribute(GenAIAttributes.AGENT_VERSION, context["agent_version"]) + # Add any custom attributes for key, value in context.items(): if key not in [ @@ -152,6 +162,9 @@ def _add_context_attributes_on_start(self, span: "Span") -> None: "user_id", "workflow_id", "workflow_type", + "agent_id", + "agent_name", + "agent_version", ]: span.set_attribute(f"custom.{key}", str(value)) From ebdeda07376500c29d505ebcfc6285c95ca868d8 Mon Sep 17 00:00:00 2001 From: Prathamesh Sonpatki Date: Mon, 16 Mar 2026 11:50:39 +0530 Subject: [PATCH 2/5] Fix black formatting in agent_tracking example Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/agent_tracking.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/agent_tracking.py b/examples/agent_tracking.py index eb69e6f..a55bef0 100644 --- a/examples/agent_tracking.py +++ b/examples/agent_tracking.py @@ -85,7 +85,9 @@ def multi_agent_routing_example(): print(f" Router: {intent['response']}") # Specialist agent handles the request - with agent_context(agent_id="refund_agent_v3", agent_name="Refund Agent", agent_version="3.1"): + with agent_context( + agent_id="refund_agent_v3", agent_name="Refund Agent", agent_version="3.1" + ): response = simulate_llm_call(tracer, "gpt-4o", "Process refund for order #12345") print(f" Refund Agent: {response['response']}") @@ -134,4 +136,5 @@ def agent_with_workflow_example(): except Exception as e: print(f"Error: {e}") import traceback + traceback.print_exc() From 03ea4f0a99815ca585cb749ad7a3f1aeeec83240 Mon Sep 17 00:00:00 2001 From: Prathamesh Sonpatki Date: Mon, 16 Mar 2026 11:53:28 +0530 Subject: [PATCH 3/5] Add tests for agent_context: propagation, nesting, cleanup, multi-agent 12 new tests covering: - Basic agent_id propagation to spans - All fields (id, name, version) - Nested span propagation - Context cleanup after exit - Inner context overrides outer - Sequential multi-agent (routing pattern) - Nesting with conversation_context - Nesting with workflow_context - Triple nesting (conversation + agent + workflow) - Multi-agent handoff within same conversation - No-span edge case Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_context.py | 171 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/tests/test_context.py b/tests/test_context.py index 7a38955..e4b2232 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -8,6 +8,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from last9_genai import ( + agent_context, conversation_context, workflow_context, propagate_attributes, @@ -249,6 +250,176 @@ def test_deeply_nested_contexts(self, tracer_setup): assert spans[0].attributes["workflow.id"] == "wf_level_1" +class TestAgentContext: + """Test agent_context() context manager""" + + def test_agent_context_basic(self, tracer_setup): + """Test basic agent_context with just agent_id""" + tracer, memory_exporter = tracer_setup + + with agent_context(agent_id="agent_123"): + with tracer.start_as_current_span("test_span"): + pass + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[GenAIAttributes.AGENT_ID] == "agent_123" + + def test_agent_context_with_all_fields(self, tracer_setup): + """Test agent_context with id, name, and version""" + tracer, memory_exporter = tracer_setup + + with agent_context(agent_id="bot_v2", agent_name="Support Bot", agent_version="2.0"): + with tracer.start_as_current_span("test_span"): + pass + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[GenAIAttributes.AGENT_ID] == "bot_v2" + assert spans[0].attributes[GenAIAttributes.AGENT_NAME] == "Support Bot" + assert spans[0].attributes[GenAIAttributes.AGENT_VERSION] == "2.0" + + def test_agent_context_propagates_to_nested_spans(self, tracer_setup): + """Test that agent context propagates to all nested spans""" + tracer, memory_exporter = tracer_setup + + with agent_context(agent_id="nested_agent", agent_name="Nested"): + with tracer.start_as_current_span("parent"): + with tracer.start_as_current_span("child"): + pass + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 2 + assert spans[0].attributes[GenAIAttributes.AGENT_ID] == "nested_agent" + assert spans[1].attributes[GenAIAttributes.AGENT_ID] == "nested_agent" + + def test_agent_context_cleanup(self, tracer_setup): + """Test that agent context is cleaned up after exit""" + tracer, memory_exporter = tracer_setup + + with agent_context(agent_id="temp_agent"): + context = get_current_context() + assert context["agent_id"] == "temp_agent" + + context = get_current_context() + assert "agent_id" not in context or context.get("agent_id") != "temp_agent" + + def test_agent_context_override(self, tracer_setup): + """Test that inner agent context overrides outer""" + tracer, memory_exporter = tracer_setup + + with agent_context(agent_id="outer_agent", agent_name="Outer"): + with agent_context(agent_id="inner_agent", agent_name="Inner"): + with tracer.start_as_current_span("test_span"): + pass + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[GenAIAttributes.AGENT_ID] == "inner_agent" + assert spans[0].attributes[GenAIAttributes.AGENT_NAME] == "Inner" + + def test_multi_agent_sequential(self, tracer_setup): + """Test sequential agent contexts (multi-agent routing)""" + tracer, memory_exporter = tracer_setup + + with agent_context(agent_id="router", agent_name="Router"): + with tracer.start_as_current_span("route"): + pass + + with agent_context(agent_id="handler", agent_name="Handler"): + with tracer.start_as_current_span("handle"): + pass + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 2 + assert spans[0].attributes[GenAIAttributes.AGENT_ID] == "router" + assert spans[1].attributes[GenAIAttributes.AGENT_ID] == "handler" + + def test_agent_with_conversation_context(self, tracer_setup): + """Test agent nested inside conversation""" + tracer, memory_exporter = tracer_setup + + with conversation_context(conversation_id="conv_abc", user_id="user_1"): + with agent_context(agent_id="agent_xyz", agent_name="Bot"): + with tracer.start_as_current_span("test_span"): + pass + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[GenAIAttributes.CONVERSATION_ID] == "conv_abc" + assert spans[0].attributes["user.id"] == "user_1" + assert spans[0].attributes[GenAIAttributes.AGENT_ID] == "agent_xyz" + assert spans[0].attributes[GenAIAttributes.AGENT_NAME] == "Bot" + + def test_agent_with_workflow_context(self, tracer_setup): + """Test agent nested with workflow""" + tracer, memory_exporter = tracer_setup + + with agent_context(agent_id="rag_agent", agent_name="RAG"): + with workflow_context(workflow_id="retrieval", workflow_type="rag"): + with tracer.start_as_current_span("test_span"): + pass + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[GenAIAttributes.AGENT_ID] == "rag_agent" + assert spans[0].attributes["workflow.id"] == "retrieval" + assert spans[0].attributes["workflow.type"] == "rag" + + def test_agent_conversation_workflow_triple_nesting(self, tracer_setup): + """Test all three contexts nested together""" + tracer, memory_exporter = tracer_setup + + with conversation_context(conversation_id="session_1"): + with agent_context(agent_id="agent_1", agent_name="Agent", agent_version="1.0"): + with workflow_context(workflow_id="wf_1"): + with tracer.start_as_current_span("test_span"): + pass + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[GenAIAttributes.CONVERSATION_ID] == "session_1" + assert spans[0].attributes[GenAIAttributes.AGENT_ID] == "agent_1" + assert spans[0].attributes[GenAIAttributes.AGENT_NAME] == "Agent" + assert spans[0].attributes[GenAIAttributes.AGENT_VERSION] == "1.0" + assert spans[0].attributes["workflow.id"] == "wf_1" + + def test_multi_agent_in_conversation(self, tracer_setup): + """Test multiple agents within same conversation (handoff pattern)""" + tracer, memory_exporter = tracer_setup + + with conversation_context(conversation_id="session_handoff"): + with agent_context(agent_id="router_v1", agent_name="Router"): + with tracer.start_as_current_span("classify"): + pass + + with agent_context(agent_id="support_v2", agent_name="Support"): + with tracer.start_as_current_span("respond"): + pass + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 2 + + # Both have same conversation, different agents + assert spans[0].attributes[GenAIAttributes.CONVERSATION_ID] == "session_handoff" + assert spans[0].attributes[GenAIAttributes.AGENT_ID] == "router_v1" + assert spans[0].attributes[GenAIAttributes.AGENT_NAME] == "Router" + + assert spans[1].attributes[GenAIAttributes.CONVERSATION_ID] == "session_handoff" + assert spans[1].attributes[GenAIAttributes.AGENT_ID] == "support_v2" + assert spans[1].attributes[GenAIAttributes.AGENT_NAME] == "Support" + + def test_agent_context_no_span(self, tracer_setup): + """Test agent_context works even without spans""" + tracer, memory_exporter = tracer_setup + + with agent_context(agent_id="no_span_agent"): + pass + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 0 + + class TestPropagateAttributes: """Test propagate_attributes() context manager""" From fb23b33809ffdaa3a51b73ef51e8dee0520a32f6 Mon Sep 17 00:00:00 2001 From: Prathamesh Sonpatki Date: Mon, 20 Apr 2026 01:24:47 +0530 Subject: [PATCH 4/5] Address review: agent_description support, require agent_name, drop unused Operations - agent_context() now accepts agent_description (maps to gen_ai.agent.description) and wires it through the span processor, matching OpenAI Agents SDK and autogen-core emission. - agent_name is now required (first positional) and agent_id is optional. Per OTel GenAI semconv, gen_ai.agent.name is required and gen_ai.agent.id is recommended. - Drop unused Operations.CREATE_AGENT / INVOKE_AGENT. These were added but had no callers and will be re-added as part of the full OTel-semconv cleanup in ENG-896 (which also fixes CHAT_COMPLETIONS / TEXT_COMPLETION / TOOL_CALL). - Extract the processor's reserved-key list into a module-level frozenset so adding a new typed context var updates both branches in one place. - Restore _custom_attributes on agent_context exit (was dropped in the previous revision if **custom_attrs were passed). - Document coexistence with native-instrumented agent frameworks (AutoGen, OpenAI Agents SDK): those frameworks set gen_ai.agent.name directly on their own invoke_agent / create_agent spans inside the span body, which overrides our on_start injection. agent_context still tags sibling / child spans correctly. - Tests updated for new signature + agent_description propagation + custom_attrs restoration. Co-Authored-By: Claude Opus 4.7 (1M context) --- last9_genai/context.py | 53 ++++++++++++++++++++++++++++------------ last9_genai/core.py | 2 -- last9_genai/processor.py | 33 ++++++++++++++++--------- tests/test_context.py | 45 ++++++++++++++++++++++++++++------ 4 files changed, 97 insertions(+), 36 deletions(-) diff --git a/last9_genai/context.py b/last9_genai/context.py index 025518f..04119d5 100644 --- a/last9_genai/context.py +++ b/last9_genai/context.py @@ -17,6 +17,7 @@ _workflow_type: ContextVar[Optional[str]] = ContextVar("workflow_type", default=None) _agent_id: ContextVar[Optional[str]] = ContextVar("agent_id", default=None) _agent_name: ContextVar[Optional[str]] = ContextVar("agent_name", default=None) +_agent_description: ContextVar[Optional[str]] = ContextVar("agent_description", default=None) _agent_version: ContextVar[Optional[str]] = ContextVar("agent_version", default=None) _custom_attributes: ContextVar[Dict[str, Any]] = ContextVar("custom_attributes", default={}) @@ -88,6 +89,10 @@ def get_current_context() -> Dict[str, Any]: if agent_name is not None: context["agent_name"] = agent_name + agent_description = _agent_description.get() + if agent_description is not None: + context["agent_description"] = agent_description + agent_version = _agent_version.get() if agent_version is not None: context["agent_version"] = agent_version @@ -111,6 +116,7 @@ def clear_context() -> None: _workflow_type.set(None) _agent_id.set(None) _agent_name.set(None) + _agent_description.set(None) _agent_version.set(None) _conversation_turn.set(None) _custom_attributes.set({}) @@ -233,47 +239,60 @@ def workflow_context( @contextmanager def agent_context( - agent_id: str, - agent_name: Optional[str] = None, + agent_name: str, + agent_id: Optional[str] = None, + agent_description: Optional[str] = None, agent_version: Optional[str] = None, **custom_attrs, ): """ Context manager for agent tracking using OTel GenAI semantic conventions. - All spans created within this context will automatically have - gen_ai.agent.id, gen_ai.agent.name, and gen_ai.agent.version attributes. + All spans created within this context automatically receive + ``gen_ai.agent.name`` (plus id/description/version when provided). Args: - agent_id: Unique agent identifier (gen_ai.agent.id) - agent_name: Human-readable agent name (gen_ai.agent.name) - agent_version: Agent version (gen_ai.agent.version) - **custom_attrs: Additional custom attributes + agent_name: Human-readable agent name (``gen_ai.agent.name``). + Required per OTel semconv. + agent_id: Unique agent identifier (``gen_ai.agent.id``). Recommended. + agent_description: Agent description (``gen_ai.agent.description``). + agent_version: Agent version (``gen_ai.agent.version``). + **custom_attrs: Additional custom attributes. + + Note on coexistence with native-instrumented agent frameworks: + Frameworks like AutoGen (``autogen-core``) and the OpenAI Agents SDK + set ``gen_ai.agent.name`` / ``gen_ai.agent.description`` directly on + their own ``invoke_agent`` / ``create_agent`` spans. Because + :class:`Last9SpanProcessor` sets these in ``on_start``, the framework + can overwrite them inside the span body. For those framework spans, + the framework's own agent identity wins — ``agent_context`` still + tags all sibling/child spans (LLM calls, tool calls, custom spans) + with the values passed here. Example: ```python - with agent_context(agent_id="agent_123", agent_name="Support Bot", agent_version="2.0"): - # All spans automatically tagged with gen_ai.agent.id + with agent_context(agent_name="Support Bot", agent_id="bot_v2", agent_version="2.0"): response = client.chat.completions.create(...) ``` - # Can be nested with conversation and workflow contexts: ```python with conversation_context(conversation_id="session_123"): - with agent_context(agent_id="agent_xyz", agent_name="Router"): - # Both conversation AND agent tracked + with agent_context(agent_name="Router", agent_id="router_v1"): result = route_request(query) ``` """ prev_agent_id = _agent_id.get() prev_agent_name = _agent_name.get() + prev_agent_description = _agent_description.get() prev_agent_version = _agent_version.get() prev_custom = _custom_attributes.get() try: - _agent_id.set(agent_id) - if agent_name is not None: - _agent_name.set(agent_name) + _agent_name.set(agent_name) + if agent_id is not None: + _agent_id.set(agent_id) + if agent_description is not None: + _agent_description.set(agent_description) if agent_version is not None: _agent_version.set(agent_version) if custom_attrs: @@ -286,5 +305,7 @@ def agent_context( finally: _agent_id.set(prev_agent_id) _agent_name.set(prev_agent_name) + _agent_description.set(prev_agent_description) _agent_version.set(prev_agent_version) _custom_attributes.set(prev_custom) + _custom_attributes.set(prev_custom) diff --git a/last9_genai/core.py b/last9_genai/core.py index 4abb5a0..cd4ec32 100644 --- a/last9_genai/core.py +++ b/last9_genai/core.py @@ -151,8 +151,6 @@ class Operations: EMBEDDINGS = "embeddings" TEXT_COMPLETION = "text.completion" TOOL_CALL = "tool.call" - CREATE_AGENT = "create_agent" - INVOKE_AGENT = "invoke_agent" class Providers: diff --git a/last9_genai/processor.py b/last9_genai/processor.py index 166ef4a..de35d19 100644 --- a/last9_genai/processor.py +++ b/last9_genai/processor.py @@ -12,6 +12,23 @@ from .context import get_current_context from .core import GenAIAttributes, Last9Attributes, calculate_llm_cost, ModelPricing +# Keys consumed by typed branches in _add_context_attributes_on_start; anything +# else in get_current_context() is emitted as custom.{key}. Centralized here so +# adding a new context var updates both sides in one place. +_RESERVED_CONTEXT_KEYS = frozenset( + { + "conversation_id", + "turn_number", + "user_id", + "workflow_id", + "workflow_type", + "agent_id", + "agent_name", + "agent_description", + "agent_version", + } +) + class Last9SpanProcessor(SpanProcessor): """ @@ -160,21 +177,15 @@ def _add_context_attributes_on_start(self, span: "Span") -> None: if "agent_name" in context: span.set_attribute(GenAIAttributes.AGENT_NAME, context["agent_name"]) + if "agent_description" in context: + span.set_attribute(GenAIAttributes.AGENT_DESCRIPTION, context["agent_description"]) + if "agent_version" in context: span.set_attribute(GenAIAttributes.AGENT_VERSION, context["agent_version"]) - # Add any custom attributes + # Add any custom attributes (keys not claimed by a typed context var above) for key, value in context.items(): - if key not in [ - "conversation_id", - "turn_number", - "user_id", - "workflow_id", - "workflow_type", - "agent_id", - "agent_name", - "agent_version", - ]: + if key not in _RESERVED_CONTEXT_KEYS: span.set_attribute(f"custom.{key}", str(value)) def _track_workflow_cost(self, span: ReadableSpan) -> None: diff --git a/tests/test_context.py b/tests/test_context.py index e4b2232..05afa4d 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -254,22 +254,27 @@ class TestAgentContext: """Test agent_context() context manager""" def test_agent_context_basic(self, tracer_setup): - """Test basic agent_context with just agent_id""" + """Test basic agent_context with just agent_name""" tracer, memory_exporter = tracer_setup - with agent_context(agent_id="agent_123"): + with agent_context(agent_name="Agent"): with tracer.start_as_current_span("test_span"): pass spans = memory_exporter.get_finished_spans() assert len(spans) == 1 - assert spans[0].attributes[GenAIAttributes.AGENT_ID] == "agent_123" + assert spans[0].attributes[GenAIAttributes.AGENT_NAME] == "Agent" def test_agent_context_with_all_fields(self, tracer_setup): - """Test agent_context with id, name, and version""" + """Test agent_context with name, id, description, version""" tracer, memory_exporter = tracer_setup - with agent_context(agent_id="bot_v2", agent_name="Support Bot", agent_version="2.0"): + with agent_context( + agent_name="Support Bot", + agent_id="bot_v2", + agent_description="Handles support tickets", + agent_version="2.0", + ): with tracer.start_as_current_span("test_span"): pass @@ -277,6 +282,7 @@ def test_agent_context_with_all_fields(self, tracer_setup): assert len(spans) == 1 assert spans[0].attributes[GenAIAttributes.AGENT_ID] == "bot_v2" assert spans[0].attributes[GenAIAttributes.AGENT_NAME] == "Support Bot" + assert spans[0].attributes[GenAIAttributes.AGENT_DESCRIPTION] == "Handles support tickets" assert spans[0].attributes[GenAIAttributes.AGENT_VERSION] == "2.0" def test_agent_context_propagates_to_nested_spans(self, tracer_setup): @@ -297,7 +303,7 @@ def test_agent_context_cleanup(self, tracer_setup): """Test that agent context is cleaned up after exit""" tracer, memory_exporter = tracer_setup - with agent_context(agent_id="temp_agent"): + with agent_context(agent_name="Temp", agent_id="temp_agent"): context = get_current_context() assert context["agent_id"] == "temp_agent" @@ -413,12 +419,37 @@ def test_agent_context_no_span(self, tracer_setup): """Test agent_context works even without spans""" tracer, memory_exporter = tracer_setup - with agent_context(agent_id="no_span_agent"): + with agent_context(agent_name="NoSpan", agent_id="no_span_agent"): pass spans = memory_exporter.get_finished_spans() assert len(spans) == 0 + def test_agent_description_propagates(self, tracer_setup): + """agent_description reaches spans as gen_ai.agent.description""" + tracer, memory_exporter = tracer_setup + + with agent_context(agent_name="Describer", agent_description="Explains things clearly"): + with tracer.start_as_current_span("test_span"): + pass + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[GenAIAttributes.AGENT_DESCRIPTION] == "Explains things clearly" + + def test_agent_custom_attrs_restored_on_exit(self, tracer_setup): + """Custom attrs set via agent_context are restored to outer scope on exit""" + tracer, memory_exporter = tracer_setup + + with propagate_attributes(outer="value"): + with agent_context(agent_name="A", inner="scoped"): + assert get_current_context().get("inner") == "scoped" + assert get_current_context().get("outer") == "value" + + # Outer scope preserved after inner agent_context exits + assert get_current_context().get("outer") == "value" + assert "inner" not in get_current_context() + class TestPropagateAttributes: """Test propagate_attributes() context manager""" From cf2bc12923d0fa19a525d130452b605ac9e80fa8 Mon Sep 17 00:00:00 2001 From: Prathamesh Sonpatki Date: Mon, 20 Apr 2026 09:28:17 +0530 Subject: [PATCH 5/5] Address copilot feedback: drop unused fixture unpacks in agent tests github-code-quality bot flagged three agent tests that unpacked (tracer, memory_exporter) but only asserted on contextvars state. Replace with either `_` for the unused element or drop the unpack entirely (keeping the fixture parameter so Last9SpanProcessor setup still runs). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_context.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_context.py b/tests/test_context.py index 05afa4d..00fe92a 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -301,8 +301,8 @@ def test_agent_context_propagates_to_nested_spans(self, tracer_setup): def test_agent_context_cleanup(self, tracer_setup): """Test that agent context is cleaned up after exit""" - tracer, memory_exporter = tracer_setup - + # Fixture sets up the tracer/exporter; this test only asserts on + # contextvars state, so the returned handles are unused. with agent_context(agent_name="Temp", agent_id="temp_agent"): context = get_current_context() assert context["agent_id"] == "temp_agent" @@ -417,7 +417,7 @@ def test_multi_agent_in_conversation(self, tracer_setup): def test_agent_context_no_span(self, tracer_setup): """Test agent_context works even without spans""" - tracer, memory_exporter = tracer_setup + _, memory_exporter = tracer_setup with agent_context(agent_name="NoSpan", agent_id="no_span_agent"): pass @@ -439,8 +439,8 @@ def test_agent_description_propagates(self, tracer_setup): def test_agent_custom_attrs_restored_on_exit(self, tracer_setup): """Custom attrs set via agent_context are restored to outer scope on exit""" - tracer, memory_exporter = tracer_setup - + # Fixture still needs to run for Last9SpanProcessor setup; no span/exporter + # handles used in this test. with propagate_attributes(outer="value"): with agent_context(agent_name="A", inner="scoped"): assert get_current_context().get("inner") == "scoped"