diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-githubcopilot/CHANGELOG.md index 76471cbefc9c..6d7ff1df9785 100644 --- a/sdk/agentserver/azure-ai-agentserver-githubcopilot/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-githubcopilot/CHANGELOG.md @@ -1,6 +1,30 @@ # Release History -## 1.0.0b1 (Unreleased) +## 1.0.0b2 (Unreleased) + +### Breaking Changes + +- **Re-platformed onto agentserver-core 2.0 + agentserver-responses 1.0.** + - `CopilotAdapter` no longer extends `FoundryCBAgent` (removed in core 2.0). + - Uses `AgentHost` + `ResponseHandler` composition model instead. + - Hypercorn replaces uvicorn as the ASGI server. + - `_copilot_response_converter.py` and `_copilot_request_converter.py` removed — replaced by `ResponseEventStream` builders from the responses package. + +### Features Added + +- SSE streaming now uses correct RAPI event ordering (`text_done → content_part.done → output_item.done → completed`). The workaround of emitting `completed` before `text_done` is no longer needed. +- Built-in SSE keep-alive via `ResponsesServerOptions(sse_keep_alive_interval_seconds=...)`. Custom heartbeat logic removed. +- `ResponseEventStream` builders provide typed, state-machine-validated RAPI event construction. +- Usage tracking (input/output tokens) included in `response.completed` event. +- Foundry model discovery with 24-hour disk cache. +- MCP OAuth consent event handling. + +### Bugs Fixed + +- SSE streaming truncation on ADC (Envoy proxy) — fixed by Hypercorn + correct event ordering. +- Duplicate text in streaming responses — only `ASSISTANT_MESSAGE_DELTA` events emit deltas, not the final `ASSISTANT_MESSAGE`. + +## 1.0.0b1 (2026-03-31) ### Features Added @@ -10,4 +34,3 @@ - `ToolAcl`: YAML-based tool permission gating (shell, read, write, url, mcp). - BYOK authentication via `DefaultAzureCredential` (Managed Identity) or static API key. - Streaming and non-streaming response modes. -- Robust cross-platform SDK imports (handles version/platform differences in `github-copilot-sdk`). diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/__init__.py b/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/__init__.py index 4b74b8794a1a..ba340526283a 100644 --- a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/__init__.py +++ b/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/__init__.py @@ -8,6 +8,9 @@ platform, translating between the Copilot SDK's event model and the Foundry Responses API (RAPI) protocol. +Uses the new agentserver packages (core 2.0 + responses 1.0) with the +AgentHost + ResponseHandler composition model. + Usage:: from azure.ai.agentserver.githubcopilot import GitHubCopilotAdapter diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py b/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py index ea6d2a72385c..53790dfb3381 100644 --- a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py +++ b/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py @@ -5,80 +5,98 @@ # pylint: disable=logging-fstring-interpolation,broad-exception-caught """Core adapter bridging the GitHub Copilot SDK to Azure AI Agent Server. +Uses the new agentserver packages (core 2.0 + responses 1.0) with the +AgentHost + ResponseHandler composition model. + Two classes are exported: ``CopilotAdapter`` - Low-level adapter extending ``FoundryCBAgent``. Handles BYOK auth, - session management, Tool ACL, OTel traces, and n:n Copilot-to-RAPI - event mapping. + Core adapter handling BYOK auth, session management, Tool ACL, + and Copilot-to-RAPI event translation via ResponseEventStream builders. ``GitHubCopilotAdapter`` - Convenience subclass that adds skill directory discovery and - conversation history bootstrap for cold starts. This is the class - most developers should use. + Convenience subclass that adds skill directory discovery, tool discovery, + model discovery, and conversation history bootstrap for cold starts. + This is the class most developers should use. """ import asyncio -import logging as _logging +import logging import os import pathlib -import time -from typing import Any, AsyncGenerator, Dict, Optional, Union +from typing import Any, Dict, Optional from copilot import CopilotClient from copilot.generated.session_events import SessionEventType +from copilot.session import PermissionRequestResult, ProviderConfig -# These types move between SDK versions/platforms. Try multiple paths. -try: - from copilot import PermissionRequestResult, ProviderConfig -except ImportError: - try: - from copilot.types import PermissionRequestResult, ProviderConfig - except ImportError: - PermissionRequestResult = None - ProviderConfig = dict - -from azure.ai.agentserver.core.constants import Constants -from azure.ai.agentserver.core.logger import get_logger -from azure.ai.agentserver.core.models import Response as OpenAIResponse -from azure.ai.agentserver.core.models.projects import ( - ResponseCompletedEvent, - ResponseContentPartAddedEvent, - ResponseContentPartDoneEvent, - ResponseCreatedEvent, - ResponseInProgressEvent, - ResponseOutputItemAddedEvent, - ResponseOutputItemDoneEvent, - ResponseStreamEvent, - ResponseTextDeltaEvent, - ResponseTextDoneEvent, +from azure.ai.agentserver.core import AgentHost +from azure.ai.agentserver.responses import ( + ResponseEventStream, + ResponsesServerOptions, + get_input_text, ) -from azure.ai.agentserver.core.server.base import FoundryCBAgent -from azure.ai.agentserver.core.server.common.agent_run_context import AgentRunContext +from azure.ai.agentserver.responses.hosting import ResponseHandler -from ._copilot_request_converter import ConvertedAttachments, CopilotRequestConverter -from ._copilot_response_converter import CopilotResponseConverter, CopilotStreamingResponseConverter from ._tool_acl import ToolAcl -logger = get_logger() - -# Suppress noisy OTel detach warnings from async generator context switches. -_logging.getLogger("opentelemetry.context").setLevel(_logging.CRITICAL) - -_COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default" +logger = logging.getLogger("azure.ai.agentserver.githubcopilot") +# Version canary — proves which code is deployed. Change this string with every deploy-affecting commit. +_BUILD_TAG = "replat-v3-conversation-id-from-rawbody" +logger.info(f"Adapter loaded: {_BUILD_TAG}") -# --------------------------------------------------------------------------- -# Health-check log filter -# --------------------------------------------------------------------------- -class _HealthCheckFilter(_logging.Filter): - """Drop health-check access-log records so they don't pollute App Insights.""" +def _extract_input_with_attachments(request) -> str: + """Extract text from a RAPI request, including any file/image attachments. - _PATHS = ("/liveness", "/readiness") + ``get_input_text`` only returns the text portion of the request input. + This helper also checks for ``input_file`` and ``input_image`` items and + appends their content to the prompt so the Copilot SDK (which only accepts + a string prompt) can still reason about attachments. + """ + text = get_input_text(request) + + # Check for attachment items in the request input + input_items = getattr(request, "input", None) + if not isinstance(input_items, list): + return text + + attachment_parts = [] + for item in input_items: + item_type = None + if isinstance(item, dict): + item_type = item.get("type") + else: + item_type = getattr(item, "type", None) + + if item_type == "input_file": + filename = (item.get("filename") if isinstance(item, dict) else getattr(item, "filename", None)) or "file" + file_data = (item.get("file_data") if isinstance(item, dict) else getattr(item, "file_data", None)) or "" + if file_data: + # base64 content — decode if possible, otherwise include raw + import base64 + try: + decoded = base64.b64decode(file_data).decode("utf-8", errors="replace") + attachment_parts.append(f"\n[Attached file: {filename}]\n{decoded}") + except Exception: + attachment_parts.append(f"\n[Attached file: {filename} (binary, {len(file_data)} chars base64)]") + + elif item_type == "input_image": + image_url = (item.get("image_url") if isinstance(item, dict) else getattr(item, "image_url", None)) or "" + if isinstance(image_url, dict): + image_url = image_url.get("url", "") + elif hasattr(image_url, "url"): + image_url = image_url.url + if image_url: + attachment_parts.append(f"\n[Attached image: {image_url[:200]}]") + + if attachment_parts: + logger.info("Extracted %d attachment(s) from request input", len(attachment_parts)) + return text + "".join(attachment_parts) + + return text - def filter(self, record: _logging.LogRecord) -> bool: # noqa: A003 - msg = record.getMessage() - return not any(p in msg for p in self._PATHS) +_COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default" # --------------------------------------------------------------------------- @@ -181,11 +199,14 @@ def _build_session_config() -> Dict[str, Any]: # CopilotAdapter — core adapter # --------------------------------------------------------------------------- -class CopilotAdapter(FoundryCBAgent): +class CopilotAdapter: """Adapter bridging a GitHub Copilot SDK session to Azure AI Agent Server. - Handles BYOK authentication, n:n event mapping, Tool ACL, OTel traces, - streaming/non-streaming modes, and multi-turn session management. + Uses the new AgentHost + ResponseHandler composition model from + agentserver-core 2.0 and agentserver-responses 1.0. + + Handles BYOK authentication, Tool ACL, streaming via ResponseEventStream + builders, and multi-turn session management. :param session_config: Override for the Copilot session config (dict). When *None* the config is built automatically from environment variables. @@ -201,16 +222,6 @@ def __init__( acl: Optional[ToolAcl] = None, credential: Optional[Any] = None, ): - super().__init__() - - # Suppress noisy health-check access logs from App Insights. - # Applied directly rather than via Starlette on_event (removed in 1.0). - # If uvicorn resets loggers at startup, the filter may be lost — this - # is cosmetic (health-check noise), not a functional issue. - _hc_filter = _HealthCheckFilter() - for _name in ("uvicorn", "uvicorn.access", "uvicorn.error"): - _logging.getLogger(_name).addFilter(_hc_filter) - # Build default config (handles BYOK provider setup from env vars) default_config = _build_session_config() @@ -241,8 +252,6 @@ def __init__( self._sessions: Dict[str, Any] = {} # Credential for BYOK token refresh. - # Check the session config (not raw env vars) because the resource URL - # may have been auto-derived from AZURE_AI_PROJECT_ENDPOINT. _has_byok_provider = ( "provider" in self._session_config and not os.getenv("AZURE_AI_FOUNDRY_API_KEY") @@ -256,6 +265,10 @@ def __init__( else: self._credential = None + # Server components (built lazily in run()) + self._server: Optional[AgentHost] = None + self._responses: Optional[ResponseHandler] = None + def _refresh_token_if_needed(self) -> Dict[str, Any]: """Return the session config, refreshing the bearer token if using Foundry.""" if "provider" not in self._session_config: @@ -263,7 +276,6 @@ def _refresh_token_if_needed(self) -> Dict[str, Any]: if self._credential is not None: token = self._credential.get_token(_COGNITIVE_SERVICES_SCOPE).token - # ProviderConfig is a TypedDict (dict subclass) — dict-style access works. self._session_config["provider"]["bearer_token"] = token return self._session_config @@ -278,418 +290,248 @@ async def _ensure_client(self) -> CopilotClient: logger.info("CopilotClient started") return self._client - # ------------------------------------------------------------------ - # agent_run — main entry point called by FoundryCBAgent - # ------------------------------------------------------------------ - - async def agent_run( - self, context: AgentRunContext - ) -> Union[OpenAIResponse, AsyncGenerator[ResponseStreamEvent, None]]: - - logger.info(f"agent_run: stream={context.stream} conversation_id={context.conversation_id}") - - # Diagnostic bypass: skip Copilot SDK entirely, return synthetic stream - if os.getenv("DIAG_BYPASS") and context.stream: - return self._diag_bypass_stream(context) - - req_converter = CopilotRequestConverter(context.request) - prompt = req_converter.convert() - converted_attachments = req_converter.convert_attachments() - - client = await self._ensure_client() - config = self._refresh_token_if_needed() - + def _make_permission_handler(self): + """Create a permission handler using the adapter's ACL.""" acl = self._acl - def _perm_result(**kwargs): - if PermissionRequestResult is not None: - return PermissionRequestResult(**kwargs) - return kwargs def _on_permission(req, _ctx): kind = getattr(req, "kind", "unknown") if acl is None: logger.info(f"Auto-approving tool request (no ACL): kind={kind}") - return _perm_result(kind="approved") + return PermissionRequestResult(kind="approved") req_dict = vars(req) if not isinstance(req, dict) else req if acl.is_allowed(req_dict): logger.info(f"ACL allowed tool request: kind={kind}") - return _perm_result(kind="approved") + return PermissionRequestResult(kind="approved") logger.warning(f"ACL denied tool request: kind={kind}") - return _perm_result(kind="denied-by-rules", rules=[]) - - conversation_id = context.conversation_id - session = self._sessions.get(conversation_id) if conversation_id else None - - if session is None: - logger.info( - "Creating new Copilot session" - + (f" for conversation {conversation_id!r}" if conversation_id else "") - ) - # Filter out internal flags (starting with _) before passing to SDK - sdk_config = {k: v for k, v in config.items() if not k.startswith("_")} - # Always enable streaming — the SDK only emits - # ASSISTANT_MESSAGE_DELTA when streaming=True. - session = await client.create_session( - **sdk_config, - on_permission_request=_on_permission, - streaming=True, - ) - if conversation_id: - self._sessions[conversation_id] = session - else: + return PermissionRequestResult(kind="denied-by-rules", rules=[]) + + return _on_permission + + async def _get_or_create_session(self, conversation_id=None): + """Get existing session or create new one.""" + if conversation_id and conversation_id in self._sessions: logger.info(f"Reusing session for conversation {conversation_id!r}") + return self._sessions[conversation_id] - if context.stream: - return self._run_streaming(session, prompt, converted_attachments, context) + client = await self._ensure_client() + config = self._refresh_token_if_needed() - # Non-streaming: collect events, extract final text + consent requests. - text = "" - oauth_items = [] - try: - async for event in _iter_copilot_events(session, prompt, attachments=converted_attachments.attachments): - if event.type == SessionEventType.ASSISTANT_MESSAGE and event.data and event.data.content: - text = event.data.content - elif event.type == SessionEventType.SESSION_ERROR and event.data: - error_msg = ( - getattr(event.data, "message", None) - or getattr(event.data, "content", None) - or repr(event.data) - ) - logger.error(f"Copilot session error: {error_msg}") - if not text: - text = f"(Agent error: {error_msg})" - elif event.type == SessionEventType.MCP_OAUTH_REQUIRED and event.data: - consent_url = getattr(event.data, "url", "") or "" - server_label = ( - getattr(event.data, "server_name", "") - or getattr(event.data, "name", "") - or "unknown" - ) - logger.info(f"MCP OAuth consent required: server={server_label} url={consent_url}") - oauth_items.append({ - "type": "oauth_consent_request", - "id": context.id_generator.generate_message_id(), - "consent_link": consent_url, - "server_label": server_label, - }) - finally: - converted_attachments.cleanup() - return CopilotResponseConverter.to_response(text, context, extra_output=oauth_items) + # Filter out internal flags (starting with _) before passing to SDK. + # skill_directories and tools are already in _session_config when + # GitHubCopilotAdapter discovers them, so they flow through here + # automatically — no need to pass them as separate kwargs. + sdk_config = {k: v for k, v in config.items() if not k.startswith("_")} + + session = await client.create_session( + **sdk_config, + on_permission_request=self._make_permission_handler(), + streaming=True, + ) + + if conversation_id: + self._sessions[conversation_id] = session + logger.info( + "Created new Copilot session" + + (f" for conversation {conversation_id!r}" if conversation_id else "") + ) + return session # ------------------------------------------------------------------ - # Streaming + # Server setup and run # ------------------------------------------------------------------ - async def _run_streaming( - self, - session: Any, - prompt: str, - converted_attachments: ConvertedAttachments, - context: AgentRunContext, - ) -> AsyncGenerator[ResponseStreamEvent, None]: - """Async generator: emits RAPI SSE events from Copilot SDK events. - - The ADC platform proxy requires continuous data flow to keep SSE - connections alive. This method: - - 1. Yields envelope events (created, in_progress, output_item.added, - content_part.added) **immediately** — before any ``await``. - 2. Starts the Copilot SDK session. - 3. Emits empty text delta heartbeats every 50 ms while waiting for - Copilot events. - 4. When Copilot content arrives, yields the real text delta + done - events. - - All RAPI events use **keyword-arg construction with model objects** - for nested fields — dict-based construction causes stream truncation - on the ADC proxy. - """ - from azure.ai.agentserver.core.models import Response as _OAIResponse - from azure.ai.agentserver.core.models.projects import ( - ItemContentOutputText as _Part, - ResponsesAssistantMessageItemResource as _Item, + def _setup_server(self): + """Build the AgentHost + ResponseHandler and wire up the create handler.""" + self._server = AgentHost() + + keepalive = int(os.getenv("AZURE_AI_RESPONSES_SERVER_SSE_KEEPALIVE_INTERVAL", "5")) + self._responses = ResponseHandler( + self._server, + options=ResponsesServerOptions( + sse_keep_alive_interval_seconds=keepalive, + ), ) - response_id = context.response_id - item_id = context.id_generator.generate_message_id() - created_at = int(time.time()) - seq = 0 - - def next_seq(): - nonlocal seq; seq += 1; return seq - - def resp_minimal(status): - return _OAIResponse({"id": response_id, "object": "response", - "status": status, "created_at": created_at}) - - def resp_full(status, output=None, usage=None): - d = {"id": response_id, "object": "response", "status": status, - "created_at": created_at, "output": output or []} - agent_id = context.get_agent_id_object() - if agent_id is not None: - d["agent_id"] = agent_id - conversation = context.get_conversation_object() - if conversation is not None: - d["conversation"] = conversation - if usage is not None: - d["usage"] = usage - return _OAIResponse(d) - - # -- Phase 1: Yield envelope BEFORE any await ----------------------- - yield ResponseCreatedEvent( - sequence_number=next_seq(), response=resp_minimal("in_progress")) - yield ResponseInProgressEvent( - sequence_number=next_seq(), response=resp_minimal("in_progress")) - yield ResponseOutputItemAddedEvent( - sequence_number=next_seq(), output_index=0, - item=_Item(id=item_id, status="in_progress", content=[])) - yield ResponseContentPartAddedEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, - part=_Part(text="", annotations=[], logprobs=[])) - - # -- Phase 2: Start Copilot SDK and collect events ------------------ + # Register the create handler — captures self for adapter state. + # The handler must be an async generator (yields events), not a function + # that returns one. We use `async for` to delegate to _handle_create. + adapter = self + + @self._responses.create_handler + async def handle_create(request, context, cancellation_signal): + async for event in adapter._handle_create(request, context, cancellation_signal): + yield event + + async def _handle_create(self, request, context, cancellation_signal): + """Handle POST /responses — bridge Copilot SDK events to RAPI stream.""" + input_text = _extract_input_with_attachments(request) + + # Resolve conversation identity for multi-turn session reuse. + # Prefer conversation_id from the context (set when the request includes + # a "conversation" field). Fall back to session_id from the raw request + # body so callers who only pass session_id still get multi-turn. + conversation_id = getattr(context, "conversation_id", None) + if not conversation_id: + raw_body = getattr(context, "raw_body", None) + if isinstance(raw_body, dict): + # Try session_id first (direct Responses API callers), + # then conversation.id (Playground via Chat Completions translation) + conversation_id = raw_body.get("session_id") + if not conversation_id: + conv = raw_body.get("conversation") + if isinstance(conv, dict): + conversation_id = conv.get("id") + if conversation_id: + # Also set on context so downstream code (e.g. history bootstrap) sees it + context.conversation_id = conversation_id + + response_id = getattr(context, "response_id", None) or "unknown" + + logger.info(f"Request: input={input_text[:100]!r} conversation_id={conversation_id}") + + session = await self._get_or_create_session(conversation_id) + + # Set up event queue queue: asyncio.Queue = asyncio.Queue() - last_key = None - event_count = 0 - - def _on_stream_event(event): - nonlocal last_key, event_count - text = "" - if event.data and hasattr(event.data, "content") and event.data.content: - text = event.data.content - key = (event.type, text) - if key == last_key: - return - last_key = key - event_count += 1 - event_name = event.type.name if event.type else "UNKNOWN" - if text: - logger.info(f"Copilot event #{event_count:03d}: {event_name} len={len(text)}") - else: - logger.info(f"Copilot event #{event_count:03d}: {event_name}") + + def on_event(event): queue.put_nowait(event) if event.type == SessionEventType.SESSION_IDLE: - queue.put_nowait(None) - - unsubscribe = session.on(_on_stream_event) - await session.send(prompt, attachments=converted_attachments.attachments or None) - - # -- Phase 3: Heartbeat + collect content --------------------------- - _HEARTBEAT_SEC = 0.05 - full_text = "" - content_started = False - usage = None - oauth_items = [] - done_sent = False - loop = asyncio.get_running_loop() - deadline = loop.time() + 120 + queue.put_nowait(None) # sentinel + + unsubscribe = session.on(on_event) + + # Build RAPI event stream using the new builders + stream = ResponseEventStream(response_id=response_id) + try: + # Emit lifecycle events BEFORE sending prompt + yield stream.emit_created() + yield stream.emit_in_progress() + + # Start message output item + msg = stream.add_output_item_message() + yield msg.emit_added() + + text_builder = msg.add_text_content() + yield text_builder.emit_added() + + # NOW send the prompt to Copilot SDK + await session.send(input_text) + + # Process Copilot SDK events + idle_timeout = float(os.getenv("COPILOT_IDLE_TIMEOUT", "300")) + accumulated_text = "" + content_started = False + event_count = 0 + usage = None + while True: - remaining = deadline - loop.time() - if remaining <= 0: - logger.error("Copilot streaming timeout after 120s") + # Check if the client disconnected + if cancellation_signal is not None and cancellation_signal.is_set(): + logger.info("Client disconnected — ending response early") break + try: - event = await asyncio.wait_for( - queue.get(), timeout=min(_HEARTBEAT_SEC, remaining)) + event = await asyncio.wait_for(queue.get(), timeout=idle_timeout) except asyncio.TimeoutError: - # Heartbeats only during the "thinking" gap before content. - # Once real text deltas start flowing, they keep the - # connection alive and empty deltas confuse the Playground. - if not content_started: - yield ResponseTextDeltaEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, delta="") - continue + logger.warning(f"Idle timeout ({idle_timeout}s) — ending response") + break + if event is None: break - # Process Copilot events — extract text/usage/consent - if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA: - # Streaming deltas use delta_content (not content) - chunk = getattr(event.data, "delta_content", None) or getattr(event.data, "content", None) or "" - if chunk: - content_started = True - full_text += chunk - yield ResponseTextDeltaEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, delta=chunk) - elif event.type == SessionEventType.ASSISTANT_MESSAGE: - if event.data and event.data.content: - if not full_text: - full_text = event.data.content - yield ResponseTextDeltaEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, delta=full_text) - else: - full_text = event.data.content - - elif event.type == SessionEventType.ASSISTANT_USAGE: - if event.data: - u = {} - if event.data.input_tokens is not None: - u["input_tokens"] = int(event.data.input_tokens) - if event.data.output_tokens is not None: - u["output_tokens"] = int(event.data.output_tokens) - if u: - u["total_tokens"] = sum(u.values()) - usage = u - elif event.type == SessionEventType.MCP_OAUTH_REQUIRED: - if event.data: - oauth_items.append({ - "type": "oauth_consent_request", - "id": context.id_generator.generate_message_id(), - "consent_link": getattr(event.data, "url", "") or "", - "server_label": getattr(event.data, "server_name", "") or getattr(event.data, "name", "") or "unknown", - }) - elif event.type == SessionEventType.SESSION_ERROR: - if event.data: - msg = getattr(event.data, "message", None) or repr(event.data) - logger.error(f"Copilot session error: {msg}") - if not full_text: - full_text = f"(Agent error: {msg})" - # Safety net: if SESSION_IDLE arrived without ASSISTANT_MESSAGE - except Exception: - logger.exception("Agent streaming failed") + event_count += 1 + event_name = event.type.name if event.type else "UNKNOWN" + data = event.data + + # Extract text content + event_text = "" + if data: + event_text = getattr(data, "delta_content", "") or getattr(data, "content", "") or "" + + # Rich logging + if event_name in ("TOOL_EXECUTION_START", "TOOL_EXECUTION_COMPLETE", "TOOL_EXECUTION_PARTIAL_RESULT") and data: + tool_name = getattr(data, "tool_name", None) or getattr(data, "name", "") + call_id = getattr(data, "call_id", "") + args = str(getattr(data, "arguments", ""))[:500] + logger.info(f"Copilot #{event_count:03d}: {event_name} tool={tool_name!r} call_id={call_id!r} args={args}") + elif event_text: + logger.info(f"Copilot #{event_count:03d}: {event_name} len={len(event_text)}") + else: + logger.info(f"Copilot #{event_count:03d}: {event_name}") + + # Yield text deltas (only from DELTA events) + if event_text and event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA: + content_started = True + accumulated_text += event_text + yield text_builder.emit_delta(event_text) + elif event_text and event.type == SessionEventType.ASSISTANT_MESSAGE: + # Final message — use as accumulated text if we missed deltas + if not content_started: + accumulated_text = event_text + yield text_builder.emit_delta(event_text) + + # Track usage + elif event.type == SessionEventType.ASSISTANT_USAGE and data: + u = {} + if getattr(data, "input_tokens", None) is not None: + u["input_tokens"] = int(data.input_tokens) + if getattr(data, "output_tokens", None) is not None: + u["output_tokens"] = int(data.output_tokens) + if u: + u["total_tokens"] = sum(u.values()) + usage = u + + # Handle errors + elif event.type == SessionEventType.SESSION_ERROR and data: + error_msg = getattr(data, "message", None) or repr(data) + logger.error(f"SESSION_ERROR: {error_msg}") + yield stream.emit_failed() + return + + # MCP OAuth consent + elif event.type == SessionEventType.MCP_OAUTH_REQUIRED and data: + consent_url = getattr(data, "url", "") or "" + server_label = getattr(data, "server_name", "") or getattr(data, "name", "") or "unknown" + logger.info(f"MCP OAuth consent required: server={server_label}") + # TODO: emit OAuth consent RAPI event when builders support it + finally: - # Unsubscribe FIRST to stop all Copilot SDK callbacks. - # This ensures no background async activity interferes - # with the done event yields below. unsubscribe() - converted_attachments.cleanup() - - # -- Phase 4: Done events AFTER unsubscribe ------------------------- - # The ADC proxy drops events after response.output_text.done. - # Workaround: emit response.completed BEFORE text_done so the - # Playground receives the completion signal. This violates RAPI - # event ordering but the Playground handles it — it already has - # all text from deltas and just needs the completion signal to - # stop the loading spinner. - if not full_text: - full_text = "(No response text was produced by the agent.)" - yield ResponseTextDeltaEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, delta=full_text) - - empty_part = _Part(text="", annotations=[]) - empty_item = _Item(id=item_id, status="completed", content=[empty_part]) - - # Completed FIRST (so it gets through before proxy closes) - yield ResponseCompletedEvent( - sequence_number=next_seq(), response=resp_minimal("completed")) - # Then the standard done sequence (may be dropped by proxy — that's OK) - yield ResponseTextDoneEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, text="") - yield ResponseContentPartDoneEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, part=empty_part) - yield ResponseOutputItemDoneEvent( - sequence_number=next_seq(), output_index=0, item=empty_item) - # ------------------------------------------------------------------ - # Identifiers - # ------------------------------------------------------------------ + # Handle empty response + if not accumulated_text: + accumulated_text = "(No response text was produced by the agent.)" + yield text_builder.emit_delta(accumulated_text) - def get_trace_attributes(self): - attrs = super().get_trace_attributes() - attrs["service.namespace"] = "azure.ai.agentserver.githubcopilot" - return attrs + # Emit done events — correct RAPI ordering (enforced by state machine) + yield text_builder.emit_done(accumulated_text) + yield msg.emit_content_done(text_builder) + yield msg.emit_done() + yield stream.emit_completed(usage=usage) - def get_agent_identifier(self) -> str: - agent_name = os.getenv(Constants.AGENT_NAME) - if agent_name: - return agent_name - agent_id = os.getenv(Constants.AGENT_ID) - if agent_id: - return agent_id - return "HostedAgent-GitHubCopilot" + logger.info(f"Response complete: {event_count} Copilot events, {len(accumulated_text)} chars") - # ------------------------------------------------------------------ - # Diagnostic bypass — mimics diag-echo-delayed inside real adapter - # ------------------------------------------------------------------ + def run(self, port: int = None): + """Start the adapter server. - async def _diag_bypass_stream( - self, context: AgentRunContext, - ) -> AsyncGenerator[ResponseStreamEvent, None]: - """Synthetic stream matching diag-echo-delayed pattern exactly. - - Proves whether the issue is in the adapter class/base class - interaction or in the Copilot SDK async pattern. + :param port: Port to listen on. Defaults to ``PORT`` env var or 8088. """ - from azure.ai.agentserver.core.models import Response as _OAIResponse - from azure.ai.agentserver.core.models.projects import ( - ItemContentOutputText as _Part, - ResponsesAssistantMessageItemResource as _Item, - ) - - response_id = context.response_id - item_id = context.id_generator.generate_message_id() - created_at = int(time.time()) - seq = 0 - - def next_seq(): - nonlocal seq; seq += 1; return seq - - def resp(status, output=None): - return _OAIResponse({"object": "response", "id": response_id, - "status": status, "created_at": created_at, - "output": output or []}) + if self._server is None: + self._setup_server() + self._server.run(port=port) - logger.info("DIAG_BYPASS: starting synthetic stream with 4s delay") + async def run_async(self, port: int = None): + """Start the adapter server asynchronously. - # Envelope (keyword args + model objects — proven pattern) - yield ResponseCreatedEvent(sequence_number=next_seq(), response=resp("in_progress")) - yield ResponseInProgressEvent(sequence_number=next_seq(), response=resp("in_progress")) - yield ResponseOutputItemAddedEvent( - sequence_number=next_seq(), output_index=0, - item=_Item(id=item_id, status="in_progress", content=[]), - ) - yield ResponseContentPartAddedEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, - part=_Part(text="", annotations=[], logprobs=[]), - ) - - # 4-second delay with 50ms heartbeats (same as diag-echo-delayed) - import asyncio as _aio - deadline = _aio.get_running_loop().time() + 4.0 - while _aio.get_running_loop().time() < deadline: - await _aio.sleep(0.05) - yield ResponseTextDeltaEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, delta="", - ) - - # Content - text = "[DIAG_BYPASS] Synthetic response after 4s delay" - yield ResponseTextDeltaEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, delta=text, - ) - - # Done - final_part = _Part(text=text, annotations=[], logprobs=[]) - yield ResponseTextDoneEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, text=text, - ) - yield ResponseContentPartDoneEvent( - sequence_number=next_seq(), item_id=item_id, - output_index=0, content_index=0, part=final_part, - ) - yield ResponseOutputItemDoneEvent( - sequence_number=next_seq(), output_index=0, - item=_Item(id=item_id, status="completed", content=[final_part]), - ) - yield ResponseCompletedEvent( - sequence_number=next_seq(), - response=resp("completed", output=[ - _Item(id=item_id, status="completed", content=[final_part])]), - ) - logger.info("DIAG_BYPASS: complete, %d events", seq) + :param port: Port to listen on. Defaults to ``PORT`` env var or 8088. + """ + if self._server is None: + self._setup_server() + await self._server.run_async(port=port) # --------------------------------------------------------------------------- @@ -708,7 +550,7 @@ class GitHubCopilotAdapter(CopilotAdapter): :param skill_directories: Explicit skill directory paths. When *None*, auto-discovered from the project root. - :param tools: Explicit list of :class:`copilot.Tool` objects. When *None*, + :param tools: Explicit list of tool objects. When *None*, auto-discovered from ``.github/tools/``. :param project_root: Root directory of the agent project. Defaults to the current working directory. @@ -837,88 +679,76 @@ async def initialize(self): async def _load_conversation_history(self, conversation_id: str) -> Optional[str]: """Load prior conversation turns from Foundry for cold-start bootstrap. - Requires ``_project_endpoint`` and ``_create_openai_client`` from the - ``FoundryCBAgent`` base class. If unavailable (e.g. older agentserver-core - version), history loading is silently skipped. + Creates its own AsyncOpenAI client to call the Conversations API. + Requires a project endpoint to be configured. """ - # The base class reads AZURE_AI_PROJECT_ENDPOINT. If the platform - # switches to FOUNDRY_PROJECT_ENDPOINT, the base class may not have it. - # Fall back to our own helper. - if not getattr(self, "_project_endpoint", None): - fallback = _get_project_endpoint() - if fallback: - self._project_endpoint = fallback - else: - return None - if not hasattr(self, "_create_openai_client"): - logger.debug("Base class does not provide _create_openai_client — skipping history") + project_endpoint = _get_project_endpoint() + if not project_endpoint: return None + try: - openai_client = await self._create_openai_client() - items = [] - async for item in openai_client.conversations.items.list(conversation_id): - items.append(item) - items.reverse() # API returns reverse chronological - - if not items: - return None - - lines = [] - for item in items: - role = getattr(item, "role", None) - content = getattr(item, "content", None) - if isinstance(content, str): - text = content - elif isinstance(content, list): - text_parts = [] - for part in content: - if isinstance(part, dict): - text_parts.append(part.get("text", "")) - elif hasattr(part, "text"): - text_parts.append(part.text) - text = " ".join(p for p in text_parts if p) - else: - continue - if not text: - continue - label = "User" if role == "user" else "Assistant" - lines.append(f"{label}: {text}") + from azure.identity.aio import DefaultAzureCredential as AsyncDefaultCredential, get_bearer_token_provider + from openai import AsyncOpenAI - return "\n".join(lines) if lines else None + cred = AsyncDefaultCredential() + try: + token_provider = get_bearer_token_provider(cred, "https://ai.azure.com/.default") + token = await token_provider() + openai_client = AsyncOpenAI( + base_url=f"{project_endpoint}/openai", + api_key=token, + default_query={"api-version": "2025-11-15-preview"}, + ) + + items = [] + async for item in openai_client.conversations.items.list(conversation_id): + items.append(item) + items.reverse() # API returns reverse chronological + + if not items: + return None + + lines = [] + for item in items: + role = getattr(item, "role", None) + content = getattr(item, "content", None) + if isinstance(content, str): + text = content + elif isinstance(content, list): + text_parts = [] + for part in content: + if isinstance(part, dict): + text_parts.append(part.get("text", "")) + elif hasattr(part, "text"): + text_parts.append(part.text) + text = " ".join(p for p in text_parts if p) + else: + continue + if not text: + continue + label = "User" if role == "user" else "Assistant" + lines.append(f"{label}: {text}") + + return "\n".join(lines) if lines else None + finally: + await cred.close() except Exception: logger.warning("Failed to load conversation history for %s", conversation_id, exc_info=True) return None - async def agent_run(self, context: AgentRunContext): - conversation_id = context.conversation_id - - # Cold-start bootstrap: pre-create session with history + async def _get_or_create_session(self, conversation_id=None): + """Override to add conversation history bootstrap on cold start.""" if conversation_id and conversation_id not in self._sessions: history = await self._load_conversation_history(conversation_id) if history: client = await self._ensure_client() config = self._refresh_token_if_needed() - acl = self._acl - - def _perm_result_boot(**kwargs): - if PermissionRequestResult is not None: - return PermissionRequestResult(**kwargs) - return kwargs - - def _on_permission_boot(req, _ctx): - kind = getattr(req, "kind", "unknown") - if acl is None: - return _perm_result_boot(kind="approved") - req_dict = vars(req) if not isinstance(req, dict) else req - if acl.is_allowed(req_dict): - return _perm_result_boot(kind="approved") - logger.warning(f"ACL denied tool request during history bootstrap: kind={kind}") - return _perm_result_boot(kind="denied-by-rules", rules=[]) sdk_config = {k: v for k, v in config.items() if not k.startswith("_")} + session = await client.create_session( **sdk_config, - on_permission_request=_on_permission_boot, + on_permission_request=self._make_permission_handler(), streaming=True, ) preamble = ( @@ -930,7 +760,7 @@ def _on_permission_boot(req, _ctx): self._sessions[conversation_id] = session logger.info("Bootstrapped session %s with %d chars of history", conversation_id, len(history)) - return await super().agent_run(context) + return await super()._get_or_create_session(conversation_id) def get_model(self) -> Optional[str]: """Get the currently configured model. @@ -965,97 +795,7 @@ def clear_default_model(self) -> None: logger.warning("Failed to clear model cache", exc_info=True) else: # Non-Foundry mode: reset to environment-based default - # Reuse _build_session_config() to ensure consistent default-model resolution default_config = _build_session_config() default_model = default_config.get("model") self._session_config["model"] = default_model logger.info(f"Reset model to environment default: {default_model}") - - -# --------------------------------------------------------------------------- -# Copilot event iterator -# --------------------------------------------------------------------------- - -async def _iter_copilot_events( - session, prompt: str, attachments: Optional[list] = None, timeout: int = 0 -): - """Send *prompt* to *session* and yield each ``SessionEvent`` as it arrives. - - True async generator — yields events immediately as the Copilot SDK - emits them. Consecutive duplicate events are silently dropped. Stops - after ``SESSION_IDLE``. - - The *timeout* is an **idle timeout** — it resets every time an event - is received. Configurable via ``COPILOT_IDLE_TIMEOUT`` env var - (default 300 s). A heartbeat log is emitted every - ``COPILOT_HEARTBEAT_INTERVAL`` seconds (default 30 s) while waiting. - """ - if timeout <= 0: - timeout = int(os.getenv("COPILOT_IDLE_TIMEOUT", "300")) - heartbeat_interval = int(os.getenv("COPILOT_HEARTBEAT_INTERVAL", "30")) - - queue: asyncio.Queue = asyncio.Queue() - last_key = None - event_count = 0 - - def on_event(event): - nonlocal last_key, event_count - text = "" - if event.data and hasattr(event.data, "content") and event.data.content: - text = event.data.content - key = (event.type, text) - if key == last_key: - return - last_key = key - - event_count += 1 - event_name = event.type.name if event.type else "UNKNOWN" - - # Rich logging: tool details, content preview, or basic event name - data = event.data - if event_name in ("TOOL_EXECUTION_START", "TOOL_EXECUTION_COMPLETE", "TOOL_EXECUTION_PARTIAL_RESULT") and data: - tool_name = getattr(data, "tool_name", None) or getattr(data, "name", "") - call_id = getattr(data, "call_id", "") - args = str(getattr(data, "arguments", ""))[:500] - logger.info(f"Copilot event #{event_count:03d}: {event_name} tool={tool_name!r} call_id={call_id!r} args={args}") - elif text: - preview = text[:300].replace("\n", "\\n") - logger.info(f"Copilot event #{event_count:03d}: {event_name} content_len={len(text)} preview={preview!r}") - else: - logger.info(f"Copilot event #{event_count:03d}: {event_name}") - - if event.type == SessionEventType.SESSION_ERROR and event.data: - error_msg = getattr(event.data, "message", None) or getattr(event.data, "content", None) or repr(event.data) - logger.warning(f"SESSION_ERROR details: {error_msg}") - - queue.put_nowait(event) - if event.type == SessionEventType.SESSION_IDLE: - queue.put_nowait(None) # sentinel - - unsubscribe = session.on(on_event) - try: - await session.send(prompt, attachments=attachments or None) - last_event_name = "SEND" - elapsed_since_last_event = 0.0 - while True: - try: - event = await asyncio.wait_for(queue.get(), timeout=heartbeat_interval) - elapsed_since_last_event = 0.0 - last_event_name = event.type.name if event and event.type else "UNKNOWN" - if event is None: - return - yield event - except asyncio.TimeoutError: - elapsed_since_last_event += heartbeat_interval - if elapsed_since_last_event >= timeout: - raise asyncio.TimeoutError( - f"Copilot idle timeout: no events for {timeout}s " - f"(last: {last_event_name}, total events: {event_count})" - ) - logger.info( - f"Heartbeat: waiting for Copilot events... " - f"{elapsed_since_last_event:.0f}s/{timeout}s idle " - f"(last: {last_event_name}, total events: {event_count})" - ) - finally: - unsubscribe() diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_copilot_request_converter.py b/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_copilot_request_converter.py deleted file mode 100644 index d47b99713bab..000000000000 --- a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_copilot_request_converter.py +++ /dev/null @@ -1,313 +0,0 @@ -# --------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# --------------------------------------------------------- -import base64 -import mimetypes -import os -import tempfile -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Tuple, Union - -from azure.ai.agentserver.core.models import CreateResponse - -# MIME type -> preferred file extension (mimetypes can return unusual choices) -_MIME_EXT_OVERRIDES: Dict[str, str] = { - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/png": ".png", - "image/gif": ".gif", - "image/webp": ".webp", - "image/bmp": ".bmp", - "image/tiff": ".tiff", - "text/plain": ".txt", - "text/csv": ".csv", - "application/pdf": ".pdf", - "application/json": ".json", -} - - -@dataclass -class ConvertedAttachments: - """Attachments ready to pass to ``MessageOptions``, plus temporary files. - - Pass :attr:`attachments` directly to ``MessageOptions(attachments=...)``. - Call :meth:`cleanup` (ideally in a ``finally`` block) to delete any - temporary files that were created while materialising base64-encoded - content parts onto disk. - - Usage:: - - converted = converter.convert_attachments() - try: - await session.send(MessageOptions(prompt=prompt, attachments=converted.attachments)) - finally: - converted.cleanup() - """ - - attachments: List[Any] - _temp_paths: List[str] = field(default_factory=list) - - def cleanup(self) -> None: - """Delete any temporary files created for this set of attachments.""" - for p in list(self._temp_paths): - try: - os.unlink(p) - except OSError: - pass - self._temp_paths.clear() - - def __bool__(self) -> bool: - return bool(self.attachments) - - -class CopilotRequestConverter: - """Converts an AgentRunContext request into a prompt string for the Copilot SDK.""" - - def __init__(self, request: CreateResponse): - self._request = request - - def convert(self) -> str: - """Extract a prompt string from the incoming CreateResponse request. - - Handles several input shapes: - - - ``str``: returned as-is - - ``list[dict]``: messages are concatenated in order - - ``dict`` with ``content`` key: treated as a single implicit user message - - For ``input_image`` content parts that carry an external HTTP/HTTPS URL, - a short ``[image: ]`` annotation is appended so the model at least - knows an image was supplied. Images sent as base64 data URIs, and files - sent via ``file_data``, produce no annotation here -- their content is - materialised onto disk by :meth:`convert_attachments` and passed as - SDK ``FileAttachment`` objects instead. - - :return: The extracted prompt string. - :rtype: str - """ - raw_input = self._request.get("input") - if raw_input is None: - return "" - if isinstance(raw_input, str): - return raw_input - if isinstance(raw_input, list): - return self._convert_message_list(raw_input) - if isinstance(raw_input, dict): - return self._extract_content(raw_input) - raise ValueError(f"Unsupported input type: {type(raw_input)}") - - def convert_attachments(self) -> ConvertedAttachments: - """Extract file and image attachments from the request's content parts. - - Scans all messages in ``input`` for ``input_file`` and ``input_image`` - content parts and materialises their data onto disk as temporary files, - returning :class:`ConvertedAttachments` with Copilot SDK - ``FileAttachment`` dicts and a list of temp paths to clean up. - - Supported cases: - - ``input_file`` with ``file_data`` (base64) - Decoded and written to a temp file. The ``filename`` field is used - to infer the file extension when present. - - ``input_image`` with a ``data:`` URI - Decoded and written to a temp file with the appropriate image - extension (e.g. ``.jpg``, ``.png``). - - ``input_file`` with only a ``file_id`` (no ``file_data``) - Cannot be materialised here -- skipped. The converter includes a - ``[file: ]`` annotation in the text prompt instead. - - ``input_image`` with an external ``http``/``https`` URL - Cannot be downloaded here -- skipped. The URL is included as - ``[image: ]`` in the text prompt by :meth:`convert`. - - :return: :class:`ConvertedAttachments` ready for ``MessageOptions``. - :rtype: ConvertedAttachments - """ - attachments: List[Any] = [] - temp_paths: List[str] = [] - - raw_input = self._request.get("input") - if not raw_input: - return ConvertedAttachments(attachments=attachments) - - messages: List[Any] = [raw_input] if isinstance(raw_input, (str, dict)) else list(raw_input) - - for msg in messages: - if isinstance(msg, str): - continue - content = msg.get("content", []) - if not isinstance(content, list): - continue - for part in content: - if not isinstance(part, dict): - continue - part_type = part.get("type") - if part_type == "input_file": - att, tmp = self._handle_input_file(part) - elif part_type == "input_image": - att, tmp = self._handle_input_image(part) - else: - continue - if att is not None: - attachments.append(att) - if tmp is not None: - temp_paths.append(tmp) - - return ConvertedAttachments(attachments=attachments, _temp_paths=temp_paths) - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - def _convert_message_list(self, messages: List[Dict[str, Any]]) -> str: - """Flatten a list of message dicts into a single prompt string.""" - parts: List[str] = [] - for msg in messages: - content = self._extract_content(msg) - if content: - parts.append(content) - return "\n".join(parts) - - @staticmethod - def _extract_content(msg: Union[Dict[str, Any], str]) -> str: - """Pull the text content out of a single message dict or string. - - Non-text content parts are handled as follows: - - * ``input_text`` -- text extracted normally. - * ``input_image`` with external URL -- annotated as ``[image: ]``. - * ``input_image`` with data URI -- omitted (passed as attachment). - * ``input_image`` with ``file_id`` -- annotated as ``[image file: ]``. - * ``input_file`` with ``file_id`` only -- annotated as ``[file: ]``. - * ``input_file`` with ``file_data`` -- omitted (passed as attachment). - """ - if isinstance(msg, str): - return msg - content = msg.get("content", "") - if isinstance(content, str): - return content - # content may be a list of content parts - if isinstance(content, list): - text_parts: List[str] = [] - for part in content: - if isinstance(part, str): - text_parts.append(part) - continue - if not isinstance(part, dict): - continue - part_type = part.get("type") - - if part_type == "input_text" or part_type is None: - text = part.get("text") - if text: - text_parts.append(str(text)) - - elif part_type == "input_image": - # Resolve URL -- may be nested dict or plain string - image_url_obj = part.get("image_url") - if isinstance(image_url_obj, dict): - url = image_url_obj.get("url", "") - elif isinstance(image_url_obj, str): - url = image_url_obj - else: - url = "" - - if url and not url.startswith("data:"): - # External URL -- include as an annotation in the prompt - text_parts.append(f"[image: {url}]") - elif not url: - file_id = part.get("file_id") - if file_id: - text_parts.append(f"[image file: {file_id}]") - # data: URIs are skipped -- content materialised as attachment - - elif part_type == "input_file": - # Only annotate when there is no file_data (that gets materialised) - if not part.get("file_data"): - name = part.get("filename") or part.get("file_id") or "file" - text_parts.append(f"[file: {name}]") - - return " ".join(text_parts) - return str(content) if content else "" - - # ------------------------------------------------------------------ - # Attachment materialisation helpers - # ------------------------------------------------------------------ - - @staticmethod - def _handle_input_file(part: Dict[str, Any]) -> Tuple[Optional[Dict], Optional[str]]: - """Materialise an ``input_file`` content part onto disk. - - Returns ``(FileAttachment | None, temp_path | None)``. - """ - file_data: Optional[str] = part.get("file_data") - filename: str = part.get("filename") or "attachment" - - if not file_data: - # file_id only -- we cannot fetch the bytes here; annotate in text instead - return None, None - - suffix = os.path.splitext(filename)[1] or ".bin" - try: - data = base64.b64decode(file_data) - except Exception: - return None, None - - fd, tmp_path = tempfile.mkstemp(suffix=suffix, prefix="copilot_file_") - try: - with os.fdopen(fd, "wb") as fh: - fh.write(data) - except Exception: - try: - os.unlink(tmp_path) - except OSError: - pass - return None, None - - att: Dict[str, Any] = {"type": "file", "path": tmp_path, "displayName": filename} - return att, tmp_path - - @staticmethod - def _handle_input_image(part: Dict[str, Any]) -> Tuple[Optional[Dict], Optional[str]]: - """Materialise an ``input_image`` content part onto disk. - - Returns ``(FileAttachment | None, temp_path | None)``. - Only base64 data URIs (``data:;base64,``) are handled. - External HTTP/HTTPS URLs cannot be fetched and are skipped. - """ - image_url_obj = part.get("image_url") - if isinstance(image_url_obj, dict): - url: str = image_url_obj.get("url", "") - elif isinstance(image_url_obj, str): - url = image_url_obj - else: - return None, None - - if not url.startswith("data:"): - # External URL -- cannot download here; annotated in prompt text instead - return None, None - - # Parse: data:;base64, - try: - header, encoded = url.split(",", 1) - mime = header.split(":")[1].split(";")[0] - ext = _MIME_EXT_OVERRIDES.get(mime) or (mimetypes.guess_extension(mime) or ".bin") - data = base64.b64decode(encoded) - except Exception: - return None, None - - fd, tmp_path = tempfile.mkstemp(suffix=ext, prefix="copilot_img_") - try: - with os.fdopen(fd, "wb") as fh: - fh.write(data) - except Exception: - try: - os.unlink(tmp_path) - except OSError: - pass - return None, None - - att: Dict[str, Any] = {"type": "file", "path": tmp_path, "displayName": f"image{ext}"} - return att, tmp_path diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_copilot_response_converter.py b/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_copilot_response_converter.py deleted file mode 100644 index 4a108c9501b8..000000000000 --- a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_copilot_response_converter.py +++ /dev/null @@ -1,355 +0,0 @@ -# --------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# --------------------------------------------------------- -import datetime -import time -from typing import Any, Dict, Generator, Optional - -from copilot.generated.session_events import SessionEvent, SessionEventType - -from azure.ai.agentserver.core.models import Response as OpenAIResponse -from azure.ai.agentserver.core.models.projects import ( - ItemContentOutputText, - ResponseCompletedEvent, - ResponseContentPartAddedEvent, - ResponseContentPartDoneEvent, - ResponseCreatedEvent, - ResponseFailedEvent, - ResponseInProgressEvent, - ResponseOutputItemAddedEvent, - ResponseOutputItemDoneEvent, - ResponsesAssistantMessageItemResource, - ResponseStreamEvent, - ResponseTextDeltaEvent, - ResponseTextDoneEvent, -) -from azure.ai.agentserver.core.server.common.agent_run_context import AgentRunContext - -import logging - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Helpers — build model objects for nested RAPI event fields. -# -# Nested objects MUST be model instances (not plain dicts). Dict-based -# nested objects cause SSE stream truncation on the ADC platform — the -# proxy drops events when as_dict() serializes differently for raw dicts -# vs typed models. Keyword-arg construction + model objects matches the -# agent_framework adapter pattern which is proven to stream correctly. -# --------------------------------------------------------------------------- - -def _make_message_item( - item_id: str, text: str, *, status: str = "completed", -) -> ResponsesAssistantMessageItemResource: - """Build an assistant message item model.""" - return ResponsesAssistantMessageItemResource( - id=item_id, status=status, - content=[ItemContentOutputText(text=text, annotations=[])], - ) - - -def _make_part(text: str = "") -> ItemContentOutputText: - """Build an output_text content part model.""" - return ItemContentOutputText(text=text, annotations=[]) - - -def _make_response( - response_id: str, - status: str, - created_at: int, - context: AgentRunContext, - output: Optional[list] = None, - usage: Optional[dict] = None, - error: Optional[dict] = None, -) -> OpenAIResponse: - """Build an OpenAI Response model.""" - resp_dict: Dict[str, Any] = { - "object": "response", - "id": response_id, - "status": status, - "created_at": created_at, - "output": output or [], - } - agent_id = context.get_agent_id_object() - if agent_id is not None: - resp_dict["agent_id"] = agent_id - conversation = context.get_conversation_object() - if conversation is not None: - resp_dict["conversation"] = conversation - if usage is not None: - resp_dict["usage"] = usage - if error is not None: - resp_dict["error"] = error - return OpenAIResponse(resp_dict) - - -class CopilotResponseConverter: - @staticmethod - def to_response(text: str, context: AgentRunContext, *, extra_output: Optional[list] = None) -> OpenAIResponse: - """Build a non-streaming OpenAI Response from the final assistant text. - - If *text* is empty, a fallback message is used so the response is - never blank. *extra_output* items (e.g. MCP consent requests) are - appended to the response output list. - """ - item_id = context.id_generator.generate_message_id() - if not text.strip(): - text = "(No response text was produced by the agent.)" - output: list = [ - ResponsesAssistantMessageItemResource( - id=item_id, - status="completed", - content=[ - ItemContentOutputText(text=text, annotations=[]), - ], - ) - ] - if extra_output: - output.extend(extra_output) - return OpenAIResponse( - id=context.response_id, - created_at=datetime.datetime.now(), - output=output, - ) - - -class CopilotStreamingResponseConverter: - """Converts Copilot SDK session events into RAPI streaming response events. - - Uses dict-based construction for all SSE events to ensure correct - serialization by the agentserver-core framework. This matches the - proven pattern from the hosted-agent-cli skills template. - - Event order per turn: - ASSISTANT_TURN_START - ASSISTANT_MESSAGE_DELTA xN (streaming text chunks) - ASSISTANT_USAGE (token counts — arrives BEFORE message) - ASSISTANT_MESSAGE (authoritative full text — always emitted) - ASSISTANT_TURN_END (always emitted, even on error) - SESSION_IDLE (session finished processing) - - In multi-turn (tool-calling) flows the turn sequence repeats. - """ - - def __init__(self, context: AgentRunContext): - self.context = context - self._sequence = -1 - self._created_at: int = int(time.time()) - self._accumulated_text: str = "" - self._turn_count: int = 0 - self._item_id: str = context.id_generator.generate_message_id() - self._usage: Optional[Dict[str, Any]] = None - self._completed: bool = False - self._failed: bool = False - self._session_error: Optional[str] = None - - def _seq(self) -> int: - self._sequence += 1 - return self._sequence - - def _resp(self, status: str, output=None, usage=None, error=None) -> OpenAIResponse: - return _make_response( - self.context.response_id, status, self._created_at, - self.context, output=output, usage=usage, error=error, - ) - - def _resp_minimal(self, status: str) -> OpenAIResponse: - """Minimal response model for envelope events — keeps initial SSE burst small. - - The ADC proxy has a limited initial read buffer. Full response - dicts (with agent_id, conversation, output, metadata) push the - first 4 envelope events over the buffer limit, causing truncation. - Use this for created/in_progress; use ``_resp`` for completed. - """ - return OpenAIResponse({"id": self.context.response_id, "object": "response", - "status": status, "created_at": self._created_at}) - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def to_stream_events( - self, events: list[SessionEvent], context: AgentRunContext, - ) -> Generator[ResponseStreamEvent, None, None]: - """Convert a collected batch of Copilot SessionEvents into RAPI stream events.""" - for event in events: - yield from self._convert_event(event, context) - - # ------------------------------------------------------------------ - # Event conversion - # ------------------------------------------------------------------ - - def _convert_event( - self, event: SessionEvent, context: AgentRunContext, - ) -> Generator[ResponseStreamEvent, None, None]: - """Yield zero or more RAPI ResponseStreamEvents for a single Copilot session event.""" - item_id = self._item_id - - match event: - - # -- Turn start -- - case SessionEvent(type=SessionEventType.ASSISTANT_TURN_START): - self._item_id = context.id_generator.generate_message_id() - item_id = self._item_id - self._accumulated_text = "" - is_first_turn = self._turn_count == 0 - self._turn_count += 1 - - if is_first_turn: - yield ResponseCreatedEvent( - sequence_number=self._seq(), - response=self._resp_minimal("in_progress"), - ) - yield ResponseInProgressEvent( - sequence_number=self._seq(), - response=self._resp_minimal("in_progress"), - ) - - yield ResponseOutputItemAddedEvent( - sequence_number=self._seq(), output_index=0, - item=_make_message_item(item_id, "", status="in_progress"), - ) - yield ResponseContentPartAddedEvent( - sequence_number=self._seq(), item_id=item_id, - output_index=0, content_index=0, - part=_make_part(""), - ) - - # -- Streaming text delta -- - case SessionEvent(type=SessionEventType.ASSISTANT_MESSAGE_DELTA, data=data) if data and data.content: - self._accumulated_text += data.content - yield ResponseTextDeltaEvent( - sequence_number=self._seq(), item_id=item_id, - output_index=0, content_index=0, delta=data.content, - ) - - # -- Token / model usage (arrives BEFORE ASSISTANT_MESSAGE) -- - case SessionEvent(type=SessionEventType.ASSISTANT_USAGE, data=data) if data: - usage: Dict[str, Any] = {} - if data.input_tokens is not None: - usage["input_tokens"] = int(data.input_tokens) - if data.output_tokens is not None: - usage["output_tokens"] = int(data.output_tokens) - total = (int(data.input_tokens or 0)) + (int(data.output_tokens or 0)) - if total: - usage["total_tokens"] = total - if usage: - self._usage = usage - - # -- Full assistant message (authoritative, always emitted) -- - # Emit a synthetic delta if no streaming deltas arrived, then - # emit all done-events immediately. - case SessionEvent(type=SessionEventType.ASSISTANT_MESSAGE, data=data) if data and data.content: - text = data.content - - if not self._accumulated_text: - self._accumulated_text = text - yield ResponseTextDeltaEvent( - sequence_number=self._seq(), item_id=item_id, - output_index=0, content_index=0, delta=text, - ) - - final_item = _make_message_item(item_id, text) - yield ResponseTextDoneEvent( - sequence_number=self._seq(), item_id=item_id, - output_index=0, content_index=0, text=text, - ) - yield ResponseContentPartDoneEvent( - sequence_number=self._seq(), item_id=item_id, - output_index=0, content_index=0, part=_make_part(text), - ) - yield ResponseOutputItemDoneEvent( - sequence_number=self._seq(), output_index=0, - item=final_item, - ) - yield ResponseCompletedEvent( - sequence_number=self._seq(), - response=self._resp("completed", output=[final_item], usage=self._usage), - ) - self._completed = True - - # -- Session error -- - case SessionEvent(type=SessionEventType.SESSION_ERROR, data=data): - error_msg = "" - if data: - error_msg = getattr(data, 'message', None) or getattr(data, 'content', None) or repr(data) - self._session_error = error_msg - logger.error(f"Copilot session error: {error_msg}") - - if not self._completed and not self._failed: - yield ResponseFailedEvent( - sequence_number=self._seq(), - response=self._resp("failed", error={"code": "server_error", "message": error_msg}), - ) - self._failed = True - - # -- Turn end -- - case SessionEvent(type=SessionEventType.ASSISTANT_TURN_END): - pass - - # -- Session idle (safety net) -- - case SessionEvent(type=SessionEventType.SESSION_IDLE): - if not self._completed and not self._failed and self._turn_count > 0: - logger.warning("SESSION_IDLE without response.completed -- forcing completion") - text = self._accumulated_text - if not text.strip(): - if self._session_error: - text = f"(Agent error: {self._session_error})" - else: - text = "(No response text was produced by the agent.)" - final_item = _make_message_item(item_id, text) - yield ResponseTextDeltaEvent( - sequence_number=self._seq(), item_id=item_id, - output_index=0, content_index=0, delta=text, - ) - yield ResponseTextDoneEvent( - sequence_number=self._seq(), item_id=item_id, - output_index=0, content_index=0, text=text, - ) - yield ResponseContentPartDoneEvent( - sequence_number=self._seq(), item_id=item_id, - output_index=0, content_index=0, part=_make_part(text), - ) - yield ResponseOutputItemDoneEvent( - sequence_number=self._seq(), output_index=0, - item=final_item, - ) - yield ResponseCompletedEvent( - sequence_number=self._seq(), - response=self._resp("completed", output=[final_item], usage=self._usage), - ) - self._completed = True - - # -- MCP OAuth consent required -- - case SessionEvent(type=SessionEventType.MCP_OAUTH_REQUIRED, data=data) if data: - consent_url = getattr(data, "url", "") or "" - server_label = ( - getattr(data, "server_name", "") - or getattr(data, "name", "") - or "unknown" - ) - logger.info(f"MCP OAuth consent required: server={server_label} url={consent_url}") - consent_item = { - "type": "oauth_consent_request", - "id": context.id_generator.generate_message_id(), - "consent_link": consent_url, - "server_label": server_label, - } - yield ResponseOutputItemAddedEvent( - sequence_number=self._seq(), output_index=1, item=consent_item, - ) - yield ResponseOutputItemDoneEvent( - sequence_number=self._seq(), output_index=1, item=consent_item, - ) - - # -- Reasoning -- - case SessionEvent(type=SessionEventType.ASSISTANT_REASONING, data=data): - if data and data.content: - logger.debug(f"Copilot reasoning: {data.content[:120]!r}") - - # -- All other events -- - case _: - ename = event.type.name if event.type else "UNKNOWN" - logger.debug(f"Unhandled Copilot event: {ename}") diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_tool_discovery.py b/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_tool_discovery.py index 3f37135ba6a3..d00748ad365e 100644 --- a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_tool_discovery.py +++ b/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_tool_discovery.py @@ -22,11 +22,7 @@ import yaml -try: - from copilot import Tool -except ImportError: - # Copilot SDK renamed/moved Tool in some versions - Tool = None # type: ignore +from copilot.tools import Tool logger = logging.getLogger(__name__) diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_version.py b/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_version.py index 3163bc00abbe..0dcf5333ec20 100644 --- a/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_version.py +++ b/sdk/agentserver/azure-ai-agentserver-githubcopilot/azure/ai/agentserver/githubcopilot/_version.py @@ -3,4 +3,4 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # --------------------------------------------------------- -VERSION = "1.0.0b1" +VERSION = "1.0.0b2" diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-githubcopilot/pyproject.toml index 3d8b3bd0cf6c..3847264819e5 100644 --- a/sdk/agentserver/azure-ai-agentserver-githubcopilot/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-githubcopilot/pyproject.toml @@ -20,9 +20,9 @@ classifiers = [ keywords = ["azure", "azure sdk"] dependencies = [ - "azure-ai-agentserver-core>=1.0.0b14,<1.0.0b18", + "azure-ai-agentserver-core>=2.0.0a1", + "azure-ai-agentserver-responses>=1.0.0a1", "github-copilot-sdk>=0.2.0,<0.3.0", - "opentelemetry-exporter-otlp-proto-http", "azure-identity", "pyyaml>=6.0", ] diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/integration/deploy.py b/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/integration/deploy.py index 4ac9cc50a74f..606e502d74cc 100644 --- a/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/integration/deploy.py +++ b/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/integration/deploy.py @@ -69,6 +69,8 @@ def build_image(staging_dir: Path, acr: str, name: str, tag: str) -> str: full_image = f"{acr}.azurecr.io/{name}:{tag}" print(f"Building {full_image} via ACR Tasks...") + is_win = sys.platform == "win32" + cmd = ["az", "acr", "build", "--registry", acr, "--image", f"{name}:{tag}", @@ -76,16 +78,29 @@ def build_image(staging_dir: Path, acr: str, name: str, tag: str) -> str: "--file", str(staging_dir / "Dockerfile"), str(staging_dir)] - is_win = sys.platform == "win32" - env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"} if is_win else None - proc = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - encoding="utf-8", errors="replace", shell=is_win, env=env, - ) - for line in proc.stdout: - sys.stdout.write(line) - sys.stdout.flush() - returncode = proc.wait() + if is_win: + # Skip log streaming on Windows to avoid colorama + cp1252 encoding crash. + cmd.insert(3, "--no-logs") + print(" (Windows: using --no-logs to avoid encoding issues)") + env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"} + result = subprocess.run( + cmd, capture_output=True, encoding="utf-8", errors="replace", + shell=True, env=env, + ) + if result.stdout: + sys.stdout.write(result.stdout) + if result.stderr: + sys.stderr.write(result.stderr) + returncode = result.returncode + else: + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + encoding="utf-8", errors="replace", + ) + for line in proc.stdout: + sys.stdout.write(line) + sys.stdout.flush() + returncode = proc.wait() if returncode != 0: print("\nWarning: az acr build returned non-zero exit code.", file=sys.stderr) diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/integration/test_agent/Dockerfile b/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/integration/test_agent/Dockerfile index 9abc3f37ec96..16a7bad6cd7e 100644 --- a/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/integration/test_agent/Dockerfile +++ b/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/integration/test_agent/Dockerfile @@ -1,16 +1,28 @@ -FROM mcr.microsoft.com/mirror/docker/library/python:3.11-slim +FROM python:3.11-slim WORKDIR /app +# Install git (needed for some pip dependencies) +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* + +# Install base packages from Azure DevOps feed FIRST (separate pip call +# to avoid the feed interfering with github-copilot-sdk from PyPI). +RUN pip install --no-cache-dir --no-input --pre \ + --extra-index-url https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/ \ + "azure-ai-agentserver-core>=2.0.0a1" \ + "azure-ai-agentserver-responses>=1.0.0a1" + # Copy the package source for local install (not on PyPI yet) -COPY _package/ /tmp/azure-ai-agentserver-github/ +COPY _package/ /tmp/azure-ai-agentserver-githubcopilot/ # Copy the test agent COPY . /app/ -# Install the package from local source + agent deps -RUN pip install --pre /tmp/azure-ai-agentserver-github/ -r requirements.txt && \ - rm -rf /tmp/azure-ai-agentserver-github/ +# Install the package from local source + agent deps (PyPI only — no dev feed). +RUN pip install --no-cache-dir --pre \ + /tmp/azure-ai-agentserver-githubcopilot/ \ + -r requirements.txt && \ + rm -rf /tmp/azure-ai-agentserver-githubcopilot/ EXPOSE 8088 diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/unit_tests/test_copilot_request_converter.py b/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/unit_tests/test_copilot_request_converter.py deleted file mode 100644 index 68e7c25ff4b8..000000000000 --- a/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/unit_tests/test_copilot_request_converter.py +++ /dev/null @@ -1,237 +0,0 @@ -# --------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -# --------------------------------------------------------- -"""Unit tests for CopilotRequestConverter. - -Tests cover prompt extraction from various input shapes and -attachment materialization from base64 content parts. -""" - -import base64 -import os -import pytest - -from azure.ai.agentserver.core.models import CreateResponse -from azure.ai.agentserver.githubcopilot._copilot_request_converter import ( - CopilotRequestConverter, - ConvertedAttachments, -) - - -# --------------------------------------------------------------------------- -# convert() — prompt extraction -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestConvertPrompt: - """Tests for CopilotRequestConverter.convert() prompt extraction.""" - - def test_string_input(self): - """Plain string input is returned as-is.""" - request = CreateResponse(input="hello world") - converter = CopilotRequestConverter(request) - assert converter.convert() == "hello world" - - def test_empty_input(self): - """Missing input returns empty string.""" - request = CreateResponse(input=None) - converter = CopilotRequestConverter(request) - assert converter.convert() == "" - - def test_message_list_with_text_content(self): - """List of messages with text content parts.""" - request = CreateResponse(input=[ - {"content": [{"type": "input_text", "text": "first message"}]}, - {"content": [{"type": "input_text", "text": "second message"}]}, - ]) - converter = CopilotRequestConverter(request) - result = converter.convert() - assert "first message" in result - assert "second message" in result - - def test_message_with_string_content(self): - """Message with plain string content (not a list).""" - request = CreateResponse(input=[ - {"content": "simple text"}, - ]) - converter = CopilotRequestConverter(request) - assert converter.convert() == "simple text" - - def test_implicit_user_message(self): - """Dict with content key treated as single message.""" - request = CreateResponse(input={"content": "implicit message"}) - converter = CopilotRequestConverter(request) - assert converter.convert() == "implicit message" - - def test_image_url_annotation(self): - """External image URLs are included as annotations in the prompt.""" - request = CreateResponse(input=[ - { - "content": [ - {"type": "input_text", "text": "look at this"}, - { - "type": "input_image", - "image_url": {"url": "https://example.com/photo.jpg"}, - }, - ] - }, - ]) - converter = CopilotRequestConverter(request) - result = converter.convert() - assert "look at this" in result - assert "[image: https://example.com/photo.jpg]" in result - - def test_data_uri_image_not_in_prompt(self): - """Base64 data URI images are NOT included in the prompt text (handled as attachments).""" - data_uri = "data:image/png;base64,iVBORw0KGgo=" - request = CreateResponse(input=[ - { - "content": [ - {"type": "input_text", "text": "see this"}, - {"type": "input_image", "image_url": {"url": data_uri}}, - ] - }, - ]) - converter = CopilotRequestConverter(request) - result = converter.convert() - assert "see this" in result - assert "data:" not in result - - def test_file_without_data_annotated(self): - """File with only file_id (no file_data) is annotated in the prompt.""" - request = CreateResponse(input=[ - { - "content": [ - {"type": "input_file", "file_id": "file_abc123", "filename": "report.pdf"}, - ] - }, - ]) - converter = CopilotRequestConverter(request) - result = converter.convert() - assert "[file: report.pdf]" in result - - -# --------------------------------------------------------------------------- -# convert_attachments() — file materialization -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestConvertAttachments: - """Tests for CopilotRequestConverter.convert_attachments().""" - - def test_no_attachments(self): - """String input produces no attachments.""" - request = CreateResponse(input="just text") - converter = CopilotRequestConverter(request) - result = converter.convert_attachments() - assert isinstance(result, ConvertedAttachments) - assert not result # bool(ConvertedAttachments) is False when empty - - def test_base64_file_attachment(self): - """Base64 file_data is materialized to a temp file.""" - content = b"hello world" - b64 = base64.b64encode(content).decode() - request = CreateResponse(input=[ - { - "content": [ - { - "type": "input_file", - "file_data": b64, - "filename": "test.txt", - }, - ] - }, - ]) - converter = CopilotRequestConverter(request) - result = converter.convert_attachments() - try: - assert result # has attachments - assert len(result.attachments) == 1 - att = result.attachments[0] - assert att["type"] == "file" - assert att["displayName"] == "test.txt" - # Verify temp file exists and has correct content - assert os.path.exists(att["path"]) - with open(att["path"], "rb") as f: - assert f.read() == content - finally: - result.cleanup() - - def test_base64_image_attachment(self): - """Base64 data URI image is materialized to a temp file.""" - # Minimal valid PNG (1x1 pixel) - png_bytes = base64.b64decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" - ) - b64 = base64.b64encode(png_bytes).decode() - data_uri = f"data:image/png;base64,{b64}" - request = CreateResponse(input=[ - { - "content": [ - {"type": "input_image", "image_url": {"url": data_uri}}, - ] - }, - ]) - converter = CopilotRequestConverter(request) - result = converter.convert_attachments() - try: - assert result - assert len(result.attachments) == 1 - att = result.attachments[0] - assert att["type"] == "file" - assert att["path"].endswith(".png") - assert os.path.exists(att["path"]) - with open(att["path"], "rb") as f: - assert f.read() == png_bytes - finally: - result.cleanup() - - def test_cleanup_removes_temp_files(self): - """cleanup() deletes all temp files.""" - content = b"temporary data" - b64 = base64.b64encode(content).decode() - request = CreateResponse(input=[ - { - "content": [ - {"type": "input_file", "file_data": b64, "filename": "tmp.bin"}, - ] - }, - ]) - converter = CopilotRequestConverter(request) - result = converter.convert_attachments() - paths = [att["path"] for att in result.attachments] - assert all(os.path.exists(p) for p in paths) - result.cleanup() - assert all(not os.path.exists(p) for p in paths) - - def test_external_url_image_not_materialized(self): - """External HTTP image URLs are NOT materialized as attachments.""" - request = CreateResponse(input=[ - { - "content": [ - { - "type": "input_image", - "image_url": {"url": "https://example.com/photo.jpg"}, - }, - ] - }, - ]) - converter = CopilotRequestConverter(request) - result = converter.convert_attachments() - assert not result # no attachments - - def test_file_id_only_not_materialized(self): - """File with only file_id (no file_data) is NOT materialized.""" - request = CreateResponse(input=[ - { - "content": [ - {"type": "input_file", "file_id": "file_abc", "filename": "doc.pdf"}, - ] - }, - ]) - converter = CopilotRequestConverter(request) - result = converter.convert_attachments() - assert not result diff --git a/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/unit_tests/test_replat_features.py b/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/unit_tests/test_replat_features.py new file mode 100644 index 000000000000..017eabb07c25 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-githubcopilot/tests/unit_tests/test_replat_features.py @@ -0,0 +1,380 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# --------------------------------------------------------- +"""Unit tests for replat features (core 2.0 + responses 1.0). + +Tests cover: +- Input text extraction with attachment handling +- Conversation ID fallback (session_id and conversation.id from raw_body) +- Session config building and BYOK URL derivation +""" + +import importlib +import os +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +# The copilot SDK may not be installed locally (or may be a different version). +# Mock the imports that _copilot_adapter needs at import time so we can test +# the pure-Python helpers without the full SDK. +_copilot_mock = MagicMock() +_copilot_mock.session.PermissionRequestResult = MagicMock +_copilot_mock.session.ProviderConfig = dict +_copilot_mock.generated.session_events.SessionEventType = MagicMock() +sys.modules.setdefault("copilot", _copilot_mock) +sys.modules.setdefault("copilot.session", _copilot_mock.session) +sys.modules.setdefault("copilot.generated", _copilot_mock.generated) +sys.modules.setdefault("copilot.generated.session_events", _copilot_mock.generated.session_events) + +# Also mock agentserver packages if not installed +for mod_name in [ + "azure.ai.agentserver.core", + "azure.ai.agentserver.responses", + "azure.ai.agentserver.responses.hosting", +]: + if mod_name not in sys.modules: + sys.modules[mod_name] = MagicMock() + +from azure.ai.agentserver.githubcopilot._copilot_adapter import ( + _build_session_config, + _derive_resource_url_from_project_endpoint, + _extract_input_with_attachments, + _get_project_endpoint, +) +from azure.ai.agentserver.githubcopilot._copilot_adapter import GitHubCopilotAdapter + + +# --------------------------------------------------------------------------- +# _extract_input_with_attachments tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestExtractInputWithAttachments: + """Tests for _extract_input_with_attachments().""" + + def test_text_only_request(self): + """Returns text from get_input_text when no attachments.""" + request = SimpleNamespace(input=[ + {"type": "message", "role": "user", "content": [ + {"type": "input_text", "text": "hello"} + ]} + ]) + with patch("azure.ai.agentserver.githubcopilot._copilot_adapter.get_input_text", return_value="hello"): + result = _extract_input_with_attachments(request) + assert result == "hello" + + def test_with_file_attachment(self): + """Appends decoded file content to prompt text.""" + import base64 + file_content = base64.b64encode(b"file contents here").decode() + request = SimpleNamespace(input=[ + {"type": "input_text", "text": "check this"}, + {"type": "input_file", "filename": "test.txt", "file_data": file_content}, + ]) + with patch("azure.ai.agentserver.githubcopilot._copilot_adapter.get_input_text", return_value="check this"): + result = _extract_input_with_attachments(request) + assert "check this" in result + assert "[Attached file: test.txt]" in result + assert "file contents here" in result + + def test_with_image_attachment(self): + """Appends image URL reference to prompt text.""" + request = SimpleNamespace(input=[ + {"type": "input_text", "text": "what is this"}, + {"type": "input_image", "image_url": {"url": "https://example.com/img.png"}}, + ]) + with patch("azure.ai.agentserver.githubcopilot._copilot_adapter.get_input_text", return_value="what is this"): + result = _extract_input_with_attachments(request) + assert "what is this" in result + assert "[Attached image: https://example.com/img.png]" in result + + def test_no_input_attribute(self): + """Returns plain text when request has no input attribute.""" + request = SimpleNamespace() + with patch("azure.ai.agentserver.githubcopilot._copilot_adapter.get_input_text", return_value="hello"): + result = _extract_input_with_attachments(request) + assert result == "hello" + + def test_dict_items(self): + """Handles input items as dicts (not objects).""" + request = SimpleNamespace(input=[ + {"type": "input_file", "filename": "data.csv", "file_data": ""}, + ]) + with patch("azure.ai.agentserver.githubcopilot._copilot_adapter.get_input_text", return_value="test"): + result = _extract_input_with_attachments(request) + # Empty file_data should not add attachment + assert result == "test" + + +# --------------------------------------------------------------------------- +# Conversation ID fallback tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestConversationIdFallback: + """Tests for conversation_id resolution in _handle_create.""" + + def _make_context(self, conversation_id=None, raw_body=None): + """Create a mock ResponseContext.""" + ctx = MagicMock() + ctx.conversation_id = conversation_id + ctx.raw_body = raw_body + ctx.response_id = "test-response-id" + ctx.request = None + return ctx + + def test_context_conversation_id_used_when_present(self): + """Uses context.conversation_id when it's set.""" + ctx = self._make_context(conversation_id="conv_123") + # conversation_id is already set — no fallback needed + assert ctx.conversation_id == "conv_123" + + def test_fallback_to_session_id_in_raw_body(self): + """Falls back to raw_body['session_id'] when conversation_id is None.""" + ctx = self._make_context( + conversation_id=None, + raw_body={"session_id": "session-abc", "input": "hello"} + ) + # Simulate the fallback logic from _handle_create + conversation_id = ctx.conversation_id + if not conversation_id: + raw_body = ctx.raw_body + if isinstance(raw_body, dict): + conversation_id = raw_body.get("session_id") + assert conversation_id == "session-abc" + + def test_fallback_to_conversation_id_in_raw_body(self): + """Falls back to raw_body['conversation']['id'] for Playground.""" + ctx = self._make_context( + conversation_id=None, + raw_body={ + "input": "hello", + "conversation": {"id": "conv_playground_456"}, + } + ) + # Simulate the fallback logic from _handle_create + conversation_id = ctx.conversation_id + if not conversation_id: + raw_body = ctx.raw_body + if isinstance(raw_body, dict): + conversation_id = raw_body.get("session_id") + if not conversation_id: + conv = raw_body.get("conversation") + if isinstance(conv, dict): + conversation_id = conv.get("id") + assert conversation_id == "conv_playground_456" + + def test_session_id_takes_priority_over_conversation(self): + """session_id in raw_body takes priority over conversation.id.""" + ctx = self._make_context( + conversation_id=None, + raw_body={ + "session_id": "session-priority", + "conversation": {"id": "conv_lower_priority"}, + } + ) + conversation_id = ctx.conversation_id + if not conversation_id: + raw_body = ctx.raw_body + if isinstance(raw_body, dict): + conversation_id = raw_body.get("session_id") + if not conversation_id: + conv = raw_body.get("conversation") + if isinstance(conv, dict): + conversation_id = conv.get("id") + assert conversation_id == "session-priority" + + def test_none_when_nothing_available(self): + """Returns None when no conversation identity is available.""" + ctx = self._make_context(conversation_id=None, raw_body={"input": "hello"}) + conversation_id = ctx.conversation_id + if not conversation_id: + raw_body = ctx.raw_body + if isinstance(raw_body, dict): + conversation_id = raw_body.get("session_id") + if not conversation_id: + conv = raw_body.get("conversation") + if isinstance(conv, dict): + conversation_id = conv.get("id") + assert conversation_id is None + + def test_none_when_raw_body_not_dict(self): + """Returns None when raw_body is not a dict.""" + ctx = self._make_context(conversation_id=None, raw_body=None) + conversation_id = ctx.conversation_id + if not conversation_id: + raw_body = ctx.raw_body + if isinstance(raw_body, dict): + conversation_id = raw_body.get("session_id") + assert conversation_id is None + + +# --------------------------------------------------------------------------- +# _build_session_config tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBuildSessionConfig: + """Tests for _build_session_config().""" + + def test_default_github_mode(self): + """Defaults to GitHub Copilot mode when no BYOK vars set.""" + with patch.dict(os.environ, {}, clear=True): + config = _build_session_config() + assert config.get("model") == "gpt-5" + assert "provider" not in config + + def test_github_mode_with_custom_model(self): + """Uses COPILOT_MODEL env var for model name.""" + with patch.dict(os.environ, {"COPILOT_MODEL": "claude-sonnet"}, clear=True): + config = _build_session_config() + assert config["model"] == "claude-sonnet" + + def test_byok_api_key_mode(self): + """Creates BYOK config with API key.""" + with patch.dict(os.environ, { + "AZURE_AI_FOUNDRY_RESOURCE_URL": "https://test.cognitiveservices.azure.com", + "AZURE_AI_FOUNDRY_API_KEY": "test-key", + }, clear=True): + config = _build_session_config() + assert config["provider"]["type"] == "openai" + assert config["provider"]["bearer_token"] == "test-key" + assert config["provider"]["wire_api"] == "completions" + assert "openai/v1/" in config["provider"]["base_url"] + + def test_byok_managed_identity_mode(self): + """Creates BYOK config with placeholder token for Managed Identity.""" + with patch.dict(os.environ, { + "AZURE_AI_FOUNDRY_RESOURCE_URL": "https://test.cognitiveservices.azure.com", + }, clear=True): + config = _build_session_config() + assert config["provider"]["type"] == "openai" + assert config["provider"]["bearer_token"] == "placeholder" + assert config["provider"]["wire_api"] == "completions" + + def test_auto_derive_from_project_endpoint(self): + """Auto-derives RESOURCE_URL from PROJECT_ENDPOINT when no GITHUB_TOKEN.""" + with patch.dict(os.environ, { + "AZURE_AI_PROJECT_ENDPOINT": "https://myresource.services.ai.azure.com/api/projects/myproject", + }, clear=True): + config = _build_session_config() + assert "provider" in config + assert "cognitiveservices.azure.com" in config["provider"]["base_url"] + + def test_github_token_prevents_auto_derive(self): + """GITHUB_TOKEN presence prevents auto-derivation of BYOK.""" + with patch.dict(os.environ, { + "AZURE_AI_PROJECT_ENDPOINT": "https://myresource.services.ai.azure.com/api/projects/myproject", + "GITHUB_TOKEN": "ghp_test", + }, clear=True): + config = _build_session_config() + # Should NOT have a provider — GITHUB_TOKEN means use GitHub auth + assert "provider" not in config + + +# --------------------------------------------------------------------------- +# URL derivation tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestUrlDerivation: + """Tests for URL derivation helpers.""" + + def test_derive_resource_url(self): + """Derives cognitiveservices URL from services.ai.azure.com endpoint.""" + result = _derive_resource_url_from_project_endpoint( + "https://myresource.services.ai.azure.com/api/projects/myproject" + ) + assert result == "https://myresource.cognitiveservices.azure.com" + + def test_derive_resource_url_china(self): + """Derives URL for China cloud.""" + result = _derive_resource_url_from_project_endpoint( + "https://myresource.services.ai.azure.cn/api/projects/myproject" + ) + assert result == "https://myresource.cognitiveservices.azure.cn" + + def test_derive_resource_url_invalid(self): + """Raises ValueError for unrecognized endpoint format.""" + with pytest.raises(ValueError, match="Cannot derive"): + _derive_resource_url_from_project_endpoint("https://unknown.example.com/foo") + + def test_get_project_endpoint_new_var(self): + """Prefers FOUNDRY_PROJECT_ENDPOINT over legacy name.""" + with patch.dict(os.environ, { + "FOUNDRY_PROJECT_ENDPOINT": "https://new.endpoint", + "AZURE_AI_PROJECT_ENDPOINT": "https://old.endpoint", + }, clear=True): + result = _get_project_endpoint() + assert result == "https://new.endpoint" + + def test_get_project_endpoint_legacy_var(self): + """Falls back to AZURE_AI_PROJECT_ENDPOINT.""" + with patch.dict(os.environ, { + "AZURE_AI_PROJECT_ENDPOINT": "https://old.endpoint", + }, clear=True): + result = _get_project_endpoint() + assert result == "https://old.endpoint" + + def test_get_project_endpoint_none(self): + """Returns None when no endpoint configured.""" + with patch.dict(os.environ, {}, clear=True): + result = _get_project_endpoint() + assert result is None + + +# --------------------------------------------------------------------------- +# Skill discovery tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSkillDiscovery: + """Tests for GitHubCopilotAdapter skill discovery.""" + + def test_discover_no_skills(self, tmp_path): + """Returns empty list when no SKILL.md files exist.""" + result = GitHubCopilotAdapter._discover_skill_directories(tmp_path) + assert result == [] + + def test_discover_github_skills(self, tmp_path): + """Discovers skills in .github/skills/ directory.""" + skills_dir = tmp_path / ".github" / "skills" / "greeting" + skills_dir.mkdir(parents=True) + (skills_dir / "SKILL.md").write_text("# Greeting skill") + + result = GitHubCopilotAdapter._discover_skill_directories(tmp_path) + assert len(result) == 1 + assert ".github" in result[0] + + def test_discover_flat_skills(self, tmp_path): + """Discovers skills in flat layout (root level).""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# My skill") + + result = GitHubCopilotAdapter._discover_skill_directories(tmp_path) + assert len(result) == 1 + + def test_github_skills_take_priority(self, tmp_path): + """Prefers .github/skills/ over flat layout.""" + # Create both + github_dir = tmp_path / ".github" / "skills" / "skill1" + github_dir.mkdir(parents=True) + (github_dir / "SKILL.md").write_text("# Skill 1") + + flat_dir = tmp_path / "skill2" + flat_dir.mkdir() + (flat_dir / "SKILL.md").write_text("# Skill 2") + + result = GitHubCopilotAdapter._discover_skill_directories(tmp_path) + assert len(result) == 1 + assert ".github" in result[0]