Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/python-packages/apiview-copilot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ async def chat():
print(f"Error: {e}")
else:
# Local mode: use async agent as before
async with get_main_agent() as agent:
with get_main_agent() as (client, agent_id):
while True:
try:
user_input = await async_input(f"{BOLD_GREEN}You:{RESET} ")
Expand All @@ -535,7 +535,11 @@ async def chat():
break
try:
response, thread_id_out, messages = await invoke_agent(
agent=agent, user_input=user_input, thread_id=current_thread_id, messages=messages
client=client,
agent_id=agent_id,
user_input=user_input,
thread_id=current_thread_id,
messages=messages,
)
print(f"{BOLD_BLUE}Agent:{RESET} {response}\n")
current_thread_id = thread_id_out
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ WEBAPP_NAME: apiview-copilot
FOUNDRY_ACCOUNT_NAME: azsdk-engsys-ai
FOUNDRY_PROJECT_NAME: azsdk-engsys-ai
FOUNDRY_KERNEL_MODEL: gpt-5
FOUNDRY_API_VERSION: 2025-05-15-preview
FOUNDRY_API_VERSION: 2025-11-15-preview
AI_RG: azsdk-engsys-ai
OPENAI_NAME: azsdk-engsys-openai
OPENAI_EMBEDDING_MODEL: text-embedding-3-large
Expand Down
194 changes: 92 additions & 102 deletions packages/python-packages/apiview-copilot/src/agent/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,124 +8,114 @@
Module for managing APIView Copilot agents.
"""

import logging
from contextlib import AsyncExitStack, asynccontextmanager
from datetime import timedelta

from azure.identity.aio import DefaultAzureCredential
from semantic_kernel import Kernel

# pylint: disable=no-name-in-module
from semantic_kernel.agents import (
AzureAIAgent,
AzureAIAgentSettings,
AzureAIAgentThread,
RunPollingOptions,
)
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
from src._settings import SettingsManager
import asyncio
from contextlib import contextmanager
from typing import Optional

from .plugins import (
ApiReviewPlugin,
SearchPlugin,
UtilityPlugin,
get_create_agent,
get_delete_agent,
get_link_agent,
get_retrieve_agent,
from azure.ai.agents import AgentsClient
from azure.ai.agents.models import (
FunctionTool,
MessageRole,
MessageTextContent,
ToolSet,
)
from src._credential import get_credential
from src._settings import SettingsManager
from src.agent.tools._api_review_tools import ApiReviewTools
from src.agent.tools._search_tools import SearchTools
from src.agent.tools._utility_tools import UtilityTools


def create_kernel() -> Kernel:
"""Creates a Kernel instance configured for Azure OpenAI."""
settings = SettingsManager()
base_url = settings.get("OPENAI_ENDPOINT")
deployment_name = settings.get("FOUNDRY_KERNEL_MODEL")
api_key = settings.get("OPENAI_API_KEY")
logging.info("Using Azure OpenAI at %s with deployment %s", base_url, deployment_name)
kernel = Kernel(
plugins={}, # Register your plugins here if needed
services={
"AzureChatCompletion": AzureChatCompletion(
base_url=base_url,
deployment_name=deployment_name,
api_key=api_key,
)
},
)
return kernel


async def invoke_agent(*, agent, user_input, thread_id=None, messages=None):
"""Invoke an agent with the provided user input and thread ID."""
async def invoke_agent(
*,
client: AgentsClient,
agent_id: str,
user_input: str,
thread_id: Optional[str] = None,
messages: Optional[list[str]] = None,
) -> tuple[str, str, list[str]]:
"""
Invoke an agent with the provided user input and thread ID.
Returns: (response_text, thread_id, messages)
"""
messages = messages or []
# Only append user_input if not already the last message
if not messages or messages[-1] != user_input:
messages.append(user_input)
# Only use thread_id if it is a valid Azure thread id (starts with 'thread')
if thread_id and isinstance(thread_id, str) and thread_id.startswith("thread"):
thread = AzureAIAgentThread(client=agent.client, thread_id=thread_id)
else:
thread = AzureAIAgentThread(client=agent.client)
response = await agent.get_response(messages=messages, thread=thread)
thread_id_out = getattr(thread, "id", None) or thread_id
return str(response), thread_id_out, messages


def _get_agent_settings() -> AzureAIAgentSettings:
"""Retrieve the Azure AI Agent settings from the configuration."""
settings = SettingsManager()
return AzureAIAgentSettings(
endpoint=settings.get("FOUNDRY_ENDPOINT"),
model_deployment_name=settings.get("FOUNDRY_KERNEL_MODEL"),
api_version=settings.get("FOUNDRY_API_VERSION"),
)

# 1) Ensure a thread exists
if not thread_id:
thread_obj = await asyncio.to_thread(client.threads.create)
thread_id = thread_obj.id

# 2) Add user message to the thread
await asyncio.to_thread(client.messages.create, thread_id=thread_id, role="user", content=user_input)

# 3) Process a run (polls until terminal state; executes tools if auto-enabled)
await asyncio.to_thread(client.runs.create_and_process, thread_id=thread_id, agent_id=agent_id)

@asynccontextmanager
async def get_main_agent():
# 4) Collect messages and extract the latest assistant text
all_messages = await asyncio.to_thread(client.messages.list, thread_id=thread_id)

def extract_text(obj):
"""Recursively extract all text values from nested lists/dicts and MessageTextContent."""
if isinstance(obj, MessageTextContent):
return obj.text.value
elif isinstance(obj, list):
return " ".join(extract_text(item) for item in obj)
elif isinstance(obj, str):
return obj
else:
return str(obj)

response_text = ""
for m in list(all_messages):
role = getattr(m, "role", None)
if role == MessageRole.AGENT.value or role == "assistant":
parts = getattr(m, "content", None)
response_text = extract_text(parts)
break
return response_text, thread_id, messages


@contextmanager
def get_main_agent():
"""Create and yield the main APIView Copilot agent."""
kernel = create_kernel()
ai_agent_settings = _get_agent_settings()
settings = SettingsManager()
endpoint = settings.get("FOUNDRY_ENDPOINT")
model_deployment_name = settings.get("FOUNDRY_KERNEL_MODEL")

ai_instructions = """
Your job is to receive a request from the user, determine their intent, and pass the request to the
appropriate agent or agents for processing. You will then return the response from that agent to the user.
If there's no agent that can handle the request, you will respond with a message indicating that you cannot
process the request. You will also handle any errors that occur during the processing of the request and return
an appropriate error message to the user.
"""
credential = get_credential()
client = AgentsClient(endpoint=endpoint, credential=credential)

# TODO: These were treated as subagents before and may not be applicable in the new format.
# delete_agent = await stack.enter_async_context(get_delete_agent())
# create_agent = await stack.enter_async_context(get_create_agent())
# retrieve_agent = await stack.enter_async_context(get_retrieve_agent())
# link_agent = await stack.enter_async_context(get_link_agent())

toolset = ToolSet()
tools = SearchTools().all_tools() + ApiReviewTools().all_tools() + UtilityTools().all_tools()
toolset.add(FunctionTool(tools))

agent = client.create_agent(
name="APIView Copilot Main Agent",
description="An agent that processes requests and passes work to other agents.",
model=model_deployment_name,
instructions=ai_instructions,
toolset=toolset,
)
# enable all tools by default
client.enable_auto_function_calls(tools=toolset)

async with AsyncExitStack() as stack:
credentials = await stack.enter_async_context(DefaultAzureCredential())
client = await stack.enter_async_context(
AzureAIAgent.create_client(
credential=credentials, endpoint=ai_agent_settings.endpoint, api_version=ai_agent_settings.api_version
)
)
delete_agent = await stack.enter_async_context(get_delete_agent())
create_agent = await stack.enter_async_context(get_create_agent())
retrieve_agent = await stack.enter_async_context(get_retrieve_agent())
link_agent = await stack.enter_async_context(get_link_agent())

agent_definition = await client.agents.create_agent(
name="ArchAgentMainAgent",
description="An agent that processed requests and passes work to other agents.",
model=ai_agent_settings.model_deployment_name,
instructions=ai_instructions,
)
agent = AzureAIAgent(
client=client,
definition=agent_definition,
plugins=[
SearchPlugin(),
UtilityPlugin(),
ApiReviewPlugin(),
delete_agent,
create_agent,
retrieve_agent,
link_agent,
],
polling_options=RunPollingOptions(run_polling_interval=timedelta(seconds=1)),
kernel=kernel,
)
yield agent
try:
yield client, agent.id
finally:
client.delete_agent(agent.id)
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@
This module initializes the plugins for the agent.
"""

