diff --git a/samples/python/hosted-agents/agent-framework/agents-in-workflow/main.py b/samples/python/hosted-agents/agent-framework/agents-in-workflow/main.py index 06025334..78e3cf78 100644 --- a/samples/python/hosted-agents/agent-framework/agents-in-workflow/main.py +++ b/samples/python/hosted-agents/agent-framework/agents-in-workflow/main.py @@ -6,7 +6,7 @@ from azure.identity import DefaultAzureCredential # pyright: ignore[reportUnknownVariableType] -def create_agent(): +def create_workflow_builder(): # Create agents researcher = AzureOpenAIChatClient(credential=DefaultAzureCredential()).create_agent( instructions=( @@ -31,16 +31,13 @@ def create_agent(): ) # Build a concurrent workflow - workflow = ConcurrentBuilder().participants([researcher, marketer, legal]).build() + workflow_builder = ConcurrentBuilder().participants([researcher, marketer, legal]) - # Convert the workflow to an agent - workflow_agent = workflow.as_agent() - - return workflow_agent + return workflow_builder def main(): # Run the agent as a hosted agent - from_agent_framework(create_agent()).run() + from_agent_framework(create_workflow_builder().build).run() if __name__ == "__main__": diff --git a/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/Dockerfile b/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/Dockerfile new file mode 100644 index 00000000..0cc939d9 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/README.md b/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/README.md new file mode 100644 index 00000000..37784a8e --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/README.md @@ -0,0 +1,282 @@ +**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md). + +Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct. + +Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates. + +Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. + +# What this sample demonstrates + +This sample demonstrates how to build a Microsoft Agent Framework chat agent with **human-in-the-loop** approval workflows, host it using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/), and deploy it to Microsoft Foundry using the Azure Developer CLI [ai agent](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli#create-a-hosted-agent) extension. + +This sample is adapted from the [agent-framework sample](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/tools/ai_function_with_approval_and_threads.py). + +## How It Works + +### Human-in-the-loop approval + +In [main.py](main.py), the agent is created using `AzureOpenAIChatClient` and includes an `@ai_function` decorated with `approval_mode="always_require"`. This means any call to the function (e.g., `add_to_calendar`) will escalate to a human reviewer before execution. + +When the agent determines it needs to call an approval-required function, the response includes a `function_call` with name `__hosted_agent_adapter_hitl__`. The caller must then provide feedback (`approve`, `reject`, or additional guidance) to continue the workflow. + +### Thread persistence + +- The sample uses `JsonLocalFileAgentThreadRepository` for `AgentThread` persistence, creating a JSON file per conversation ID under `./thread_storage`. + +- An in-memory alternative, `InMemoryAgentThreadRepository`, lives in the `azure.ai.agentserver.agentframework.persistence` module. + +- To store thread messages elsewhere, inherit from `SerializedAgentThreadRepository` and override the following methods: +```python +class SerializedAgentThreadRepository(AgentThreadRepository): + async def read_from_storage(self, conversation_id: str) -> Optional[Any]: + """Read the serialized thread from storage. + + :param conversation_id: The conversation ID. + :type conversation_id: str + + :return: The serialized thread if available, None otherwise. + :rtype: Optional[Any] + """ + ... + + async def write_to_storage(self, conversation_id: str, serialized_thread: Any) -> None: + """Write the serialized thread to storage. + + :param conversation_id: The conversation ID. + :type conversation_id: str + :param serialized_thread: The serialized thread to save. + :type serialized_thread: Any + :return: None + :rtype: None + """ + ... +``` + +These hooks let you plug in any backing store (blob storage, databases, etc.) without changing the rest of the sample. + +### Agent Hosting + +The agent is hosted using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/), which provisions a REST API endpoint compatible with the OpenAI Responses protocol. This allows interaction with the agent using OpenAI Responses compatible clients. + +### Agent Deployment + +The hosted agent can be seamlessly deployed to Microsoft Foundry using the Azure Developer CLI [ai agent](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli#create-a-hosted-agent) extension. The extension builds a container image into Azure Container Registry (ACR), and creates a hosted agent version and deployment on Microsoft Foundry. + +## Validate the deployed Agent +```python +# Before running the sample: +# pip install --pre azure-ai-projects>=2.0.0b1 +# pip install azure-identity + +from azure.identity import DefaultAzureCredential +from azure.ai.projects import AIProjectClient +import json + +foundry_account = "" +foundry_project = "" +agent_name = "" + +project_endpoint = f"https://{foundry_account}.services.ai.azure.com/api/projects/{foundry_project}" + +project_client = AIProjectClient( + endpoint=project_endpoint, + credential=DefaultAzureCredential(), +) + +# Get an existing agent +agent = project_client.agents.get(agent_name=agent_name) +print(f"Retrieved agent: {agent.name}") + +openai_client = project_client.get_openai_client() +conversation = openai_client.conversations.create() + +response = openai_client.responses.create( + input="Add a dentist appointment on March 15th", + conversation=conversation.id, + extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, +) + +call_id = "" +for item in response.output: + if item.type == "function_call" and item.name == "__hosted_agent_adapter_hitl__": + args = json.loads(item.arguments) + print(f"Agent will add {args['event_name']} on {args['date']}") + call_id = item.call_id + +if not call_id: + print(f"No human input is required, output: {response.output_text}") +else: + human_response = "approve" + response = openai_client.responses.create( + input=[ + { + "type": "function_call_output", + "call_id": call_id, + "output": human_response + }], + conversation=conversation.id, + extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, + ) + print(f"Human response: {human_response}") + print(f"Agent response: {response.output_text}") +``` + +## Running the Agent Locally + +### Prerequisites + +Before running this sample, ensure you have: + +1. **Azure OpenAI Service** + - Endpoint configured + - Chat model deployed (e.g., `gpt-4o-mini` or `gpt-4`) + - Note your endpoint URL and deployment name + +2. **Azure CLI** + - Installed and authenticated + - Run `az login` and verify with `az account show` + +3. **Python 3.10 or higher** + - Verify your version: `python --version` + - If you have Python 3.9 or older, install a newer version: + - Windows: `winget install Python.Python.3.12` + - macOS: `brew install python@3.12` + - Linux: Use your package manager + +### Environment Variables + +Set the following environment variables: + +- `AZURE_OPENAI_ENDPOINT` - Your Azure OpenAI endpoint URL (required) +- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` - The deployment name for your chat model (required) +- `OPENAI_API_VERSION` - The API version (e.g., `2025-03-01-preview`) + +This sample loads environment variables from a local `.env` file if present. Copy `.envtemplate` to `.env` and fill in your Azure OpenAI details: + +``` +AZURE_OPENAI_ENDPOINT=https://.cognitiveservices.azure.com/ +OPENAI_API_VERSION=2025-03-01-preview +AZURE_OPENAI_CHAT_DEPLOYMENT_NAME= +``` + +```powershell +# Replace with your actual values +$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/" +$env:AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o-mini" +$env:OPENAI_API_VERSION="2025-03-01-preview" +``` + +### Installing Dependencies + +Install the required Python dependencies using pip: + +```powershell +pip install -r requirements.txt +``` + +### Running the Sample + +To run the agent, execute the following command in your terminal: + +```powershell +python main.py +``` + +This will start the hosted agent locally on `http://localhost:8088/`. + +### Interacting with the Agent locally + +**Step 1: Send a user request** + +**PowerShell (Windows):** +```powershell +$body = @{ + agent = @{ name = "local_agent"; type = "agent_reference" } + stream = $false + input = "Add a dentist appointment on March 15th" +} | ConvertTo-Json + +Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json" +``` + +**Bash/curl (Linux/macOS):** +```bash +curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ + -d '{"agent":{"name":"local_agent","type":"agent_reference"},"stream":false,"input":"Add a dentist appointment on March 15th"}' +``` + +A response that requires a human decision looks like this (formatted for clarity): + +```json +{ + "conversation": {"id": ""}, + "output": [ + {...}, + { + "type": "function_call", + "id": "func_xxx", + "name": "__hosted_agent_adapter_hitl__", + "call_id": "", + "arguments": "{\"event_name\":\"Dentist Appointment\",\"date\":\"2024-03-15\"}" + } + ] +} +``` + +Capture these values from the response; you will need them to provide feedback: + +- `conversation.id` +- The `call_id` associated with `__hosted_agent_adapter_hitl__` + +**Step 2: Provide human feedback** + +Send a `CreateResponse` request with a `function_call_output` message that contains your decision (`approve`, `reject`, or additional guidance). Replace the placeholders before running the command: + +**PowerShell (Windows):** +```powershell +$body = @{ + agent = @{ name = "local_agent"; type = "agent_reference" } + stream = $false + conversation = @{ id = "" } + input = @( + @{ + call_id = "" + output = "approve" + type = "function_call_output" + } + ) +} | ConvertTo-Json -Depth 3 + +Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json" +``` + +**Bash/curl (Linux/macOS):** +```bash +curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ + -d '{"agent":{"name":"local_agent","type":"agent_reference"},"stream":false,"conversation":{"id":""},"input":[{"call_id":"","output":"approve","type":"function_call_output"}]}' +``` + +When the reviewer response is accepted, the agent executes the approved function and returns the final output. + +### Deploying the Agent to Microsoft Foundry + +To deploy your agent to Microsoft Foundry, follow the comprehensive deployment guide at https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli + +## Troubleshooting + +### Images built on Apple Silicon or other ARM64 machines do not work on our service + +We **recommend using `azd` cloud build**, which always builds images with the correct architecture. + +If you choose to **build locally**, and your machine is **not `linux/amd64`** (for example, an Apple Silicon Mac), the image will **not be compatible with our service**, causing runtime failures. + +**Fix for local builds** + +Use this command to build the image locally: + +```shell +docker build --platform=linux/amd64 -t image . +``` + +This forces the image to be built for the required `amd64` architecture. diff --git a/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/agent.yaml b/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/agent.yaml new file mode 100644 index 00000000..992c3a73 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/agent.yaml @@ -0,0 +1,30 @@ +name: calendar-agent-with-human-in-the-loop +description: This AgentFramework agent demonstrates how to integrate human-in-the-loop functionality using AI Functions. +metadata: + example: + - role: user + content: |- + Add a dentist appointment on March 15th + tags: + - Azure AI AgentServer + - Microsoft Agent Framework + - Human in the Loop + authors: + - junanchen +template: + name: calendar-agent-with-human-in-the-loop + kind: hosted + protocols: + - protocol: responses + version: v1 + environment_variables: + - name: AZURE_OPENAI_ENDPOINT + value: ${AZURE_OPENAI_ENDPOINT} + - name: OPENAI_API_VERSION + value: 2025-03-01-preview + - name: AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + value: "{{chat}}" +resources: + - kind: model + id: gpt-4o + name: chat diff --git a/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/main.py b/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/main.py new file mode 100644 index 00000000..2d5ca523 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/main.py @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft. All rights reserved. +import asyncio +from typing import Annotated, Any, Collection + +from agent_framework import ChatAgent, ChatMessage, ChatMessageStoreProtocol, ai_function +from agent_framework._threads import ChatMessageStoreState +from agent_framework.azure import AzureOpenAIChatClient + +from azure.ai.agentserver.agentframework import from_agent_framework +from azure.ai.agentserver.agentframework.persistence.agent_thread_repository import JsonLocalFileAgentThreadRepository +from azure.identity import DefaultAzureCredential + +""" +Tool Approvals with Threads + +This sample demonstrates using tool approvals with threads. +With threads, you don't need to manually pass previous messages - +the thread stores and retrieves them automatically. +""" + +class CustomChatMessageStore(ChatMessageStoreProtocol): + """Implementation of custom chat message store. + In real applications, this can be an implementation of relational database or vector store.""" + + def __init__(self, messages: Collection[ChatMessage] | None = None) -> None: + self._messages: list[ChatMessage] = [] + if messages: + self._messages.extend(messages) + + async def add_messages(self, messages: Collection[ChatMessage]) -> None: + self._messages.extend(messages) + + async def list_messages(self) -> list[ChatMessage]: + return self._messages + + @classmethod + async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "CustomChatMessageStore": + """Create a new instance from serialized state.""" + store = cls() + await store.update_from_state(serialized_store_state, **kwargs) + return store + + async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None: + """Update this instance from serialized state.""" + if serialized_store_state: + state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs) + if state.messages: + self._messages.extend(state.messages) + + async def serialize(self, **kwargs: Any) -> Any: + """Serialize this store's state.""" + state = ChatMessageStoreState(messages=self._messages) + return state.to_dict(**kwargs) + + +@ai_function(approval_mode="always_require") +def add_to_calendar( + event_name: Annotated[str, "Name of the event"], date: Annotated[str, "Date of the event"] +) -> str: + """Add an event to the calendar (requires approval).""" + print(f">>> EXECUTING: add_to_calendar(event_name='{event_name}', date='{date}')") + return f"Added '{event_name}' to calendar on {date}" + + +def build_agent(): + return ChatAgent( + chat_client=AzureOpenAIChatClient(credential=DefaultAzureCredential()), + name="CalendarAgent", + instructions="You are a helpful calendar assistant.", + tools=[add_to_calendar], + chat_message_store_factory=CustomChatMessageStore, + ) + + +async def main() -> None: + agent = build_agent() + thread_repository = JsonLocalFileAgentThreadRepository(agent=agent, storage_path="./thread_storage") + await from_agent_framework(agent, thread_repository=thread_repository).run_async() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/requirements.txt b/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/requirements.txt new file mode 100644 index 00000000..f6e95668 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/human-in-the-loop/agent-with-thread-and-hitl/requirements.txt @@ -0,0 +1 @@ +azure_ai_agentserver_agentframework==1.0.0b9 \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/Dockerfile b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/Dockerfile new file mode 100644 index 00000000..eaffb94f --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/README.md b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/README.md new file mode 100644 index 00000000..1df66000 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/README.md @@ -0,0 +1,243 @@ +**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md). + +Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct. + +Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates. + +Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. + +# What this sample demonstrates + +This sample demonstrates how to build a Microsoft Agent Framework workflow that persists checkpoints and pauses for human-in-the-loop (HITL) review before completing a response. The workflow is hosted with the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/) and can be deployed to Microsoft Foundry using the Azure Developer CLI [ai agent](https://aka.ms/azdaiagent/docs) extension. + +## How It Works + +### Checkpoints + +Agent-framework workflow can resume by loading a checkpoint. Hosted agent provides a CheckpointRepository API for users to manage their checkpoints. It defines as below: + +```py +class CheckpointRepository(ABC): + """ + Repository interface for storing and retrieving checkpoints. + + :meta private: + """ + @abstractmethod + async def get_or_create(self, conversation_id: str) -> Optional[CheckpointStorage]: + """Retrieve or create a checkpoint storage by conversation ID. + + :param conversation_id: The unique identifier for the checkpoint. + :type conversation_id: str + :return: The CheckpointStorage if found or created, None otherwise. + :rtype: Optional[CheckpointStorage] + """ +``` + +An in-memory checkpoint repository `azure.ai.agentserver.agentframework.persistence.InMemoryCheckpointRepository` and a local file based `azure.ai.agentserver.agentframework.persistence.FileCheckpointRepository(storage_path: str)` are provided. + +If checkpoint repository is provided, hosted agent adapter will search for previous checkpoints by `conversation_id`, load the latest checkpoint to `WorkflowAgent`, and then invoke the workflow agent with `CheckpointStorage` instance. Thus, the checkpoint will be updated by agent framework. + +In this sample, the workflow persists checkpoints through `FileCheckpointRepository(storage_path="./checkpoints")`, ensuring the pending review queue survives restarts. + + +### Workflow with HITL + +`workflow_as_agent_reflection_pattern.py` defines two executors: + +- `Worker` – Generates answers with `AzureOpenAIChatClient`, tracks pending review requests, emits final responses, and implements `on_checkpoint_save` / `on_checkpoint_restore` so pending work can be resumed in multiturn conversions. +- `ReviewerWithHumanInTheLoop` – Always escalates to a human. The `HumanReviewRequest` payload captures the entire conversation so the reviewer can approve or reject the draft. When the reviewer responds, the workflow either emits the answer or regenerates it with the supplied feedback. Hosted agent adapter converts the HITL request to a `function_call` item with `HumanReviewRequest` information as argument. `HumanReviewRequest.convert_to_payload` is used for conversion. +- Human feedback should be provided as a `function_call_output` item with `conversation_id` and `call_id` matching with feedback request. Hosted agent adapter convert the feedback to targeted data instance by calling `ReviewResponse.convert_from_payload`. + + +### Agent hosting + +`main.py` builds the workflow, adapts it with `from_agent_framework`, and starts a local OpenAI Responses-compatible endpoint on `http://localhost:8088`. The endpoint supports both streaming and non-streaming modes and emits `function_call` items whenever the workflow pauses for human feedback. + +### Agent deployment + +The same container image can be deployed to Microsoft Foundry with the Azure Developer CLI [ai agent](https://aka.ms/azdaiagent/docs) extension, which pushes the image to Azure Container Registry and creates hosted agent versions and deployments. + +## Validate the deployed Agent +```python +# Before running the sample: +# pip install --pre azure-ai-projects>=2.0.0b1 +# pip install azure-identity + +from azure.identity import DefaultAzureCredential +from azure.ai.projects import AIProjectClient +import json + +foundry_account = "" +foundry_project = "" +agent_name = "" + +project_endpoint = f"https://{foundry_account}.services.ai.azure.com/api/projects/{foundry_project}" + +project_client = AIProjectClient( + endpoint=project_endpoint, + credential=DefaultAzureCredential(), +) + +# Get an existing agent +agent = project_client.agents.get(agent_name=agent_name) +print(f"Retrieved agent: {agent.name}") + +openai_client = project_client.get_openai_client() +conversation = openai_client.conversations.create() + +response = openai_client.responses.create( + input="Draft a launch plan for a sustainable backpack brand", + conversation=conversation.id, + extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, +) + +call_id = "" +request_id = "" +for item in response.output: + if item.type == "function_call" and item.name == "__hosted_agent_adapter_hitl__": + agent_request = json.loads(item.arguments).get("agent_request", {}) + request_id = agent_request.get("request_id", "") + + agent_messages = agent_request.get("agent_messages", []) + agent_messages_str = "\n".join(json.dumps(msg, indent=4) for msg in agent_messages) + print(f"Agent requests: {agent_messages_str}") + call_id = item.call_id + +if not call_id or not request_id: + print(f"No human input is required, output: {response.output_text}") +else: + human_response = { + "request_id": request_id, + "feedback": "approve", + "approved": True, + } + response = openai_client.responses.create( + input=[ + { + "type": "function_call_output", + "call_id": call_id, + "output": json.dumps(human_response) + }], + conversation=conversation.id, + extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, + ) + print(f"Human response: {human_response['feedback']}") + print(f"Agent response: {response.output_text}") +``` + +## Running the Agent Locally + +### Prerequisites + +1. **Azure OpenAI Service** – An endpoint with a deployed chat model (for example `gpt-4o-mini`). Record the endpoint URL and deployment name. +2. **Azure CLI** – Installed and signed in (`az login`). The workflow uses `AzureCliCredential` for Azure OpenAI authentication. +3. **Python 3.10 or higher** – Verify with `python --version`. Install a newer version if required. +4. **pip** – To install the sample dependencies. + +### Environment variables + +Set the following variables before running the sample (use a `.env` file or your shell environment): + +- `AZURE_OPENAI_ENDPOINT` – Azure OpenAI endpoint URL (required). +- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` – Deployment name for your chat model (required). + +```powershell +# Replace the placeholder values +$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/" +$env:AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +### Installing dependencies + +From the `workflow-agent-with-checkpoint-and-hitl` folder: + +```powershell +pip install -r requirements.txt +``` + +### Running the sample + +Start the hosted workflow locally: + +```powershell +python main.py +``` + +The server listens on `http://localhost:8088/` and writes checkpoints to the `./checkpoints` directory. + +### Interacting with the agent locally + +Send a `POST` request to `http://0.0.0.0:8088/responses` + +```json +{ + "agent": {"name": "local_agent", "type": "agent_reference"}, + "stream": false, + "input": "Draft a launch plan for a sustainable backpack brand", +} +``` + +A response with human-review request looks like this (formatted for clarity): + +```json +{ + "conversation": {"id": ""}, + "output": [ + {...}, + { + "type": "function_call", + "id": "func_xxx", + "name": "__hosted_agent_adapter_hitl__", + "call_id": "", + "arguments": "{\"agent_request\":{\"request_id\":\"\",...}}" + } + ] +} +``` + +Capture three values from the response: + +- `conversation.id` +- The `call_id` of the `__hosted_agent_adapter_hitl__` function call +- The `request_id` inside the serialized `agent_request` + +Respond by sending a `CreateResponse` request with `function_call_output` message that carries your review decision. Replace the placeholders before running the command: + +```json +{ + "agent": {"name": "local_agent", "type": "agent_reference"}, + "stream": false, + "convseration": {"id": ""}, + "input": [ + { + "call_id": "", + "output": "{\"request_id\":\"\",\"approved\":true,\"feedback\":\"approve\"}", + "type": "function_call_output", + } + ] +} +``` + +## Deploying the agent to Microsoft Foundry + +Follow the hosted agent deployment guide at https://aka.ms/azdaiagent/docs to: + +1. Configure the Azure Developer CLI and authenticate with your Azure subscription. +2. Build the container image (use `azd` cloud build or `docker build --platform=linux/amd64 ...`). +3. Publish the image to Azure Container Registry. +4. Create a hosted agent version and deployment inside your Azure AI Foundry project. + +## Troubleshooting + +### Images built on Apple Silicon or other ARM64 machines do not work on the service + +We **recommend using `azd` cloud build**, which always produces a `linux/amd64` image. + +If you must build locally on non-`amd64` hardware (for example, Apple Silicon), force the correct architecture: + +```bash +docker build --platform=linux/amd64 -t image . +``` + +This ensures the hosted agent runs correctly in Microsoft Foundry. diff --git a/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/agent.yaml b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/agent.yaml new file mode 100644 index 00000000..99c14d59 --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/agent.yaml @@ -0,0 +1,29 @@ +# Unique identifier/name for this agent +name: af-worfklow-agent-with-checkpoint-and-hitl +# Brief description of what this agent does +description: > + A workflow agent built using the Microsoft Agent Framework that includes checkpointing and human-in-the-loop (HITL) capabilities. +metadata: + # Categorization tags for organizing and discovering agents + authors: + - Microsoft + tags: + - Azure AI AgentServer + - Microsoft Agent Framework + - Human in the Loop +template: + name: af-worfklow-agent-with-checkpoint-and-hitl + kind: hosted + protocols: + - protocol: responses + environment_variables: + - name: AZURE_OPENAI_ENDPOINT + value: ${AZURE_OPENAI_ENDPOINT} + - name: OPENAI_API_VERSION + value: 2025-03-01-preview + - name: AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + value: "{{chat}}" +resources: + - kind: model + id: gpt-4o + name: chat diff --git a/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/main.py b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/main.py new file mode 100644 index 00000000..77ae5c4f --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/main.py @@ -0,0 +1,116 @@ +# Copyright (c) Microsoft. All rights reserved. +import asyncio +import json +from dataclasses import dataclass +from typing import Any + +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework import ( # noqa: E402 + Executor, + WorkflowBuilder, + WorkflowContext, + handler, + response_handler, +) +from workflow_as_agent_reflection_pattern import ( # noqa: E402 + ReviewRequest, + ReviewResponse, + Worker, +) + +from azure.ai.agentserver.agentframework import from_agent_framework +from azure.ai.agentserver.agentframework.persistence import FileCheckpointRepository + +@dataclass +class HumanReviewRequest: + """A request message type for escalation to a human reviewer.""" + + agent_request: ReviewRequest | None = None + + def convert_to_payload(self) -> str: + """Convert the HumanReviewRequest to a payload string.""" + request = self.agent_request + payload: dict[str, Any] = {"agent_request": None} + + if request: + payload["agent_request"] = { + "request_id": request.request_id, + "user_messages": [msg.to_dict() for msg in request.user_messages], + "agent_messages": [msg.to_dict() for msg in request.agent_messages], + } + + return json.dumps(payload, indent=2) + + +class ReviewerWithHumanInTheLoop(Executor): + """Executor that always escalates reviews to a human manager.""" + + def __init__(self, worker_id: str, reviewer_id: str | None = None) -> None: + unique_id = reviewer_id or f"{worker_id}-reviewer" + super().__init__(id=unique_id) + self._worker_id = worker_id + + @handler + async def review(self, request: ReviewRequest, ctx: WorkflowContext) -> None: + # In this simplified example, we always escalate to a human manager. + # See workflow_as_agent_reflection.py for an implementation + # using an automated agent to make the review decision. + print(f"Reviewer: Evaluating response for request {request.request_id[:8]}...") + print("Reviewer: Escalating to human manager...") + + # Forward the request to a human manager by sending a HumanReviewRequest. + await ctx.request_info( + request_data=HumanReviewRequest(agent_request=request), + response_type=ReviewResponse, + ) + + @response_handler + async def accept_human_review( + self, + original_request: HumanReviewRequest, + response: ReviewResponse, + ctx: WorkflowContext[ReviewResponse], + ) -> None: + # Accept the human review response and forward it back to the Worker. + print(f"Reviewer: Accepting human review for request {response.request_id[:8]}...") + print(f"Reviewer: Human feedback: {response.feedback}") + print(f"Reviewer: Human approved: {response.approved}") + print("Reviewer: Forwarding human review back to worker...") + await ctx.send_message(response, target_id=self._worker_id) + +def create_builder(): + # Build a workflow with bidirectional communication between Worker and Reviewer, + # and escalation paths for human review. + builder = ( + WorkflowBuilder() + .register_executor( + lambda: Worker( + id="sub-worker", + chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), + ), + name="worker", + ) + .register_executor( + lambda: ReviewerWithHumanInTheLoop(worker_id="sub-worker"), + name="reviewer", + ) + .add_edge("worker", "reviewer") # Worker sends requests to Reviewer + .add_edge("reviewer", "worker") # Reviewer sends feedback to Worker + .set_start_executor("worker") + ) + return builder + + +async def run_agent() -> None: + """Run the workflow inside the agent server adapter.""" + builder = create_builder() + await from_agent_framework( + builder, # pass workflow builder to adapter + checkpoint_repository=FileCheckpointRepository(storage_path="./checkpoints"), # for checkpoint storage + ).run_async() + +if __name__ == "__main__": + asyncio.run(run_agent()) diff --git a/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/requirements.txt b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/requirements.txt new file mode 100644 index 00000000..adac06de --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/requirements.txt @@ -0,0 +1 @@ +azure-ai-agentserver-agentframework==1.0.0b9 \ No newline at end of file diff --git a/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/workflow_as_agent_reflection_pattern.py b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/workflow_as_agent_reflection_pattern.py new file mode 100644 index 00000000..a0b0709e --- /dev/null +++ b/samples/python/hosted-agents/agent-framework/human-in-the-loop/workflow-agent-with-checkpoint-and-hitl/workflow_as_agent_reflection_pattern.py @@ -0,0 +1,139 @@ +# Copyright (c) Microsoft. All rights reserved. + +from dataclasses import dataclass +import json +from uuid import uuid4 + +from agent_framework import ( + AgentRunResponseUpdate, + AgentRunUpdateEvent, + ChatClientProtocol, + ChatMessage, + Contents, + Executor, + Role, + WorkflowContext, + handler, +) + +@dataclass +class ReviewRequest: + """Structured request passed from Worker to Reviewer for evaluation.""" + + request_id: str + user_messages: list[ChatMessage] + agent_messages: list[ChatMessage] + + +@dataclass +class ReviewResponse: + """Structured response from Reviewer back to Worker.""" + + request_id: str + feedback: str + approved: bool + + @staticmethod + def convert_from_payload(payload: str) -> "ReviewResponse": + """Convert a JSON payload string to a ReviewResponse instance.""" + data = json.loads(payload) + return ReviewResponse( + request_id=data["request_id"], + feedback=data["feedback"], + approved=data["approved"], + ) + + +PendingReviewState = tuple[ReviewRequest, list[ChatMessage]] + + +class Worker(Executor): + """Executor that generates responses and incorporates feedback when necessary.""" + + def __init__(self, id: str, chat_client: ChatClientProtocol) -> None: + super().__init__(id=id) + self._chat_client = chat_client + self._pending_requests: dict[str, PendingReviewState] = {} + + @handler + async def handle_user_messages(self, user_messages: list[ChatMessage], ctx: WorkflowContext[ReviewRequest]) -> None: + print("Worker: Received user messages, generating response...") + + # Initialize chat with system prompt. + messages = [ChatMessage(role=Role.SYSTEM, text="You are a helpful assistant.")] + messages.extend(user_messages) + + print("Worker: Calling LLM to generate response...") + response = await self._chat_client.get_response(messages=messages) + print(f"Worker: Response generated: {response.messages[-1].text}") + + # Add agent messages to context. + messages.extend(response.messages) + + # Create review request and send to Reviewer. + request = ReviewRequest(request_id=str(uuid4()), user_messages=user_messages, agent_messages=response.messages) + print(f"Worker: Sending response for review (ID: {request.request_id[:8]})") + await ctx.send_message(request) + + # Track request for possible retry. + self._pending_requests[request.request_id] = (request, messages) + + @handler + async def handle_review_response(self, review: ReviewResponse, ctx: WorkflowContext[ReviewRequest]) -> None: + print(f"Worker: Received review for request {review.request_id[:8]} - Approved: {review.approved}") + + if review.request_id not in self._pending_requests: + raise ValueError(f"Unknown request ID in review: {review.request_id}") + + request, messages = self._pending_requests.pop(review.request_id) + + if review.approved: + print("Worker: Response approved. Emitting to external consumer...") + contents: list[Contents] = [] + for message in request.agent_messages: + contents.extend(message.contents) + + # Emit approved result to external consumer via AgentRunUpdateEvent. + await ctx.add_event( + AgentRunUpdateEvent(self.id, data=AgentRunResponseUpdate(contents=contents, role=Role.ASSISTANT)) + ) + return + + print(f"Worker: Response not approved. Feedback: {review.feedback}") + print("Worker: Regenerating response with feedback...") + + # Incorporate review feedback. + messages.append(ChatMessage(role=Role.SYSTEM, text=review.feedback)) + messages.append( + ChatMessage(role=Role.SYSTEM, text="Please incorporate the feedback and regenerate the response.") + ) + messages.extend(request.user_messages) + + # Retry with updated prompt. + response = await self._chat_client.get_response(messages=messages) + print(f"Worker: New response generated: {response.messages[-1].text}") + + messages.extend(response.messages) + + # Send updated request for re-review. + new_request = ReviewRequest( + request_id=review.request_id, user_messages=request.user_messages, agent_messages=response.messages + ) + await ctx.send_message(new_request) + + # Track new request for further evaluation. + self._pending_requests[new_request.request_id] = (new_request, messages) + + async def on_checkpoint_save(self) -> dict: + """ + Persist pending requests during checkpointing. + In memory implementation for demonstration purposes. + """ + return {"pending_requests": self._pending_requests} + + async def on_checkpoint_restore(self, data: dict) -> None: + """ + Load pending requests from checkpoint data. + In memory implementation for demonstration purposes. + """ + self._pending_requests = data.get("pending_requests", {}) diff --git a/samples/python/hosted-agents/custom/system-utility-agent/local_tools.py b/samples/python/hosted-agents/custom/system-utility-agent/local_tools.py index 304d2f35..c9dd8d2c 100644 --- a/samples/python/hosted-agents/custom/system-utility-agent/local_tools.py +++ b/samples/python/hosted-agents/custom/system-utility-agent/local_tools.py @@ -14,8 +14,6 @@ - This is designed to work with any model/server that supports an OpenAI-style tool calling contract. """ -from __future__ import annotations - import os import platform import re diff --git a/samples/python/hosted-agents/custom/system-utility-agent/main.py b/samples/python/hosted-agents/custom/system-utility-agent/main.py index 09d6b51d..c0d1ff0d 100644 --- a/samples/python/hosted-agents/custom/system-utility-agent/main.py +++ b/samples/python/hosted-agents/custom/system-utility-agent/main.py @@ -12,8 +12,6 @@ - list_environment_variables """ -from __future__ import annotations - import datetime import os import json diff --git a/samples/python/hosted-agents/langgraph/human-in-the-loop/Dockerfile b/samples/python/hosted-agents/langgraph/human-in-the-loop/Dockerfile new file mode 100644 index 00000000..0cc939d9 --- /dev/null +++ b/samples/python/hosted-agents/langgraph/human-in-the-loop/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] diff --git a/samples/python/hosted-agents/langgraph/human-in-the-loop/README.md b/samples/python/hosted-agents/langgraph/human-in-the-loop/README.md new file mode 100644 index 00000000..0830043b --- /dev/null +++ b/samples/python/hosted-agents/langgraph/human-in-the-loop/README.md @@ -0,0 +1,270 @@ +**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [LangGraph](https://docs.langchain.com/oss/python/langgraph/workflows-agents). + +Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct. + +Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates. + +Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. + +# What this sample demonstrates + +This sample demonstrates how to build a LangGraph agent with **human-in-the-loop capabilities** that can interrupt execution to ask for human input when needed, host it using the +[Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-langgraph/), +and deploy it to Microsoft Foundry using the Azure Developer CLI [ai agent](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli#create-a-hosted-agent) extension. + +## How It Works + +### Human-in-the-Loop Integration + +In [main.py](main.py), the agent is created using LangGraph's `StateGraph` and includes a custom `AskHuman` tool that uses the `interrupt()` function to pause execution and wait for human feedback. The key components are: + +- **LangGraph Agent**: An AI agent that can intelligently decide when to ask humans for input during task execution +- **Human Interrupt Mechanism**: Uses LangGraph's `interrupt()` function to pause execution and wait for human feedback +- **Conditional Routing**: The agent determines whether to execute tools, ask for human input, or complete the task + +### Agent Hosting + +The agent is hosted using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-langgraph/), +which provisions a REST API endpoint compatible with the OpenAI Responses protocol. This allows interaction with the agent using OpenAI Responses compatible clients. + +### Agent Deployment + +The hosted agent can be seamlessly deployed to Microsoft Foundry using the Azure Developer CLI [ai agent](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli#create-a-hosted-agent) extension. +The extension builds a container image into Azure Container Registry (ACR), and creates a hosted agent version and deployment on Microsoft Foundry. + +## Validate the deployed Agent +```python +# Before running the sample: +# pip install --pre azure-ai-projects>=2.0.0b1 +# pip install azure-identity + +from azure.identity import DefaultAzureCredential +from azure.ai.projects import AIProjectClient +import json + +foundry_account = "" +foundry_project = "" +agent_name = "" + +project_endpoint = f"https://{foundry_account}.services.ai.azure.com/api/projects/{foundry_project}" + +project_client = AIProjectClient( + endpoint=project_endpoint, + credential=DefaultAzureCredential(), +) + +# Get an existing agent +agent = project_client.agents.get(agent_name=agent_name) +print(f"Retrieved agent: {agent.name}") + +openai_client = project_client.get_openai_client() +conversation = openai_client.conversations.create() + +response = openai_client.responses.create( + input=[{"role": "user", "content": "Ask the user where they are, then look up the weather there."}], + conversation=conversation.id, + extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, +) + +call_id = "" +for item in response.output: + if item.type == "function_call" and item.name == "__hosted_agent_adapter_hitl__": + print(f"Agent ask: {item.arguments}") + call_id = item.call_id + +if not call_id: + print(f"No human input is required, output: {response.output_text}") +else: + human_response = {"resume": "San Francisco"} + response = openai_client.responses.create( + input=[ + { + "type": "function_call_output", + "call_id": call_id, + "output": json.dumps(human_response) + }], + conversation=conversation.id, + extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, + ) + print(f"Human response: {human_response['resume']}") + print(f"Agent response: {response.output_text}") +``` + +## Running the Agent Locally + +### Prerequisites + +Before running this sample, ensure you have: + +1. **Azure OpenAI Service** + - Endpoint configured + - Chat model deployed (e.g., `gpt-4o-mini` or `gpt-4`) + - Note your endpoint URL and deployment name + +2. **Azure CLI** + - Installed and authenticated + - Run `az login` and verify with `az account show` + +3. **Python 3.10 or higher** + - Verify your version: `python --version` + - If you have Python 3.9 or older, install a newer version: + - Windows: `winget install Python.Python.3.12` + - macOS: `brew install python@3.12` + - Linux: Use your package manager + +### Environment Variables + +Set the following environment variables: + +- `AZURE_OPENAI_ENDPOINT` - Your Azure OpenAI endpoint URL (required) +- `AZURE_AI_MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (required) + +This sample loads environment variables from a local `.env` file if present. + +```powershell +# Replace with your actual values +$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +### Installing Dependencies + +Install the required Python dependencies using pip: + +```powershell +pip install -r requirements.txt +``` + +### Running the Sample + +To run the agent, execute the following command in your terminal: + +```powershell +python main.py +``` + +This will start the hosted agent locally on `http://localhost:8088/`. + +### Interacting with the Agent locally + +#### Initial Request (Triggering Human Input) + +Send a request that will cause the agent to ask for human input: + +**PowerShell (Windows):** +```powershell +$body = @{ + input = "Ask the user where they are, then look up the weather there." + stream = $false +} | ConvertTo-Json + +Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json" +``` + +**Bash/curl (Linux/macOS):** +```bash +curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ + -d '{"input": "Ask the user where they are, then look up the weather there.", "stream": false}' +``` + +**Response Structure:** + +The agent will respond with an interrupt request: + +```json +{ + "conversation": { + "id": "conv_abc123..." + }, + "output": [ + { + "type": "function_call", + "name": "__hosted_agent_adapter_interrupt__", + "call_id": "call_xyz789...", + "arguments": "{\"question\": \"Where are you located?\"}" + } + ] +} +``` + +#### Providing Human Feedback + +Resume the conversation by providing the human's response: + +**PowerShell (Windows):** +```powershell +$body = @{ + input = @( + @{ + type = "function_call_output" + call_id = "call_xyz789..." + output = '{"resume": "San Francisco"}' + } + ) + stream = $false + conversation = @{ + id = "conv_abc123..." + } +} | ConvertTo-Json -Depth 4 + +Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json" +``` + +**Bash/curl (Linux/macOS):** +```bash +curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ + -d '{ + "input": [ + { + "type": "function_call_output", + "call_id": "call_xyz789...", + "output": "{\"resume\": \"San Francisco\"}" + } + ], + "stream": false, + "conversation": { + "id": "conv_abc123..." + } + }' +``` + +**Final Response:** + +The agent will continue execution and provide the final result: + +```json +{ + "conversation": { + "id": "conv_abc123..." + }, + "output": [ + { + "type": "message", + "role": "assistant", + "content": "I looked up the weather in San Francisco. Result: It's sunny in San Francisco." + } + ] +} +``` + +### Deploying the Agent to Microsoft Foundry + +To deploy your agent to Microsoft Foundry, follow the comprehensive deployment guide at https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli + +## Troubleshooting + +### Images built on Apple Silicon or other ARM64 machines do not work on our service + +We **recommend using `azd` cloud build**, which always builds images with the correct architecture. + +If you choose to **build locally**, and your machine is **not `linux/amd64`** (for example, an Apple Silicon Mac), the image will **not be compatible with our service**, causing runtime failures. + +**Fix for local builds** + +Use this command to build the image locally: + +```shell +docker build --platform=linux/amd64 -t image . +``` + +This forces the image to be built for the required `amd64` architecture. diff --git a/samples/python/hosted-agents/langgraph/human-in-the-loop/agent.yaml b/samples/python/hosted-agents/langgraph/human-in-the-loop/agent.yaml new file mode 100644 index 00000000..3c25e50c --- /dev/null +++ b/samples/python/hosted-agents/langgraph/human-in-the-loop/agent.yaml @@ -0,0 +1,29 @@ +name: HumanInTheLoopAgent +description: This LangGraph agent demonstrates human-in-the-loop capabilities. +metadata: + example: + - role: user + content: |- + Ask the user where they are, then look up the weather there. + tags: + - example + - learning + authors: + - junanchen +template: + name: HumanInTheLoopAgentLG + kind: hosted + protocols: + - protocol: responses + version: v1 + environment_variables: + - name: AZURE_OPENAI_ENDPOINT + value: ${AZURE_OPENAI_ENDPOINT} + - name: OPENAI_API_VERSION + value: 2025-03-01-preview + - name: AZURE_AI_MODEL_DEPLOYMENT_NAME + value: "{{chat}}" +resources: + - kind: model + id: gpt-4o + name: chat diff --git a/samples/python/hosted-agents/langgraph/human-in-the-loop/main.py b/samples/python/hosted-agents/langgraph/human-in-the-loop/main.py new file mode 100644 index 00000000..ddbea4cc --- /dev/null +++ b/samples/python/hosted-agents/langgraph/human-in-the-loop/main.py @@ -0,0 +1,191 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +""" +Human-in-the-Loop Agent Example + +This sample demonstrates how to create a LangGraph agent that can interrupt +execution to ask for human input when needed. The agent uses Azure OpenAI +and includes a custom tool for asking human questions. +""" + +import os + +from pydantic import BaseModel + +from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from langchain.chat_models import init_chat_model +from langchain_core.messages import ToolMessage +from langchain_core.tools import tool +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.graph import END, START, MessagesState, StateGraph +from langgraph.prebuilt import ToolNode +from langgraph.types import interrupt + +from azure.ai.agentserver.langgraph import from_langgraph + + +# ============================================================================= +# Model Initialization +# ============================================================================= + +def initialize_llm(): + """Initialize the language model with Azure OpenAI credentials.""" + deployment_name = os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o-mini") + return init_chat_model( + f"azure_openai:{deployment_name}", + azure_ad_token_provider=get_bearer_token_provider( + DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default" + ) + ) + + +llm = initialize_llm() + +# ============================================================================= +# Tools and Models +# ============================================================================= + +@tool +def search(query: str) -> str: + """ + Call to search the web for information. + + Args: + query: The search query string + + Returns: + Search results as a string + """ + # This is a placeholder for the actual implementation + return f"I looked up: {query}. Result: It's sunny in San Francisco." + + +class AskHuman(BaseModel): + """Schema for asking the human a question.""" + question: str + + +# Initialize tools and bind to model +tools = [search] +tool_node = ToolNode(tools) +model = llm.bind_tools(tools + [AskHuman]) + + +# ============================================================================= +# Graph Nodes +# ============================================================================= + +def call_model(state: MessagesState) -> dict: + """ + Call the language model with the current conversation state. + + Args: + state: The current messages state + + Returns: + Dictionary with the model's response message + """ + messages = state["messages"] + response = model.invoke(messages) + return {"messages": [response]} + + +def ask_human(state: MessagesState) -> dict: + """ + Interrupt execution to ask the human for input. + + Args: + state: The current messages state + + Returns: + Dictionary with the human's response as a tool message + """ + last_message = state["messages"][-1] + tool_call_id = last_message.tool_calls[0]["id"] + ask = AskHuman.model_validate(last_message.tool_calls[0]["args"]) + + # Interrupt and wait for human input + location = interrupt(ask.question) + + tool_message = ToolMessage(tool_call_id=tool_call_id, content=location) + return {"messages": [tool_message]} + + +# ============================================================================= +# Graph Logic +# ============================================================================= + +def should_continue(state: MessagesState) -> str: + """ + Determine the next step in the graph based on the last message. + + Args: + state: The current messages state + + Returns: + The name of the next node to execute, or END to finish + """ + messages = state["messages"] + last_message = messages[-1] + + # If there's no function call, we're done + if not last_message.tool_calls: + return END + + # If asking for human input, route to ask_human node + if last_message.tool_calls[0]["name"] == "AskHuman": + return "ask_human" + + # Otherwise, execute the tool call + return "action" + + +# ============================================================================= +# Graph Construction +# ============================================================================= + +def build_graph() -> StateGraph: + """ + Build and compile the LangGraph workflow. + + Returns: + Compiled StateGraph with checkpointing enabled + """ + workflow = StateGraph(MessagesState) + + # Add nodes + workflow.add_node("agent", call_model) + workflow.add_node("action", tool_node) + workflow.add_node("ask_human", ask_human) + + # Set entry point + workflow.add_edge(START, "agent") + + # Add conditional routing from agent + workflow.add_conditional_edges( + "agent", + should_continue, + path_map=["ask_human", "action", END], + ) + + # Add edges back to agent + workflow.add_edge("action", "agent") + workflow.add_edge("ask_human", "agent") + + # Compile with memory checkpointer + memory = InMemorySaver() + return workflow.compile(checkpointer=memory) + + +app = build_graph() + + +# ============================================================================= +# Main Entry Point +# ============================================================================= + +if __name__ == "__main__": + adapter = from_langgraph(app) + adapter.run() + diff --git a/samples/python/hosted-agents/langgraph/human-in-the-loop/requirements.txt b/samples/python/hosted-agents/langgraph/human-in-the-loop/requirements.txt new file mode 100644 index 00000000..a95dd751 --- /dev/null +++ b/samples/python/hosted-agents/langgraph/human-in-the-loop/requirements.txt @@ -0,0 +1 @@ +azure-ai-agentserver-langgraph==1.0.0b9 diff --git a/samples/python/hosted-agents/langgraph/react-agent-with-foundry-tools/agent.yaml b/samples/python/hosted-agents/langgraph/react-agent-with-foundry-tools/agent.yaml index 425dcf5b..bb983790 100644 --- a/samples/python/hosted-agents/langgraph/react-agent-with-foundry-tools/agent.yaml +++ b/samples/python/hosted-agents/langgraph/react-agent-with-foundry-tools/agent.yaml @@ -28,5 +28,5 @@ template: value: "" resources: - kind: model - id: gpt-4o-mini + id: gpt-4o name: chat