Skip to content

Commit 76a672c

Browse files
authored
Merge pull request #25 from pamelafox/hitl
Add HITL workflow examples, Spanish translations, and slide plan
2 parents 5651a75 + ed6c4fd commit 76a672c

20 files changed

Lines changed: 2915 additions & 206 deletions

.devcontainer/devcontainer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
},
2121
"extensions": [
2222
"ms-python.python",
23-
"ms-azuretools.vscode-bicep"
23+
"ms-azuretools.vscode-bicep",
24+
"ms-ossdata.vscode-pgsql"
2425
]
2526
}
2627
},

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
.azure
33
*_env
44

5+
# Workflow checkpoint files
6+
examples/checkpoints/
7+
58
# Byte-compiled / optimized / DLL files
69
__pycache__/
710
*.py[cod]

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ Each example .py file should have a corresponding file with the same name under
3232
* Comments: Spanish
3333
* Docstrings: Spanish
3434
* System prompts (agent instructions): Spanish
35-
* Tool descriptions (metadata like description=): English
36-
* Parameter descriptions (Field(description=...)): English
3735
* Identifiers (functions/classes/vars): English
3836
* User-facing output/data (e.g., example responses, sample values): Spanish
37+
* @tool function names: English
38+
* @tool parameter descriptions (Field(description=...)): English
39+
* @tool docstrings: Spanish
3940
* HITL control words: bilingual (approve/aprobar, exit/salir)
4041
* Agent and workflow names: English ("TravelPlannerAgent" should be the same in both versions, not "AgentePlanificadorDeViajes")
4142

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ You can run the examples in this repository by executing the scripts in the `exa
194194
| [agent_with_subagent.py](examples/agent_with_subagent.py) | Context isolation with sub-agents to keep prompts focused on relevant tools. |
195195
| [agent_without_subagent.py](examples/agent_without_subagent.py) | Context bloat example where one agent carries all tool schemas in a single prompt. |
196196
| [agent_summarization.py](examples/agent_summarization.py) | Context compaction via summarization middleware to reduce token usage in long conversations. |
197-
| [workflow_magenticone.py](examples/workflow_magenticone.py) | A MagenticOne multi-agent workflow. |
197+
| [agent_tool_approval.py](examples/agent_tool_approval.py) | Standalone agent with tool approval — gates sensitive operations before execution. |
198198
| [agent_middleware.py](examples/agent_middleware.py) | Agent, chat, and function middleware for logging, timing, and blocking. |
199199
| [agent_knowledge_aisearch.py](examples/agent_knowledge_aisearch.py) | Knowledge retrieval (RAG) using Azure AI Search with AgentFrameworkAzureAISearchRAG. |
200200
| [agent_knowledge_sqlite.py](examples/agent_knowledge_sqlite.py) | Knowledge retrieval (RAG) using a custom context provider with SQLite FTS5. |
@@ -223,6 +223,12 @@ You can run the examples in this repository by executing the scripts in the `exa
223223
| [workflow_converge.py](examples/workflow_converge.py) | A branch-and-converge workflow: Reviewer routes to Publisher or Editor, then converges before final summary output. |
224224
| [workflow_handoffbuilder.py](examples/workflow_handoffbuilder.py) | Autonomous handoff orchestration using `HandoffBuilder` (agents transfer control without human-in-the-loop). |
225225
| [workflow_handoffbuilder_rules.py](examples/workflow_handoffbuilder_rules.py) | Handoff orchestration with explicit routing rules using `HandoffBuilder.add_handoff()`. |
226+
| [workflow_hitl_requests.py](examples/workflow_hitl_requests.py) | Simple HITL chat — always pause for human input after every agent response (`ctx.request_info`, `@response_handler`). |
227+
| [workflow_hitl_requests_structured.py](examples/workflow_hitl_requests_structured.py) | Trip planner HITL with structured outputs — agent decides when to ask vs. finish via `PlannerOutput.status`. |
228+
| [workflow_hitl_tool_approval.py](examples/workflow_hitl_tool_approval.py) | Email agent workflow with `@tool(approval_mode="always_require")` for gating sensitive tool calls. |
229+
| [workflow_hitl_checkpoint.py](examples/workflow_hitl_checkpoint.py) | Content review with `FileCheckpointStorage` — pause, exit process, and resume from checkpoint. |
230+
| [workflow_hitl_checkpoint_pg.py](examples/workflow_hitl_checkpoint_pg.py) | Same content review workflow with a custom `PostgresCheckpointStorage` backend. |
231+
| [workflow_hitl_handoff.py](examples/workflow_hitl_handoff.py) | Interactive handoff (no autonomous mode) — framework pauses for user input via `HandoffAgentUserRequest`. |
226232
| [agent_otel_aspire.py](examples/agent_otel_aspire.py) | An agent with OpenTelemetry tracing, metrics, and structured logs exported to the [Aspire Dashboard](https://aspire.dev/dashboard/standalone/). |
227233
| [agent_otel_appinsights.py](examples/agent_otel_appinsights.py) | An agent with OpenTelemetry tracing, metrics, and structured logs exported to [Azure Application Insights](https://learn.microsoft.com/azure/azure-monitor/app/app-insights-overview). Requires Azure provisioning via `azd provision`. |
228234
| [agent_evaluation_generate.py](examples/agent_evaluation_generate.py) | Generate synthetic evaluation data for the travel planner agent. |

examples/agent_tool_approval.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Standalone agent with tool approval — no workflow required.
2+
3+
Demonstrates: @tool(approval_mode="always_require") with a plain Agent,
4+
handling user_input_requests, and re-running the agent with approval context.
5+
6+
An expense reporting agent can look up receipts automatically but must get
7+
human approval before submitting an expense report. This shows the simplest
8+
HITL pattern: tool approval on a standalone agent without any workflow.
9+
10+
Run:
11+
uv run examples/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+
# Configure the chat client based on the API host
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+
# --- Tools ---
49+
50+
submitted_reports: list[dict[str, str]] = []
51+
52+
receipts_db: dict[str, dict[str, str]] = {
53+
"R-001": {"vendor": "Office Depot", "amount": "$142.50", "category": "Office Supplies", "date": "2026-03-01"},
54+
"R-002": {"vendor": "Delta Airlines", "amount": "$489.00", "category": "Travel", "date": "2026-02-28"},
55+
"R-003": {"vendor": "Uber Eats", "amount": "$32.75", "category": "Meals", "date": "2026-03-03"},
56+
}
57+
58+
59+
@tool(approval_mode="never_require")
60+
def lookup_receipt(
61+
receipt_id: Annotated[str, "The receipt ID to look up"],
62+
) -> dict[str, str]:
63+
"""Look up a receipt by ID and return its details."""
64+
return receipts_db.get(receipt_id, {"error": f"Receipt {receipt_id} not found"})
65+
66+
67+
@tool(approval_mode="always_require")
68+
def submit_expense_report(
69+
description: Annotated[str, "Description of the expense report"],
70+
total_amount: Annotated[str, "Total amount to reimburse"],
71+
receipt_ids: Annotated[str, "Comma-separated receipt IDs included"],
72+
) -> str:
73+
"""Submit an expense report for reimbursement. Requires manager approval."""
74+
report = {"description": description, "total_amount": total_amount, "receipt_ids": receipt_ids}
75+
submitted_reports.append(report)
76+
return f"Expense report submitted: {description} for {total_amount} (receipts: {receipt_ids})"
77+
78+
79+
# --- Main ---
80+
81+
82+
agent = Agent(
83+
client=client,
84+
name="ExpenseAgent",
85+
instructions=(
86+
"You are an expense reporting assistant. Help users look up receipts and submit expense reports. "
87+
"Always look up the receipt details before including them in an expense report."
88+
),
89+
tools=[lookup_receipt, submit_expense_report],
90+
)
91+
92+
93+
async def main() -> None:
94+
query = "Look up receipts R-001 and R-002, then submit an expense report for both."
95+
print(f"👤 User: {query}\n")
96+
97+
result = await agent.run(query)
98+
99+
# Loop while there are pending approval requests
100+
while len(result.user_input_requests) > 0:
101+
new_inputs: list[Any] = [query]
102+
103+
for request in result.user_input_requests:
104+
func_call = request.function_call
105+
print(f"🔒 Approval requested: {func_call.name}")
106+
print(f" Arguments: {func_call.arguments}")
107+
108+
# Add the assistant message containing the approval request
109+
new_inputs.append(Message("assistant", [request]))
110+
111+
approval = input(" Approve? (y/n): ").strip().lower()
112+
approved = approval == "y"
113+
print(f" {'✅ Approved' if approved else '❌ Rejected'}\n")
114+
115+
# Add the user's approval response
116+
new_inputs.append(Message("user", [request.to_function_approval_response(approved)]))
117+
118+
# Re-run with approval context
119+
result = await agent.run(new_inputs)
120+
121+
print(f"🤖 {agent.name}: {result.text}")
122+
123+
if submitted_reports:
124+
print(f"\n📋 {len(submitted_reports)} report(s) submitted:")
125+
for report in submitted_reports:
126+
print(f" - {report['description']} | {report['total_amount']} | receipts: {report['receipt_ids']}")
127+
128+
if async_credential:
129+
await async_credential.close()
130+
131+
132+
if __name__ == "__main__":
133+
asyncio.run(main())

examples/spanish/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ Puedes ejecutar los ejemplos en este repositorio ejecutando los scripts en el di
189189
| [agent_with_subagent.py](agent_with_subagent.py) | Aislamiento de contexto con subagentes para mantener los prompts enfocados en herramientas relevantes. |
190190
| [agent_without_subagent.py](agent_without_subagent.py) | Ejemplo de inflado de contexto cuando un solo agente carga todos los esquemas de herramientas en un mismo prompt. |
191191
| [agent_summarization.py](agent_summarization.py) | Compactación de contexto mediante middleware de resumen para reducir el uso de tokens en conversaciones largas. |
192-
| [workflow_magenticone.py](workflow_magenticone.py) | Un workflow multi-agente MagenticOne. |
192+
| [agent_tool_approval.py](agent_tool_approval.py) | Agente independiente con aprobación de herramientas — controla operaciones sensibles antes de ejecutarlas. |
193193
| [agent_middleware.py](agent_middleware.py) | Middleware de agente, chat y funciones para logging, timing y bloqueo. |
194194
| [agent_knowledge_aisearch.py](agent_knowledge_aisearch.py) | Recuperación de conocimiento (RAG) usando Azure AI Search con AgentFrameworkAzureAISearchRAG. |
195195
| [agent_knowledge_sqlite.py](agent_knowledge_sqlite.py) | Recuperación de conocimiento (RAG) usando un proveedor de contexto personalizado con SQLite FTS5. |
@@ -218,6 +218,12 @@ Puedes ejecutar los ejemplos en este repositorio ejecutando los scripts en el di
218218
| [workflow_converge.py](workflow_converge.py) | Un workflow con rama y convergencia: Revisor enruta a Publicador o Editor y luego converge antes del resumen final. |
219219
| [workflow_handoffbuilder.py](workflow_handoffbuilder.py) | Orquestación de handoff autónoma usando `HandoffBuilder` (los agentes se transfieren el control sin HITL). |
220220
| [workflow_handoffbuilder_rules.py](workflow_handoffbuilder_rules.py) | Orquestación de handoff con reglas explícitas usando `HandoffBuilder.add_handoff()`. |
221+
| [workflow_hitl_requests.py](workflow_hitl_requests.py) | Chat HITL simple — siempre pausa para entrada humana después de cada respuesta del agente (`ctx.request_info`, `@response_handler`). |
222+
| [workflow_hitl_requests_structured.py](workflow_hitl_requests_structured.py) | Planificador de viajes HITL con salidas estructuradas — el agente decide cuándo preguntar vs. finalizar vía `PlannerOutput.status`. |
223+
| [workflow_hitl_tool_approval.py](workflow_hitl_tool_approval.py) | Workflow de agente de correo con `@tool(approval_mode="always_require")` para controlar llamadas sensibles. |
224+
| [workflow_hitl_checkpoint.py](workflow_hitl_checkpoint.py) | Revisión de contenido con `FileCheckpointStorage` — pausar, salir del proceso y reanudar desde checkpoint. |
225+
| [workflow_hitl_checkpoint_pg.py](workflow_hitl_checkpoint_pg.py) | Mismo workflow de revisión con un backend personalizado `PostgresCheckpointStorage`. |
226+
| [workflow_hitl_handoff.py](workflow_hitl_handoff.py) | Handoff interactivo (sin modo autónomo) — el framework pausa para entrada del usuario vía `HandoffAgentUserRequest`. |
221227
| [agent_otel_aspire.py](agent_otel_aspire.py) | Un agente con trazas, métricas y logs estructurados de OpenTelemetry exportados al [Aspire Dashboard](https://aspire.dev/dashboard/standalone/). |
222228
| [agent_otel_appinsights.py](agent_otel_appinsights.py) | Un agente con trazas, métricas y logs estructurados de OpenTelemetry exportados a [Azure Application Insights](https://learn.microsoft.com/azure/azure-monitor/app/app-insights-overview). Requiere aprovisionamiento de Azure con `azd provision`. |
223229
| [agent_evaluation_generate.py](agent_evaluation_generate.py) | Genera datos sintéticos de evaluación para el agente planificador de viajes. |
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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

Comments
 (0)