diff --git a/.github/workflows/python-claude-sample.yml b/.github/workflows/python-claude-sample.yml new file mode 100644 index 00000000..adad8165 --- /dev/null +++ b/.github/workflows/python-claude-sample.yml @@ -0,0 +1,93 @@ +name: Python Claude Sample - Build Validation + +on: + push: + branches: + - main + - develop + paths: + - 'python/claude/sample-agent/**' + - '.github/workflows/python-claude-sample.yml' + pull_request: + branches: + - main + - develop + paths: + - 'python/claude/sample-agent/**' + - '.github/workflows/python-claude-sample.yml' + +permissions: + contents: read + +jobs: + build-and-validate: + name: Build and Validate Claude Sample + runs-on: ubuntu-latest + defaults: + run: + working-directory: python/claude/sample-agent + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Create virtual environment + run: uv venv + + - name: Install dependencies + run: | + uv pip install -e . + + - name: Check Python syntax + run: | + python -m py_compile agent.py + python -m py_compile host_agent_server.py + python -m py_compile start_with_generic_host.py + python -m py_compile agent_interface.py + python -m py_compile local_authentication_options.py + python -m py_compile token_cache.py + python -m py_compile observability_helpers.py + + - name: Validate pyproject.toml + run: | + if [ ! -f "pyproject.toml" ]; then + echo "pyproject.toml not found" + exit 1 + fi + python -c "import tomllib; tomllib.load(open('pyproject.toml', 'rb'))" 2>/dev/null || python -c "import tomli; tomli.load(open('pyproject.toml', 'rb'))" + + - name: Check for required files + run: | + required_files=("README.md" "AGENT-CODE-WALKTHROUGH.md" ".env.template" "ToolingManifest.json") + for file in "${required_files[@]}"; do + if [ ! -f "$file" ]; then + echo "Required file $file not found" + exit 1 + fi + done + + - name: Validate imports + run: | + python -c " + import sys + sys.path.insert(0, '.') + try: + import agent_interface + import local_authentication_options + import token_cache + import observability_helpers + print('āœ… All imports validated successfully') + except ImportError as e: + print(f'āŒ Import validation failed: {e}') + sys.exit(1) + " diff --git a/.gitignore b/.gitignore index 32331b3f..24de6f32 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ build/ .eggs/ .pytest_cache/ _version.py +uv.lock # Virtual environments .venv/ diff --git a/python/claude/sample-agent/.env.template b/python/claude/sample-agent/.env.template new file mode 100644 index 00000000..cb1e4474 --- /dev/null +++ b/python/claude/sample-agent/.env.template @@ -0,0 +1,120 @@ +# ============================================================================= +# CLAUDE AGENT SDK CONFIGURATION +# ============================================================================= + +# Anthropic API Key (required) +# Get your API key from: https://console.anthropic.com/ +ANTHROPIC_API_KEY=your_anthropic_api_key_here + +# Claude Model to use (optional, defaults to claude-sonnet-4-20250514) +# Options: claude-opus-4-20250514, claude-sonnet-4-20250514, claude-haiku-4-20250514 +CLAUDE_MODEL=claude-sonnet-4-20250514 + + +# ============================================================================= +# MCP (Model Context Protocol) CONFIGURATION (Optional) +# ============================================================================= + +# MCP Server Host +MCP_SERVER_HOST= + +# MCP Platform Endpoint +MCP_PLATFORM_ENDPOINT= + +# ============================================================================= +# MICROSOFT 365 AGENTS SDK CONFIGURATION +# ============================================================================= + +# Agent ID (required for agentic authentication) +AGENT_ID=your-agent-id + +# Environment ID (optional, defaults to prod) +# Options: dev, test, preprod, prod +ENVIRONMENT_ID=prod +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 + +# 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) +# Example: https://api.powerplatform.com/.default +AGENTIC_AUTH_SCOPE=https://api.powerplatform.com/.default + +# ============================================================================= +# AGENT365 AGENTIC AUTHENTICATION CONFIGURATION +# ============================================================================= + +# Service connection settings for Agent365 +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SCOPES= + +# Agent application user authorization settings +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__TYPE=AgenticUserAuthorization +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__SCOPES=https://graph.microsoft.com/.default +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__ALTERNATEBLUEPRINTCONNECTIONNAME=https://graph.microsoft.com/.default + +# Connections map configuration +CONNECTIONSMAP_0_SERVICEURL=* +CONNECTIONSMAP_0_CONNECTION=SERVICE_CONNECTION + +# ============================================================================= +# CLIENT CREDENTIALS AUTHENTICATION (Optional) +# ============================================================================= +# For production deployments, use client credentials instead of bearer token + +# Azure AD Client ID +# CLIENT_ID=your-client-id + +# Azure AD Tenant ID +# TENANT_ID=your-tenant-id + +# Azure AD Client Secret +# CLIENT_SECRET=your-client-secret + +# ============================================================================= +# SERVER CONFIGURATION +# ============================================================================= + +# Port to run the server on (optional, defaults to 3978) +PORT=3978 + +# ============================================================================= +# LOGGING CONFIGURATION +# ============================================================================= + +# Logging level (optional, defaults to INFO) +# Options: DEBUG, INFO, WARNING, ERROR, CRITICAL +LOG_LEVEL=INFO + +# ============================================================================= +# OBSERVABILITY CONFIGURATION (Optional) +# ============================================================================= + +# Enable observability tracing (set to true to track agent operations) +ENABLE_OBSERVABILITY=true + +# Service name for observability +OBSERVABILITY_SERVICE_NAME=claude-agent + +# 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 +# When false, uses ConsoleSpanExporter for local debugging +ENABLE_A365_OBSERVABILITY_EXPORTER=false + +# Python environment (influences target cluster/category) +# Options: development, production +PYTHON_ENVIRONMENT=development diff --git a/python/claude/sample-agent/AGENT-CODE-WALKTHROUGH.md b/python/claude/sample-agent/AGENT-CODE-WALKTHROUGH.md new file mode 100644 index 00000000..9cfaa310 --- /dev/null +++ b/python/claude/sample-agent/AGENT-CODE-WALKTHROUGH.md @@ -0,0 +1,917 @@ +# Agent Code Walkthrough + +Step-by-step walkthrough of the complete agent implementation in `python/claude/sample-agent`. + +## Overview + +| Component | Purpose | +|-----------|---------| +| **Claude Agent SDK** | Core AI orchestration with extended thinking and built-in tools | +| **Microsoft 365 Agents SDK** | Enterprise hosting and authentication integration | +| **Agent Notifications** | Handle @mentions from Outlook, Word, and Teams | +| **Microsoft Agent 365 Observability** | Comprehensive tracing and monitoring | + +## File Structure and Organization + +The code is organized into well-defined sections with clear separators for developer readability. + +**Key Files**: +- `agent.py` - Core Claude agent implementation +- `host_agent_server.py` - Generic agent host with notification support +- `start_with_generic_host.py` - Server startup script +- `agent_interface.py` - Abstract interface for agent implementations +- `local_authentication_options.py` - Authentication configuration +- `observability_helpers.py` - Telemetry and tracing utilities + +--- + +## Step 1: Dependency Imports + +```python +# Claude Agent SDK +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + TextBlock, + ThinkingBlock, +) + +# Agent Interface +from agent_interface import AgentInterface + +# Microsoft Agents SDK +from local_authentication_options import LocalAuthenticationOptions +from microsoft_agents.hosting.core import Authorization, TurnContext + +# Notifications (optional) +try: + from microsoft_agents_a365.notifications.agent_notification import NotificationTypes + NOTIFICATIONS_AVAILABLE = True +except ImportError: + NOTIFICATIONS_AVAILABLE = False + +# Observability (optional) +try: + from microsoft_agents_a365.observability.core import ( + InferenceScope, + InvokeAgentDetails, + InvokeAgentScope, + ) + from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder + from observability_helpers import ( + create_agent_details, + create_tenant_details, + create_request_details, + create_inference_details, + ) + OBSERVABILITY_AVAILABLE = True +except ImportError: + OBSERVABILITY_AVAILABLE = False +``` + +**What it does**: Brings in all the external libraries and tools the agent needs to work. + +**Key Imports**: +- **Claude Agent SDK**: Client, message types, and configuration options for Claude AI +- **Microsoft 365 Agents**: Enterprise security and hosting features +- **Notifications**: Handle @mentions from Outlook, Word, and Teams (optional) +- **Observability**: Tracks what the agent is doing for monitoring and debugging (optional) + +**Unique to Claude**: The SDK uses an async context manager pattern with `ClaudeSDKClient` and provides structured message types (`AssistantMessage`, `TextBlock`, `ThinkingBlock`) for handling responses. + +--- + +## Step 2: Agent Initialization + +```python +def __init__(self): + """Initialize the Claude agent.""" + self.logger = logging.getLogger(self.__class__.__name__) + + # Initialize authentication options + self.auth_options = LocalAuthenticationOptions.from_environment() + + # Create Claude client configuration + self._create_client() + + # Claude client instance (will be set per conversation) + self.client: ClaudeSDKClient | None = None +``` + +**What it does**: Creates the Claude agent and sets up its basic configuration. + +**What happens**: +1. **Sets up Logging**: Creates a logger for the agent +2. **Loads Auth Config**: Gets authentication settings from environment +3. **Configures Claude**: Creates Claude client options +4. **Prepares Client**: Sets up placeholder for per-conversation client instances + +**Key Design**: The agent creates a new client for each conversation to maintain proper isolation and state management. + +--- + +## Step 3: Client Configuration + +```python +def _create_client(self): + """Create the Claude Agent SDK client options""" + # 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: + raise EnvironmentError("Missing ANTHROPIC_API_KEY. Please set it before running.") + + # 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}") +``` + +**What it does**: Configures the Claude Agent SDK with model settings and capabilities. + +**Configuration Options**: +- `model`: Claude model to use (default: claude-sonnet-4-20250514) +- `max_thinking_tokens`: Tokens allocated for extended thinking (1024) +- `allowed_tools`: Built-in tools available to the agent +- `permission_mode`: Auto-accept file edits for smoother operation +- `continue_conversation`: Maintain conversation context across turns + +**Available Tools**: +- **WebSearch**: Search the internet for information +- **Read**: Read files from the filesystem +- **Write**: Create and modify files +- **WebFetch**: Fetch content from web URLs + +**Environment Variables**: +- `CLAUDE_MODEL`: Model version to use +- `ANTHROPIC_API_KEY`: Required API key for authentication + +--- + +## Step 4: Message Processing with Observability + +```python +async def process_user_message( + self, message: str, auth: Authorization, 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") + + # 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) + + # Get session ID from conversation + session_id = context.activity.conversation.id if context and context.activity and context.activity.conversation else None + + # Create invoke details + invoke_details = InvokeAgentDetails( + details=agent_details, + session_id=session_id, + ) + request_details = create_request_details(message, session_id) + + # Extract tenant_id and agent_id from context + tenant_id = None + agent_id = None + if context and context.activity and hasattr(context.activity, 'recipient'): + tenant_id = getattr(context.activity.recipient, 'tenant_id', None) + agent_id = getattr(context.activity.recipient, 'agentic_app_id', None) + + # 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_details, + ) + invoke_scope.__enter__() + + except Exception as e: + logger.warning(f"Failed to start observability scope: {e}") + invoke_scope = None + baggage_context = None +``` + +**What it does**: Sets up comprehensive observability tracing for the agent invocation. + +**What happens**: +1. **Checks Observability**: Determines if tracing is enabled via environment variable +2. **Creates Agent Details**: Extracts agent ID, conversation ID, and metadata +3. **Creates Tenant Details**: Extracts tenant information for multi-tenant scenarios +4. **Builds Baggage Context**: Sets up distributed tracing context with tenant/agent IDs +5. **Starts Invoke Scope**: Begins tracking the agent invocation lifecycle + +**Key Components**: +- `InvokeAgentScope`: Tracks the entire agent invocation +- `BaggageBuilder`: Propagates tenant/agent IDs through distributed traces +- `create_agent_details()`: Helper that extracts agent metadata from context +- `create_tenant_details()`: Helper that extracts tenant metadata from context + +**Environment Variable**: +- `ENABLE_OBSERVABILITY`: Set to "true", "1", or "yes" to enable tracing + +--- + +## Step 5: Inference Processing with Token Tracking + +```python + 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: + inference_details = create_inference_details( + model=self.claude_options.model, + input_tokens=0, # Will update after response + output_tokens=0, + ) + + inference_scope = InferenceScope.start( + details=inference_details, + agent_details=agent_details, + tenant_details=tenant_details, + request=request_details, + ) + inference_scope.__enter__() + except Exception as e: + logger.warning(f"Failed to start inference scope: {e}") + + # 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: + # Collect thinking (Claude's reasoning) + if isinstance(block, ThinkingBlock): + thinking_parts.append(f"šŸ’­ {block.thinking}") + total_thinking_tokens += len(block.thinking.split()) + logger.info(f"šŸ’­ Claude thinking: {block.thinking[:100]}...") + + # Collect actual response text + elif isinstance(block, TextBlock): + response_parts.append(block.text) + total_output_tokens += len(block.text.split()) + logger.info(f"šŸ’¬ Claude response: {block.text[:100]}...") +``` + +**What it does**: Processes the message using Claude Agent SDK with async context management and token tracking. + +**Key Pattern - Async Context Manager**: +```python +async with ClaudeSDKClient(self.claude_options) as client: +``` +This creates a new client for each conversation, ensuring proper isolation and automatic cleanup. + +**Message Flow**: +1. **Send Query**: `await client.query(message)` sends the user's message to Claude +2. **Receive Responses**: `async for msg in client.receive_response()` streams back responses +3. **Process Blocks**: Each message contains blocks (thinking or text) +4. **Track Tokens**: Approximates token counts for observability + +**Message Types**: +- `AssistantMessage`: Container for Claude's response +- `ThinkingBlock`: Contains Claude's extended thinking/reasoning +- `TextBlock`: Contains the actual response text + +**Unique to Claude**: Extended thinking blocks show Claude's reasoning process, providing transparency into how it arrives at answers and decides to use tools. + +--- + +## Step 6: Response Formatting and Cleanup + +```python + # Track input tokens (approximate) + total_input_tokens = len(message.split()) + + # Combine thinking and response + 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" + + # 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: + try: + 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: + 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 + + except Exception as e: + logger.error(f"Error processing message: {e}") + + # Record error in scopes + 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 after error: {cleanup_error}") + + return f"Sorry, I encountered an error: {str(e)}" +``` + +**What it does**: Formats the final response and ensures proper cleanup of observability resources. + +**Response Formatting**: +1. **Combines Thinking**: If extended thinking is present, shows it first with a header +2. **Adds Response**: Appends the actual response text +3. **Fallback Message**: Provides friendly error message if no response generated + +**Observability Cleanup**: +- Logs token counts (input, output, thinking) for metrics +- Closes inference scope to complete tracing span +- Closes invoke scope and baggage context +- Records errors in traces if exceptions occur + +**Error Handling**: +- Catches all exceptions to prevent crashes +- Records errors in observability spans for debugging +- Ensures all scopes are properly closed even on failure +- Returns user-friendly error messages + +--- + +## Step 7: Notification Handling + +```python +async def handle_agent_notification_activity( + self, notification_activity, auth: Authorization, context: TurnContext +) -> str: + """Handle agent notification activities (email, Word mentions, etc.)""" + if not NOTIFICATIONS_AVAILABLE: + return "Notification handling is not available in this configuration." + + try: + notification_type = notification_activity.notification_type + logger.info(f"šŸ“¬ Processing notification: {notification_type}") + + # Handle Email Notifications + if notification_type == NotificationTypes.EMAIL_NOTIFICATION: + if not hasattr(notification_activity, "email") or not notification_activity.email: + return "I could not find the email notification details." + + email = notification_activity.email + email_body = getattr(email, "html_body", "") or getattr(email, "body", "") + + 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, context) + return response or "Email notification processed." + + # Handle Word Comment Notifications + elif notification_type == NotificationTypes.WPX_COMMENT: + if not hasattr(notification_activity, "wpx_comment") or not notification_activity.wpx_comment: + return "I could not find the Word notification details." + + wpx = notification_activity.wpx_comment + doc_id = getattr(wpx, "document_id", "") + comment_text = notification_activity.text or "" + + message = ( + f"You have been mentioned in a Word document comment.\n" + f"Document ID: {doc_id}\n" + f"Comment: {comment_text}\n\n" + f"Please respond to this comment appropriately." + ) + + response = await self.process_user_message(message, auth, context) + return response or "Word notification processed." + + # Generic notification handling + else: + notification_message = ( + getattr(notification_activity.activity, 'text', None) or + str(getattr(notification_activity.activity, 'value', None)) or + f"Notification received: {notification_type}" + ) + + response = await self.process_user_message(notification_message, auth, context) + return response or "Notification processed successfully." + + except Exception as e: + logger.error(f"Error processing notification: {e}") + return f"Sorry, I encountered an error processing the notification: {str(e)}" +``` + +**What it does**: Handles @mentions from various Microsoft 365 applications. + +**Notification Types**: +- **EMAIL_NOTIFICATION**: User @mentioned agent in Outlook +- **WPX_COMMENT**: User @mentioned agent in Word comment +- **Generic**: Other notification types (Teams messages, etc.) + +**What happens**: +1. **Checks Availability**: Verifies notification packages are installed +2. **Identifies Type**: Determines what kind of notification it is +3. **Extracts Context**: Gets relevant information (email body, comment text, etc.) +4. **Formats Message**: Creates a clear prompt for Claude +5. **Processes with AI**: Uses Claude to generate intelligent response +6. **Returns Result**: Sends response back to user + +**Key Feature**: The agent intelligently responds to notifications by processing the context through Claude's extended thinking, enabling contextual and helpful responses. + +--- + +## Step 8: Agent Cleanup + +```python +async def cleanup(self) -> None: + """Clean up agent resources""" + try: + logger.info("Cleaning up agent resources...") + + # Claude SDK client cleanup is handled by context manager + # No additional cleanup needed + + logger.info("Agent cleanup completed") + + except Exception as e: + logger.error(f"Error during cleanup: {e}") +``` + +**What it does**: Properly cleans up agent resources when shutting down. + +**What happens**: +- Logs cleanup start and completion +- Claude SDK handles client cleanup automatically via async context manager +- No manual resource management needed +- Errors are logged but don't crash + +**Unique to Claude**: The async context manager pattern (`async with ClaudeSDKClient`) handles all cleanup automatically, making resource management simple and reliable. + +--- + +## Step 9: Main Entry Point for Testing + +```python +async def main(): + """Main function to run the Claude Agent""" + try: + # Create and initialize the agent + agent = ClaudeAgent() + await agent.initialize() + + # Test the agent with a simple message + logger.info("\n" + "=" * 80) + logger.info("Testing Claude Agent") + logger.info("=" * 80 + "\n") + + # Dummy auth and context for standalone testing + class DummyAuth: + async def exchange_token(self, context, scopes, handler_id): + return type('obj', (object,), {'token': 'dummy-token'})() + + class DummyContext: + pass + + response = await agent.process_user_message( + "What is the capital of France?", + DummyAuth(), + DummyContext() + ) + + logger.info("\n" + "=" * 80) + logger.info("Response:") + logger.info("=" * 80) + logger.info(response) + logger.info("=" * 80 + "\n") + + except Exception as e: + logger.error(f"Failed to start agent: {e}") + + finally: + # Cleanup + if "agent" in locals(): + await agent.cleanup() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**What it does**: Provides a standalone test entry point for the agent. + +**What happens**: +1. **Creates Agent**: Instantiates the ClaudeAgent class +2. **Initializes**: Calls the initialize method +3. **Creates Test Fixtures**: Creates dummy auth and context objects +4. **Tests Message**: Sends a simple test message to verify functionality +5. **Displays Response**: Logs the response with clear formatting +6. **Ensures Cleanup**: Always cleans up resources in finally block + +**Why it's useful**: Allows quick testing of the agent without starting the full server, useful for development and debugging. + +--- + +## Step 10: Observability Helper Functions + +The `observability_helpers.py` file provides utility functions for creating observability objects: + +```python +def create_agent_details(context: Optional[TurnContext] = None) -> AgentDetails: + """Create agent details for observability""" + agent_id = environ.get("AGENT_ID", "claude-agent") + conversation_id = None + + if context and context.activity: + if hasattr(context.activity, "recipient"): + agent_id = context.activity.recipient.agentic_app_id or agent_id + if 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_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""" + return InferenceCallDetails( + operationName=InferenceOperationType.CHAT, + model=model, + providerName="anthropic-claude", + inputTokens=input_tokens, + outputTokens=output_tokens + thinking_tokens, # Total includes thinking + finishReasons=finish_reasons or ["end_turn"], + responseId=response_id, + ) +``` + +**What it does**: Provides reusable functions for creating observability objects. + +**Helper Functions**: +- `create_agent_details()`: Extracts agent metadata from context +- `create_tenant_details()`: Extracts tenant information for multi-tenancy +- `create_request_details()`: Creates request tracking objects +- `create_inference_details()`: Creates inference call tracking with token counts +- `create_tool_call_details()`: Creates tool execution tracking + +**Key Features**: +- Safely extracts data with fallbacks +- Handles missing context gracefully +- Includes Claude-specific fields (thinking_tokens) +- Provides sensible defaults + +--- + +## Architecture Patterns + +### 1. **Async Context Manager Pattern** +```python +async with ClaudeSDKClient(self.claude_options) as client: + await client.query(message) + async for msg in client.receive_response(): + # Process streaming response +``` + +**Why**: Ensures proper resource cleanup and connection management automatically. + +### 2. **Optional Dependencies** +```python +try: + from microsoft_agents_a365.notifications.agent_notification import NotificationTypes + NOTIFICATIONS_AVAILABLE = True +except ImportError: + NOTIFICATIONS_AVAILABLE = False +``` + +**Why**: Allows the agent to work without optional packages, improving flexibility and reducing dependencies. + +### 3. **Separation of Concerns** +``` +agent.py -> AI logic and Claude integration +host_agent_server.py -> HTTP hosting and routing +agent_interface.py -> Abstract contract +observability_helpers.py -> Telemetry utilities +``` + +**Why**: Clean architecture makes the code maintainable and testable. + +### 4. **Structured Observability** +```python +# Invoke scope for entire agent invocation +with InvokeAgentScope.start(...): + # Inference scope for specific AI call + with InferenceScope.start(...): + # Process with Claude +``` + +**Why**: Provides hierarchical tracing with proper context propagation. + +### 5. **Error Resilience** +```python +try: + response = await self.process_user_message(...) +except Exception as e: + # Record error in traces + if invoke_scope: + invoke_scope.record_error(e) + return f"Sorry, I encountered an error: {str(e)}" +``` + +**Why**: Always returns helpful errors to users instead of crashing, while tracking failures for debugging. + +--- + +## Key Differences from Other Samples + +### vs. AgentFramework Sample +- āœ… **Built-in Tools**: Claude includes WebSearch, Read, Write, WebFetch without MCP setup +- āœ… **Extended Thinking**: See Claude's reasoning process transparently +- āœ… **Simpler Configuration**: Just API key and model selection needed +- āœ… **Async Context Manager**: Cleaner resource management pattern +- āŒ **No Custom MCP Servers**: Can't add Mail, Calendar, SharePoint tools (yet) + +### vs. OpenAI Sample +- āœ… **Built-in Tools**: No tool registration or function schemas needed +- āœ… **Extended Thinking**: Unique transparency into AI reasoning +- āœ… **Notification Support**: Full Outlook/Word/@mention handling +- āœ… **Streaming Responses**: Process thinking and text as it arrives +- āŒ **No MCP Extensibility**: Can't add custom tools (yet) +- āŒ **Manual Observability**: No auto-instrumentation yet + +### Unique Advantages +1. **Simplicity**: Fewer dependencies, easier setup, no MCP server configuration +2. **Transparency**: Extended thinking shows reasoning process +3. **Built-in Tools**: Powerful capabilities out of the box (WebSearch, Read, Write, WebFetch) +4. **Notifications**: Full support for Microsoft 365 integrations +5. **Resource Safety**: Async context managers ensure proper cleanup + +### Current Limitations +1. **No MCP Support**: Can't add custom tools like Mail, Calendar, SharePoint +2. **Manual Observability**: Requires helper functions until auto-instrumentation arrives +3. **Token Approximation**: Token counts are word-based estimates, not actual tokens + +--- + +## Environment Variables Reference + +| Variable | Required | Default | Purpose | +|----------|----------|---------|---------| +| `ANTHROPIC_API_KEY` | āœ… Yes | - | Authentication for Claude API | +| `CLAUDE_MODEL` | No | claude-sonnet-4-20250514 | Claude model version to use | +| `ENABLE_OBSERVABILITY` | No | false | Enable/disable observability tracing | +| `OBSERVABILITY_SERVICE_NAME` | No | claude-agent | Service name for telemetry | +| `OBSERVABILITY_SERVICE_NAMESPACE` | No | agent365-samples | Service namespace for telemetry | +| `AGENT_ID` | No | claude-agent | Agent identifier for observability | +| `TENANT_ID` | No | default-tenant | Tenant identifier for multi-tenancy | +| `PORT` | No | 3978 | Server port | +| `LOG_LEVEL` | No | INFO | Logging level (DEBUG, INFO, WARNING, ERROR) | + +--- + +## Testing Checklist + +- [ ] Anthropic API key set in `.env` file +- [ ] Dependencies installed (`uv pip install -e .` or `pip install -e .`) +- [ ] Server starts without errors (`python start_with_generic_host.py`) +- [ ] Can send messages and get responses +- [ ] Extended thinking appears in responses +- [ ] Built-in tools work (WebSearch, Read, Write, WebFetch) +- [ ] Notifications route correctly (if enabled) +- [ ] Observability traces appear (if enabled) +- [ ] Error handling works gracefully + +--- + +## Debugging Guide + +### 1. Enable Debug Logging +```bash +export LOG_LEVEL=DEBUG +python start_with_generic_host.py +``` + +### 2. Test Agent Standalone +```bash +python agent.py +``` +This runs the agent without the server to isolate issues. + +### 3. Check API Key +```bash +python -c "import os; from dotenv import load_dotenv; load_dotenv(); print('API Key:', 'SET' if os.getenv('ANTHROPIC_API_KEY') else 'MISSING')" +``` + +### 4. Verify Observability +Check logs for: +``` +āœ… Observability scope started +šŸ“Š Tokens - Input: X, Output: Y, Thinking: Z +``` + +### 5. Test Notifications +Check logs for notification routing: +``` +šŸ“¬ Processing notification: EMAIL_NOTIFICATION +šŸ“§ Processing email notification +``` + +### 6. Common Issues + +**Issue**: "Missing ANTHROPIC_API_KEY" +- **Solution**: Add `ANTHROPIC_API_KEY=your-key-here` to `.env` file + +**Issue**: No extended thinking in responses +- **Solution**: Verify `max_thinking_tokens=1024` in `claude_options` + +**Issue**: Tools not working +- **Solution**: Check `allowed_tools` list in `claude_options` + +**Issue**: Observability not working +- **Solution**: Set `ENABLE_OBSERVABILITY=true` and verify packages installed + +--- + +This architecture provides a solid foundation for building production-ready AI agents with Claude Agent SDK, emphasizing simplicity, transparency, and proper resource management while integrating seamlessly with Microsoft 365 Agents SDK for enterprise features. + +3. Testing works without production credentials + +**Implementation Detail**: This dual registration was necessary because the Agents Playground sends notifications with `channel="msteams"` while production uses `channel="agents"`. + +--- + +This architecture provides a solid foundation for building production-ready AI agents with Claude Agent SDK, emphasizing simplicity, transparency, and proper resource management while integrating seamlessly with Microsoft 365 Agents SDK for enterprise features. + + +This architecture provides a solid foundation for building production-ready AI agents with Claude Agent SDK, emphasizing simplicity, transparency, and proper resource management while integrating seamlessly with Microsoft 365 Agents SDK for enterprise features. + + return f"Sorry, I encountered an error: {str(e)}" +``` + +Always return helpful errors to users instead of crashing. + +--- + +## Package Patches Required + +The Microsoft Agent 365 notification package has bugs requiring 3 patches: + +### 1. `__init__.py` - Fixed Imports +```python +# Fixed: Removed broken agents_sdk_extensions import +from .agent_notification import AgentNotification +from .models.agent_notification_activity import AgentNotificationActivity, NotificationTypes +``` + +### 2. `agent_notification.py` - Fixed NoneType Handling +```python +# Fixed: Default to "agents" channel when channel_id is None +received_channel = (ch.channel if ch and ch.channel else "agents").lower() +received_subchannel = (ch.sub_channel if ch and ch.sub_channel else "").lower() +``` + +### 3. `agent_notification_activity.py` - Fixed None Activity Name +```python +# Fixed: Check for None before creating NotificationTypes enum +if self._notification_type is None and self.activity.name is not None: + try: + self._notification_type = NotificationTypes(self.activity.name) + except ValueError: + self._notification_type = None +``` + +**Note**: These patches are automatically applied when you run the agent for the first time and encounter notification errors. + +--- + +## Key Differences from Other Samples + +### vs. AgentFramework Sample +- āœ… **No MCP Setup**: Claude has built-in tools (Read, Write, WebSearch, Bash, Grep) +- āœ… **Extended Thinking**: See Claude's reasoning process +- āœ… **Simpler Configuration**: Just API key needed +- āŒ **No Custom MCP Servers**: Can't add Mail, Calendar, SharePoint tools (yet) + +### vs. OpenAI Sample +- āœ… **Built-in Tools**: No tool registration needed +- āœ… **Extended Thinking**: Transparency into AI reasoning +- āœ… **Notification Support**: Full Outlook/Word/@mention handling (OpenAI sample lacks this) +- āŒ **No MCP Extensibility**: Can't add custom tools (yet) + +### Unique Advantages +1. **Simplicity**: Fewer dependencies, easier setup +2. **Transparency**: Extended thinking shows reasoning +3. **Built-in Tools**: Powerful capabilities out of the box +4. **Notifications**: Full support for Microsoft 365 integrations + +### Current Limitations +1. **No MCP Support**: Can't add Mail, Calendar, SharePoint tools +2. **No Auto-instrumentation**: Manual telemetry required +3. **Package Bugs**: Requires 3 patches to notification package + +--- + +## Debugging Guide + +### 1. Enable Debug Logging +```bash +export LOG_LEVEL=DEBUG +python start_with_generic_host.py +``` + +### 2. Test Notification Routing +```python +# Check debug logs for channel matching +INFO:agent_notification:šŸ” Route selector check: +INFO:agent_notification: Received channel: 'msteams' vs Registered: 'msteams' +INFO:agent_notification: āœ… Channel match! +``` + +### 3. Verify Claude Authentication +```bash +claude login +# Test with a simple query +python -c "from anthropic_ai.claude_agent_sdk import query; print(list(query('hello')))" +``` + +### 4. Check Environment Variables +```python +python -c "import os; print('API Key:', 'SET' if os.getenv('ANTHROPIC_API_KEY') else 'MISSING')" +``` + +--- + +## Testing Checklist + +- [ ] Claude CLI authenticated (`claude login`) +- [ ] API key set in `.env` +- [ ] Dependencies installed (`uv pip install -e .`) +- [ ] Server starts without errors +- [ ] Can send messages and get responses +- [ ] Extended thinking appears in responses +- [ ] Notifications route correctly (both channels) +- [ ] Observability traces appear in console +- [ ] Tools work (Read, Write, WebSearch, etc.) + +--- + +This architecture provides a solid foundation for building production-ready AI agents with Claude Agent SDK while maintaining flexibility for future enhancements like MCP support. diff --git a/python/claude/sample-agent/README.md b/python/claude/sample-agent/README.md new file mode 100644 index 00000000..8dc2bfc3 --- /dev/null +++ b/python/claude/sample-agent/README.md @@ -0,0 +1,99 @@ +# Claude Sample Agent - Python + +This directory contains a sample agent implementation using Python and Anthropic's Claude Agent SDK with extended thinking capabilities. This sample demonstrates how to build an agent using the Agent365 framework with Python and Claude Agent SDK. It covers: + +- **Observability**: End-to-end tracing, caching, and monitoring for agent applications +- **Notifications**: Services and models for managing user notifications +- **Tools**: Built-in Claude tools (Read, Write, WebSearch, Bash, Grep) for building advanced agent solutions +- **Hosting Patterns**: Hosting with Microsoft 365 Agents SDK + +This sample uses the [Microsoft Agent 365 SDK for Python](https://github.com/microsoft/Agent365-python). + +For comprehensive documentation and guidance on building agents with the Microsoft Agent 365 SDK, including how to add tooling, observability, and notifications, visit the [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/). + +## Prerequisites + +- Python 3.11+ +- Anthropic Claude API access (API key) + +## Documentation + +For detailed setup and running instructions, please refer to the official documentation: + +- **[Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/)** - Complete setup and testing guide +- **[AGENT-CODE-WALKTHROUGH.md](AGENT-CODE-WALKTHROUGH.md)** - Detailed code explanation and architecture walkthrough + +## Architecture + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Microsoft 365 Agents Playground / Client │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ host_agent_server.py │ +│ - HTTP endpoint (/api/messages) │ +│ - Authentication middleware │ +│ - Notification routing │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ agent.py (ClaudeAgent) │ +│ - Message processing │ +│ - Notification handling │ +│ - Claude SDK integration │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Claude Agent SDK │ +│ - Extended thinking │ +│ - Built-in tools │ +│ - Streaming responses │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +## Built-in Claude Tools + +The Claude Agent SDK provides these tools out of the box: + +- **Read**: Read files from the workspace +- **Write**: Create/modify files +- **WebSearch**: Search the web for information +- **Bash**: Execute shell commands +- **Grep**: Search file contents + +No additional MCP server configuration needed! + +## Support + +For issues, questions, or feedback: + +- **Issues**: Please file issues in the [GitHub Issues](https://github.com/microsoft/Agent365-python/issues) section +- **Documentation**: See the [Microsoft Agents 365 Developer documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/) + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit . + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Resources + +- [Claude Agent SDK](https://anthropic.mintlify.app/en/api/agent-sdk/overview) +- [Microsoft 365 Agents SDK](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/) +- [Microsoft Agents A365 Python](https://github.com/microsoft/Agent365-python) + +## Trademarks + +*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* + +## License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT License - see the [LICENSE](../../../LICENSE.md) file for details. \ No newline at end of file diff --git a/python/claude/sample-agent/agent.py b/python/claude/sample-agent/agent.py new file mode 100644 index 00000000..d3cfd714 --- /dev/null +++ b/python/claude/sample-agent/agent.py @@ -0,0 +1,438 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Claude Agent SDK Agent with Microsoft 365 Integration + +This agent uses the Claude Agent SDK and integrates with Microsoft 365 Agents SDK +for enterprise hosting, authentication, and observability. + +Features: +- Claude Agent SDK with extended thinking capability +- Microsoft 365 Agents SDK hosting and authentication +- Simplified observability setup +- Conversation continuity across turns +- Comprehensive error handling and cleanup +""" + +import asyncio +import logging +import os + +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# ============================================================================= +# DEPENDENCY IMPORTS +# ============================================================================= +# + +# Claude Agent SDK +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + TextBlock, + ThinkingBlock, +) + +# Agent Interface +from agent_interface import AgentInterface + +# Microsoft Agents SDK +from local_authentication_options import LocalAuthenticationOptions +from microsoft_agents.hosting.core import Authorization, TurnContext + +# 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") + +# + + +class ClaudeAgent(AgentInterface): + """Claude Agent integrated with Microsoft 365 Agents SDK""" + + # ========================================================================= + # INITIALIZATION + # ========================================================================= + # + + def __init__(self): + """Initialize the Claude agent.""" + self.logger = logging.getLogger(self.__class__.__name__) + + # 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 + + # + + # ========================================================================= + # CLIENT CREATION + # ========================================================================= + # + + def _create_client(self): + """Create the Claude Agent SDK client options""" + # 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: + raise EnvironmentError("Missing ANTHROPIC_API_KEY. Please set it before running.") + + # 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 + # ========================================================================= + # + + 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 + + async def process_user_message( + self, message: str, auth: Authorization, 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") + + # 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) + + # 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=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) + + # 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_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 + + inference_details = create_inference_details( + model=self.claude_options.model, + input_tokens=0, # Will update after response + output_tokens=0, + ) + request_details = create_request_details(message, session_id) + + # 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_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]}...") + + # 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 = "" + + # 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" + + # 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: + 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: + 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 + + except Exception as e: + logger.error(f"Error processing message: {e}") + logger.exception("Full error details:") + + # Record error in scopes + 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}") + + return f"Sorry, I encountered an error: {str(e)}" + + # + + # ========================================================================= + # NOTIFICATION HANDLING + # ========================================================================= + # + + async def handle_agent_notification_activity( + self, notification_activity, auth: Authorization, context: TurnContext + ) -> str: + """ + Handle agent notification activities (email, Word mentions, etc.) + + Args: + notification_activity: The notification activity from Agent365 + auth: Authorization for token exchange + context: Turn context from M365 SDK + + Returns: + Response string to send back + """ + try: + notification_type = notification_activity.notification_type + logger.info(f"šŸ“¬ Processing notification: {notification_type}") + + # Handle Email Notifications + if notification_type == NotificationTypes.EMAIL_NOTIFICATION: + if not hasattr(notification_activity, "email") or not notification_activity.email: + return "I could not find the email notification details." + + 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) + return response or "Email notification processed." + + # Handle Word Comment Notifications + elif notification_type == NotificationTypes.WPX_COMMENT: + if not hasattr(notification_activity, "wpx_comment") or not notification_activity.wpx_comment: + return "I could not find the Word notification details." + + wpx = notification_activity.wpx_comment + doc_id = getattr(wpx, "document_id", "") + comment_text = notification_activity.text or "" + + 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" + f"Comment: {comment_text}\n\n" + f"Please respond to this comment appropriately." + ) + + # 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}") + logger.info(f" Text: {getattr(notification_activity.activity, 'text', 'N/A')}") + logger.info(f" Value: {getattr(notification_activity.activity, 'value', 'N/A')}") + 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 + f"Notification received: {notification_type}" + ) + logger.info(f"šŸ“Ø Processing generic notification: {notification_type}") + + # Process with Claude + response = await self.process_user_message(notification_message, auth, context) + return response or "Notification processed successfully." + + except Exception as e: + logger.error(f"Error processing notification: {e}") + logger.exception("Full error details:") + return f"Sorry, I encountered an error processing the notification: {str(e)}" + + # + + # ========================================================================= + # CLEANUP + # ========================================================================= + # + + async def cleanup(self) -> None: + """Clean up agent resources""" + try: + logger.info("Cleaning up agent resources...") + + # Claude SDK client cleanup is handled by context manager + # No additional cleanup needed + + logger.info("Agent cleanup completed") + + except Exception as e: + logger.error(f"Error during cleanup: {e}") + + # + diff --git a/python/claude/sample-agent/agent_interface.py b/python/claude/sample-agent/agent_interface.py new file mode 100644 index 00000000..13fbc081 --- /dev/null +++ b/python/claude/sample-agent/agent_interface.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Agent Base Class +Defines the abstract base class that agents must inherit from to work with the generic host. +""" + +from abc import ABC, abstractmethod +from microsoft_agents.hosting.core import Authorization, TurnContext + + +class AgentInterface(ABC): + """ + Abstract base class that any hosted agent must inherit from. + + This ensures agents implement the required methods at class definition time, + providing stronger guarantees than a Protocol. + """ + + @abstractmethod + async def initialize(self) -> None: + """Initialize the agent and any required resources.""" + pass + + @abstractmethod + async def process_user_message( + self, message: str, auth: Authorization, context: TurnContext + ) -> str: + """Process a user message and return a response.""" + pass + + @abstractmethod + async def cleanup(self) -> None: + """Clean up any resources used by the agent.""" + pass + + +def check_agent_inheritance(agent_class) -> bool: + """ + Check that an agent class inherits from AgentInterface. + + Args: + agent_class: The agent class to check + + Returns: + True if the agent inherits from AgentInterface, False otherwise + """ + if not issubclass(agent_class, AgentInterface): + print(f"āŒ Agent {agent_class.__name__} does not inherit from AgentInterface") + return False + + print(f"āœ… Agent {agent_class.__name__} properly inherits from AgentInterface") + return True diff --git a/python/claude/sample-agent/host_agent_server.py b/python/claude/sample-agent/host_agent_server.py new file mode 100644 index 00000000..3c881631 --- /dev/null +++ b/python/claude/sample-agent/host_agent_server.py @@ -0,0 +1,532 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Generic Agent Host Server +A generic hosting server that can host any agent class that implements the required interface. +""" + +import logging +import os +import socket +from os import environ + +from aiohttp.web import Application, Request, Response, json_response, run_app +from aiohttp.web_middlewares import middleware as web_middleware +from dotenv import load_dotenv +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) + +# Microsoft Agents SDK imports +from microsoft_agents.hosting.core import ( + Authorization, + AgentApplication, + AgentAuthConfiguration, + AuthenticationConstants, + ClaimsIdentity, + MemoryStorage, + TurnContext, + TurnState, +) + +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.activity import load_configuration_from_env + +# Import our agent base class +from agent_interface import AgentInterface, check_agent_inheritance + +# Configure logging +ms_agents_logger = logging.getLogger("microsoft_agents") +ms_agents_logger.addHandler(logging.StreamHandler()) +ms_agents_logger.setLevel(logging.INFO) + +logger = logging.getLogger(__name__) + +# Notifications imports +from microsoft_agents_a365.notifications.agent_notification import ( + AgentNotification, + AgentNotificationActivity, + ChannelId, +) + +# 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() +agents_sdk_config = load_configuration_from_env(environ) + + +class GenericAgentHost: + """Generic host that can host any agent implementing the AgentInterface""" + + 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 + **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.agent_class = agent_class + self.agent_args = agent_args + self.agent_kwargs = agent_kwargs + self.agent_instance = None + + # Microsoft Agents SDK components + self.storage = MemoryStorage() + self.connection_manager = MsalConnectionManager(**agents_sdk_config) + self.adapter = CloudAdapter(connection_manager=self.connection_manager) + self.authorization = Authorization( + self.storage, self.connection_manager, **agents_sdk_config + ) + self.agent_app = AgentApplication[TurnState]( + storage=self.storage, + adapter=self.adapter, + authorization=self.authorization, + **agents_sdk_config, + ) + + # Initialize notification support + self.agent_notification = AgentNotification(self.agent_app) + logger.info("āœ… Notification handlers will be registered") + + # Setup message handlers + self._setup_handlers() + + def _setup_handlers(self): + """Setup the Microsoft Agents SDK message handlers""" + + async def help_handler(context: TurnContext, _: TurnState): + """Handle help requests and member additions""" + welcome_message = ( + "šŸ‘‹ **Welcome to Generic Agent Host!**\n\n" + f"I'm powered by: **{self.agent_class.__name__}**\n\n" + "Ask me anything and I'll do my best to help!\n" + "Type '/help' for this message." + ) + await context.send_activity(welcome_message) + logger.info("šŸ“Ø Sent help/welcome message") + + # Register handlers + self.agent_app.conversation_update("membersAdded")(help_handler) + self.agent_app.message("/help")(help_handler) + + @self.agent_app.activity("message") + 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 + + user_message = context.activity.text or "" + logger.info(f"šŸ“Ø Processing message: '{user_message}'") + + # 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 + + # 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) + + logger.info("āœ… Response sent successfully to client") + + except Exception as e: + error_msg = f"Sorry, I encountered an error: {str(e)}" + logger.error(f"āŒ Error processing message: {e}") + await context.send_activity(error_msg) + + # Register notification handler + # Shared notification handler logic + async def handle_notification_common( + context: TurnContext, + state: TurnState, + notification_activity: AgentNotificationActivity, + ): + """Common notification handler for both 'agents' and 'msteams' channels""" + try: + logger.info(f"šŸ”” Notification received! Type: {context.activity.type}, Channel: {context.activity.channel_id if hasattr(context.activity, 'channel_id') else 'None'}") + + result = await self._validate_agent_and_setup_context(context) + if result is None: + 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: + await self._handle_notification_with_agent( + context, notification_activity + ) + + except Exception as e: + logger.error(f"āŒ Notification error: {e}") + await context.send_activity( + f"Sorry, I encountered an error processing the notification: {str(e)}" + ) + + # Register for 'agents' channel (production - Outlook, Teams notifications) + @self.agent_notification.on_agent_notification( + channel_id=ChannelId(channel="agents", sub_channel="*"), + ) + async def on_notification_agents( + context: TurnContext, + state: TurnState, + notification_activity: AgentNotificationActivity, + ): + """Handle notifications from 'agents' channel (production)""" + await handle_notification_common(context, state, notification_activity) + + # Register for 'msteams' channel (testing - Agents Playground) + @self.agent_notification.on_agent_notification( + channel_id=ChannelId(channel="msteams", sub_channel="*"), + ) + async def on_notification_msteams( + context: TurnContext, + state: TurnState, + notification_activity: AgentNotificationActivity, + ): + """Handle notifications from 'msteams' channel (testing)""" + await handle_notification_common(context, state, notification_activity) + + logger.info("āœ… Notification handlers registered for 'agents' and 'msteams' channels") + + async def _handle_notification_with_agent( + self, context: TurnContext, notification_activity: AgentNotificationActivity + ): + """ + Handle notification with the agent instance. + + Args: + context: Turn context + notification_activity: The notification activity to process + """ + logger.info(f"šŸ“¬ {notification_activity.notification_type}") + + # Check if agent supports notifications + if not hasattr(self.agent_instance, "handle_agent_notification_activity"): + logger.warning("āš ļø Agent doesn't support notifications") + await context.send_activity( + "This agent doesn't support notification handling yet." + ) + return + + # Process the notification with the agent + response = await self.agent_instance.handle_agent_notification_activity( + notification_activity, self.agent_app.auth, context + ) + + # Send the response + await context.send_activity(response) + + async def _validate_agent_and_setup_context(self, context: TurnContext): + """ + Validate agent availability and setup observability context. + + Args: + context: Turn context from M365 SDK + + Returns: + Tuple of (tenant_id, agent_id) if successful, None if validation fails + """ + # Extract tenant and agent IDs + tenant_id = context.activity.recipient.tenant_id if context.activity.recipient else None + agent_id = context.activity.recipient.agentic_app_id if context.activity.recipient else None + + # Ensure agent is available + if not self.agent_instance: + logger.error("Agent not available") + await context.send_activity("āŒ Sorry, the agent is not available.") + return None + + # Setup observability token if available + if tenant_id and agent_id: + await self._setup_observability_token(context, tenant_id, agent_id) + + return tenant_id, agent_id + + async def _setup_observability_token( + self, context: TurnContext, tenant_id: str, agent_id: str + ): + """ + Cache observability token for Agent365 exporter. + + Args: + context: Turn context + 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(), + ) + cache_agentic_token(tenant_id, agent_id, exaau_token.token) + logger.debug(f"āœ… Cached observability token for {tenant_id}:{agent_id}") + except Exception as e: + logger.warning(f"āš ļø Failed to cache observability token: {e}") + + async def initialize_agent(self): + """Initialize the hosted agent instance""" + if self.agent_instance is None: + try: + logger.info(f"šŸ¤– Initializing {self.agent_class.__name__}...") + + # Create the agent instance + self.agent_instance = self.agent_class(*self.agent_args, **self.agent_kwargs) + + # Initialize the agent + await self.agent_instance.initialize() + + logger.info(f"āœ… {self.agent_class.__name__} initialized successfully") + except Exception as e: + logger.error(f"āŒ Failed to initialize {self.agent_class.__name__}: {e}") + raise + + def create_auth_configuration(self) -> AgentAuthConfiguration | None: + """Create authentication configuration based on available environment variables.""" + client_id = environ.get("CLIENT_ID") + tenant_id = environ.get("TENANT_ID") + client_secret = environ.get("CLIENT_SECRET") + + if client_id and tenant_id and client_secret: + logger.info("šŸ”’ Using Client Credentials authentication (CLIENT_ID/TENANT_ID provided)") + try: + return AgentAuthConfiguration( + client_id=client_id, + tenant_id=tenant_id, + client_secret=client_secret, + scopes=["https://api.botframework.com/.default"], + ) + except Exception as e: + logger.error( + f"Failed to create AgentAuthConfiguration, falling back to anonymous: {e}" + ) + return None + + if environ.get("BEARER_TOKEN"): + logger.info( + "šŸ”‘ BEARER_TOKEN present but incomplete app registration; continuing in anonymous dev mode" + ) + else: + logger.warning("āš ļø No authentication env vars found; running anonymous") + + return None + + def start_server(self, auth_configuration: AgentAuthConfiguration | None = None): + """Start the server using Microsoft Agents SDK""" + + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + async def init_app(app): + await self.initialize_agent() + + # Health endpoint + async def health(_req: Request) -> Response: + status = { + "status": "ok", + "agent_type": self.agent_class.__name__, + "agent_initialized": self.agent_instance is not None, + "auth_mode": "authenticated" if auth_configuration else "anonymous", + } + return json_response(status) + + # Build middleware list + middlewares = [] + if auth_configuration: + middlewares.append(jwt_authorization_middleware) + + # Anonymous claims middleware + @web_middleware + async def anonymous_claims(request, handler): + if not auth_configuration: + request["claims_identity"] = ClaimsIdentity( + { + AuthenticationConstants.AUDIENCE_CLAIM: "anonymous", + AuthenticationConstants.APP_ID_CLAIM: "anonymous-app", + }, + False, + "Anonymous", + ) + return await handler(request) + + middlewares.append(anonymous_claims) + app = Application(middlewares=middlewares) + logger.info( + "šŸ”’ Auth middleware enabled" + if auth_configuration + else "šŸ”§ Anonymous mode (no auth middleware)" + ) + + # Routes + app.router.add_post("/api/messages", entry_point) + app.router.add_get("/api/messages", lambda _: Response(status=200)) + app.router.add_get("/api/health", health) + + # Context + app["agent_configuration"] = auth_configuration + app["agent_app"] = self.agent_app + app["adapter"] = self.agent_app.adapter + + app.on_startup.append(init_app) + + # Port configuration + desired_port = int(environ.get("PORT", 3978)) + port = desired_port + + # Simple port availability check + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + if s.connect_ex(("127.0.0.1", desired_port)) == 0: + logger.warning( + f"āš ļø Port {desired_port} already in use. Attempting {desired_port + 1}." + ) + port = desired_port + 1 + + print("=" * 80) + print(f"šŸ¢ Generic Agent Host - {self.agent_class.__name__}") + print("=" * 80) + print(f"\nšŸ”’ Authentication: {'Enabled' if auth_configuration else 'Anonymous'}") + print("šŸ¤– Using Microsoft Agents SDK patterns") + print("šŸŽÆ Compatible with Agents Playground") + if port != desired_port: + print(f"āš ļø Requested port {desired_port} busy; using fallback {port}") + print(f"\nšŸš€ Starting server on localhost:{port}") + print(f"šŸ“š Bot Framework endpoint: http://localhost:{port}/api/messages") + print(f"ā¤ļø Health: http://localhost:{port}/api/health") + print("šŸŽÆ Ready for testing!\n") + + try: + run_app(app, host="localhost", port=port) + except KeyboardInterrupt: + print("\nšŸ‘‹ Server stopped") + except Exception as error: + logger.error(f"Server error: {error}") + raise error + + async def cleanup(self): + """Clean up resources""" + if self.agent_instance: + try: + await self.agent_instance.cleanup() + logger.info("Agent cleanup completed") + except Exception as e: + logger.error(f"Error during agent cleanup: {e}") + + +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 + **agent_kwargs: Keyword arguments to pass to the agent constructor + """ + try: + # 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") + + # 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, **agent_kwargs) + + # Create authentication configuration + auth_config = host.create_auth_configuration() + + # Start the server + host.start_server(auth_config) + + except Exception as error: + logger.error(f"Failed to start generic agent host: {error}") + raise error + + +if __name__ == "__main__": + print("Generic Agent Host - Use create_and_run_host() function to start with your agent class") + print("Example:") + print(" from common.host_agent_server import create_and_run_host") + print(" from my_agent import MyAgent") + print(" create_and_run_host(MyAgent, api_key='your_key')") diff --git a/python/claude/sample-agent/local_authentication_options.py b/python/claude/sample-agent/local_authentication_options.py new file mode 100644 index 00000000..28941eee --- /dev/null +++ b/python/claude/sample-agent/local_authentication_options.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Local Authentication Options +Loads authentication configuration from environment variables. +""" + +import os +from dataclasses import dataclass + + +@dataclass +class LocalAuthenticationOptions: + """ + Authentication options loaded from environment variables. + + Attributes: + bearer_token: Bearer token for API authentication + env_id: Environment ID (dev, test, prod) + """ + + bearer_token: str | None + env_id: str + + @classmethod + def from_environment(cls) -> "LocalAuthenticationOptions": + """ + Load authentication options from environment variables. + + Returns: + LocalAuthenticationOptions instance with values from environment + """ + bearer_token = os.getenv("BEARER_TOKEN") + env_id = os.getenv("ENVIRONMENT_ID", "prod") + + return cls(bearer_token=bearer_token, env_id=env_id) 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 new file mode 100644 index 00000000..72046edb --- /dev/null +++ b/python/claude/sample-agent/pyproject.toml @@ -0,0 +1,58 @@ +[project] +name = "sample-claude-agent" +version = "0.1.0" +description = "Sample Claude Agent using Microsoft Agent 365 SDK" +dependencies = [ + # Claude Agent SDK + "claude-agent-sdk>=0.1.0", + + # Microsoft Agents SDK - Official packages for hosting and integration + "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-core", + "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", + + # 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", +] +requires-python = ">=3.11" + +# Package index configuration +# PyPI is the default/primary source, local packages are fallback +[[tool.uv.index]] +name = "pypi" +url = "https://pypi.org/simple" +default = true + +[[tool.uv.index]] +name = "microsoft_agent365" +url = "../../../dist" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] + +[tool.uv] +dev-dependencies = [ + "pytest>=8.0", + "pytest-asyncio>=0.24.0", + "ruff>=0.1.0", + "mypy>=1.0.0", +] diff --git a/python/claude/sample-agent/start_with_generic_host.py b/python/claude/sample-agent/start_with_generic_host.py new file mode 100644 index 00000000..b8bd1a7e --- /dev/null +++ b/python/claude/sample-agent/start_with_generic_host.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +#!/usr/bin/env python3 +""" +Example: Direct usage of Claude Agent with Generic Host +This script starts the M365 Agents SDK hosting server with ClaudeAgent. +""" + +import sys + +try: + from agent import ClaudeAgent + from host_agent_server import create_and_run_host +except ImportError as e: + print(f"Import error: {e}") + print("Please ensure you're running from the correct directory") + sys.exit(1) + + +def main(): + """Main entry point - start the generic host with ClaudeAgent""" + try: + print("āœ… Starting Generic Agent Host with ClaudeAgent...") + print() + + # Use the convenience function to start hosting + create_and_run_host(ClaudeAgent) + + except Exception as e: + print(f"āŒ Failed to start server: {e}") + import traceback + traceback.print_exc() + return 1 + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/python/claude/sample-agent/token_cache.py b/python/claude/sample-agent/token_cache.py new file mode 100644 index 00000000..a9b1677d --- /dev/null +++ b/python/claude/sample-agent/token_cache.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Token Cache +Caches agentic tokens for observability export. +""" + +import logging + +logger = logging.getLogger(__name__) + +# In-memory cache for agentic tokens +# Key format: "tenant_id:agent_id" +_token_cache: dict[str, str] = {} + + +def cache_agentic_token(tenant_id: str, agent_id: str, token: str) -> None: + """ + Cache an agentic token for later use by observability exporter. + + Args: + tenant_id: Tenant identifier + agent_id: Agent identifier + token: Agentic authentication token + """ + cache_key = f"{tenant_id}:{agent_id}" + _token_cache[cache_key] = token + logger.debug(f"Cached agentic token for {cache_key}") + + +def get_cached_agentic_token(tenant_id: str, agent_id: str) -> str | None: + """ + Retrieve a cached agentic token. + + Args: + tenant_id: Tenant identifier + agent_id: Agent identifier + + Returns: + Cached token if found, None otherwise + """ + cache_key = f"{tenant_id}:{agent_id}" + token = _token_cache.get(cache_key) + + if token: + logger.debug(f"Retrieved cached token for {cache_key}") + else: + logger.debug(f"No cached token found for {cache_key}") + + return token + + +def clear_token_cache() -> None: + """Clear all cached tokens.""" + _token_cache.clear() + logger.debug("Token cache cleared")