From 4fc2682854203bd8a1d687130e0e9dc5e39a425d Mon Sep 17 00:00:00 2001 From: Johan Broberg Date: Fri, 23 Jan 2026 08:27:11 -0800 Subject: [PATCH] =?UTF-8?q?Revert=20"Updated=20Claude=20Python=20to=20use?= =?UTF-8?q?=20A365=20oservability=20SDK=20and=20MCP=20tooling=20(#?= =?UTF-8?q?=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ba390df7a648f818a4463d591ff93c5d06fb2d8c. --- python/claude/sample-agent/.env.template | 31 +- python/claude/sample-agent/agent.py | 382 +++++++++--------- .../claude/sample-agent/host_agent_server.py | 209 ++++------ .../sample-agent/observability_config.py | 75 ---- .../sample-agent/observability_helpers.py | 164 ++++++++ python/claude/sample-agent/pyproject.toml | 20 +- 6 files changed, 460 insertions(+), 421 deletions(-) delete mode 100644 python/claude/sample-agent/observability_config.py create mode 100644 python/claude/sample-agent/observability_helpers.py diff --git a/python/claude/sample-agent/.env.template b/python/claude/sample-agent/.env.template index f2dbbfda..cb1e4474 100644 --- a/python/claude/sample-agent/.env.template +++ b/python/claude/sample-agent/.env.template @@ -37,16 +37,15 @@ ENV_ID= # AUTHENTICATION OPTIONS # ============================================================================= -# 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 +# Use agentic authentication (optional, defaults to false) +# Set to "true" to use agentic authentication with M365 Agents SDK +USE_AGENTIC_AUTH=false # 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 for agentic authentication) +# Agentic authentication scope (required if USE_AGENTIC_AUTH=true) # Example: https://api.powerplatform.com/.default AGENTIC_AUTH_SCOPE=https://api.powerplatform.com/.default @@ -99,25 +98,21 @@ PORT=3978 LOG_LEVEL=INFO # ============================================================================= -# OBSERVABILITY CONFIGURATION +# OBSERVABILITY CONFIGURATION (Optional) # ============================================================================= -# Service name for observability (required) -OBSERVABILITY_SERVICE_NAME=claude-sample-agent - -# 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 tracing (set to true to track agent operations) +ENABLE_OBSERVABILITY=true +# Service name for observability +OBSERVABILITY_SERVICE_NAME=claude-agent -ENABLE_OBSERVABILITY=true +# Service namespace for observability +OBSERVABILITY_SERVICE_NAMESPACE=agent365-samples # Enable Agent 365 Observability Exporter (optional, defaults to false) -# Set to "true" to export telemetry to Agent 365 backend for production monitoring -# When false, uses ConsoleSpanExporter for local debugging (logs to console only) +# Set to "true" to export telemetry to Agent 365 backend +# When false, uses ConsoleSpanExporter for local debugging 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 d830f0f7..d3cfd714 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 -- Complete observability with BaggageBuilder +- Simplified observability setup - Conversation continuity across turns - Comprehensive error handling and cleanup """ +import asyncio import logging import os -import uuid from dotenv import load_dotenv @@ -49,31 +49,29 @@ 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") + # @@ -89,16 +87,14 @@ 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() - - logger.info("Claude Agent uses built-in tools: WebSearch, Read, Write, WebFetch") + + # Claude client instance (will be set per conversation) + self.client: ClaudeSDKClient | None = None # @@ -112,6 +108,7 @@ 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: @@ -120,19 +117,21 @@ 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 @@ -142,181 +141,166 @@ def _create_client(self): async def initialize(self): """Initialize the agent""" logger.info("Initializing Claude Agent...") - logger.info("Claude Agent initialized successfully") - - + try: + logger.info("Claude Agent initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize agent: {e}") + raise async def process_user_message( - self, message: str, auth: Authorization, auth_handler_name: str, context: TurnContext + self, message: str, auth: Authorization, context: TurnContext ) -> str: """Process user message using the Claude Agent SDK with observability tracing""" - # 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 + # Check if observability is enabled + enable_observability = os.getenv("ENABLE_OBSERVABILITY", "false").lower() in ("true", "1", "yes") - # 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"), - ) - - - # 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 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", - ) - - tenant_details = TenantDetails(tenant_id=tenant_id or "default-tenant") + # 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) - # Create Request without source_metadata (causes incorrect attributes) - request = Request( - content=message, - execution_type=ExecutionType.HUMAN_TO_AGENT, - session_id=conversation_id, - ) + # Get session ID from conversation + session_id = None + if context and context.activity and context.activity.conversation: + session_id = context.activity.conversation.id + # Create invoke details invoke_details = InvokeAgentDetails( details=agent_details, - session_id=conversation_id, + session_id=session_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) - # Use context manager pattern per documentation - with InvokeAgentScope.start( + # Build and start baggage context + baggage_context = BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build() + baggage_context.__enter__() + + invoke_scope = 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]) + request=request_details, + ) + 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 - # Create InferenceScope for tracking LLM call - inference_details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, + inference_details = create_inference_details( model=self.claude_options.model, - providerName="Anthropic Claude", + input_tokens=0, # Will update after response + output_tokens=0, ) + request_details = create_request_details(message, session_id) - with InferenceScope.start( + # Correct API: details, agent_details, tenant_details, request + inference_scope = InferenceScope.start( details=inference_details, agent_details=agent_details, tenant_details=tenant_details, - 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}") - - # Record finish reasons - if hasattr(inference_scope, 'record_finish_reasons'): - inference_scope.record_finish_reasons(["end_turn"]) + 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]}...") - # 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]) + # 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 reason - if inference_scope and hasattr(inference_scope, 'record_finish_reasons'): - inference_scope.record_finish_reasons(["end_turn"]) + # 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" - # Close scopes successfully + # 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 if inference_scope: - inference_scope.__exit__(None, None, None) + 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 if invoke_scope: - invoke_scope.__exit__(None, None, None) - - logger.info("βœ… Observability scopes closed successfully") + 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}") return full_response @@ -325,23 +309,15 @@ 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__) - except Exception: - pass - + 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}") + return f"Sorry, I encountered an error: {str(e)}" # @@ -352,7 +328,7 @@ async def process_user_message( # async def handle_agent_notification_activity( - self, notification_activity, auth: Authorization, auth_handler_name: str, context: TurnContext + self, notification_activity, auth: Authorization, context: TurnContext ) -> str: """ Handle agent notification activities (email, Word mentions, etc.) @@ -377,10 +353,13 @@ 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") - response = await self.process_user_message(message, auth, auth_handler_name, context) + # Process with Claude + response = await self.process_user_message(message, auth, context) return response or "Email notification processed." # Handle Word Comment Notifications @@ -394,6 +373,8 @@ 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" @@ -401,11 +382,13 @@ async def handle_agent_notification_activity( f"Please respond to this comment appropriately." ) - response = await self.process_user_message(message, auth, auth_handler_name, context) + # Process with Claude + response = await self.process_user_message(message, auth, 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}") @@ -414,6 +397,7 @@ 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 @@ -421,7 +405,8 @@ async def handle_agent_notification_activity( ) logger.info(f"πŸ“¨ Processing generic notification: {notification_type}") - response = await self.process_user_message(notification_message, auth, auth_handler_name, context) + # Process with Claude + response = await self.process_user_message(notification_message, auth, context) return response or "Notification processed successfully." except Exception as e: @@ -450,3 +435,4 @@ 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 adf63873..fb7b4a06 100644 --- a/python/claude/sample-agent/host_agent_server.py +++ b/python/claude/sample-agent/host_agent_server.py @@ -10,7 +10,6 @@ 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 @@ -53,11 +52,15 @@ ChannelId, ) from microsoft_agents_a365.notifications import EmailResponse, NotificationTypes -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 + +# 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 # Load configuration load_dotenv() @@ -67,22 +70,19 @@ class GenericAgentHost: """Generic host that can host any agent implementing the AgentInterface""" - def __init__(self, agent_class: type[AgentInterface], *agent_args, auth_handler_name: str | None = None, **agent_kwargs): + def __init__(self, agent_class: type[AgentInterface], *agent_args, **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,37 +109,6 @@ def __init__(self, agent_class: type[AgentInterface], *agent_args, auth_handler_ # 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""" @@ -158,86 +127,41 @@ async def help_handler(context: TurnContext, _: TurnState): self.agent_app.conversation_update("membersAdded")(help_handler) self.agent_app.message("/help")(help_handler) - handler = [self.auth_handler_name] - @self.agent_app.activity("message", auth_handlers=handler) + @self.agent_app.activity("message") async def on_message(context: TurnContext, _: TurnState): """Handle all messages with the hosted agent""" try: - 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, - ) - - # Cache the agentic token for Agent 365 Observability exporter use - cache_agentic_token( - tenant_id, - agent_id, - exaau_token.token, - ) + # 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 - user_message = context.activity.text or "" - logger.info(f"πŸ“¨ Processing message: '{user_message}'") + user_message = context.activity.text or "" + logger.info(f"πŸ“¨ Processing message: '{user_message}'") - # Skip empty messages - if not user_message.strip(): - return + # Skip empty messages + if not user_message.strip(): + return - # Skip messages that are handled by other decorators (like /help) - if user_message.strip() == "/help": - return + # Skip messages that are handled by other decorators (like /help) + if user_message.strip() == "/help": + 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, self.auth_handler_name, context - ) + # 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 + ) - # Send response back - logger.info( - f"πŸ“€ Sending response: '{response[:100] if len(response) > 100 else response}'" - ) - await context.send_activity(response) + # 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)}" @@ -260,7 +184,12 @@ async def handle_notification_common( return tenant_id, agent_id = result - with BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build(): + if OBSERVABILITY_AVAILABLE: + with BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build(): + await self._handle_notification_with_agent( + context, notification_activity + ) + else: await self._handle_notification_with_agent( context, notification_activity ) @@ -319,7 +248,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, self.auth_handler_name, context + notification_activity, self.agent_app.auth, context ) # For email notifications, wrap response in EmailResponse entity @@ -368,11 +297,17 @@ 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}") @@ -530,14 +465,13 @@ async def cleanup(self): logger.error(f"Error during agent cleanup: {e}") -def create_and_run_host(agent_class: type[AgentInterface], *agent_args, auth_handler_name: str | None = None, **agent_kwargs): +def create_and_run_host(agent_class: type[AgentInterface], *agent_args, **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: @@ -545,11 +479,46 @@ def create_and_run_host(agent_class: type[AgentInterface], *agent_args, auth_han if not check_agent_inheritance(agent_class): raise TypeError(f"Agent class {agent_class.__name__} must inherit from AgentInterface") - # Token resolver for Agent365 exporter - # Note: Actual observability configuration is done in agent.py _setup_observability() + # 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") # Create the host - host = GenericAgentHost(agent_class, *agent_args, auth_handler_name=auth_handler_name, **agent_kwargs) + host = GenericAgentHost(agent_class, *agent_args, **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 deleted file mode 100644 index 7c0ce83d..00000000 --- a/python/claude/sample-agent/observability_config.py +++ /dev/null @@ -1,75 +0,0 @@ -# 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 new file mode 100644 index 00000000..5dace7e3 --- /dev/null +++ b/python/claude/sample-agent/observability_helpers.py @@ -0,0 +1,164 @@ +# 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 a269c0c8..72046edb 100644 --- a/python/claude/sample-agent/pyproject.toml +++ b/python/claude/sample-agent/pyproject.toml @@ -1,10 +1,7 @@ [project] name = "sample-claude-agent" version = "0.1.0" -description = "Sample Claude Agent using Microsoft Agent 365 SDK with MCP tooling support" -authors = [ - { name = "Microsoft", email = "support@microsoft.com" } -] +description = "Sample Claude Agent using Microsoft Agent 365 SDK" dependencies = [ # Claude Agent SDK "claude-agent-sdk>=0.1.0", @@ -15,6 +12,12 @@ 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", @@ -22,12 +25,9 @@ dependencies = [ # Additional utilities "typing-extensions>=4.0.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" + # Local packages from local index + # - Update package versions to match your built wheels + "microsoft_agents_a365_runtime >= 0.1.0", ] requires-python = ">=3.11"