|
| 1 | +"""Agente independiente con aprobación de herramientas — no se requiere workflow. |
| 2 | +
|
| 3 | +Demuestra: @tool(approval_mode="always_require") con un Agent simple, |
| 4 | +manejo de user_input_requests, y re-ejecución del agente con contexto de aprobación. |
| 5 | +
|
| 6 | +Un agente de reportes de gastos puede buscar recibos automáticamente pero debe obtener |
| 7 | +aprobación humana antes de enviar un reporte de gastos. Esto muestra el patrón HITL |
| 8 | +más simple: aprobación de herramientas en un agente independiente sin ningún workflow. |
| 9 | +
|
| 10 | +Ejecutar: |
| 11 | + uv run examples/spanish/agent_tool_approval.py |
| 12 | +""" |
| 13 | + |
| 14 | +import asyncio |
| 15 | +import os |
| 16 | +from typing import Annotated, Any |
| 17 | + |
| 18 | +from agent_framework import Agent, Message, tool |
| 19 | +from agent_framework.openai import OpenAIChatClient |
| 20 | +from azure.identity.aio import DefaultAzureCredential, get_bearer_token_provider |
| 21 | +from dotenv import load_dotenv |
| 22 | + |
| 23 | +load_dotenv(override=True) |
| 24 | +API_HOST = os.getenv("API_HOST", "github") |
| 25 | + |
| 26 | +# Configura el cliente según el host de la API |
| 27 | +async_credential = None |
| 28 | +if API_HOST == "azure": |
| 29 | + async_credential = DefaultAzureCredential() |
| 30 | + token_provider = get_bearer_token_provider(async_credential, "https://cognitiveservices.azure.com/.default") |
| 31 | + client = OpenAIChatClient( |
| 32 | + base_url=f"{os.environ['AZURE_OPENAI_ENDPOINT']}/openai/v1/", |
| 33 | + api_key=token_provider, |
| 34 | + model_id=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT"], |
| 35 | + ) |
| 36 | +elif API_HOST == "github": |
| 37 | + client = OpenAIChatClient( |
| 38 | + base_url="https://models.github.ai/inference", |
| 39 | + api_key=os.environ["GITHUB_TOKEN"], |
| 40 | + model_id=os.getenv("GITHUB_MODEL", "openai/gpt-4.1-mini"), |
| 41 | + ) |
| 42 | +else: |
| 43 | + client = OpenAIChatClient( |
| 44 | + api_key=os.environ["OPENAI_API_KEY"], model_id=os.environ.get("OPENAI_MODEL", "gpt-4.1-mini") |
| 45 | + ) |
| 46 | + |
| 47 | + |
| 48 | +# --- Herramientas --- |
| 49 | + |
| 50 | +submitted_reports: list[dict[str, str]] = [] |
| 51 | + |
| 52 | +receipts_db: dict[str, dict[str, str]] = { |
| 53 | + "R-001": { |
| 54 | + "vendor": "Librería Nacional", "amount": "$142.50", "category": "Útiles de Oficina", "date": "2026-03-01", |
| 55 | + }, |
| 56 | + "R-002": {"vendor": "LATAM Airlines", "amount": "$489.00", "category": "Viajes", "date": "2026-02-28"}, |
| 57 | + "R-003": {"vendor": "Rappi", "amount": "$32.75", "category": "Comidas", "date": "2026-03-03"}, |
| 58 | +} |
| 59 | + |
| 60 | + |
| 61 | +@tool(approval_mode="never_require") |
| 62 | +def lookup_receipt( |
| 63 | + receipt_id: Annotated[str, "The receipt ID to look up"], |
| 64 | +) -> dict[str, str]: |
| 65 | + """Busca un recibo por ID y devuelve sus detalles.""" |
| 66 | + return receipts_db.get(receipt_id, {"error": f"Recibo {receipt_id} no encontrado"}) |
| 67 | + |
| 68 | + |
| 69 | +@tool(approval_mode="always_require") |
| 70 | +def submit_expense_report( |
| 71 | + description: Annotated[str, "Description of the expense report"], |
| 72 | + total_amount: Annotated[str, "Total amount to reimburse"], |
| 73 | + receipt_ids: Annotated[str, "Comma-separated receipt IDs included"], |
| 74 | +) -> str: |
| 75 | + """Envía un reporte de gastos para reembolso. Requiere aprobación del gerente.""" |
| 76 | + report = {"description": description, "total_amount": total_amount, "receipt_ids": receipt_ids} |
| 77 | + submitted_reports.append(report) |
| 78 | + return f"Reporte de gastos enviado: {description} por {total_amount} (recibos: {receipt_ids})" |
| 79 | + |
| 80 | + |
| 81 | +# --- Principal --- |
| 82 | + |
| 83 | + |
| 84 | +agent = Agent( |
| 85 | + client=client, |
| 86 | + name="ExpenseAgent", |
| 87 | + instructions=( |
| 88 | + "Eres un asistente de reportes de gastos. Ayuda a los usuarios a buscar recibos y enviar reportes de gastos. " |
| 89 | + "Siempre busca los detalles del recibo antes de incluirlos en un reporte de gastos." |
| 90 | + ), |
| 91 | + tools=[lookup_receipt, submit_expense_report], |
| 92 | +) |
| 93 | + |
| 94 | + |
| 95 | +async def main() -> None: |
| 96 | + query = "Busca los recibos R-001 y R-002, luego envía un reporte de gastos por ambos." |
| 97 | + print(f"👤 Usuario: {query}\n") |
| 98 | + |
| 99 | + result = await agent.run(query) |
| 100 | + |
| 101 | + # Bucle mientras haya solicitudes de aprobación pendientes |
| 102 | + while len(result.user_input_requests) > 0: |
| 103 | + new_inputs: list[Any] = [query] |
| 104 | + |
| 105 | + for request in result.user_input_requests: |
| 106 | + func_call = request.function_call |
| 107 | + print(f"🔒 Aprobación solicitada: {func_call.name}") |
| 108 | + print(f" Argumentos: {func_call.arguments}") |
| 109 | + |
| 110 | + # Agrega el mensaje del asistente que contiene la solicitud de aprobación |
| 111 | + new_inputs.append(Message("assistant", [request])) |
| 112 | + |
| 113 | + approval = input(" ¿Aprobar/Approve? (s/n): ").strip().lower() |
| 114 | + approved = approval == "s" |
| 115 | + print(f" {'✅ Aprobado' if approved else '❌ Rechazado'}\n") |
| 116 | + |
| 117 | + # Agrega la respuesta de aprobación del usuario |
| 118 | + new_inputs.append(Message("user", [request.to_function_approval_response(approved)])) |
| 119 | + |
| 120 | + # Re-ejecuta con contexto de aprobación |
| 121 | + result = await agent.run(new_inputs) |
| 122 | + |
| 123 | + print(f"🤖 {agent.name}: {result.text}") |
| 124 | + |
| 125 | + if submitted_reports: |
| 126 | + print(f"\n📋 {len(submitted_reports)} reporte(s) enviado(s):") |
| 127 | + for report in submitted_reports: |
| 128 | + print(f" - {report['description']} | {report['total_amount']} | recibos: {report['receipt_ids']}") |
| 129 | + |
| 130 | + if async_credential: |
| 131 | + await async_credential.close() |
| 132 | + |
| 133 | + |
| 134 | +if __name__ == "__main__": |
| 135 | + asyncio.run(main()) |
0 commit comments