mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Fix Http Schema (#2112)
* Rename to threadid * Respond in plain text * Make snake-case * Add http prefix * rename to wait-for-response * Add query param check * address comments
This commit is contained in:
committed by
GitHub
Unverified
parent
ebab25b196
commit
ff28066c9c
@@ -8,7 +8,7 @@ with Azure Durable Entities, enabling stateful and durable AI agent execution.
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
@@ -18,17 +18,16 @@ from agent_framework import AgentProtocol, get_logger
|
||||
from ._callbacks import AgentResponseCallbackProtocol
|
||||
from ._entities import create_agent_entity
|
||||
from ._errors import IncomingRequestError
|
||||
from ._models import AgentSessionId, ChatRole, RunRequest
|
||||
from ._models import AgentSessionId, RunRequest
|
||||
from ._state import AgentState
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions")
|
||||
|
||||
SESSION_ID_FIELD: str = "sessionId"
|
||||
SESSION_KEY_FIELD: str = "sessionKey"
|
||||
SESSION_IDENTIFIER_KEYS: tuple[str, str] = (
|
||||
SESSION_ID_FIELD,
|
||||
SESSION_KEY_FIELD,
|
||||
)
|
||||
THREAD_ID_FIELD: str = "thread_id"
|
||||
RESPONSE_FORMAT_JSON: str = "json"
|
||||
RESPONSE_FORMAT_TEXT: str = "text"
|
||||
WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
|
||||
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
|
||||
|
||||
|
||||
class AgentFunctionApp(df.DFApp):
|
||||
@@ -88,18 +87,18 @@ class AgentFunctionApp(df.DFApp):
|
||||
def __init__(
|
||||
self,
|
||||
agents: list[AgentProtocol] | None = None,
|
||||
http_auth_level: func.AuthLevel = func.AuthLevel.ANONYMOUS,
|
||||
http_auth_level: func.AuthLevel = func.AuthLevel.FUNCTION,
|
||||
enable_health_check: bool = True,
|
||||
enable_http_endpoints: bool = True,
|
||||
max_poll_retries: int = 10,
|
||||
poll_interval_seconds: float = 0.5,
|
||||
max_poll_retries: int = 30,
|
||||
poll_interval_seconds: float = 1,
|
||||
default_callback: AgentResponseCallbackProtocol | None = None,
|
||||
):
|
||||
"""Initialize the AgentFunctionApp.
|
||||
|
||||
Args:
|
||||
agents: List of agent instances to register
|
||||
http_auth_level: HTTP authentication level (default: ANONYMOUS)
|
||||
http_auth_level: HTTP authentication level (default: FUNCTION)
|
||||
enable_health_check: Enable built-in health check endpoint (default: True)
|
||||
enable_http_endpoints: Enable HTTP endpoints for agents (default: True)
|
||||
max_poll_retries: Maximum number of polling attempts when waiting for a response
|
||||
@@ -231,7 +230,7 @@ class AgentFunctionApp(df.DFApp):
|
||||
Args:
|
||||
agent_name: The agent name (used for both routing and entity identification)
|
||||
"""
|
||||
run_function_name = self._build_function_name(agent_name, "run")
|
||||
run_function_name = self._build_function_name(agent_name, "http")
|
||||
|
||||
@self.function_name(run_function_name)
|
||||
@self.route(route=f"agents/{agent_name}/run", methods=["POST"])
|
||||
@@ -242,7 +241,7 @@ class AgentFunctionApp(df.DFApp):
|
||||
Expected request body (RunRequest format):
|
||||
{
|
||||
"message": "user message to agent",
|
||||
"sessionId": "optional session id (or sessionKey)",
|
||||
"thread_id": "optional conversation identifier",
|
||||
"role": "user|system" (optional, default: "user"),
|
||||
"response_format": {...} (optional JSON schema for structured responses),
|
||||
"enable_tool_calls": true|false (optional, default: true)
|
||||
@@ -250,22 +249,28 @@ class AgentFunctionApp(df.DFApp):
|
||||
"""
|
||||
logger.debug(f"[HTTP Trigger] Received request on route: /api/agents/{agent_name}/run")
|
||||
|
||||
response_format: str = RESPONSE_FORMAT_JSON
|
||||
thread_id: str | None = None
|
||||
|
||||
try:
|
||||
req_body, message = self._parse_incoming_request(req)
|
||||
session_key = self._resolve_session_key(req=req, req_body=req_body)
|
||||
wait_for_completion = self._should_wait_for_completion(req=req, req_body=req_body)
|
||||
req_body, message, response_format = self._parse_incoming_request(req)
|
||||
thread_id = self._resolve_thread_id(req=req, req_body=req_body)
|
||||
wait_for_response = self._should_wait_for_response(req=req, req_body=req_body)
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Message: {message}")
|
||||
logger.debug(f"[HTTP Trigger] Session Key: {session_key}")
|
||||
logger.debug(f"[HTTP Trigger] wait_for_completion: {wait_for_completion}")
|
||||
logger.debug(f"[HTTP Trigger] Thread ID: {thread_id}")
|
||||
logger.debug(f"[HTTP Trigger] wait_for_response: {wait_for_response}")
|
||||
|
||||
if not message:
|
||||
logger.warning("[HTTP Trigger] Request rejected: Missing message")
|
||||
return func.HttpResponse(
|
||||
json.dumps({"error": "Message is required"}), status_code=400, mimetype="application/json"
|
||||
return self._create_http_response(
|
||||
payload={"error": "Message is required"},
|
||||
status_code=400,
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
session_id = self._create_session_id(agent_name, session_key)
|
||||
session_id = self._create_session_id(agent_name, thread_id)
|
||||
correlation_id = self._generate_unique_id()
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Using session ID: {session_id}")
|
||||
@@ -276,7 +281,7 @@ class AgentFunctionApp(df.DFApp):
|
||||
run_request = self._build_request_data(
|
||||
req_body,
|
||||
message,
|
||||
session_key,
|
||||
thread_id,
|
||||
correlation_id,
|
||||
)
|
||||
logger.debug("Signalling entity %s with request: %s", entity_instance_id, run_request)
|
||||
@@ -284,43 +289,60 @@ class AgentFunctionApp(df.DFApp):
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Signal sent to entity {session_id}")
|
||||
|
||||
if wait_for_completion:
|
||||
if wait_for_response:
|
||||
result = await self._get_response_from_entity(
|
||||
client=client,
|
||||
entity_instance_id=entity_instance_id,
|
||||
correlation_id=correlation_id,
|
||||
message=message,
|
||||
session_key=session_key,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Result status: {result.get('status', 'unknown')}")
|
||||
return func.HttpResponse(
|
||||
json.dumps(result),
|
||||
return self._create_http_response(
|
||||
payload=result,
|
||||
status_code=200 if result.get("status") == "success" else 500,
|
||||
mimetype="application/json",
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
logger.debug("[HTTP Trigger] wait_for_completion disabled; returning correlation ID")
|
||||
logger.debug("[HTTP Trigger] wait_for_response disabled; returning correlation ID")
|
||||
|
||||
accepted_response = self._build_accepted_response(
|
||||
message=message, session_key=session_key, correlation_id=correlation_id
|
||||
message=message, thread_id=thread_id, correlation_id=correlation_id
|
||||
)
|
||||
|
||||
return func.HttpResponse(json.dumps(accepted_response), status_code=202, mimetype="application/json")
|
||||
return self._create_http_response(
|
||||
payload=accepted_response,
|
||||
status_code=202,
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
except IncomingRequestError as exc:
|
||||
logger.warning(f"[HTTP Trigger] Request rejected: {exc!s}")
|
||||
return func.HttpResponse(
|
||||
json.dumps({"error": str(exc)}), status_code=exc.status_code, mimetype="application/json"
|
||||
return self._create_http_response(
|
||||
payload={"error": str(exc)},
|
||||
status_code=exc.status_code,
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.error(f"[HTTP Trigger] Invalid JSON: {exc!s}")
|
||||
return func.HttpResponse(
|
||||
json.dumps({"error": "Invalid JSON"}), status_code=400, mimetype="application/json"
|
||||
return self._create_http_response(
|
||||
payload={"error": "Invalid JSON"},
|
||||
status_code=400,
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"[HTTP Trigger] Error: {exc!s}", exc_info=True)
|
||||
return func.HttpResponse(json.dumps({"error": str(exc)}), status_code=500, mimetype="application/json")
|
||||
return self._create_http_response(
|
||||
payload={"error": str(exc)},
|
||||
status_code=500,
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
_ = http_start
|
||||
|
||||
@@ -365,7 +387,7 @@ class AgentFunctionApp(df.DFApp):
|
||||
{
|
||||
"name": name,
|
||||
"type": type(agent).__name__,
|
||||
"httpEndpointEnabled": self.agent_http_endpoint_flags.get(
|
||||
"http_endpoint_enabled": self.agent_http_endpoint_flags.get(
|
||||
name,
|
||||
self.enable_http_endpoints,
|
||||
),
|
||||
@@ -381,17 +403,20 @@ class AgentFunctionApp(df.DFApp):
|
||||
_ = health_check
|
||||
|
||||
@staticmethod
|
||||
def _build_function_name(agent_name: str, suffix: str) -> str:
|
||||
"""Generate a unique, Azure Functions-compliant name for an agent function."""
|
||||
sanitized = re.sub(r"[^0-9a-zA-Z_]", "_", agent_name or "agent").strip("_")
|
||||
def _build_function_name(agent_name: str, prefix: str) -> str:
|
||||
"""Generate the sanitized function name in the form "{prefix}-{sanitized_agent_name}".
|
||||
|
||||
if not sanitized:
|
||||
sanitized = "agent"
|
||||
Example: agent_name="Weather Agent" and prefix="http" becomes "http-Weather_Agent".
|
||||
"""
|
||||
sanitized_agent = re.sub(r"[^0-9a-zA-Z_]", "_", agent_name or "agent").strip("_")
|
||||
|
||||
if sanitized[0].isdigit():
|
||||
sanitized = f"agent_{sanitized}"
|
||||
if not sanitized_agent:
|
||||
sanitized_agent = "agent"
|
||||
|
||||
return f"{sanitized}_{suffix}"
|
||||
if sanitized_agent[0].isdigit():
|
||||
sanitized_agent = f"agent_{sanitized_agent}"
|
||||
|
||||
return f"{prefix}-{sanitized_agent}"
|
||||
|
||||
async def _read_cached_state(
|
||||
self,
|
||||
@@ -418,7 +443,7 @@ class AgentFunctionApp(df.DFApp):
|
||||
entity_instance_id: df.EntityId,
|
||||
correlation_id: str,
|
||||
message: str,
|
||||
session_key: str,
|
||||
thread_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Poll the entity state until a response is available or timeout occurs."""
|
||||
import asyncio
|
||||
@@ -438,7 +463,7 @@ class AgentFunctionApp(df.DFApp):
|
||||
entity_instance_id=entity_instance_id,
|
||||
correlation_id=correlation_id,
|
||||
message=message,
|
||||
session_key=session_key,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
if result is not None:
|
||||
break
|
||||
@@ -453,7 +478,7 @@ class AgentFunctionApp(df.DFApp):
|
||||
f"[HTTP Trigger] Response with correlation ID {correlation_id} "
|
||||
f"not found in time (waited {max_retries * interval} seconds)"
|
||||
)
|
||||
return await self._build_timeout_result(message=message, session_key=session_key, correlation_id=correlation_id)
|
||||
return await self._build_timeout_result(message=message, thread_id=thread_id, correlation_id=correlation_id)
|
||||
|
||||
async def _poll_entity_for_response(
|
||||
self,
|
||||
@@ -461,7 +486,7 @@ class AgentFunctionApp(df.DFApp):
|
||||
entity_instance_id: df.EntityId,
|
||||
correlation_id: str,
|
||||
message: str,
|
||||
session_key: str,
|
||||
thread_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
result: dict[str, Any] | None = None
|
||||
try:
|
||||
@@ -475,7 +500,7 @@ class AgentFunctionApp(df.DFApp):
|
||||
result = self._build_success_result(
|
||||
response_data=agent_response,
|
||||
message=message,
|
||||
session_key=session_key,
|
||||
thread_id=thread_id,
|
||||
correlation_id=correlation_id,
|
||||
state=state,
|
||||
)
|
||||
@@ -486,113 +511,181 @@ class AgentFunctionApp(df.DFApp):
|
||||
|
||||
return result
|
||||
|
||||
async def _build_timeout_result(self, message: str, session_key: str, correlation_id: str) -> dict[str, Any]:
|
||||
async def _build_timeout_result(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
|
||||
"""Create the timeout response."""
|
||||
return {
|
||||
"response": "Agent is still processing or timed out...",
|
||||
"message": message,
|
||||
SESSION_ID_FIELD: session_key,
|
||||
THREAD_ID_FIELD: thread_id,
|
||||
"status": "timeout",
|
||||
"correlationId": correlation_id,
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
|
||||
def _build_success_result(
|
||||
self, response_data: dict[str, Any], message: str, session_key: str, correlation_id: str, state: AgentState
|
||||
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: AgentState
|
||||
) -> dict[str, Any]:
|
||||
"""Build the success result returned to the HTTP caller."""
|
||||
return {
|
||||
"response": response_data.get("content"),
|
||||
"message": message,
|
||||
SESSION_ID_FIELD: session_key,
|
||||
THREAD_ID_FIELD: thread_id,
|
||||
"status": "success",
|
||||
"message_count": response_data.get("message_count", state.message_count),
|
||||
"correlationId": correlation_id,
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
|
||||
def _build_request_data(
|
||||
self, req_body: dict[str, Any], message: str, conversation_id: str, correlation_id: str
|
||||
self, req_body: dict[str, Any], message: str, thread_id: str, correlation_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Create the durable entity request payload."""
|
||||
enable_tool_calls_value = req_body.get("enable_tool_calls")
|
||||
enable_tool_calls = True if enable_tool_calls_value is None else self._coerce_to_bool(enable_tool_calls_value)
|
||||
|
||||
role = self._coerce_chat_role(req_body.get("role"))
|
||||
|
||||
return RunRequest(
|
||||
message=message,
|
||||
role=role,
|
||||
role=req_body.get("role"),
|
||||
response_format=req_body.get("response_format"),
|
||||
enable_tool_calls=enable_tool_calls,
|
||||
conversation_id=conversation_id,
|
||||
thread_id=thread_id,
|
||||
correlation_id=correlation_id,
|
||||
).to_dict()
|
||||
|
||||
def _build_accepted_response(self, message: str, session_key: str, correlation_id: str) -> dict[str, Any]:
|
||||
def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
|
||||
"""Build the response returned when not waiting for completion."""
|
||||
return {
|
||||
"response": "Agent request accepted",
|
||||
"message": message,
|
||||
SESSION_ID_FIELD: session_key,
|
||||
THREAD_ID_FIELD: thread_id,
|
||||
"status": "accepted",
|
||||
"correlationId": correlation_id,
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
|
||||
def _create_http_response(
|
||||
self,
|
||||
payload: dict[str, Any] | str,
|
||||
status_code: int,
|
||||
response_format: str,
|
||||
thread_id: str | None,
|
||||
) -> func.HttpResponse:
|
||||
"""Create the HTTP response using helper serializers for clarity."""
|
||||
if response_format == RESPONSE_FORMAT_TEXT:
|
||||
return self._build_plain_text_response(payload=payload, status_code=status_code, thread_id=thread_id)
|
||||
|
||||
return self._build_json_response(payload=payload, status_code=status_code)
|
||||
|
||||
def _build_plain_text_response(
|
||||
self,
|
||||
payload: dict[str, Any] | str,
|
||||
status_code: int,
|
||||
thread_id: str | None,
|
||||
) -> func.HttpResponse:
|
||||
"""Return a plain-text response with optional thread identifier header."""
|
||||
body_text = payload if isinstance(payload, str) else self._convert_payload_to_text(payload)
|
||||
headers = {"x-ms-thread-id": thread_id} if thread_id is not None else None
|
||||
return func.HttpResponse(body_text, status_code=status_code, mimetype="text/plain", headers=headers)
|
||||
|
||||
def _build_json_response(self, payload: dict[str, Any] | str, status_code: int) -> func.HttpResponse:
|
||||
"""Return the JSON response, serializing dictionaries as needed."""
|
||||
body_json = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
return func.HttpResponse(body_json, status_code=status_code, mimetype="application/json")
|
||||
|
||||
def _convert_payload_to_text(self, payload: dict[str, Any]) -> str:
|
||||
"""Convert a structured payload into a human-readable text response."""
|
||||
for key in ("response", "error", "message"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return json.dumps(payload)
|
||||
|
||||
def _generate_unique_id(self) -> str:
|
||||
"""Generate a new unique identifier."""
|
||||
import uuid
|
||||
|
||||
return uuid.uuid4().hex
|
||||
|
||||
def _create_session_id(self, func_name: str, session_key: str | None) -> AgentSessionId:
|
||||
"""Create a session identifier using the provided key or a random value."""
|
||||
if session_key:
|
||||
return AgentSessionId(name=func_name, key=session_key)
|
||||
def _create_session_id(self, func_name: str, thread_id: str | None) -> AgentSessionId:
|
||||
"""Create a session identifier using the provided thread id or a random value."""
|
||||
if thread_id:
|
||||
return AgentSessionId(name=func_name, key=thread_id)
|
||||
return AgentSessionId.with_random_key(name=func_name)
|
||||
|
||||
def _resolve_session_key(self, req: func.HttpRequest, req_body: dict[str, Any]) -> str:
|
||||
"""Retrieve the session key from request body or query parameters."""
|
||||
def _resolve_thread_id(self, req: func.HttpRequest, req_body: dict[str, Any]) -> str:
|
||||
"""Retrieve the thread identifier from request body or query parameters."""
|
||||
params = req.params or {}
|
||||
|
||||
for key in SESSION_IDENTIFIER_KEYS:
|
||||
if key in req_body:
|
||||
value = req_body.get(key)
|
||||
if value is not None:
|
||||
return str(value)
|
||||
if THREAD_ID_FIELD in req_body:
|
||||
value = req_body.get(THREAD_ID_FIELD)
|
||||
if value is not None:
|
||||
return str(value)
|
||||
|
||||
for key in SESSION_IDENTIFIER_KEYS:
|
||||
if key in params:
|
||||
value = params.get(key)
|
||||
if value is not None:
|
||||
return str(value)
|
||||
if THREAD_ID_FIELD in params:
|
||||
value = params.get(THREAD_ID_FIELD)
|
||||
if value is not None:
|
||||
return str(value)
|
||||
|
||||
logger.debug("[HTTP Trigger] No session identifier provided; using random session key")
|
||||
logger.debug("[HTTP Trigger] No thread identifier provided; using random thread id")
|
||||
return self._generate_unique_id()
|
||||
|
||||
def _parse_incoming_request(self, req: func.HttpRequest) -> tuple[dict[str, Any], Any]:
|
||||
def _parse_incoming_request(self, req: func.HttpRequest) -> tuple[dict[str, Any], str, str]:
|
||||
"""Parse the incoming run request supporting JSON and plain text bodies."""
|
||||
headers = self._extract_normalized_headers(req)
|
||||
|
||||
normalized_content_type = self._extract_content_type(headers)
|
||||
body_parser, body_format = self._select_body_parser(normalized_content_type)
|
||||
prefers_json = self._accepts_json_response(headers)
|
||||
response_format = self._select_response_format(body_format=body_format, prefers_json=prefers_json)
|
||||
|
||||
req_body, message = body_parser(req)
|
||||
return req_body, message, response_format
|
||||
|
||||
def _extract_normalized_headers(self, req: func.HttpRequest) -> dict[str, str]:
|
||||
"""Create a lowercase header mapping from the incoming request."""
|
||||
headers: dict[str, str] = {}
|
||||
raw_headers = req.headers
|
||||
if isinstance(raw_headers, Mapping):
|
||||
headers_mapping = cast(Mapping[Any, Any], raw_headers)
|
||||
for key, value in headers_mapping.items():
|
||||
if value is not None:
|
||||
headers[str(key)] = str(value)
|
||||
|
||||
content_type_header = headers.get("content-type")
|
||||
|
||||
normalized_content_type = ""
|
||||
if content_type_header:
|
||||
normalized_content_type = content_type_header.split(";")[0].strip().lower()
|
||||
|
||||
if normalized_content_type in {"application/json"} or normalized_content_type.endswith("+json"):
|
||||
parser = self._parse_json_body
|
||||
else:
|
||||
parser = self._parse_text_body
|
||||
|
||||
return parser(req)
|
||||
headers[str(key).lower()] = str(value)
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_body(req: func.HttpRequest) -> tuple[dict[str, Any], Any]:
|
||||
def _extract_content_type(headers: dict[str, str]) -> str:
|
||||
"""Return the normalized content-type value (without parameters)."""
|
||||
content_type_header = headers.get("content-type", "")
|
||||
return content_type_header.split(";")[0].strip().lower() if content_type_header else ""
|
||||
|
||||
def _select_body_parser(
|
||||
self,
|
||||
normalized_content_type: str,
|
||||
) -> tuple[Callable[[func.HttpRequest], tuple[dict[str, Any], str]], str]:
|
||||
"""Choose the body parser and declared body format."""
|
||||
if normalized_content_type in {"application/json"} or normalized_content_type.endswith("+json"):
|
||||
return self._parse_json_body, RESPONSE_FORMAT_JSON
|
||||
return self._parse_text_body, RESPONSE_FORMAT_TEXT
|
||||
|
||||
@staticmethod
|
||||
def _accepts_json_response(headers: dict[str, str]) -> bool:
|
||||
"""Check whether the caller explicitly requests a JSON response."""
|
||||
accept_header = headers.get("accept")
|
||||
if not accept_header:
|
||||
return False
|
||||
|
||||
for value in accept_header.split(","):
|
||||
media_type = value.split(";")[0].strip().lower()
|
||||
if media_type == "application/json":
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _select_response_format(body_format: str, prefers_json: bool) -> str:
|
||||
"""Combine body format and accept preference to determine response format."""
|
||||
if body_format == RESPONSE_FORMAT_JSON or prefers_json:
|
||||
return RESPONSE_FORMAT_JSON
|
||||
return RESPONSE_FORMAT_TEXT
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_body(req: func.HttpRequest) -> tuple[dict[str, Any], str]:
|
||||
req_body = req.get_json()
|
||||
if not isinstance(req_body, dict):
|
||||
raise IncomingRequestError("Invalid JSON payload. Expected an object.")
|
||||
@@ -603,46 +696,35 @@ class AgentFunctionApp(df.DFApp):
|
||||
return typed_req_body, message
|
||||
|
||||
@staticmethod
|
||||
def _parse_text_body(req: func.HttpRequest) -> tuple[dict[str, Any], Any]:
|
||||
def _parse_text_body(req: func.HttpRequest) -> tuple[dict[str, Any], str]:
|
||||
body_bytes = req.get_body()
|
||||
text_body = body_bytes.decode("utf-8", errors="replace") if body_bytes else ""
|
||||
message = text_body.strip()
|
||||
|
||||
if not message:
|
||||
raise IncomingRequestError("Message is required")
|
||||
|
||||
return {}, message
|
||||
|
||||
def _should_wait_for_completion(self, req: func.HttpRequest, req_body: dict[str, Any]) -> bool:
|
||||
"""Determine whether the caller requested to wait for completion."""
|
||||
def _should_wait_for_response(self, req: func.HttpRequest, req_body: dict[str, Any]) -> bool:
|
||||
"""Determine whether the caller requested to wait for the response."""
|
||||
header_value = None
|
||||
raw_headers = req.headers
|
||||
if isinstance(raw_headers, Mapping):
|
||||
headers_mapping = cast(Mapping[Any, Any], raw_headers)
|
||||
for key, value in headers_mapping.items():
|
||||
if str(key).lower() == "x-wait-for-completion":
|
||||
if str(key).lower() == WAIT_FOR_RESPONSE_HEADER:
|
||||
header_value = value
|
||||
break
|
||||
|
||||
if header_value is not None:
|
||||
return self._coerce_to_bool(header_value)
|
||||
|
||||
for key in ("wait_for_completion", "waitForCompletion", "WaitForCompletion"):
|
||||
if key in req_body:
|
||||
return self._coerce_to_bool(req_body.get(key))
|
||||
params = req.params or {}
|
||||
if WAIT_FOR_RESPONSE_FIELD in params:
|
||||
return self._coerce_to_bool(params.get(WAIT_FOR_RESPONSE_FIELD))
|
||||
|
||||
return False
|
||||
if WAIT_FOR_RESPONSE_FIELD in req_body:
|
||||
return self._coerce_to_bool(req_body.get(WAIT_FOR_RESPONSE_FIELD))
|
||||
|
||||
def _coerce_chat_role(self, value: Any) -> ChatRole:
|
||||
"""Convert user-provided role to ChatRole, defaulting to user on error."""
|
||||
if isinstance(value, ChatRole):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return ChatRole(value.strip().lower())
|
||||
except ValueError:
|
||||
logger.warning("[AgentFunctionApp] Invalid role '%s'; defaulting to user", value)
|
||||
return ChatRole.USER
|
||||
return True
|
||||
|
||||
def _coerce_to_bool(self, value: Any) -> bool:
|
||||
"""Convert various representations into a boolean flag."""
|
||||
|
||||
@@ -20,7 +20,7 @@ class AgentCallbackContext:
|
||||
|
||||
agent_name: str
|
||||
correlation_id: str
|
||||
conversation_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
request_message: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ from collections.abc import AsyncIterable
|
||||
from typing import Any, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
from agent_framework import AgentProtocol, AgentRunResponse, AgentRunResponseUpdate, get_logger
|
||||
from agent_framework import AgentProtocol, AgentRunResponse, AgentRunResponseUpdate, Role, get_logger
|
||||
|
||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
from ._models import AgentResponse, ChatRole, RunRequest
|
||||
from ._models import AgentResponse, RunRequest
|
||||
from ._state import AgentState
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.entities")
|
||||
@@ -81,33 +81,32 @@ class AgentEntity:
|
||||
"""
|
||||
# Convert string or dict to RunRequest
|
||||
if isinstance(request, str):
|
||||
run_request = RunRequest(message=request, role=ChatRole.USER)
|
||||
run_request = RunRequest(message=request, role=Role.USER)
|
||||
elif isinstance(request, dict):
|
||||
run_request = RunRequest.from_dict(request)
|
||||
else:
|
||||
run_request = request
|
||||
|
||||
message = run_request.message
|
||||
conversation_id = run_request.conversation_id
|
||||
thread_id = run_request.thread_id
|
||||
correlation_id = run_request.correlation_id
|
||||
if not conversation_id:
|
||||
raise ValueError("RunRequest must include a conversation_id")
|
||||
if not thread_id:
|
||||
raise ValueError("RunRequest must include a thread_id")
|
||||
if not correlation_id:
|
||||
raise ValueError("RunRequest must include a correlation_id")
|
||||
role = run_request.role or ChatRole.USER
|
||||
role = run_request.role or Role.USER
|
||||
response_format = run_request.response_format
|
||||
enable_tool_calls = run_request.enable_tool_calls
|
||||
|
||||
logger.debug(f"[AgentEntity.run_agent] Received message: {message}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Conversation ID: {conversation_id}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Thread ID: {thread_id}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Correlation ID: {correlation_id}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Role: {role.value if isinstance(role, ChatRole) else role}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Role: {role.value}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Enable tool calls: {enable_tool_calls}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Response format: {'provided' if response_format else 'none'}")
|
||||
|
||||
# Store message in history with role
|
||||
role_str = role.value if isinstance(role, ChatRole) else role
|
||||
self.state.add_user_message(message, role=role_str, correlation_id=correlation_id)
|
||||
self.state.add_user_message(message, role=role, correlation_id=correlation_id)
|
||||
|
||||
logger.debug("[AgentEntity.run_agent] Executing agent...")
|
||||
|
||||
@@ -123,7 +122,7 @@ class AgentEntity:
|
||||
agent_run_response: AgentRunResponse = await self._invoke_agent(
|
||||
run_kwargs=run_kwargs,
|
||||
correlation_id=correlation_id,
|
||||
conversation_id=conversation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=message,
|
||||
)
|
||||
|
||||
@@ -160,7 +159,7 @@ class AgentEntity:
|
||||
agent_response = AgentResponse(
|
||||
response=response_text,
|
||||
message=str(message),
|
||||
conversation_id=str(conversation_id),
|
||||
thread_id=str(thread_id),
|
||||
status="success",
|
||||
message_count=self.state.message_count,
|
||||
structured_response=structured_response,
|
||||
@@ -185,7 +184,7 @@ class AgentEntity:
|
||||
error_response = AgentResponse(
|
||||
response=f"Error: {exc!s}",
|
||||
message=str(message),
|
||||
conversation_id=str(conversation_id),
|
||||
thread_id=str(thread_id),
|
||||
status="error",
|
||||
message_count=self.state.message_count,
|
||||
error=str(exc),
|
||||
@@ -197,7 +196,7 @@ class AgentEntity:
|
||||
self,
|
||||
run_kwargs: dict[str, Any],
|
||||
correlation_id: str,
|
||||
conversation_id: str,
|
||||
thread_id: str,
|
||||
request_message: str,
|
||||
) -> AgentRunResponse:
|
||||
"""Execute the agent, preferring streaming when available."""
|
||||
@@ -205,7 +204,7 @@ class AgentEntity:
|
||||
if self.callback is not None:
|
||||
callback_context = self._build_callback_context(
|
||||
correlation_id=correlation_id,
|
||||
conversation_id=conversation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=request_message,
|
||||
)
|
||||
|
||||
@@ -319,7 +318,7 @@ class AgentEntity:
|
||||
def _build_callback_context(
|
||||
self,
|
||||
correlation_id: str,
|
||||
conversation_id: str,
|
||||
thread_id: str,
|
||||
request_message: str,
|
||||
) -> AgentCallbackContext:
|
||||
"""Create the callback context provided to consumers."""
|
||||
@@ -327,7 +326,7 @@ class AgentEntity:
|
||||
return AgentCallbackContext(
|
||||
agent_name=agent_name,
|
||||
correlation_id=correlation_id,
|
||||
conversation_id=conversation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=request_message,
|
||||
)
|
||||
|
||||
@@ -375,11 +374,8 @@ def create_agent_entity(
|
||||
if operation == "run_agent":
|
||||
input_data: Any = context.get_input()
|
||||
|
||||
# Support both old format (message + conversation_id) and new format (RunRequest dict)
|
||||
# This provides backward compatibility
|
||||
request: str | dict[str, Any]
|
||||
if isinstance(input_data, dict) and "message" in input_data:
|
||||
# Input can be either old format or new RunRequest format
|
||||
request = cast(dict[str, Any], input_data)
|
||||
else:
|
||||
# Fall back to treating input as message string
|
||||
|
||||
@@ -5,21 +5,23 @@
|
||||
This module defines the request and response models used by the framework.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import uuid
|
||||
from collections.abc import MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
from agent_framework import AgentThread
|
||||
from agent_framework import AgentThread, Role
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type checking imports only
|
||||
from pydantic import BaseModel
|
||||
|
||||
_PydanticBaseModel: type["BaseModel"] | None
|
||||
_PydanticBaseModel: type[BaseModel] | None
|
||||
|
||||
try:
|
||||
from pydantic import BaseModel as _RuntimeBaseModel
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
@@ -28,14 +30,6 @@ else:
|
||||
_PydanticBaseModel = _RuntimeBaseModel
|
||||
|
||||
|
||||
class ChatRole(str, Enum):
|
||||
"""Chat message role enum."""
|
||||
|
||||
USER = "user"
|
||||
SYSTEM = "system"
|
||||
ASSISTANT = "assistant"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSessionId:
|
||||
"""Represents an agent session ID, which is used to identify a long-running agent session.
|
||||
@@ -63,7 +57,7 @@ class AgentSessionId:
|
||||
return f"{AgentSessionId.ENTITY_NAME_PREFIX}{name}"
|
||||
|
||||
@staticmethod
|
||||
def with_random_key(name: str) -> "AgentSessionId":
|
||||
def with_random_key(name: str) -> AgentSessionId:
|
||||
"""Creates a new AgentSessionId with the specified name and a randomly generated key.
|
||||
|
||||
Args:
|
||||
@@ -83,7 +77,7 @@ class AgentSessionId:
|
||||
return df.EntityId(self.to_entity_name(self.name), self.key)
|
||||
|
||||
@staticmethod
|
||||
def from_entity_id(entity_id: df.EntityId) -> "AgentSessionId":
|
||||
def from_entity_id(entity_id: df.EntityId) -> AgentSessionId:
|
||||
"""Creates an AgentSessionId from a Durable Functions EntityId.
|
||||
|
||||
Args:
|
||||
@@ -113,7 +107,7 @@ class AgentSessionId:
|
||||
return f"AgentSessionId(name='{self.name}', key='{self.key}')"
|
||||
|
||||
@staticmethod
|
||||
def parse(session_id_string: str) -> "AgentSessionId":
|
||||
def parse(session_id_string: str) -> AgentSessionId:
|
||||
"""Parses a string representation of an agent session ID.
|
||||
|
||||
Args:
|
||||
@@ -172,7 +166,7 @@ class DurableAgentThread(AgentThread):
|
||||
service_thread_id: str | None = None,
|
||||
message_store: Any = None,
|
||||
context_provider: Any = None,
|
||||
) -> "DurableAgentThread":
|
||||
) -> DurableAgentThread:
|
||||
"""Creates a durable thread pre-associated with the supplied session ID."""
|
||||
return cls(
|
||||
session_id=session_id,
|
||||
@@ -195,7 +189,7 @@ class DurableAgentThread(AgentThread):
|
||||
*,
|
||||
message_store: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> "DurableAgentThread":
|
||||
) -> DurableAgentThread:
|
||||
"""Restores a durable thread, rehydrating the stored session identifier."""
|
||||
state_payload = dict(serialized_thread_state)
|
||||
session_id_value = state_payload.pop(cls._SERIALIZED_SESSION_ID_KEY, None)
|
||||
@@ -217,7 +211,7 @@ class DurableAgentThread(AgentThread):
|
||||
return thread
|
||||
|
||||
|
||||
def _serialize_response_format(response_format: type["BaseModel"] | None) -> Any:
|
||||
def _serialize_response_format(response_format: type[BaseModel] | None) -> Any:
|
||||
"""Serialize response format for transport across durable function boundaries."""
|
||||
if response_format is None:
|
||||
return None
|
||||
@@ -235,7 +229,7 @@ def _serialize_response_format(response_format: type["BaseModel"] | None) -> Any
|
||||
}
|
||||
|
||||
|
||||
def _deserialize_response_format(response_format: Any) -> type["BaseModel"] | None:
|
||||
def _deserialize_response_format(response_format: Any) -> type[BaseModel] | None:
|
||||
"""Deserialize response format back into actionable type if possible."""
|
||||
if response_format is None:
|
||||
return None
|
||||
@@ -287,17 +281,45 @@ class RunRequest:
|
||||
role: The role of the message sender (user, system, or assistant)
|
||||
response_format: Optional Pydantic BaseModel type describing the structured response format
|
||||
enable_tool_calls: Whether to enable tool calls for this request
|
||||
conversation_id: Optional conversation/session ID for tracking
|
||||
thread_id: Optional thread ID for tracking
|
||||
correlation_id: Optional correlation ID for tracking the response to this specific request
|
||||
"""
|
||||
|
||||
message: str
|
||||
role: ChatRole = ChatRole.USER
|
||||
response_format: type["BaseModel"] | None = None
|
||||
role: Role = Role.USER
|
||||
response_format: type[BaseModel] | None = None
|
||||
enable_tool_calls: bool = True
|
||||
conversation_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
role: Role | str | None = Role.USER,
|
||||
response_format: type[BaseModel] | None = None,
|
||||
enable_tool_calls: bool = True,
|
||||
thread_id: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
self.message = message
|
||||
self.role = self.coerce_role(role)
|
||||
self.response_format = response_format
|
||||
self.enable_tool_calls = enable_tool_calls
|
||||
self.thread_id = thread_id
|
||||
self.correlation_id = correlation_id
|
||||
|
||||
@staticmethod
|
||||
def coerce_role(value: Role | str | None) -> Role:
|
||||
"""Normalize various role representations into a Role instance."""
|
||||
if isinstance(value, Role):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return Role.USER
|
||||
return Role(value=normalized.lower())
|
||||
return Role.USER
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
result = {
|
||||
@@ -307,30 +329,21 @@ class RunRequest:
|
||||
}
|
||||
if self.response_format:
|
||||
result["response_format"] = _serialize_response_format(self.response_format)
|
||||
if self.conversation_id:
|
||||
result["conversation_id"] = self.conversation_id
|
||||
if self.thread_id:
|
||||
result["thread_id"] = self.thread_id
|
||||
if self.correlation_id:
|
||||
result["correlation_id"] = self.correlation_id
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "RunRequest":
|
||||
def from_dict(cls, data: dict[str, Any]) -> RunRequest:
|
||||
"""Create RunRequest from dictionary."""
|
||||
role_str = data.get("role")
|
||||
if role_str:
|
||||
try:
|
||||
role = ChatRole(role_str.lower())
|
||||
except ValueError:
|
||||
role = ChatRole.USER # Default to USER if invalid
|
||||
else:
|
||||
role = ChatRole.USER
|
||||
|
||||
return cls(
|
||||
message=data.get("message", ""),
|
||||
role=role,
|
||||
role=cls.coerce_role(data.get("role")),
|
||||
response_format=_deserialize_response_format(data.get("response_format")),
|
||||
enable_tool_calls=data.get("enable_tool_calls", True),
|
||||
conversation_id=data.get("conversation_id"),
|
||||
thread_id=data.get("thread_id"),
|
||||
correlation_id=data.get("correlation_id"),
|
||||
)
|
||||
|
||||
@@ -342,7 +355,7 @@ class AgentResponse:
|
||||
Attributes:
|
||||
response: The agent's text response (or None for structured responses)
|
||||
message: The original message sent to the agent
|
||||
conversation_id: The conversation/session ID
|
||||
thread_id: The thread identifier
|
||||
status: Status of the execution (success, error, etc.)
|
||||
message_count: Number of messages in the conversation
|
||||
error: Error message if status is error
|
||||
@@ -352,7 +365,7 @@ class AgentResponse:
|
||||
|
||||
response: str | None
|
||||
message: str
|
||||
conversation_id: str | None
|
||||
thread_id: str | None
|
||||
status: str
|
||||
message_count: int = 0
|
||||
error: str | None = None
|
||||
@@ -363,7 +376,7 @@ class AgentResponse:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
result = {
|
||||
"message": self.message,
|
||||
"conversation_id": self.conversation_id,
|
||||
"thread_id": self.thread_id,
|
||||
"status": self.status,
|
||||
"message_count": self.message_count,
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ class DurableAIAgent(AgentProtocol):
|
||||
message=message_str,
|
||||
enable_tool_calls=enable_tool_calls,
|
||||
correlation_id=correlation_id,
|
||||
conversation_id=session_id.key,
|
||||
thread_id=session_id.key,
|
||||
response_format=response_format,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ serializing agent framework responses.
|
||||
|
||||
from collections.abc import MutableMapping
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import AgentRunResponse, ChatMessage, get_logger
|
||||
from agent_framework import AgentRunResponse, ChatMessage, Role, get_logger
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.state")
|
||||
|
||||
@@ -38,7 +38,7 @@ class AgentState:
|
||||
def add_user_message(
|
||||
self,
|
||||
content: str,
|
||||
role: Literal["user", "system", "assistant", "tool"] = "user",
|
||||
role: Role = Role.USER,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
"""Add a user message to the conversation history as a ChatMessage object.
|
||||
|
||||
@@ -45,9 +45,9 @@ class TestSampleSingleAgent:
|
||||
"""Test sending a simple message with JSON payload."""
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run",
|
||||
{"message": "Tell me a short joke about cloud computing.", "sessionId": "test-simple-json"},
|
||||
{"message": "Tell me a short joke about cloud computing.", "thread_id": "test-simple-json"},
|
||||
)
|
||||
# Agent can return 200 (immediate) or 202 (async with wait_for_completion=false)
|
||||
# Agent can return 200 (immediate) or 202 (async with wait_for_response=false)
|
||||
assert response.status_code in [200, 202]
|
||||
data = response.json()
|
||||
|
||||
@@ -58,37 +58,35 @@ class TestSampleSingleAgent:
|
||||
assert data["message_count"] >= 1
|
||||
else:
|
||||
# Async response - check we got correlation info
|
||||
assert "correlationId" in data or "sessionId" in data
|
||||
assert "correlation_id" in data or "thread_id" in data
|
||||
|
||||
def test_simple_message_plain_text(self) -> None:
|
||||
"""Test sending a message with plain text payload."""
|
||||
response = SampleTestHelper.post_text(f"{self.base_url}/run", "Tell me a short joke about networking.")
|
||||
assert response.status_code in [200, 202]
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
assert data["status"] == "success"
|
||||
assert "response" in data
|
||||
# Agent responded with plain text when the request body was text/plain.
|
||||
assert response.text.strip()
|
||||
assert response.headers.get("x-ms-thread-id") is not None
|
||||
|
||||
def test_session_key_in_query(self) -> None:
|
||||
"""Test using sessionKey in query parameter."""
|
||||
def test_thread_id_in_query(self) -> None:
|
||||
"""Test using thread_id in query parameter."""
|
||||
response = SampleTestHelper.post_text(
|
||||
f"{self.base_url}/run?sessionKey=test-query-session", "Tell me a short joke about weather in Texas."
|
||||
f"{self.base_url}/run?thread_id=test-query-thread", "Tell me a short joke about weather in Texas."
|
||||
)
|
||||
assert response.status_code in [200, 202]
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
assert data["status"] == "success"
|
||||
assert response.text.strip()
|
||||
assert response.headers.get("x-ms-thread-id") == "test-query-thread"
|
||||
|
||||
def test_conversation_continuity(self) -> None:
|
||||
"""Test conversation context is maintained across requests."""
|
||||
session_id = "test-continuity"
|
||||
thread_id = "test-continuity"
|
||||
|
||||
# First message
|
||||
response1 = SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run",
|
||||
{"message": "Tell me a short joke about weather in Seattle.", "sessionId": session_id},
|
||||
{"message": "Tell me a short joke about weather in Seattle.", "thread_id": thread_id},
|
||||
)
|
||||
assert response1.status_code in [200, 202]
|
||||
|
||||
@@ -98,7 +96,7 @@ class TestSampleSingleAgent:
|
||||
|
||||
# Second message in same session
|
||||
response2 = SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run", {"message": "What about San Francisco?", "sessionId": session_id}
|
||||
f"{self.base_url}/run", {"message": "What about San Francisco?", "thread_id": thread_id}
|
||||
)
|
||||
assert response2.status_code == 200
|
||||
data2 = response2.json()
|
||||
@@ -107,7 +105,7 @@ class TestSampleSingleAgent:
|
||||
# In async mode, we can't easily test message count
|
||||
# Just verify we can make multiple calls
|
||||
response2 = SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run", {"message": "What about Texas?", "sessionId": session_id}
|
||||
f"{self.base_url}/run", {"message": "What about Texas?", "thread_id": thread_id}
|
||||
)
|
||||
assert response2.status_code == 202
|
||||
|
||||
|
||||
@@ -38,21 +38,26 @@ class TestSampleMultiAgent:
|
||||
def test_weather_agent(self) -> None:
|
||||
"""Test WeatherAgent endpoint."""
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.weather_base_url}/run", {"message": "What is the weather in Seattle?"}
|
||||
f"{self.weather_base_url}/run",
|
||||
{"message": "What is the weather in Seattle?"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "accepted"
|
||||
assert data["status"] == "success"
|
||||
assert "response" in data
|
||||
|
||||
def test_math_agent(self) -> None:
|
||||
"""Test MathAgent endpoint."""
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.math_base_url}/run", {"message": "Calculate a 20% tip on a $50 bill"}
|
||||
f"{self.math_base_url}/run",
|
||||
{"message": "Calculate a 20% tip on a $50 bill", "wait_for_response": False},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "accepted"
|
||||
assert "response" in data
|
||||
assert "correlation_id" in data
|
||||
assert "thread_id" in data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -14,6 +14,8 @@ Usage:
|
||||
uv run pytest packages/azurefunctions/tests/integration_tests/test_03_callbacks.py -v
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
@@ -39,39 +41,60 @@ class TestSampleCallbacks:
|
||||
"""Provide the callback agent base URL for each test."""
|
||||
self.base_url = f"{base_url}/api/agents/CallbackAgent"
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_callback_events(base_url: str, thread_id: str) -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
response = SampleTestHelper.get(f"{base_url}/callbacks/{thread_id}")
|
||||
if response.status_code == 200:
|
||||
events = response.json()
|
||||
return events
|
||||
|
||||
def test_agent_with_callbacks(self) -> None:
|
||||
"""Test agent execution with callback tracking."""
|
||||
conversation_id = "test-callback"
|
||||
thread_id = "test-callback"
|
||||
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run", {"message": "Tell me about Python", "conversationId": conversation_id}
|
||||
f"{self.base_url}/run",
|
||||
{"message": "Tell me about Python", "thread_id": thread_id},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "accepted"
|
||||
|
||||
assert data["status"] == "success"
|
||||
|
||||
events = self._wait_for_callback_events(self.base_url, thread_id)
|
||||
|
||||
assert events
|
||||
assert any(event.get("event_type") == "final" for event in events)
|
||||
|
||||
def test_get_callbacks(self) -> None:
|
||||
"""Test retrieving callback events."""
|
||||
conversation_id = "test-callback-retrieve"
|
||||
thread_id = "test-callback-retrieve"
|
||||
|
||||
# Send a message first
|
||||
SampleTestHelper.post_json(f"{self.base_url}/run", {"message": "Hello", "conversationId": conversation_id})
|
||||
SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run",
|
||||
{"message": "Hello", "thread_id": thread_id, "wait_for_response": False},
|
||||
)
|
||||
|
||||
# Get callbacks
|
||||
response = SampleTestHelper.get(f"{self.base_url}/callbacks/{conversation_id}")
|
||||
response = SampleTestHelper.get(f"{self.base_url}/callbacks/{thread_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_delete_callbacks(self) -> None:
|
||||
"""Test clearing callback events."""
|
||||
conversation_id = "test-callback-delete"
|
||||
thread_id = "test-callback-delete"
|
||||
|
||||
# Send a message first
|
||||
SampleTestHelper.post_json(f"{self.base_url}/run", {"message": "Test", "conversationId": conversation_id})
|
||||
SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run",
|
||||
{"message": "Test", "thread_id": thread_id, "wait_for_response": False},
|
||||
)
|
||||
|
||||
# Delete callbacks
|
||||
response = requests.delete(f"{self.base_url}/callbacks/{conversation_id}", timeout=TIMEOUT)
|
||||
response = requests.delete(f"{self.base_url}/callbacks/{thread_id}", timeout=TIMEOUT)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import pytest
|
||||
from agent_framework import AgentRunResponse, ChatMessage
|
||||
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from agent_framework_azurefunctions._app import WAIT_FOR_RESPONSE_FIELD, WAIT_FOR_RESPONSE_HEADER
|
||||
from agent_framework_azurefunctions._entities import AgentEntity, AgentState, create_agent_entity
|
||||
from agent_framework_azurefunctions._errors import IncomingRequestError
|
||||
|
||||
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
|
||||
|
||||
@@ -150,6 +150,38 @@ class TestAgentFunctionAppSetup:
|
||||
# Verify agent is registered
|
||||
assert "TestAgent" in app.agents
|
||||
|
||||
def test_http_function_name_uses_prefix_format(self) -> None:
|
||||
"""Ensure function names follow the prefix-agent naming convention."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "Agent 42"
|
||||
|
||||
captured_names: list[str] = []
|
||||
|
||||
def capture_function_name(
|
||||
self: AgentFunctionApp, name: str, *args: Any, **kwargs: Any
|
||||
) -> Callable[[TFunc], TFunc]:
|
||||
def decorator(func: TFunc) -> TFunc:
|
||||
captured_names.append(name)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
|
||||
def decorator(func: TFunc) -> TFunc:
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
with (
|
||||
patch.object(AgentFunctionApp, "function_name", new=capture_function_name),
|
||||
patch.object(AgentFunctionApp, "route", new=passthrough_decorator),
|
||||
patch.object(AgentFunctionApp, "durable_client_input", new=passthrough_decorator),
|
||||
patch.object(AgentFunctionApp, "entity_trigger", new=passthrough_decorator),
|
||||
):
|
||||
AgentFunctionApp(agents=[mock_agent])
|
||||
|
||||
assert captured_names == ["http-Agent_42"]
|
||||
|
||||
def test_setup_skips_http_trigger_when_disabled(self) -> None:
|
||||
"""Test that HTTP trigger is not created when disabled."""
|
||||
mock_agent = Mock()
|
||||
@@ -236,8 +268,8 @@ class TestAgentFunctionAppSetup:
|
||||
assert "Agent2" in app2.agents
|
||||
|
||||
|
||||
class TestWaitForCompletionAndCorrelationId:
|
||||
"""Tests for wait_for_completion flag and correlation ID handling."""
|
||||
class TestWaitForResponseAndCorrelationId:
|
||||
"""Tests for wait_for_response flag and correlation ID handling."""
|
||||
|
||||
def _create_app(self) -> AgentFunctionApp:
|
||||
mock_agent = Mock()
|
||||
@@ -255,21 +287,35 @@ class TestWaitForCompletionAndCorrelationId:
|
||||
request.params = params or {}
|
||||
return request
|
||||
|
||||
def test_wait_for_completion_header_true(self) -> None:
|
||||
"""Test that the wait-for-completion header is honored."""
|
||||
def test_wait_for_response_header_true(self) -> None:
|
||||
"""Test that the wait-for-response header is honored."""
|
||||
app = self._create_app()
|
||||
request = self._make_request(headers={"X-Wait-For-Completion": "true"})
|
||||
request = self._make_request(headers={WAIT_FOR_RESPONSE_HEADER: "true"})
|
||||
|
||||
assert app._should_wait_for_completion(request, {}) is True
|
||||
assert app._should_wait_for_response(request, {}) is True
|
||||
|
||||
def test_wait_for_completion_body_variants(self) -> None:
|
||||
"""Test that multiple payload spellings are accepted."""
|
||||
def test_wait_for_response_body_snake_case(self) -> None:
|
||||
"""Test that payload controls wait_for_response."""
|
||||
app = self._create_app()
|
||||
request = self._make_request()
|
||||
|
||||
assert app._should_wait_for_completion(request, {"wait_for_completion": "true"}) is True
|
||||
assert app._should_wait_for_completion(request, {"waitForCompletion": "1"}) is True
|
||||
assert app._should_wait_for_completion(request, {"WaitForCompletion": "no"}) is False
|
||||
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "true"}) is True
|
||||
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "false"}) is False
|
||||
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "0"}) is False
|
||||
|
||||
def test_wait_for_response_query_parameter(self) -> None:
|
||||
"""Test that query parameter controls wait_for_response."""
|
||||
app = self._create_app()
|
||||
request = self._make_request(params={WAIT_FOR_RESPONSE_FIELD: "true"})
|
||||
|
||||
assert app._should_wait_for_response(request, {}) is True
|
||||
|
||||
def test_wait_for_response_query_precedence(self) -> None:
|
||||
"""Test that query parameter overrides body value."""
|
||||
app = self._create_app()
|
||||
request = self._make_request(params={WAIT_FOR_RESPONSE_FIELD: "false"})
|
||||
|
||||
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "true"}) is False
|
||||
|
||||
|
||||
class TestAgentEntityOperations:
|
||||
@@ -287,13 +333,13 @@ class TestAgentEntityOperations:
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Test message", "conversation_id": "test-conv-123", "correlation_id": "corr-app-entity-1"},
|
||||
{"message": "Test message", "thread_id": "test-conv-123", "correlation_id": "corr-app-entity-1"},
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["response"] == "Test response"
|
||||
assert result["message"] == "Test message"
|
||||
assert result["conversation_id"] == "test-conv-123"
|
||||
assert result["thread_id"] == "test-conv-123"
|
||||
assert entity.state.message_count == 1
|
||||
|
||||
async def test_entity_stores_conversation_history(self) -> None:
|
||||
@@ -308,7 +354,7 @@ class TestAgentEntityOperations:
|
||||
|
||||
# Send first message
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "conversation_id": "conv-1", "correlation_id": "corr-app-entity-2"}
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-app-entity-2"}
|
||||
)
|
||||
|
||||
history = entity.state.conversation_history
|
||||
@@ -337,12 +383,12 @@ class TestAgentEntityOperations:
|
||||
assert entity.state.message_count == 0
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "conversation_id": "conv-1", "correlation_id": "corr-app-entity-3a"}
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-app-entity-3a"}
|
||||
)
|
||||
assert entity.state.message_count == 1
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 2", "conversation_id": "conv-1", "correlation_id": "corr-app-entity-3b"}
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-app-entity-3b"}
|
||||
)
|
||||
assert entity.state.message_count == 2
|
||||
|
||||
@@ -391,7 +437,7 @@ class TestAgentEntityFactory:
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"conversation_id": "conv-123",
|
||||
"thread_id": "conv-123",
|
||||
"correlation_id": "corr-app-factory-1",
|
||||
}
|
||||
mock_context.get_state.return_value = None
|
||||
@@ -478,7 +524,7 @@ class TestErrorHandling:
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Test message", "conversation_id": "conv-1", "correlation_id": "corr-app-error-1"}
|
||||
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlation_id": "corr-app-error-1"}
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
@@ -521,48 +567,67 @@ class TestIncomingRequestParsing:
|
||||
app = self._create_app()
|
||||
|
||||
request = Mock()
|
||||
request.headers = {}
|
||||
request.params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
request.get_body.return_value = b"Plain text message"
|
||||
|
||||
req_body, message = app._parse_incoming_request(request)
|
||||
req_body, message, response_format = app._parse_incoming_request(request)
|
||||
|
||||
assert req_body == {}
|
||||
assert message == "Plain text message"
|
||||
|
||||
def test_parse_plain_text_requires_content(self) -> None:
|
||||
"""Test that plain-text requests require message content."""
|
||||
assert response_format == "text"
|
||||
|
||||
def test_parse_plain_text_trims_whitespace(self) -> None:
|
||||
"""Plain-text parser returns an empty string when the body contains only whitespace."""
|
||||
app = self._create_app()
|
||||
|
||||
request = Mock()
|
||||
request.headers = {}
|
||||
request.params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
request.get_body.return_value = b" "
|
||||
|
||||
with pytest.raises(IncomingRequestError) as exc_info:
|
||||
app._parse_incoming_request(request)
|
||||
req_body, message, response_format = app._parse_incoming_request(request)
|
||||
|
||||
assert "Message is required" in str(exc_info.value)
|
||||
assert req_body == {}
|
||||
assert message == ""
|
||||
assert response_format == "text"
|
||||
|
||||
def test_extract_session_key_from_query_params(self) -> None:
|
||||
"""Test session key extraction from query parameters."""
|
||||
def test_accept_header_prefers_json(self) -> None:
|
||||
"""Test that the Accept header can force JSON responses for plain-text bodies."""
|
||||
app = self._create_app()
|
||||
|
||||
request = Mock()
|
||||
request.params = {"sessionId": "query-session"}
|
||||
request.headers = {"accept": "application/json"}
|
||||
request.params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
request.get_body.return_value = b"Plain text message"
|
||||
|
||||
_, message, response_format = app._parse_incoming_request(request)
|
||||
|
||||
assert message == "Plain text message"
|
||||
assert response_format == "json"
|
||||
|
||||
def test_extract_thread_id_from_query_params(self) -> None:
|
||||
"""Test thread identifier extraction from query parameters."""
|
||||
app = self._create_app()
|
||||
|
||||
request = Mock()
|
||||
request.params = {"thread_id": "query-thread"}
|
||||
req_body = {}
|
||||
|
||||
session_key = app._resolve_session_key(request, req_body)
|
||||
thread_id = app._resolve_thread_id(request, req_body)
|
||||
|
||||
assert session_key == "query-session"
|
||||
assert thread_id == "query-thread"
|
||||
|
||||
|
||||
class TestHttpRunRoute:
|
||||
"""Tests for the HTTP run route behavior."""
|
||||
|
||||
async def test_http_run_accepts_plain_text(self) -> None:
|
||||
"""Test that the HTTP handler accepts plain-text requests."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "HttpAgent"
|
||||
|
||||
@staticmethod
|
||||
def _get_run_handler(agent: Mock) -> Callable[[func.HttpRequest, Any], Awaitable[func.HttpResponse]]:
|
||||
captured_handlers: dict[str | None, Callable[..., Awaitable[func.HttpResponse]]] = {}
|
||||
|
||||
def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
|
||||
@@ -585,13 +650,20 @@ class TestHttpRunRoute:
|
||||
patch.object(AgentFunctionApp, "durable_client_input", new=capture_decorator),
|
||||
patch.object(AgentFunctionApp, "entity_trigger", new=capture_decorator),
|
||||
):
|
||||
AgentFunctionApp(agents=[mock_agent], enable_health_check=False)
|
||||
AgentFunctionApp(agents=[agent], enable_health_check=False)
|
||||
|
||||
run_route = f"agents/{mock_agent.name}/run"
|
||||
handler = captured_handlers[run_route]
|
||||
run_route = f"agents/{agent.name}/run"
|
||||
return captured_handlers[run_route]
|
||||
|
||||
async def test_http_run_accepts_plain_text(self) -> None:
|
||||
"""Test that the HTTP handler accepts plain-text requests."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "HttpAgent"
|
||||
|
||||
handler = self._get_run_handler(mock_agent)
|
||||
|
||||
request = Mock()
|
||||
request.headers = {}
|
||||
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false"}
|
||||
request.params = {}
|
||||
request.route_params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
@@ -602,12 +674,64 @@ class TestHttpRunRoute:
|
||||
response = await handler(request, client)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.mimetype == "text/plain"
|
||||
assert response.headers.get("x-ms-thread-id") is not None
|
||||
assert response.get_body().decode("utf-8") == "Agent request accepted"
|
||||
|
||||
signal_args = client.signal_entity.call_args[0]
|
||||
run_request = signal_args[2]
|
||||
|
||||
assert run_request["message"] == "Plain text via HTTP"
|
||||
assert run_request["role"] == "user"
|
||||
assert "thread_id" in run_request
|
||||
|
||||
async def test_http_run_accept_header_returns_json(self) -> None:
|
||||
"""Test that Accept header requesting JSON results in JSON response."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "HttpAgentJson"
|
||||
|
||||
handler = self._get_run_handler(mock_agent)
|
||||
|
||||
request = Mock()
|
||||
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false", "Accept": "application/json"}
|
||||
request.params = {}
|
||||
request.route_params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
request.get_body.return_value = b"Plain text via HTTP"
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
response = await handler(request, client)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.mimetype == "application/json"
|
||||
assert response.headers.get("x-ms-thread-id") is None
|
||||
body = response.get_body().decode("utf-8")
|
||||
assert '"status": "accepted"' in body
|
||||
|
||||
async def test_http_run_rejects_empty_message(self) -> None:
|
||||
"""Test that the HTTP handler rejects empty messages with a 400 response."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "HttpAgentEmpty"
|
||||
|
||||
handler = self._get_run_handler(mock_agent)
|
||||
|
||||
request = Mock()
|
||||
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false"}
|
||||
request.params = {}
|
||||
request.route_params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
request.get_body.return_value = b" "
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
response = await handler(request, client)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.mimetype == "text/plain"
|
||||
assert response.headers.get("x-ms-thread-id") is not None
|
||||
assert response.get_body().decode("utf-8") == "Message is required"
|
||||
client.signal_entity.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -12,11 +12,11 @@ from typing import Any, TypeVar
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage
|
||||
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, Role
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
|
||||
from agent_framework_azurefunctions._models import ChatRole, RunRequest
|
||||
from agent_framework_azurefunctions._models import RunRequest
|
||||
from agent_framework_azurefunctions._state import AgentState
|
||||
|
||||
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
|
||||
@@ -112,7 +112,7 @@ class TestAgentEntityRunAgent:
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Test message", "conversation_id": "conv-123", "correlation_id": "corr-entity-1"}
|
||||
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlation_id": "corr-entity-1"}
|
||||
)
|
||||
|
||||
# Verify agent.run was called
|
||||
@@ -130,7 +130,7 @@ class TestAgentEntityRunAgent:
|
||||
assert result["status"] == "success"
|
||||
assert result["response"] == "Test response"
|
||||
assert result["message"] == "Test message"
|
||||
assert result["conversation_id"] == "conv-123"
|
||||
assert result["thread_id"] == "conv-123"
|
||||
|
||||
async def test_run_agent_streaming_callbacks_invoked(self) -> None:
|
||||
"""Ensure streaming updates trigger callbacks and run() is not used."""
|
||||
@@ -157,7 +157,7 @@ class TestAgentEntityRunAgent:
|
||||
mock_context,
|
||||
{
|
||||
"message": "Tell me something",
|
||||
"conversation_id": "session-1",
|
||||
"thread_id": "session-1",
|
||||
"correlation_id": "corr-stream-1",
|
||||
},
|
||||
)
|
||||
@@ -175,7 +175,7 @@ class TestAgentEntityRunAgent:
|
||||
context = recorded_call.args[1]
|
||||
assert context.agent_name == "StreamingAgent"
|
||||
assert context.correlation_id == "corr-stream-1"
|
||||
assert context.conversation_id == "session-1"
|
||||
assert context.thread_id == "session-1"
|
||||
assert context.request_message == "Tell me something"
|
||||
|
||||
final_call = callback.response_mock.await_args
|
||||
@@ -183,7 +183,7 @@ class TestAgentEntityRunAgent:
|
||||
final_response, final_context = final_call.args
|
||||
assert final_context.agent_name == "StreamingAgent"
|
||||
assert final_context.correlation_id == "corr-stream-1"
|
||||
assert final_context.conversation_id == "session-1"
|
||||
assert final_context.thread_id == "session-1"
|
||||
assert final_context.request_message == "Tell me something"
|
||||
assert getattr(final_response, "text", "").strip()
|
||||
|
||||
@@ -204,7 +204,7 @@ class TestAgentEntityRunAgent:
|
||||
mock_context,
|
||||
{
|
||||
"message": "Hi",
|
||||
"conversation_id": "session-2",
|
||||
"thread_id": "session-2",
|
||||
"correlation_id": "corr-final-1",
|
||||
},
|
||||
)
|
||||
@@ -220,7 +220,7 @@ class TestAgentEntityRunAgent:
|
||||
final_context = final_call.args[1]
|
||||
assert final_context.agent_name == "NonStreamingAgent"
|
||||
assert final_context.correlation_id == "corr-final-1"
|
||||
assert final_context.conversation_id == "session-2"
|
||||
assert final_context.thread_id == "session-2"
|
||||
assert final_context.request_message == "Hi"
|
||||
|
||||
async def test_run_agent_updates_conversation_history(self) -> None:
|
||||
@@ -233,7 +233,7 @@ class TestAgentEntityRunAgent:
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "User message", "conversation_id": "conv-1", "correlation_id": "corr-entity-2"}
|
||||
mock_context, {"message": "User message", "thread_id": "conv-1", "correlation_id": "corr-entity-2"}
|
||||
)
|
||||
|
||||
# Should have 2 entries: user message + assistant response
|
||||
@@ -260,17 +260,17 @@ class TestAgentEntityRunAgent:
|
||||
assert entity.state.message_count == 0
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "conversation_id": "conv-1", "correlation_id": "corr-entity-3a"}
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-3a"}
|
||||
)
|
||||
assert entity.state.message_count == 1
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 2", "conversation_id": "conv-1", "correlation_id": "corr-entity-3b"}
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-3b"}
|
||||
)
|
||||
assert entity.state.message_count == 2
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 3", "conversation_id": "conv-1", "correlation_id": "corr-entity-3c"}
|
||||
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-3c"}
|
||||
)
|
||||
assert entity.state.message_count == 3
|
||||
|
||||
@@ -283,27 +283,27 @@ class TestAgentEntityRunAgent:
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "conversation_id": "conv-1", "correlation_id": "corr-entity-4a"}
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-4a"}
|
||||
)
|
||||
assert entity.state.last_response == "Response 1"
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 2", "conversation_id": "conv-1", "correlation_id": "corr-entity-4b"}
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-4b"}
|
||||
)
|
||||
assert entity.state.last_response == "Response 2"
|
||||
|
||||
async def test_run_agent_with_none_conversation_id(self) -> None:
|
||||
"""Test run_agent with a None conversation identifier."""
|
||||
async def test_run_agent_with_none_thread_id(self) -> None:
|
||||
"""Test run_agent with a None thread identifier."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
with pytest.raises(ValueError, match="conversation_id"):
|
||||
with pytest.raises(ValueError, match="thread_id"):
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message", "conversation_id": None, "correlation_id": "corr-entity-5"}
|
||||
mock_context, {"message": "Message", "thread_id": None, "correlation_id": "corr-entity-5"}
|
||||
)
|
||||
|
||||
async def test_run_agent_handles_response_without_text_attribute(self) -> None:
|
||||
@@ -322,7 +322,7 @@ class TestAgentEntityRunAgent:
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Message", "conversation_id": "conv-1", "correlation_id": "corr-entity-6"}
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-6"}
|
||||
)
|
||||
|
||||
# Should handle gracefully
|
||||
@@ -338,7 +338,7 @@ class TestAgentEntityRunAgent:
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Message", "conversation_id": "conv-1", "correlation_id": "corr-entity-7"}
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-7"}
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
@@ -354,13 +354,13 @@ class TestAgentEntityRunAgent:
|
||||
|
||||
# Send multiple messages
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "conversation_id": "conv-1", "correlation_id": "corr-entity-8a"}
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-8a"}
|
||||
)
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 2", "conversation_id": "conv-1", "correlation_id": "corr-entity-8b"}
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-8b"}
|
||||
)
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 3", "conversation_id": "conv-1", "correlation_id": "corr-entity-8c"}
|
||||
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-8c"}
|
||||
)
|
||||
|
||||
history = entity.state.conversation_history
|
||||
@@ -421,10 +421,10 @@ class TestAgentEntityReset:
|
||||
|
||||
# Have a conversation
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "conversation_id": "conv-1", "correlation_id": "corr-entity-10a"}
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-10a"}
|
||||
)
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 2", "conversation_id": "conv-1", "correlation_id": "corr-entity-10b"}
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-10b"}
|
||||
)
|
||||
|
||||
# Verify state before reset
|
||||
@@ -463,7 +463,7 @@ class TestCreateAgentEntity:
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"conversation_id": "conv-123",
|
||||
"thread_id": "conv-123",
|
||||
"correlation_id": "corr-entity-factory",
|
||||
}
|
||||
mock_context.get_state.return_value = None
|
||||
@@ -591,7 +591,7 @@ class TestErrorHandling:
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Message", "conversation_id": "conv-1", "correlation_id": "corr-entity-error-1"}
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-1"}
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
@@ -608,7 +608,7 @@ class TestErrorHandling:
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Message", "conversation_id": "conv-1", "correlation_id": "corr-entity-error-2"}
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-2"}
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
@@ -624,7 +624,7 @@ class TestErrorHandling:
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Message", "conversation_id": "conv-1", "correlation_id": "corr-entity-error-3"}
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-3"}
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
@@ -659,12 +659,12 @@ class TestErrorHandling:
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Test message", "conversation_id": "conv-123", "correlation_id": "corr-entity-error-4"},
|
||||
{"message": "Test message", "thread_id": "conv-123", "correlation_id": "corr-entity-error-4"},
|
||||
)
|
||||
|
||||
# Even on error, message info should be preserved
|
||||
assert result["message"] == "Test message"
|
||||
assert result["conversation_id"] == "conv-123"
|
||||
assert result["thread_id"] == "conv-123"
|
||||
assert result["status"] == "error"
|
||||
|
||||
|
||||
@@ -680,7 +680,7 @@ class TestConversationHistory:
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message", "conversation_id": "conv-1", "correlation_id": "corr-entity-history-1"}
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-history-1"}
|
||||
)
|
||||
|
||||
# Check both user and assistant messages have timestamps
|
||||
@@ -701,19 +701,19 @@ class TestConversationHistory:
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
|
||||
await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Message 1", "conversation_id": "conv-1", "correlation_id": "corr-entity-history-2a"},
|
||||
{"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2a"},
|
||||
)
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
|
||||
await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Message 2", "conversation_id": "conv-1", "correlation_id": "corr-entity-history-2b"},
|
||||
{"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2b"},
|
||||
)
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 3"))
|
||||
await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Message 3", "conversation_id": "conv-1", "correlation_id": "corr-entity-history-2c"},
|
||||
{"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2c"},
|
||||
)
|
||||
|
||||
# Verify order
|
||||
@@ -735,11 +735,11 @@ class TestConversationHistory:
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Message 1", "conversation_id": "conv-1", "correlation_id": "corr-entity-history-3a"},
|
||||
{"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-history-3a"},
|
||||
)
|
||||
await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Message 2", "conversation_id": "conv-1", "correlation_id": "corr-entity-history-3b"},
|
||||
{"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-history-3b"},
|
||||
)
|
||||
|
||||
# Check role alternation
|
||||
@@ -763,8 +763,8 @@ class TestRunRequestSupport:
|
||||
|
||||
request = RunRequest(
|
||||
message="Test message",
|
||||
conversation_id="conv-123",
|
||||
role=ChatRole.USER,
|
||||
thread_id="conv-123",
|
||||
role=Role.USER,
|
||||
enable_tool_calls=True,
|
||||
correlation_id="corr-runreq-1",
|
||||
)
|
||||
@@ -774,7 +774,7 @@ class TestRunRequestSupport:
|
||||
assert result["status"] == "success"
|
||||
assert result["response"] == "Response"
|
||||
assert result["message"] == "Test message"
|
||||
assert result["conversation_id"] == "conv-123"
|
||||
assert result["thread_id"] == "conv-123"
|
||||
|
||||
async def test_run_agent_with_dict_request(self) -> None:
|
||||
"""Test run_agent with a dictionary request."""
|
||||
@@ -786,7 +786,7 @@ class TestRunRequestSupport:
|
||||
|
||||
request_dict = {
|
||||
"message": "Test message",
|
||||
"conversation_id": "conv-456",
|
||||
"thread_id": "conv-456",
|
||||
"role": "system",
|
||||
"enable_tool_calls": False,
|
||||
"correlation_id": "corr-runreq-2",
|
||||
@@ -796,7 +796,7 @@ class TestRunRequestSupport:
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["message"] == "Test message"
|
||||
assert result["conversation_id"] == "conv-456"
|
||||
assert result["thread_id"] == "conv-456"
|
||||
|
||||
async def test_run_agent_with_string_raises_without_correlation(self) -> None:
|
||||
"""Test that run_agent rejects legacy string input without correlation ID."""
|
||||
@@ -820,8 +820,8 @@ class TestRunRequestSupport:
|
||||
# Send as system role
|
||||
request = RunRequest(
|
||||
message="System message",
|
||||
conversation_id="conv-runreq-3",
|
||||
role=ChatRole.SYSTEM,
|
||||
thread_id="conv-runreq-3",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-runreq-3",
|
||||
)
|
||||
|
||||
@@ -843,7 +843,7 @@ class TestRunRequestSupport:
|
||||
|
||||
request = RunRequest(
|
||||
message="What is the answer?",
|
||||
conversation_id="conv-runreq-4",
|
||||
thread_id="conv-runreq-4",
|
||||
response_format=EntityStructuredResponse,
|
||||
correlation_id="corr-runreq-4",
|
||||
)
|
||||
@@ -864,7 +864,7 @@ class TestRunRequestSupport:
|
||||
mock_context = Mock()
|
||||
|
||||
request = RunRequest(
|
||||
message="Test", conversation_id="conv-runreq-5", enable_tool_calls=False, correlation_id="corr-runreq-5"
|
||||
message="Test", thread_id="conv-runreq-5", enable_tool_calls=False, correlation_id="corr-runreq-5"
|
||||
)
|
||||
|
||||
result = await entity.run_agent(mock_context, request)
|
||||
@@ -884,7 +884,7 @@ class TestRunRequestSupport:
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"conversation_id": "conv-789",
|
||||
"thread_id": "conv-789",
|
||||
"role": "user",
|
||||
"enable_tool_calls": True,
|
||||
"correlation_id": "corr-runreq-6",
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for data models (AgentSessionId, RunRequest, AgentResponse, ChatRole)."""
|
||||
"""Unit tests for data models (AgentSessionId, RunRequest, AgentResponse)."""
|
||||
|
||||
import azure.durable_functions as df
|
||||
import pytest
|
||||
from agent_framework import Role
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_azurefunctions._models import AgentResponse, AgentSessionId, ChatRole, RunRequest
|
||||
from agent_framework_azurefunctions._models import AgentResponse, AgentSessionId, RunRequest
|
||||
|
||||
|
||||
class ModuleStructuredResponse(BaseModel):
|
||||
value: int
|
||||
|
||||
|
||||
class TestChatRole:
|
||||
"""Test suite for ChatRole enum."""
|
||||
|
||||
def test_chat_role_values(self) -> None:
|
||||
"""Test that ChatRole has correct values."""
|
||||
assert ChatRole.USER == "user"
|
||||
assert ChatRole.SYSTEM == "system"
|
||||
assert ChatRole.ASSISTANT == "assistant"
|
||||
|
||||
def test_chat_role_is_string(self) -> None:
|
||||
"""Test that ChatRole values are strings."""
|
||||
assert isinstance(ChatRole.USER.value, str)
|
||||
assert isinstance(ChatRole.SYSTEM.value, str)
|
||||
assert isinstance(ChatRole.ASSISTANT.value, str)
|
||||
|
||||
|
||||
class TestAgentSessionId:
|
||||
"""Test suite for AgentSessionId."""
|
||||
|
||||
@@ -164,49 +149,55 @@ class TestRunRequest:
|
||||
|
||||
def test_init_with_defaults(self) -> None:
|
||||
"""Test RunRequest initialization with defaults."""
|
||||
request = RunRequest(message="Hello", conversation_id="conv-default")
|
||||
request = RunRequest(message="Hello", thread_id="thread-default")
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == ChatRole.USER
|
||||
assert request.role == Role.USER
|
||||
assert request.response_format is None
|
||||
assert request.enable_tool_calls is True
|
||||
assert request.conversation_id == "conv-default"
|
||||
assert request.thread_id == "thread-default"
|
||||
|
||||
def test_init_with_all_fields(self) -> None:
|
||||
"""Test RunRequest initialization with all fields."""
|
||||
schema = ModuleStructuredResponse
|
||||
request = RunRequest(
|
||||
message="Hello",
|
||||
conversation_id="conv-123",
|
||||
role=ChatRole.SYSTEM,
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
response_format=schema,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == ChatRole.SYSTEM
|
||||
assert request.role == Role.SYSTEM
|
||||
assert request.response_format is schema
|
||||
assert request.enable_tool_calls is False
|
||||
assert request.conversation_id == "conv-123"
|
||||
assert request.thread_id == "thread-123"
|
||||
|
||||
def test_init_coerces_string_role(self) -> None:
|
||||
"""Ensure string role values are coerced into Role instances."""
|
||||
request = RunRequest(message="Hello", thread_id="thread-str-role", role="system") # type: ignore[arg-type]
|
||||
|
||||
assert request.role == Role.SYSTEM
|
||||
|
||||
def test_to_dict_with_defaults(self) -> None:
|
||||
"""Test to_dict with default values."""
|
||||
request = RunRequest(message="Test message", conversation_id="conv-to-dict")
|
||||
request = RunRequest(message="Test message", thread_id="thread-to-dict")
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test message"
|
||||
assert data["enable_tool_calls"] is True
|
||||
assert data["role"] == "user"
|
||||
assert "response_format" not in data or data["response_format"] is None
|
||||
assert data["conversation_id"] == "conv-to-dict"
|
||||
assert data["thread_id"] == "thread-to-dict"
|
||||
|
||||
def test_to_dict_with_all_fields(self) -> None:
|
||||
"""Test to_dict with all fields."""
|
||||
schema = ModuleStructuredResponse
|
||||
request = RunRequest(
|
||||
message="Hello",
|
||||
conversation_id="conv-456",
|
||||
role=ChatRole.ASSISTANT,
|
||||
thread_id="thread-456",
|
||||
role=Role.ASSISTANT,
|
||||
response_format=schema,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
@@ -218,17 +209,17 @@ class TestRunRequest:
|
||||
assert data["response_format"]["module"] == schema.__module__
|
||||
assert data["response_format"]["qualname"] == schema.__qualname__
|
||||
assert data["enable_tool_calls"] is False
|
||||
assert data["conversation_id"] == "conv-456"
|
||||
assert data["thread_id"] == "thread-456"
|
||||
|
||||
def test_from_dict_with_defaults(self) -> None:
|
||||
"""Test from_dict with minimal data."""
|
||||
data = {"message": "Hello", "conversation_id": "conv-from-dict"}
|
||||
data = {"message": "Hello", "thread_id": "thread-from-dict"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == ChatRole.USER
|
||||
assert request.role == Role.USER
|
||||
assert request.enable_tool_calls is True
|
||||
assert request.conversation_id == "conv-from-dict"
|
||||
assert request.thread_id == "thread-from-dict"
|
||||
|
||||
def test_from_dict_with_all_fields(self) -> None:
|
||||
"""Test from_dict with all fields."""
|
||||
@@ -241,38 +232,39 @@ class TestRunRequest:
|
||||
"qualname": ModuleStructuredResponse.__qualname__,
|
||||
},
|
||||
"enable_tool_calls": False,
|
||||
"conversation_id": "conv-789",
|
||||
"thread_id": "thread-789",
|
||||
}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.role == ChatRole.SYSTEM
|
||||
assert request.role == Role.SYSTEM
|
||||
assert request.response_format is ModuleStructuredResponse
|
||||
assert request.enable_tool_calls is False
|
||||
assert request.conversation_id == "conv-789"
|
||||
assert request.thread_id == "thread-789"
|
||||
|
||||
def test_from_dict_invalid_role_defaults_to_user(self) -> None:
|
||||
"""Test from_dict with invalid role defaults to USER."""
|
||||
data = {"message": "Test", "role": "invalid_role", "conversation_id": "conv-invalid-role"}
|
||||
def test_from_dict_with_unknown_role_preserves_value(self) -> None:
|
||||
"""Test from_dict keeps custom roles intact."""
|
||||
data = {"message": "Test", "role": "reviewer", "thread_id": "thread-with-custom-role"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.role == ChatRole.USER
|
||||
assert request.role.value == "reviewer"
|
||||
assert request.role != Role.USER
|
||||
|
||||
def test_from_dict_empty_message(self) -> None:
|
||||
"""Test from_dict with empty message."""
|
||||
data = {"conversation_id": "conv-empty"}
|
||||
data = {"thread_id": "thread-empty"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == ""
|
||||
assert request.role == ChatRole.USER
|
||||
assert request.conversation_id == "conv-empty"
|
||||
assert request.role == Role.USER
|
||||
assert request.thread_id == "thread-empty"
|
||||
|
||||
def test_round_trip_dict_conversion(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
conversation_id="conv-123",
|
||||
role=ChatRole.SYSTEM,
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
response_format=ModuleStructuredResponse,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
@@ -284,13 +276,13 @@ class TestRunRequest:
|
||||
assert restored.role == original.role
|
||||
assert restored.response_format is ModuleStructuredResponse
|
||||
assert restored.enable_tool_calls == original.enable_tool_calls
|
||||
assert restored.conversation_id == original.conversation_id
|
||||
assert restored.thread_id == original.thread_id
|
||||
|
||||
def test_round_trip_with_pydantic_response_format(self) -> None:
|
||||
"""Ensure Pydantic response formats serialize and deserialize properly."""
|
||||
original = RunRequest(
|
||||
message="Structured",
|
||||
conversation_id="conv-pydantic",
|
||||
thread_id="thread-pydantic",
|
||||
response_format=ModuleStructuredResponse,
|
||||
)
|
||||
|
||||
@@ -305,14 +297,14 @@ class TestRunRequest:
|
||||
|
||||
def test_init_with_correlation_id(self) -> None:
|
||||
"""Test RunRequest initialization with correlation_id."""
|
||||
request = RunRequest(message="Test message", conversation_id="conv-corr-init", correlation_id="corr-123")
|
||||
request = RunRequest(message="Test message", thread_id="thread-corr-init", correlation_id="corr-123")
|
||||
|
||||
assert request.message == "Test message"
|
||||
assert request.correlation_id == "corr-123"
|
||||
|
||||
def test_to_dict_with_correlation_id(self) -> None:
|
||||
"""Test to_dict includes correlation_id."""
|
||||
request = RunRequest(message="Test", conversation_id="conv-corr-to-dict", correlation_id="corr-456")
|
||||
request = RunRequest(message="Test", thread_id="thread-corr-to-dict", correlation_id="corr-456")
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test"
|
||||
@@ -320,19 +312,19 @@ class TestRunRequest:
|
||||
|
||||
def test_from_dict_with_correlation_id(self) -> None:
|
||||
"""Test from_dict with correlation_id."""
|
||||
data = {"message": "Test", "correlation_id": "corr-789", "conversation_id": "conv-corr-from-dict"}
|
||||
data = {"message": "Test", "correlation_id": "corr-789", "thread_id": "thread-corr-from-dict"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.correlation_id == "corr-789"
|
||||
assert request.conversation_id == "conv-corr-from-dict"
|
||||
assert request.thread_id == "thread-corr-from-dict"
|
||||
|
||||
def test_round_trip_with_correlation_id(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict with correlation_id."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
conversation_id="conv-123",
|
||||
role=ChatRole.SYSTEM,
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-123",
|
||||
)
|
||||
|
||||
@@ -342,7 +334,7 @@ class TestRunRequest:
|
||||
assert restored.message == original.message
|
||||
assert restored.role == original.role
|
||||
assert restored.correlation_id == original.correlation_id
|
||||
assert restored.conversation_id == original.conversation_id
|
||||
assert restored.thread_id == original.thread_id
|
||||
|
||||
|
||||
class TestAgentResponse:
|
||||
@@ -351,12 +343,12 @@ class TestAgentResponse:
|
||||
def test_init_with_required_fields(self) -> None:
|
||||
"""Test AgentResponse initialization with required fields."""
|
||||
response = AgentResponse(
|
||||
response="Test response", message="Test message", conversation_id="conv-123", status="success"
|
||||
response="Test response", message="Test message", thread_id="thread-123", status="success"
|
||||
)
|
||||
|
||||
assert response.response == "Test response"
|
||||
assert response.message == "Test message"
|
||||
assert response.conversation_id == "conv-123"
|
||||
assert response.thread_id == "thread-123"
|
||||
assert response.status == "success"
|
||||
assert response.message_count == 0
|
||||
assert response.error is None
|
||||
@@ -369,7 +361,7 @@ class TestAgentResponse:
|
||||
response = AgentResponse(
|
||||
response=None,
|
||||
message="What is the answer?",
|
||||
conversation_id="conv-456",
|
||||
thread_id="thread-456",
|
||||
status="success",
|
||||
message_count=5,
|
||||
error=None,
|
||||
@@ -384,13 +376,13 @@ class TestAgentResponse:
|
||||
def test_to_dict_with_text_response(self) -> None:
|
||||
"""Test to_dict with text response."""
|
||||
response = AgentResponse(
|
||||
response="Text response", message="Message", conversation_id="conv-1", status="success", message_count=3
|
||||
response="Text response", message="Message", thread_id="thread-1", status="success", message_count=3
|
||||
)
|
||||
data = response.to_dict()
|
||||
|
||||
assert data["response"] == "Text response"
|
||||
assert data["message"] == "Message"
|
||||
assert data["conversation_id"] == "conv-1"
|
||||
assert data["thread_id"] == "thread-1"
|
||||
assert data["status"] == "success"
|
||||
assert data["message_count"] == 3
|
||||
assert "structured_response" not in data
|
||||
@@ -403,7 +395,7 @@ class TestAgentResponse:
|
||||
response = AgentResponse(
|
||||
response=None,
|
||||
message="Question",
|
||||
conversation_id="conv-2",
|
||||
thread_id="thread-2",
|
||||
status="success",
|
||||
structured_response=structured,
|
||||
)
|
||||
@@ -417,7 +409,7 @@ class TestAgentResponse:
|
||||
response = AgentResponse(
|
||||
response=None,
|
||||
message="Failed message",
|
||||
conversation_id="conv-3",
|
||||
thread_id="thread-3",
|
||||
status="error",
|
||||
error="Something went wrong",
|
||||
error_type="ValueError",
|
||||
@@ -434,7 +426,7 @@ class TestAgentResponse:
|
||||
response = AgentResponse(
|
||||
response="Text response",
|
||||
message="Message",
|
||||
conversation_id="conv-4",
|
||||
thread_id="thread-4",
|
||||
status="success",
|
||||
structured_response=structured,
|
||||
)
|
||||
@@ -452,26 +444,26 @@ class TestModelIntegration:
|
||||
def test_run_request_with_session_id(self) -> None:
|
||||
"""Test using RunRequest with AgentSessionId."""
|
||||
session_id = AgentSessionId.with_random_key("AgentEntity")
|
||||
request = RunRequest(message="Test message", conversation_id=str(session_id))
|
||||
request = RunRequest(message="Test message", thread_id=str(session_id))
|
||||
|
||||
assert request.conversation_id is not None
|
||||
assert request.conversation_id == str(session_id)
|
||||
assert request.conversation_id.startswith("@AgentEntity@")
|
||||
assert request.thread_id is not None
|
||||
assert request.thread_id == str(session_id)
|
||||
assert request.thread_id.startswith("@AgentEntity@")
|
||||
|
||||
def test_response_from_run_request(self) -> None:
|
||||
"""Test creating AgentResponse from RunRequest."""
|
||||
request = RunRequest(message="What is 2+2?", conversation_id="conv-123", role=ChatRole.USER)
|
||||
request = RunRequest(message="What is 2+2?", thread_id="thread-123", role=Role.USER)
|
||||
|
||||
response = AgentResponse(
|
||||
response="4",
|
||||
message=request.message,
|
||||
conversation_id=request.conversation_id,
|
||||
thread_id=request.thread_id,
|
||||
status="success",
|
||||
message_count=1,
|
||||
)
|
||||
|
||||
assert response.message == request.message
|
||||
assert response.conversation_id == request.conversation_id
|
||||
assert response.thread_id == request.thread_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -129,8 +129,8 @@ class TestDurableAIAgent:
|
||||
assert request["enable_tool_calls"] is True
|
||||
assert "correlation_id" in request
|
||||
assert request["correlation_id"] == "correlation-guid"
|
||||
assert "conversation_id" in request
|
||||
assert request["conversation_id"] == "thread-guid"
|
||||
assert "thread_id" in request
|
||||
assert request["thread_id"] == "thread-guid"
|
||||
|
||||
def test_run_without_thread(self) -> None:
|
||||
"""Test that run() works without explicit thread (creates unique session key)."""
|
||||
|
||||
@@ -7,7 +7,7 @@ This sample demonstrates how to use the Durable Extension for Agent Framework to
|
||||
- Defining a simple agent with the Microsoft Agent Framework and wiring it into
|
||||
an Azure Functions app via the Durable Extension for Agent Framework.
|
||||
- Calling the agent through generated HTTP endpoints (`/api/agents/Joker/run`).
|
||||
- Managing conversation state with session identifiers, so multiple clients can
|
||||
- Managing conversation state with thread identifiers, so multiple clients can
|
||||
interact with the agent concurrently without sharing context.
|
||||
|
||||
## Prerequisites
|
||||
@@ -24,6 +24,13 @@ curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-d "Tell me a short joke about cloud computing."
|
||||
```
|
||||
|
||||
The agent responds with a JSON payload that includes the generated joke.
|
||||
|
||||
> **Note:** To return immediately with an HTTP 202 response instead of waiting for the agent output, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body. The default behavior waits for the response.
|
||||
|
||||
## Expected Output
|
||||
|
||||
When you send a POST request with plain-text input, the Functions host responds with an HTTP 202 and queues the request for the durable agent entity. A typical response body looks like the following:
|
||||
Expected HTTP 202 payload:
|
||||
|
||||
```json
|
||||
@@ -31,7 +38,7 @@ Expected HTTP 202 payload:
|
||||
"status": "accepted",
|
||||
"response": "Agent request accepted",
|
||||
"message": "Tell me a short joke about cloud computing.",
|
||||
"conversation_id": "<guid>",
|
||||
"thread_id": "<guid>",
|
||||
"correlation_id": "<guid>"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -13,12 +13,10 @@ Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "Add a security element to it.",
|
||||
"sessionId": "session-003",
|
||||
"waitForCompletion": true
|
||||
"thread_id": "thread-001"
|
||||
}
|
||||
|
||||
### Ask for a joke (plain text payload)
|
||||
POST {{agentRoute}}/run
|
||||
x-wait-for-completion: true
|
||||
|
||||
Give me a programming joke about race conditions.
|
||||
@@ -6,7 +6,7 @@ This sample demonstrates how to use the Durable Extension for Agent Framework to
|
||||
|
||||
- Using the Microsoft Agent Framework to define multiple AI agents with unique names and instructions.
|
||||
- Registering multiple agents with the Function app and running them using HTTP.
|
||||
- Conversation management (via session IDs) for isolated interactions per agent.
|
||||
- Conversation management (via thread IDs) for isolated interactions per agent.
|
||||
- Two different methods for registering agents: list-based initialization and incremental addition.
|
||||
|
||||
## Prerequisites
|
||||
@@ -15,6 +15,15 @@ Complete the common environment preparation steps described in `../README.md`, i
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup and function app running, you can test the sample by sending HTTP requests to the different agent endpoints.
|
||||
|
||||
You can use the `demo.http` file to send messages to the agents, or a command line tool like `curl` as shown below:
|
||||
|
||||
> **Note:** Each endpoint waits for the agent response by default. To receive an immediate HTTP 202 instead, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body.
|
||||
|
||||
### Test the Weather Agent
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
Weather agent request:
|
||||
|
||||
```bash
|
||||
@@ -30,7 +39,7 @@ Expected HTTP 202 payload:
|
||||
"status": "accepted",
|
||||
"response": "Agent request accepted",
|
||||
"message": "What is the weather in Seattle?",
|
||||
"conversation_id": "<guid>",
|
||||
"thread_id": "<guid>",
|
||||
"correlation_id": "<guid>"
|
||||
}
|
||||
```
|
||||
@@ -50,7 +59,7 @@ Expected HTTP 202 payload:
|
||||
"status": "accepted",
|
||||
"response": "Agent request accepted",
|
||||
"message": "Calculate a 20% tip on a $50 bill",
|
||||
"conversation_id": "<guid>",
|
||||
"thread_id": "<guid>",
|
||||
"correlation_id": "<guid>"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -36,8 +36,7 @@ Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "What is the weather in Seattle?",
|
||||
"sessionId": "weather-user-001",
|
||||
"waitForCompletion": true
|
||||
"thread_id": "weather-user-001"
|
||||
}
|
||||
|
||||
###
|
||||
@@ -51,8 +50,7 @@ Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "Calculate a 20% tip on a $50 bill",
|
||||
"sessionId": "math-user-001",
|
||||
"waitForCompletion": true
|
||||
"thread_id": "math-user-001"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@@ -10,8 +10,8 @@ an HTTP API that can be polled by a web client or dashboard.
|
||||
- Registers a default `AgentResponseCallbackProtocol` implementation that logs streaming and final
|
||||
responses.
|
||||
- Persists callback events in an in-memory store and exposes them via
|
||||
`GET /api/agents/{agentName}/callbacks/{conversationId}`.
|
||||
- Shows how to reset stored callback events with `DELETE /api/agents/{agentName}/callbacks/{conversationId}`.
|
||||
`GET /api/agents/{agentName}/callbacks/{thread_id}`.
|
||||
- Shows how to reset stored callback events with `DELETE /api/agents/{agentName}/callbacks/{thread_id}`.
|
||||
- Works alongside the standard `/api/agents/{agentName}/run` endpoint so you can correlate callback
|
||||
telemetry with agent responses.
|
||||
|
||||
@@ -32,6 +32,8 @@ curl -X POST http://localhost:7071/api/agents/CallbackAgent/run \
|
||||
-d '{"message": "Tell me a short joke"}'
|
||||
```
|
||||
|
||||
> **Note:** The run endpoint waits for the agent response by default. To return immediately, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body.
|
||||
|
||||
Poll callback telemetry (replace `<conversationId>` with the value from the POST response):
|
||||
|
||||
```bash
|
||||
@@ -46,7 +48,7 @@ curl -X DELETE http://localhost:7071/api/agents/CallbackAgent/callbacks/<convers
|
||||
|
||||
## Expected Output
|
||||
|
||||
When you call `GET /api/agents/CallbackAgent/callbacks/{conversationId}` after sending a request to the agent,
|
||||
When you call `GET /api/agents/CallbackAgent/callbacks/{thread_id}` after sending a request to the agent,
|
||||
the API returns a list of streaming and final callback events similar to the following:
|
||||
|
||||
```json
|
||||
@@ -54,7 +56,7 @@ the API returns a list of streaming and final callback events similar to the fol
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"agent_name": "CallbackAgent",
|
||||
"conversation_id": "<conversationId>",
|
||||
"thread_id": "<thread_id>",
|
||||
"correlation_id": "<guid>",
|
||||
"request_message": "Tell me a short joke",
|
||||
"event_type": "stream",
|
||||
@@ -64,7 +66,7 @@ the API returns a list of streaming and final callback events similar to the fol
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:01Z",
|
||||
"agent_name": "CallbackAgent",
|
||||
"conversation_id": "<conversationId>",
|
||||
"thread_id": "<thread_id>",
|
||||
"correlation_id": "<guid>",
|
||||
"request_message": "Tell me a short joke",
|
||||
"event_type": "final",
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
###
|
||||
### Endpoints introduced in this sample:
|
||||
### - POST /api/agents/{agentName}/run : send a message to the agent
|
||||
### - GET /api/agents/{agentName}/callbacks/{conversationId} : retrieve callback telemetry
|
||||
### - DELETE /api/agents/{agentName}/callbacks/{conversationId} : clear stored callback events
|
||||
### - GET /api/agents/{agentName}/callbacks/{thread_id} : retrieve callback telemetry
|
||||
### - DELETE /api/agents/{agentName}/callbacks/{thread_id} : clear stored callback events
|
||||
|
||||
@baseUrl = http://localhost:7071
|
||||
@agentName = CallbackAgent
|
||||
@agentRoute = {{baseUrl}}/api/agents/{{agentName}}
|
||||
@conversationId = test-stream-00
|
||||
@thread_id = test-thread-00
|
||||
|
||||
### Health Check
|
||||
GET {{baseUrl}}/api/health
|
||||
@@ -20,11 +20,11 @@ Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "Generate a short weather update for Paris and mention streaming callbacks.",
|
||||
"sessionId": "{{conversationId}}"
|
||||
"thread_id": "{{thread_id}}"
|
||||
}
|
||||
|
||||
### Inspect callback telemetry
|
||||
GET {{agentRoute}}/callbacks/{{conversationId}}
|
||||
GET {{agentRoute}}/callbacks/{{thread_id}}
|
||||
|
||||
### Clear stored callback telemetry for the conversation
|
||||
DELETE {{agentRoute}}/callbacks/{{conversationId}}
|
||||
### Clear stored callback telemetry for the thread
|
||||
DELETE {{agentRoute}}/callbacks/{{thread_id}}
|
||||
|
||||
@@ -24,7 +24,7 @@ from agent_framework.azurefunctions import AgentFunctionApp, AgentCallbackContex
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 1. Maintain an in-memory store for callback events (replace with durable storage in production).
|
||||
# 1. Maintain an in-memory store for callback events keyed by thread ID (replace with durable storage in production).
|
||||
CallbackStore = DefaultDict[str, list[dict[str, Any]]]
|
||||
callback_events: CallbackStore = defaultdict(list)
|
||||
|
||||
@@ -65,8 +65,8 @@ class ConversationAuditTrail(AgentResponseCallbackProtocol):
|
||||
"text": getattr(update, "text", None),
|
||||
}
|
||||
)
|
||||
conversation_id = context.conversation_id or ""
|
||||
callback_events[conversation_id].append(event)
|
||||
thread_id = context.thread_id or ""
|
||||
callback_events[thread_id].append(event)
|
||||
|
||||
preview = event.get("text") or event.get("update_kind")
|
||||
self._logger.info(
|
||||
@@ -85,8 +85,8 @@ class ConversationAuditTrail(AgentResponseCallbackProtocol):
|
||||
"usage": _serialize_usage(getattr(response, "usage_details", None)),
|
||||
}
|
||||
)
|
||||
conversation_id = context.conversation_id or ""
|
||||
callback_events[conversation_id].append(event)
|
||||
thread_id = context.thread_id or ""
|
||||
callback_events[thread_id].append(event)
|
||||
|
||||
self._logger.info(
|
||||
"[%s][%s] final response recorded",
|
||||
@@ -96,10 +96,11 @@ class ConversationAuditTrail(AgentResponseCallbackProtocol):
|
||||
|
||||
@staticmethod
|
||||
def _build_base_event(context: AgentCallbackContext) -> dict[str, Any]:
|
||||
thread_id = context.thread_id
|
||||
return {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"agent_name": context.agent_name,
|
||||
"conversation_id": context.conversation_id,
|
||||
"thread_id": thread_id,
|
||||
"correlation_id": context.correlation_id,
|
||||
"request_message": context.request_message,
|
||||
}
|
||||
@@ -122,12 +123,12 @@ app.add_agent(callback_agent)
|
||||
|
||||
|
||||
@app.function_name("get_callback_events")
|
||||
@app.route(route="agents/{agent_name}/callbacks/{conversationId}", methods=["GET"])
|
||||
@app.route(route="agents/{agent_name}/callbacks/{thread_id}", methods=["GET"])
|
||||
async def get_callback_events(req: func.HttpRequest) -> func.HttpResponse:
|
||||
"""Return all callback events collected for a conversation."""
|
||||
"""Return all callback events collected for a thread."""
|
||||
|
||||
conversation_id = req.route_params.get("conversationId", "")
|
||||
events = callback_events.get(conversation_id, [])
|
||||
thread_id = req.route_params.get("thread_id", "")
|
||||
events = callback_events.get(thread_id, [])
|
||||
return func.HttpResponse(
|
||||
json.dumps(events, indent=2),
|
||||
status_code=200,
|
||||
@@ -136,44 +137,44 @@ async def get_callback_events(req: func.HttpRequest) -> func.HttpResponse:
|
||||
|
||||
|
||||
@app.function_name("reset_callback_events")
|
||||
@app.route(route="agents/{agent_name}/callbacks/{conversationId}", methods=["DELETE"])
|
||||
@app.route(route="agents/{agent_name}/callbacks/{thread_id}", methods=["DELETE"])
|
||||
async def reset_callback_events(req: func.HttpRequest) -> func.HttpResponse:
|
||||
"""Clear the stored callback events for a conversation."""
|
||||
"""Clear the stored callback events for a thread."""
|
||||
|
||||
conversation_id = req.route_params.get("conversationId", "")
|
||||
callback_events.pop(conversation_id, None)
|
||||
thread_id = req.route_params.get("thread_id", "")
|
||||
callback_events.pop(thread_id, None)
|
||||
return func.HttpResponse(status_code=204)
|
||||
|
||||
|
||||
"""
|
||||
Expected output when querying `GET /api/agents/CallbackAgent/callbacks/{conversationId}`:
|
||||
Expected output when querying `GET /api/agents/CallbackAgent/callbacks/{thread_id}`:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
[
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"agent_name": "CallbackAgent",
|
||||
"conversation_id": "<conversationId>",
|
||||
"correlation_id": "<guid>",
|
||||
"request_message": "Tell me a short joke",
|
||||
"event_type": "stream",
|
||||
"update_kind": "text",
|
||||
"text": "Sure, here's a joke..."
|
||||
},
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:01Z",
|
||||
"agent_name": "CallbackAgent",
|
||||
"conversation_id": "<conversationId>",
|
||||
"correlation_id": "<guid>",
|
||||
"request_message": "Tell me a short joke",
|
||||
"event_type": "final",
|
||||
"response_text": "Why did the cloud...",
|
||||
"usage": {
|
||||
"type": "usage_details",
|
||||
"input_token_count": 159,
|
||||
"output_token_count": 29,
|
||||
"total_token_count": 188
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"agent_name": "CallbackAgent",
|
||||
"thread_id": "<thread_id>",
|
||||
"correlation_id": "<guid>",
|
||||
"request_message": "Tell me a short joke",
|
||||
"event_type": "stream",
|
||||
"update_kind": "text",
|
||||
"text": "Sure, here's a joke..."
|
||||
},
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:01Z",
|
||||
"agent_name": "CallbackAgent",
|
||||
"thread_id": "<thread_id>",
|
||||
"correlation_id": "<guid>",
|
||||
"request_message": "Tell me a short joke",
|
||||
"event_type": "final",
|
||||
"response_text": "Why did the cloud...",
|
||||
"usage": {
|
||||
"type": "usage_details",
|
||||
"input_token_count": 159,
|
||||
"output_token_count": 29,
|
||||
"total_token_count": 188
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
"""
|
||||
|
||||
+2
@@ -25,6 +25,8 @@ Poll the returned `statusQueryGetUri` until completion:
|
||||
curl http://localhost:7071/api/singleagent/status/<instanceId>
|
||||
```
|
||||
|
||||
> **Note:** The underlying agent run endpoint now waits for responses by default. If you invoke it directly and prefer an immediate HTTP 202, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the payload.
|
||||
|
||||
The orchestration first requests an inspirational sentence from the agent, then refines the initial response while
|
||||
keeping it under 25 words—mirroring the behaviour of the corresponding .NET sample.
|
||||
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ Poll the returned `statusQueryGetUri` until completion:
|
||||
curl http://localhost:7071/api/multiagent/status/<instanceId>
|
||||
```
|
||||
|
||||
> **Note:** The agent run endpoints wait for responses by default. If you call them directly and need an immediate HTTP 202, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request payload.
|
||||
|
||||
The orchestration launches both agents simultaneously so their domain-specific answers can be combined for the caller.
|
||||
|
||||
## Expected Output
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ Poll the returned `statusQueryGetUri` or call the status route directly:
|
||||
curl http://localhost:7071/api/spamdetection/status/<instanceId>
|
||||
```
|
||||
|
||||
> **Note:** The spam detection run endpoint waits for responses by default. To opt into an immediate HTTP 202, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the POST body.
|
||||
|
||||
## Expected Responses
|
||||
- Spam payloads return `Email marked as spam: <reason>` by invoking the `handle_spam_email` activity.
|
||||
- Legitimate emails return `Email sent: <draft>` after the email assistant agent produces a structured reply.
|
||||
|
||||
+2
@@ -39,6 +39,8 @@ curl -X POST http://localhost:7071/api/hitl/approve/<instanceId> \
|
||||
-d '{"approved": true, "feedback": "Looks good"}'
|
||||
```
|
||||
|
||||
> **Note:** Calls to the underlying agent run endpoint wait for responses by default. If you need an immediate HTTP 202 response, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body.
|
||||
|
||||
## Expected Responses
|
||||
- `POST /api/hitl/run` returns a 202 Accepted payload with the Durable Functions instance ID.
|
||||
- `POST /api/hitl/approve/{instanceId}` echoes the decision that the orchestration receives.
|
||||
|
||||
Reference in New Issue
Block a user