diff --git a/python/claude/sample-agent/.env.template b/python/claude/sample-agent/.env.template index cb1e4474..f2dbbfda 100644 --- a/python/claude/sample-agent/.env.template +++ b/python/claude/sample-agent/.env.template @@ -37,15 +37,16 @@ ENV_ID= # AUTHENTICATION OPTIONS # ============================================================================= -# Use agentic authentication (optional, defaults to false) -# Set to "true" to use agentic authentication with M365 Agents SDK -USE_AGENTIC_AUTH=false +# Auth handler name (optional, defaults to "AGENTIC") +# The name of the authentication handler to use for token exchange +# This should match the handler name configured in your agent authentication settings +AGENT_AUTH_HANDLER_NAME=AGENTIC # Bearer token (required if not using client credentials) # Use for development/testing without full app registration BEARER_TOKEN=your_bearer_token_here -# Agentic authentication scope (required if USE_AGENTIC_AUTH=true) +# Agentic authentication scope (required for agentic authentication) # Example: https://api.powerplatform.com/.default AGENTIC_AUTH_SCOPE=https://api.powerplatform.com/.default @@ -98,21 +99,25 @@ PORT=3978 LOG_LEVEL=INFO # ============================================================================= -# OBSERVABILITY CONFIGURATION (Optional) +# OBSERVABILITY CONFIGURATION # ============================================================================= -# Enable observability tracing (set to true to track agent operations) -ENABLE_OBSERVABILITY=true - -# Service name for observability -OBSERVABILITY_SERVICE_NAME=claude-agent +# Service name for observability (required) +OBSERVABILITY_SERVICE_NAME=claude-sample-agent -# Service namespace for observability +# Service namespace for observability (required) OBSERVABILITY_SERVICE_NAMESPACE=agent365-samples +# Agent observability attributes (optional but recommended) +# These populate the gen_ai.agent.* attributes in traces +AGENT_AUID=your_agent_auid + + +ENABLE_OBSERVABILITY=true + # Enable Agent 365 Observability Exporter (optional, defaults to false) -# Set to "true" to export telemetry to Agent 365 backend -# When false, uses ConsoleSpanExporter for local debugging +# Set to "true" to export telemetry to Agent 365 backend for production monitoring +# When false, uses ConsoleSpanExporter for local debugging (logs to console only) ENABLE_A365_OBSERVABILITY_EXPORTER=false # Python environment (influences target cluster/category) diff --git a/python/claude/sample-agent/agent.py b/python/claude/sample-agent/agent.py index d3cfd714..d830f0f7 100644 --- a/python/claude/sample-agent/agent.py +++ b/python/claude/sample-agent/agent.py @@ -10,14 +10,14 @@ Features: - Claude Agent SDK with extended thinking capability - Microsoft 365 Agents SDK hosting and authentication -- Simplified observability setup +- Complete observability with BaggageBuilder - Conversation continuity across turns - Comprehensive error handling and cleanup """ -import asyncio import logging import os +import uuid from dotenv import load_dotenv @@ -49,29 +49,31 @@ from local_authentication_options import LocalAuthenticationOptions from microsoft_agents.hosting.core import Authorization, TurnContext +# Observability Components +from microsoft_agents_a365.observability.core import ( + InvokeAgentScope, + InvokeAgentDetails, + InferenceScope, + InferenceCallDetails, + InferenceOperationType, + AgentDetails, + TenantDetails, + Request, + ExecutionType, +) +from microsoft_agents_a365.observability.core.models.caller_details import CallerDetails +from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder + +# Observability configuration (must be imported early) +from observability_config import is_observability_configured + +# MCP Tooling - not yet implemented for Claude SDK +# Claude has built-in tools: WebSearch, Read, Write, WebFetch +MCP_AVAILABLE = False + # Notifications from microsoft_agents_a365.notifications.agent_notification import NotificationTypes -# Observability (optional - only imported if enabled) -try: - from microsoft_agents_a365.observability.core import ( - InferenceScope, - InvokeAgentDetails, - InvokeAgentScope, - ) - from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder - from microsoft_agents_a365.observability.core.tool_type import ToolType - from observability_helpers import ( - create_agent_details, - create_tenant_details, - create_request_details, - create_inference_details, - ) - OBSERVABILITY_AVAILABLE = True -except ImportError: - OBSERVABILITY_AVAILABLE = False - logger.debug("Observability packages not installed - tracing disabled") - # @@ -87,14 +89,16 @@ def __init__(self): """Initialize the Claude agent.""" self.logger = logging.getLogger(self.__class__.__name__) + # Observability is already configured at module level + # No need to configure again here + # Initialize authentication options self.auth_options = LocalAuthenticationOptions.from_environment() # Create Claude client self._create_client() - - # Claude client instance (will be set per conversation) - self.client: ClaudeSDKClient | None = None + + logger.info("Claude Agent uses built-in tools: WebSearch, Read, Write, WebFetch") # @@ -108,7 +112,6 @@ def _create_client(self): # Get model from environment or use default model = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-20250514") - # Get API key api_key = os.getenv("ANTHROPIC_API_KEY") if not api_key: @@ -117,21 +120,19 @@ def _create_client(self): # Configure Claude options self.claude_options = ClaudeAgentOptions( model=model, - # Enable extended thinking for detailed reasoning max_thinking_tokens=1024, - # Allow web search and basic file operations allowed_tools=["WebSearch", "Read", "Write", "WebFetch"], - # Auto-accept edits for smoother operation permission_mode="acceptEdits", continue_conversation=True ) logger.info(f"βœ… Claude Agent configured with model: {model}") + # + - # # ========================================================================= # INITIALIZATION AND MESSAGE PROCESSING @@ -141,166 +142,181 @@ def _create_client(self): async def initialize(self): """Initialize the agent""" logger.info("Initializing Claude Agent...") - try: - logger.info("Claude Agent initialized successfully") - except Exception as e: - logger.error(f"Failed to initialize agent: {e}") - raise + logger.info("Claude Agent initialized successfully") + + async def process_user_message( - self, message: str, auth: Authorization, context: TurnContext + self, message: str, auth: Authorization, auth_handler_name: str, context: TurnContext ) -> str: """Process user message using the Claude Agent SDK with observability tracing""" - # Check if observability is enabled - enable_observability = os.getenv("ENABLE_OBSERVABILITY", "false").lower() in ("true", "1", "yes") + # Extract context details for observability + activity = context.activity + recipient = activity.recipient if activity.recipient else None + tenant_id = recipient.tenant_id if recipient else None + agent_id = recipient.agentic_app_id if recipient else None + agent_upn = getattr(recipient, "user_principal_name", None) or getattr(recipient, "upn", None) if recipient else None + conversation_id = activity.conversation.id if activity.conversation else None - # Create observability objects if available and enabled - invoke_scope = None - baggage_context = None - if OBSERVABILITY_AVAILABLE and enable_observability: - try: - agent_details = create_agent_details(context) - tenant_details = create_tenant_details(context) + # Extract caller information + caller_id = activity.from_property.id if activity.from_property else None + caller_name = activity.from_property.name if activity.from_property else None + caller_aad_object_id = activity.from_property.aad_object_id if activity.from_property else None + + try: + logger.info(f"πŸ“¨ Processing message: {message[:100]}...") + + # Verify observability is configured before using BaggageBuilder + if not is_observability_configured(): + logger.warning("⚠️ Observability not configured, spans may not be exported") + + # Use BaggageBuilder to set contextual information that flows through all spans + with ( + BaggageBuilder() + .tenant_id(tenant_id or "default-tenant") + .agent_id(agent_id or os.getenv("AGENT_ID", "claude-agent")) + .correlation_id(conversation_id or str(uuid.uuid4())) + .build() + ): + # Create AgentDetails with valid parameters only + agent_details = AgentDetails( + agent_id=agent_id or os.getenv("AGENT_ID", "claude-agent"), + conversation_id=conversation_id, + agent_name=os.getenv("OBSERVABILITY_SERVICE_NAME", "Claude Agent"), + agent_description="AI agent powered by Anthropic Claude Agent SDK", + tenant_id=tenant_id or "default-tenant", + agent_upn=agent_upn, # Get from turn context recipient + agent_blueprint_id=os.getenv("CLIENT_ID") or os.getenv("AGENT_BLUEPRINT_ID"), + agent_auid=os.getenv("AGENT_AUID"), + ) + - # Get session ID from conversation - session_id = None - if context and context.activity and context.activity.conversation: - session_id = context.activity.conversation.id + # Extract caller information (add UPN and IP if available) + caller = activity.from_property if activity and activity.from_property else None + caller_id = getattr(caller, "id", None) + caller_name = getattr(caller, "name", None) + caller_upn = ( + getattr(caller, "user_principal_name", None) + or getattr(caller, "upn", None) + ) + # Client IP may be set by hosting middleware. If you can’t read it directly, + # carry it via source_metadata so exporter can reflect it in attributes. + client_ip = getattr(activity, "caller_client_ip", None) + - # Create invoke details - invoke_details = InvokeAgentDetails( - details=agent_details, - session_id=session_id, + # Create CallerDetails (don't include tenant_id per schema) + caller_details = CallerDetails( + caller_id=caller_id or "unknown-caller", + caller_upn=caller_upn or caller_name or "unknown-user", + caller_user_id=caller_aad_object_id or caller_id or "unknown-user-id", ) - request_details = create_request_details(message, session_id) - # Build baggage context - # Extract tenant_id and agent_id from context - tenant_id = None - agent_id = None - if context and context.activity: - if hasattr(context.activity, 'recipient'): - tenant_id = getattr(context.activity.recipient, 'tenant_id', None) - agent_id = getattr(context.activity.recipient, 'agentic_app_id', None) + tenant_details = TenantDetails(tenant_id=tenant_id or "default-tenant") - # Build and start baggage context - baggage_context = BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build() - baggage_context.__enter__() + # Create Request without source_metadata (causes incorrect attributes) + request = Request( + content=message, + execution_type=ExecutionType.HUMAN_TO_AGENT, + session_id=conversation_id, + ) - invoke_scope = InvokeAgentScope.start( - invoke_agent_details=invoke_details, - tenant_details=tenant_details, - request=request_details, + invoke_details = InvokeAgentDetails( + details=agent_details, + session_id=conversation_id, ) - invoke_scope.__enter__() - logger.debug("βœ… Observability scope started") - except Exception as e: - logger.warning(f"Failed to start observability scope: {e}") - invoke_scope = None - baggage_context = None - - try: - logger.info(f"πŸ“¨ Processing message: {message[:100]}...") - - # Track tokens for observability - total_input_tokens = 0 - total_output_tokens = 0 - total_thinking_tokens = 0 - - # Create inference scope if observability enabled - inference_scope = None - if OBSERVABILITY_AVAILABLE and enable_observability: - try: - agent_details = create_agent_details(context) - tenant_details = create_tenant_details(context) - session_id = context.activity.conversation.id if context and context.activity and context.activity.conversation else None + # Use context manager pattern per documentation + with InvokeAgentScope.start( + invoke_agent_details=invoke_details, + tenant_details=tenant_details, + request=request, + caller_details=caller_details, + ) as invoke_scope: + # Record input message + if hasattr(invoke_scope, 'record_input_messages'): + invoke_scope.record_input_messages([message]) - inference_details = create_inference_details( + # Create InferenceScope for tracking LLM call + inference_details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, model=self.claude_options.model, - input_tokens=0, # Will update after response - output_tokens=0, + providerName="Anthropic Claude", ) - request_details = create_request_details(message, session_id) - # Correct API: details, agent_details, tenant_details, request - inference_scope = InferenceScope.start( + with InferenceScope.start( details=inference_details, agent_details=agent_details, tenant_details=tenant_details, - request=request_details, - ) - inference_scope.__enter__() - logger.debug("βœ… Inference scope started") - except Exception as e: - logger.warning(f"Failed to start inference scope: {e}") - inference_scope = None - - # Create a new client for this conversation - # Claude SDK uses async context manager - async with ClaudeSDKClient(self.claude_options) as client: - # Send the user message - await client.query(message) - - # Collect the response - response_parts = [] - thinking_parts = [] - - # Receive and process messages - async for msg in client.receive_response(): - if isinstance(msg, AssistantMessage): - for block in msg.content: - # Collect thinking (Claude's reasoning) - if isinstance(block, ThinkingBlock): - thinking_parts.append(f"πŸ’­ {block.thinking}") - # Track thinking tokens - total_thinking_tokens += len(block.thinking.split()) - logger.info(f"πŸ’­ Claude thinking: {block.thinking[:100]}...") + request=request, + ) as inference_scope: + # Create a new client for this conversation + async with ClaudeSDKClient(self.claude_options) as client: + # Send the user message + await client.query(message) + + # Collect the response + response_parts = [] + thinking_parts = [] + + # Receive and process messages + async for msg in client.receive_response(): + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, ThinkingBlock): + thinking_parts.append(f"πŸ’­ {block.thinking}") + logger.info(f"πŸ’­ Claude thinking: {block.thinking[:100]}...") + elif isinstance(block, TextBlock): + response_parts.append(block.text) + logger.info(f"πŸ’¬ Claude response: {block.text[:100]}...") + + # Combine thinking and response + full_response = "" + if thinking_parts: + full_response += "**Claude's Thinking:**\n" + full_response += "\n".join(thinking_parts) + full_response += "\n\n**Response:**\n" + + if response_parts: + full_response += "".join(response_parts) + else: + full_response += "I couldn't process your request at this time." + + # Capture usage statistics + usage = getattr(client, "last_usage", None) + if usage and hasattr(inference_scope, "record_input_tokens"): + try: + input_tokens = getattr(usage, "input_tokens", 0) or 0 + output_tokens = getattr(usage, "output_tokens", 0) or 0 + inference_scope.record_input_tokens(int(input_tokens)) + inference_scope.record_output_tokens(int(output_tokens)) + logger.info(f"πŸ“Š Tokens: {input_tokens} in, {output_tokens} out") + except Exception as e: + logger.debug(f"Could not record tokens: {e}") - # Collect actual response text - elif isinstance(block, TextBlock): - response_parts.append(block.text) - # Track output tokens - total_output_tokens += len(block.text.split()) - logger.info(f"πŸ’¬ Claude response: {block.text[:100]}...") - - # Track input tokens (approximate) - total_input_tokens = len(message.split()) - - # Combine thinking and response - full_response = "" + # Record finish reasons + if hasattr(inference_scope, 'record_finish_reasons'): + inference_scope.record_finish_reasons(["end_turn"]) + + # Record output messages on inference scope (gen_ai.output.messages) + if hasattr(inference_scope, 'record_output_messages'): + inference_scope.record_output_messages([full_response]) + + # Record output message on invoke scope (inside invoke scope, after inference scope closes) + if hasattr(invoke_scope, 'record_output_messages'): + invoke_scope.record_output_messages([full_response]) - # Add thinking if present (for transparency) - if thinking_parts: - full_response += "**Claude's Thinking:**\n" - full_response += "\n".join(thinking_parts) - full_response += "\n\n**Response:**\n" + # Record finish reason + if inference_scope and hasattr(inference_scope, 'record_finish_reasons'): + inference_scope.record_finish_reasons(["end_turn"]) - # Add the actual response - if response_parts: - full_response += "".join(response_parts) - else: - full_response += "I couldn't process your request at this time." - - # Close inference scope with token counts + # Close scopes successfully if inference_scope: - try: - # Update inference details with actual token counts - # Note: These are approximate counts based on word splitting - logger.info(f"πŸ“Š Tokens - Input: {total_input_tokens}, Output: {total_output_tokens}, Thinking: {total_thinking_tokens}") - inference_scope.__exit__(None, None, None) - except Exception as e: - logger.warning(f"Failed to close inference scope: {e}") - - # Close invoke scope successfully + inference_scope.__exit__(None, None, None) if invoke_scope: - try: - invoke_scope.__exit__(None, None, None) - if baggage_context is not None: - baggage_context.__exit__(None, None, None) - except Exception as e: - logger.warning(f"Failed to close invoke scope: {e}") + invoke_scope.__exit__(None, None, None) + + logger.info("βœ… Observability scopes closed successfully") return full_response @@ -309,15 +325,23 @@ async def process_user_message( logger.exception("Full error details:") # Record error in scopes + if invoke_scope and hasattr(invoke_scope, 'record_error'): + invoke_scope.record_error(e) + if inference_scope and hasattr(inference_scope, 'record_error'): + inference_scope.record_error(e) + + # Close scopes with error + if inference_scope: + try: + inference_scope.__exit__(type(e), e, e.__traceback__) + except Exception: + pass if invoke_scope: try: - invoke_scope.record_error(e) invoke_scope.__exit__(type(e), e, e.__traceback__) - if baggage_context is not None: - baggage_context.__exit__(None, None, None) - except Exception as cleanup_error: - logger.warning(f"Failed to clean up invoke/baggage context after error: {cleanup_error}") - + except Exception: + pass + return f"Sorry, I encountered an error: {str(e)}" # @@ -328,7 +352,7 @@ async def process_user_message( # async def handle_agent_notification_activity( - self, notification_activity, auth: Authorization, context: TurnContext + self, notification_activity, auth: Authorization, auth_handler_name: str, context: TurnContext ) -> str: """ Handle agent notification activities (email, Word mentions, etc.) @@ -353,13 +377,10 @@ async def handle_agent_notification_activity( email = notification_activity.email email_body = getattr(email, "html_body", "") or getattr(email, "body", "") - # Create message for Claude to process the email message = f"You have received the following email. Please follow any instructions in it.\n\n{email_body}" - logger.info(f"πŸ“§ Processing email notification") - # Process with Claude - response = await self.process_user_message(message, auth, context) + response = await self.process_user_message(message, auth, auth_handler_name, context) return response or "Email notification processed." # Handle Word Comment Notifications @@ -373,8 +394,6 @@ async def handle_agent_notification_activity( logger.info(f"πŸ“„ Processing Word comment notification for doc {doc_id}") - # Note: Without MCP tools, we can't retrieve the actual Word document - # So we'll just process the comment text directly message = ( f"You have been mentioned in a Word document comment.\n" f"Document ID: {doc_id}\n" @@ -382,13 +401,11 @@ async def handle_agent_notification_activity( f"Please respond to this comment appropriately." ) - # Process with Claude - response = await self.process_user_message(message, auth, context) + response = await self.process_user_message(message, auth, auth_handler_name, context) return response or "Word notification processed." # Generic notification handling else: - # Log full activity structure for debugging logger.info(f"πŸ” Full notification activity structure:") logger.info(f" Type: {notification_activity.activity.type}") logger.info(f" Name: {notification_activity.activity.name}") @@ -397,7 +414,6 @@ async def handle_agent_notification_activity( logger.info(f" Entities: {notification_activity.activity.entities}") logger.info(f" Channel ID: {notification_activity.activity.channel_id}") - # Try to get message from activity.text or activity.value notification_message = ( getattr(notification_activity.activity, 'text', None) or str(getattr(notification_activity.activity, 'value', None)) or @@ -405,8 +421,7 @@ async def handle_agent_notification_activity( ) logger.info(f"πŸ“¨ Processing generic notification: {notification_type}") - # Process with Claude - response = await self.process_user_message(notification_message, auth, context) + response = await self.process_user_message(notification_message, auth, auth_handler_name, context) return response or "Notification processed successfully." except Exception as e: @@ -435,4 +450,3 @@ async def cleanup(self) -> None: logger.error(f"Error during cleanup: {e}") # - diff --git a/python/claude/sample-agent/host_agent_server.py b/python/claude/sample-agent/host_agent_server.py index fb7b4a06..adf63873 100644 --- a/python/claude/sample-agent/host_agent_server.py +++ b/python/claude/sample-agent/host_agent_server.py @@ -10,6 +10,7 @@ import os import socket from os import environ +import uuid from aiohttp.web import Application, Request, Response, json_response, run_app from aiohttp.web_middlewares import middleware as web_middleware @@ -52,15 +53,11 @@ ChannelId, ) from microsoft_agents_a365.notifications import EmailResponse, NotificationTypes - -# Observability imports (optional) -try: - from microsoft_agents_a365.observability.core.config import configure as configure_observability - from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder - from token_cache import get_cached_agentic_token, cache_agentic_token - OBSERVABILITY_AVAILABLE = True -except ImportError: - OBSERVABILITY_AVAILABLE = False +from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder +from microsoft_agents_a365.runtime.environment_utils import ( + get_observability_authentication_scope, +) +from token_cache import cache_agentic_token # Load configuration load_dotenv() @@ -70,19 +67,22 @@ class GenericAgentHost: """Generic host that can host any agent implementing the AgentInterface""" - def __init__(self, agent_class: type[AgentInterface], *agent_args, **agent_kwargs): + def __init__(self, agent_class: type[AgentInterface], *agent_args, auth_handler_name: str | None = None, **agent_kwargs): """ Initialize the generic host with an agent class and its initialization parameters. Args: agent_class: The agent class to instantiate (must implement AgentInterface) *agent_args: Positional arguments to pass to the agent constructor + auth_handler_name: Name of the auth handler to use (defaults to AGENT_AUTH_HANDLER_NAME env var or "AGENTIC") **agent_kwargs: Keyword arguments to pass to the agent constructor """ # Check that the agent inherits from AgentInterface if not check_agent_inheritance(agent_class): raise TypeError(f"Agent class {agent_class.__name__} must inherit from AgentInterface") + self.auth_handler_name = auth_handler_name or os.getenv("AGENT_AUTH_HANDLER_NAME", "AGENTIC") + self.agent_class = agent_class self.agent_args = agent_args self.agent_kwargs = agent_kwargs @@ -109,6 +109,37 @@ def __init__(self, agent_class: type[AgentInterface], *agent_args, **agent_kwarg # Setup message handlers self._setup_handlers() + + def _extract_conversation_item_link(self, activity): + # 1) Outlook / Word / Loop / SharePoint notifications + try: + link = activity.value.get("resource", {}).get("webUrl") + if link: + return link + except Exception: + pass + + # 2) Teams-based interactions + try: + for entity in activity.entities or []: + link = ( + entity.get("conversationItemLink") or + entity.get("link") + ) + if link: + return link + except Exception: + pass + + # 3) Teams channelData + try: + return activity.channel_data.get("clientInfo", {}).get("conversationItemLink") + except Exception: + pass + + return None + + def _setup_handlers(self): """Setup the Microsoft Agents SDK message handlers""" @@ -127,41 +158,86 @@ async def help_handler(context: TurnContext, _: TurnState): self.agent_app.conversation_update("membersAdded")(help_handler) self.agent_app.message("/help")(help_handler) - @self.agent_app.activity("message") + handler = [self.auth_handler_name] + @self.agent_app.activity("message", auth_handlers=handler) async def on_message(context: TurnContext, _: TurnState): """Handle all messages with the hosted agent""" try: - # Ensure the agent is available - if not self.agent_instance: - error_msg = "❌ Sorry, the agent is not available." - logger.error(error_msg) - await context.send_activity(error_msg) - return + tenant_id = context.activity.recipient.tenant_id + agent_id = context.activity.recipient.agentic_app_id + + # Get agent identity from recipient + recipient = context.activity.recipient + agent_upn = getattr(recipient, "user_principal_name", None) or getattr(recipient, "upn", None) + + caller = getattr(context.activity, "from_property", None) or getattr(context.activity, "from", None) + caller_id = getattr(caller, "id", None) + caller_name = getattr(caller, "name", None) + caller_upn = getattr(caller, "userPrincipalName", None) or getattr(caller, "upn", None) + + conversation_item_link = self._extract_conversation_item_link(context.activity) + conversation_id = context.activity.conversation.id if context.activity.conversation else None + correlation_id = str(uuid.uuid4()) + + with ( + BaggageBuilder() + .tenant_id(tenant_id) + .agent_id(agent_id) + .agent_name(self.agent_class.__name__) + .agent_description("An AI agent powered by Anthropic Claude") + .agent_upn(agent_upn) # Get from turn context recipient + .caller_id(caller_id) + .caller_name(caller_name) + .caller_upn(caller_upn) + .conversation_id(conversation_id) + .conversation_item_link(conversation_item_link) # optional + .correlation_id(correlation_id) + .build() + ): + # Ensure the agent is available + if not self.agent_instance: + error_msg = "❌ Sorry, the agent is not available." + logger.error(error_msg) + await context.send_activity(error_msg) + return + + exaau_token = await self.agent_app.auth.exchange_token( + context, + scopes=get_observability_authentication_scope(), + auth_handler_id=self.auth_handler_name, + ) - user_message = context.activity.text or "" - logger.info(f"πŸ“¨ Processing message: '{user_message}'") + # Cache the agentic token for Agent 365 Observability exporter use + cache_agentic_token( + tenant_id, + agent_id, + exaau_token.token, + ) - # Skip empty messages - if not user_message.strip(): - return + user_message = context.activity.text or "" + logger.info(f"πŸ“¨ Processing message: '{user_message}'") - # Skip messages that are handled by other decorators (like /help) - if user_message.strip() == "/help": - return + # Skip empty messages + if not user_message.strip(): + return - # Process with the hosted agent - logger.info(f"πŸ€– Processing with {self.agent_class.__name__}...") - response = await self.agent_instance.process_user_message( - user_message, self.agent_app.auth, context - ) + # Skip messages that are handled by other decorators (like /help) + if user_message.strip() == "/help": + return - # Send response back - logger.info( - f"πŸ“€ Sending response: '{response[:100] if len(response) > 100 else response}'" - ) - await context.send_activity(response) + # Process with the hosted agent + logger.info(f"πŸ€– Processing with {self.agent_class.__name__}...") + response = await self.agent_instance.process_user_message( + user_message, self.agent_app.auth, self.auth_handler_name, context + ) + + # Send response back + logger.info( + f"πŸ“€ Sending response: '{response[:100] if len(response) > 100 else response}'" + ) + await context.send_activity(response) - logger.info("βœ… Response sent successfully to client") + logger.info("βœ… Response sent successfully to client") except Exception as e: error_msg = f"Sorry, I encountered an error: {str(e)}" @@ -184,12 +260,7 @@ async def handle_notification_common( return tenant_id, agent_id = result - if OBSERVABILITY_AVAILABLE: - with BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build(): - await self._handle_notification_with_agent( - context, notification_activity - ) - else: + with BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build(): await self._handle_notification_with_agent( context, notification_activity ) @@ -248,7 +319,7 @@ async def _handle_notification_with_agent( # Process the notification with the agent response = await self.agent_instance.handle_agent_notification_activity( - notification_activity, self.agent_app.auth, context + notification_activity, self.agent_app.auth, self.auth_handler_name, context ) # For email notifications, wrap response in EmailResponse entity @@ -297,17 +368,11 @@ async def _setup_observability_token( tenant_id: Tenant identifier agent_id: Agent identifier """ - if not OBSERVABILITY_AVAILABLE: - return - try: - from microsoft_agents_a365.runtime.environment_utils import ( - get_observability_authentication_scope, - ) - exaau_token = await self.agent_app.auth.exchange_token( context, scopes=get_observability_authentication_scope(), + auth_handler_id=self.auth_handler_name, ) cache_agentic_token(tenant_id, agent_id, exaau_token.token) logger.debug(f"βœ… Cached observability token for {tenant_id}:{agent_id}") @@ -465,13 +530,14 @@ async def cleanup(self): logger.error(f"Error during agent cleanup: {e}") -def create_and_run_host(agent_class: type[AgentInterface], *agent_args, **agent_kwargs): +def create_and_run_host(agent_class: type[AgentInterface], *agent_args, auth_handler_name: str | None = None, **agent_kwargs): """ Convenience function to create and run a generic agent host. Args: agent_class: The agent class to host (must implement AgentInterface) *agent_args: Positional arguments to pass to the agent constructor + auth_handler_name: Name of the auth handler to use (defaults to AGENT_AUTH_HANDLER_NAME env var or "AGENTIC") **agent_kwargs: Keyword arguments to pass to the agent constructor """ try: @@ -479,46 +545,11 @@ def create_and_run_host(agent_class: type[AgentInterface], *agent_args, **agent_ if not check_agent_inheritance(agent_class): raise TypeError(f"Agent class {agent_class.__name__} must inherit from AgentInterface") - # Configure observability if available and enabled - if OBSERVABILITY_AVAILABLE: - enable_observability = os.getenv("ENABLE_OBSERVABILITY", "false").lower() in ("true", "1", "yes") - if enable_observability: - service_name = os.getenv("OBSERVABILITY_SERVICE_NAME", "generic-agent-host") - service_namespace = os.getenv("OBSERVABILITY_SERVICE_NAMESPACE", "agent365") - - # Token resolver for Agent365 exporter (optional) - def token_resolver(agent_id: str, tenant_id: str) -> str | None: - """Resolve authentication token for observability exporter""" - try: - logger.debug(f"Token resolver called for agent_id: {agent_id}, tenant_id: {tenant_id}") - # Use cached agentic token if available - cached_token = get_cached_agentic_token(tenant_id, agent_id) - if cached_token: - logger.debug("Using cached agentic token for observability") - return cached_token - logger.debug("No cached token available for observability") - return None - except Exception as e: - logger.warning(f"Error resolving token for observability: {e}") - return None - - try: - configure_observability( - service_name=service_name, - service_namespace=service_namespace, - token_resolver=token_resolver, - cluster_category=os.getenv("PYTHON_ENVIRONMENT", "development"), - ) - logger.info(f"βœ… Observability configured: {service_name} ({service_namespace})") - except Exception as e: - logger.warning(f"⚠️ Failed to configure observability: {e}") - else: - logger.info("ℹ️ Observability disabled (ENABLE_OBSERVABILITY=false)") - else: - logger.debug("ℹ️ Observability packages not available") + # Token resolver for Agent365 exporter + # Note: Actual observability configuration is done in agent.py _setup_observability() # Create the host - host = GenericAgentHost(agent_class, *agent_args, **agent_kwargs) + host = GenericAgentHost(agent_class, *agent_args, auth_handler_name=auth_handler_name, **agent_kwargs) # Create authentication configuration auth_config = host.create_auth_configuration() diff --git a/python/claude/sample-agent/observability_config.py b/python/claude/sample-agent/observability_config.py new file mode 100644 index 00000000..7c0ce83d --- /dev/null +++ b/python/claude/sample-agent/observability_config.py @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Observability Configuration Module + +Handles one-time initialization of Agent 365 Observability SDK. +This module should be imported early in the application lifecycle to ensure +observability is configured before any agents are instantiated. +""" + +import logging +import os + +from opentelemetry import trace +from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter +from microsoft_agents_a365.observability.core.config import configure +from token_cache import get_cached_agentic_token + +logger = logging.getLogger(__name__) + +# Flag to track if observability has been configured +_observability_configured = False + + +def _initialize_observability_once(): + """Initialize observability SDK once at module level before any agent instances are created""" + global _observability_configured + + if _observability_configured: + logger.debug("Observability already configured, skipping") + return True + + def token_resolver(agent_id: str, tenant_id: str) -> str | None: + """Token resolver for Agent 365 Observability exporter""" + try: + logger.info(f"Token resolver called for agent_id: {agent_id}, tenant_id: {tenant_id}") + cached_token = get_cached_agentic_token(tenant_id, agent_id) + if cached_token: + logger.info("Using cached agentic token from agent authentication") + return cached_token + else: + logger.warning(f"No cached agentic token found for agent_id: {agent_id}, tenant_id: {tenant_id}") + return None + except Exception as e: + logger.error(f"Error resolving token for agent {agent_id}, tenant {tenant_id}: {e}") + return None + + try: + status = configure( + service_name=os.getenv("OBSERVABILITY_SERVICE_NAME", "claude-sample-agent"), + service_namespace=os.getenv("OBSERVABILITY_SERVICE_NAMESPACE", "agent365-samples"), + token_resolver=token_resolver, + ) + + if not status: + logger.warning("⚠️ Agent 365 Observability configuration failed") + return False + + _observability_configured = True + logger.info("βœ… Agent 365 Observability configured successfully") + return True + + except Exception as e: + logger.error(f"❌ Error setting up observability: {e}") + return False + + +def is_observability_configured() -> bool: + """Check if observability has been configured""" + return _observability_configured + + +# Initialize observability immediately at module load time +_initialize_observability_once() diff --git a/python/claude/sample-agent/observability_helpers.py b/python/claude/sample-agent/observability_helpers.py deleted file mode 100644 index 5dace7e3..00000000 --- a/python/claude/sample-agent/observability_helpers.py +++ /dev/null @@ -1,164 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -""" -Observability helper utilities for Claude Agent. -Creates observability objects for tracing agent invocations, tool execution, and inference. -""" - -import logging -from os import environ -from typing import Optional - -from microsoft_agents.hosting.core import TurnContext -from microsoft_agents_a365.observability.core.agent_details import AgentDetails -from microsoft_agents_a365.observability.core.execution_type import ExecutionType -from microsoft_agents_a365.observability.core.inference_call_details import InferenceCallDetails -from microsoft_agents_a365.observability.core.inference_operation_type import InferenceOperationType -from microsoft_agents_a365.observability.core.request import Request -from microsoft_agents_a365.observability.core.tenant_details import TenantDetails -from microsoft_agents_a365.observability.core.tool_call_details import ToolCallDetails -from microsoft_agents_a365.observability.core.tool_type import ToolType - -logger = logging.getLogger(__name__) - - -def create_agent_details(context: Optional[TurnContext] = None) -> AgentDetails: - """ - Create agent details for observability. - - Args: - context: Optional TurnContext from M365 SDK - - Returns: - AgentDetails instance with agent information - """ - agent_id = environ.get("AGENT_ID", "claude-agent") - conversation_id = None - - if context and context.activity: - # Extract agent ID from recipient if available - if hasattr(context.activity, "recipient") and hasattr(context.activity.recipient, "agentic_app_id"): - agent_id = context.activity.recipient.agentic_app_id or agent_id - - # Extract conversation ID - if hasattr(context.activity, "conversation") and context.activity.conversation: - conversation_id = context.activity.conversation.id - - return AgentDetails( - agent_id=agent_id, - conversation_id=conversation_id, - agent_name=environ.get("OBSERVABILITY_SERVICE_NAME", "Claude Agent"), - agent_description="AI agent powered by Anthropic Claude Agent SDK with extended thinking", - ) - - -def create_tenant_details(context: Optional[TurnContext] = None) -> TenantDetails: - """ - Create tenant details for observability. - - Args: - context: Optional TurnContext from M365 SDK - - Returns: - TenantDetails instance with tenant information - """ - tenant_id = "default-tenant" - - if context and context.activity and hasattr(context.activity, "recipient"): - # Extract tenant ID from activity recipient - if hasattr(context.activity.recipient, "tenant_id") and context.activity.recipient.tenant_id: - tenant_id = context.activity.recipient.tenant_id - logger.debug(f"Extracted tenant from recipient: {tenant_id}") - - # Fall back to environment variable - if tenant_id == "default-tenant": - tenant_id = environ.get("TENANT_ID", "default-tenant") - logger.debug(f"Using tenant ID from environment: {tenant_id}") - - return TenantDetails(tenant_id=tenant_id) - - -def create_request_details( - user_message: str, - session_id: Optional[str] = None, - execution_type: ExecutionType = ExecutionType.HUMAN_TO_AGENT -) -> Request: - """ - Create request details for observability. - - Args: - user_message: The user's input message - session_id: Optional session/conversation ID - execution_type: Type of execution (default: HUMAN_TO_AGENT) - - Returns: - Request instance with request information - """ - return Request( - content=user_message, - execution_type=execution_type, - session_id=session_id, - ) - - -def create_inference_details( - model: str, - input_tokens: int = 0, - output_tokens: int = 0, - thinking_tokens: int = 0, - finish_reasons: Optional[list[str]] = None, - response_id: Optional[str] = None -) -> InferenceCallDetails: - """ - Create inference call details for observability. - - Args: - model: Claude model name (e.g., "claude-sonnet-4-20250514") - input_tokens: Number of input tokens - output_tokens: Number of output tokens - thinking_tokens: Number of extended thinking tokens (unique to Claude) - finish_reasons: List of finish reasons (e.g., ["end_turn"]) - response_id: Optional response ID from Claude - - Returns: - InferenceCallDetails instance with inference information - """ - return InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model=model, - providerName="anthropic-claude", - inputTokens=input_tokens, - outputTokens=output_tokens + thinking_tokens, # Total output includes thinking - finishReasons=finish_reasons or ["end_turn"], - responseId=response_id, - ) - - -def create_tool_call_details( - tool_name: str, - tool_type: ToolType, - tool_call_id: Optional[str] = None, - arguments: Optional[str] = None, - description: Optional[str] = None -) -> ToolCallDetails: - """ - Create tool call details for observability. - - Args: - tool_name: Name of the tool (e.g., "WebSearch", "Read", "Write") - tool_type: Type of tool (ToolType.FUNCTION) - tool_call_id: Optional unique ID for this tool call - arguments: Optional tool arguments as string - description: Optional tool description - - Returns: - ToolCallDetails instance with tool information - """ - return ToolCallDetails( - tool_name=tool_name, - tool_type=tool_type, - tool_call_id=tool_call_id, - arguments=arguments, - description=description or f"Execute {tool_name} tool", - ) diff --git a/python/claude/sample-agent/pyproject.toml b/python/claude/sample-agent/pyproject.toml index 72046edb..a269c0c8 100644 --- a/python/claude/sample-agent/pyproject.toml +++ b/python/claude/sample-agent/pyproject.toml @@ -1,7 +1,10 @@ [project] name = "sample-claude-agent" version = "0.1.0" -description = "Sample Claude Agent using Microsoft Agent 365 SDK" +description = "Sample Claude Agent using Microsoft Agent 365 SDK with MCP tooling support" +authors = [ + { name = "Microsoft", email = "support@microsoft.com" } +] dependencies = [ # Claude Agent SDK "claude-agent-sdk>=0.1.0", @@ -12,12 +15,6 @@ dependencies = [ "microsoft-agents-authentication-msal", "microsoft-agents-activity", - # Agent 365 Observability - "microsoft-agents-a365-observability-core>=0.1.0", - - # Agent 365 Notifications (Email, Word @mentions, etc.) - "microsoft_agents_a365_notifications >= 0.1.0", - # Core dependencies "python-dotenv", "aiohttp", @@ -25,9 +22,12 @@ dependencies = [ # Additional utilities "typing-extensions>=4.0.0", - # Local packages from local index - # - Update package versions to match your built wheels - "microsoft_agents_a365_runtime >= 0.1.0", + # Microsoft Agent 365 SDK packages + "microsoft_agents_a365_tooling>=0.1.0", + "microsoft_agents_a365_observability_core>=0.1.0", + "microsoft_agents_a365_notifications>=0.1.0", + "microsoft_agents_a365_runtime>=0.1.0", + "wrapt>=2.0.1" ] requires-python = ">=3.11"