from ._api_review_plugin import ApiReviewPlugin
from ._database_plugin import (
from ._api_review_tools import ApiReviewTools
from ._database_tools import (
get_create_agent,
get_delete_agent,
get_link_agent,
get_retrieve_agent,
)
from ._search_plugin import SearchPlugin
from ._utility_plugin import UtilityPlugin
from ._search_tools import SearchTools
from ._utility_tools import UtilityTools

__all__ = [
"ApiReviewPlugin",
"SearchPlugin",
"UtilityPlugin",
"ApiReviewTools",
"SearchTools",
"UtilityTools",
"get_create_agent",
"get_delete_agent",
"get_retrieve_agent",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,22 @@
# --------------------------------------------------------------------------

"""
Plugin for performing API reviews using the ApiViewReview class.
Tools for performing API reviews using the ApiViewReview class.
"""

import asyncio
import json

from semantic_kernel.functions import kernel_function
from src._apiview import ApiViewClient
from src._apiview_reviewer import ApiViewReview
from src.agent.tools._base import Tool


class ApiReviewPlugin:
"""Plugin for API review operations."""
class ApiReviewTools(Tool):
"""Tools for API review operations."""

@kernel_function(description="Perform an API review on a single API.")
async def review_api(self, *, language: str, target: str):
def review_api(self, *, language: str, target: str):
"""
Perform an API review on a single API.
Args:
Expand All @@ -30,8 +31,7 @@ async def review_api(self, *, language: str, target: str):
results = reviewer.run()
return json.dumps(results.model_dump(), indent=2)

@kernel_function(description="Perform an API review on a diff between two APIs.")
async def review_api_diff(self, *, language: str, target: str, base: str):
def review_api_diff(self, *, language: str, target: str, base: str):
"""
Perform an API review on a diff between two APIs.
Args:
Expand All @@ -43,43 +43,39 @@ async def review_api_diff(self, *, language: str, target: str, base: str):
results = reviewer.run()
return json.dumps(results.model_dump(), indent=2)

@kernel_function(description="Get the text of an API revision.")
async def get_apiview_revision(self, *, revision_id: str) -> str:
def get_apiview_revision(self, *, revision_id: str) -> str:
"""
Get the text of an API revision.
Args:
revision_id (str): The ID of the API revision to retrieve.
"""
client = ApiViewClient()
return await client.get_revision_text(revision_id=revision_id)
return asyncio.run(client.get_revision_text(revision_id=revision_id))

@kernel_function(description="Get the text of an API revision by review ID and label.")
async def get_apiview_revision_by_review(self, *, review_id: str, label: str = "Latest") -> str:
def get_apiview_revision_by_review(self, *, review_id: str, label: str = "Latest") -> str:
"""
Get the text of an API revision by review ID and label.
Args:
review_id (str): The ID of the API review to retrieve.
label (str): The label of the API revision to retrieve.
"""
client = ApiViewClient()
return await client.get_revision_text(review_id=review_id, label=label)
return asyncio.run(client.get_revision_text(review_id=review_id, label=label))

@kernel_function(description="Get the outline for a given API revision")
async def get_apiview_revision_outline(self, *, revision_id: str) -> str:
def get_apiview_revision_outline(self, *, revision_id: str) -> str:
"""
Get the outline for a given API revision.
Args:
revision_id (str): The ID of the API revision to retrieve.
"""
client = ApiViewClient()
return await client.get_revision_outline(revision_id=revision_id)
return asyncio.run(client.get_revision_outline(revision_id=revision_id))

@kernel_function(description="Retrieves any existing comments for a given API revision")
async def get_apiview_revision_comments(self, *, revision_id: str) -> str:
def get_apiview_revision_comments(self, *, revision_id: str) -> str:
"""
Get the comments visible for a given API revision.
Retrieves any existing comments for a given API revision
Args:
revision_id (str): The ID of the API revision to retrieve comments for.
"""
client = ApiViewClient()
return await client.get_review_comments(revision_id=revision_id)
return asyncio.run(client.get_review_comments(revision_id=revision_id))
18 changes: 18 additions & 0 deletions packages/python-packages/apiview-copilot/src/agent/tools/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------


class Tool:
"""Base class for agent tools. Provides automatic tool discovery."""

def all_tools(self):
"""Return all non-internal callable methods for agent registration."""
tools = [
getattr(self, name)
for name in dir(self)
if not name.startswith("_") and callable(getattr(self, name)) and name != "all_tools"
]
return tools
Loading
Loading