Skip to content

Commit f7d233e

Browse files
authored
Merge pull request #68 from kellyaa/git-agent
Create Github issue agent
2 parents 7d821d7 + 8bc65db commit f7d233e

15 files changed

Lines changed: 3885 additions & 1 deletion

File tree

a2a/git_issue_agent/.env.template

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
TASK_MODEL_ID = "ollama/ibm/granite4:latest"
2+
LLM_API_BASE = "http://host.docker.internal:11434"
3+
LLM_API_KEY = "ollama"
4+
MODEL_TEMPERATURE = 0
5+
MCP_URL = "https://api.githubcopilot.com/mcp/"
6+
SERVICE_PORT = 8000
7+
LOG_LEVEL = "INFO"
8+
GITHUB_TOKEN = ""

a2a/git_issue_agent/Dockerfile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
2+
ARG RELEASE_VERSION="main"
3+
4+
WORKDIR /app
5+
COPY . .
6+
RUN uv sync --no-cache --locked --link-mode copy
7+
8+
ENV PRODUCTION_MODE=True \
9+
HOME=/app \
10+
RELEASE_VERSION=${RELEASE_VERSION}
11+
12+
RUN chown -R 1001:1001 /app
13+
USER 1001
14+
15+
CMD ["uv", "run", "--no-sync", "server", "--host", "0.0.0.0", "--port", "8000"]

a2a/git_issue_agent/a2a_agent.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
"""
2+
Module for A2A Agent.
3+
"""
4+
5+
import logging
6+
import sys
7+
import traceback
8+
from typing import Callable
9+
10+
import uvicorn
11+
from crewai_tools import MCPServerAdapter
12+
from crewai_tools.adapters.tool_collection import ToolCollection
13+
14+
from mcp import ClientSession
15+
from mcp.client.streamable_http import streamablehttp_client
16+
17+
from a2a.server.agent_execution import AgentExecutor, RequestContext
18+
from a2a.server.apps import A2AStarletteApplication
19+
from a2a.server.events.event_queue import EventQueue
20+
from a2a.server.request_handlers import DefaultRequestHandler
21+
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
22+
from a2a.types import AgentCapabilities, AgentCard, AgentSkill, TaskState, TextPart, SecurityScheme, HTTPAuthSecurityScheme
23+
from a2a.utils import new_agent_text_message, new_task
24+
25+
from starlette.authentication import AuthCredentials, SimpleUser, AuthenticationBackend
26+
from starlette.middleware.authentication import AuthenticationMiddleware
27+
28+
from git_issue_agent.auth import on_auth_error, BearerAuthBackend, auth_headers
29+
from git_issue_agent.config import settings, Settings
30+
from git_issue_agent.event import Event
31+
from git_issue_agent.main import GitIssueAgent
32+
33+
logger = logging.getLogger(__name__)
34+
logging.basicConfig(level=settings.LOG_LEVEL, stream=sys.stdout, format='%(levelname)s: %(message)s')
35+
36+
class BearerAuthBackend(AuthenticationBackend):
37+
""" Very temporary demo to grab auth token and print it"""
38+
async def authenticate(self, conn):
39+
try:
40+
auth = conn.headers.get("authorization")
41+
if not auth or not auth.lower().startswith("bearer "):
42+
print("No bearer token provided")
43+
return
44+
token = auth.split(" ", 1)[1]
45+
print(f"TOKEN: {token}")
46+
47+
# Storing the token as the username - not a real life scenario - just demo-ing the passing of creds
48+
user = SimpleUser(token)
49+
return AuthCredentials(["authenticated"]), user
50+
except Exception as e:
51+
logger.error("Exception when attempting to obtain user token")
52+
logger.error(e)
53+
54+
55+
def get_agent_card(host: str, port: int):
56+
"""Returns the Agent Card for the AG2 Agent."""
57+
capabilities = AgentCapabilities(streaming=True)
58+
skill = AgentSkill(
59+
id="github_issue_agent",
60+
name="Github issue agent",
61+
description="Answer queries by searching through a given slack server",
62+
tags=["git", "github", "issues"],
63+
examples=[
64+
"Find me the issues with the most comments in kubernetes/kubernetes",
65+
"Show all issues assigned to me across any repository",
66+
],
67+
)
68+
return AgentCard(
69+
name="Github issue agent",
70+
description="Answer queries about Github issues",
71+
url=f"http://{host}:{port}/",
72+
version="1.0.0",
73+
default_input_modes=["text"],
74+
default_output_modes=["text"],
75+
capabilities=capabilities,
76+
skills=[skill],
77+
securitySchemes={
78+
"Bearer": SecurityScheme(
79+
root=HTTPAuthSecurityScheme(
80+
type="http",
81+
scheme="bearer",
82+
bearerFormat="JWT",
83+
description="OAuth 2.0 JWT token"
84+
)
85+
)
86+
},
87+
)
88+
89+
90+
class A2AEvent(Event):
91+
"""
92+
A class to handle events for A2A Agent.
93+
94+
Attributes:
95+
task_updater (TaskUpdater): The task updater instance.
96+
"""
97+
98+
def __init__(self, task_updater: TaskUpdater):
99+
"""
100+
Initializes the A2AEvent instance.
101+
102+
Args:
103+
task_updater (TaskUpdater): The task updater instance.
104+
"""
105+
self.task_updater = task_updater
106+
107+
async def emit_event(self, message: str, final: bool = False) -> None:
108+
"""
109+
Emits an event with the given message.
110+
111+
Args:
112+
message (str): The event message.
113+
final (bool): Whether the event is final. Defaults to False.
114+
"""
115+
logger.info("Emitting event %s", message)
116+
117+
if final:
118+
parts = [TextPart(text=message)]
119+
await self.task_updater.add_artifact(parts)
120+
await self.task_updater.complete()
121+
else:
122+
await self.task_updater.update_status(
123+
TaskState.working,
124+
new_agent_text_message(
125+
message,
126+
self.task_updater.context_id,
127+
self.task_updater.task_id,
128+
),
129+
)
130+
131+
132+
class GithubExecutor(AgentExecutor):
133+
"""
134+
A class to handle research execution for A2A Agent.
135+
"""
136+
async def _run_agent(self,
137+
messages: dict,
138+
settings: Settings,
139+
event_emitter: Event,
140+
toolkit: ToolCollection):
141+
142+
git_issue_agent = GitIssueAgent(
143+
config=settings,
144+
eventer=event_emitter,
145+
mcp_toolkit=toolkit,
146+
)
147+
result = await git_issue_agent.execute(messages)
148+
await event_emitter.emit_event(result, True)
149+
150+
async def execute(self, context: RequestContext, event_queue: EventQueue):
151+
"""
152+
Executes the task.
153+
154+
Args:
155+
context (RequestContext): The request context.
156+
event_queue (EventQueue): The event queue instance.
157+
158+
Returns:
159+
None
160+
"""
161+
###
162+
# commenting this out for now since we have external github MCP.
163+
# in the future we need to figure out the token exchange story for this scenario
164+
#
165+
#if settings.JWKS_URI:
166+
# user_token = context.call_context.user._user.access_token
167+
#else:
168+
user_token = settings.GITHUB_TOKEN
169+
user_input = [context.get_user_input()]
170+
task = context.current_task
171+
if not task:
172+
task = new_task(context.message)
173+
await event_queue.enqueue_event(task)
174+
task_updater = TaskUpdater(event_queue, task.id, task.context_id)
175+
event_emitter = A2AEvent(task_updater)
176+
messages = []
177+
for message in user_input:
178+
messages.append(
179+
{
180+
"role": "User",
181+
"content": message,
182+
}
183+
)
184+
185+
# Hook up MCP tools
186+
try:
187+
if settings.MCP_URL:
188+
logging.info("Connecting to MCP server at %s", settings.MCP_URL)
189+
190+
headers = await auth_headers(
191+
user_token,
192+
target_audience=settings.TARGET_AUDIENCE,
193+
target_scopes=settings.TARGET_SCOPES
194+
)
195+
196+
server_params = {
197+
"url": settings.MCP_URL,
198+
"transport": "streamable-http",
199+
"headers": headers,
200+
}
201+
with MCPServerAdapter(server_params, connect_timeout=60) as mcp_tools:
202+
# Keep only search and list issue-related tools.
203+
issue_tools = [
204+
tool
205+
for tool in mcp_tools
206+
if ("issue" in tool.name.lower() or "label" in tool.name.lower()) and
207+
("search" in tool.name.lower() or "list" in tool.name.lower())
208+
]
209+
210+
if not issue_tools:
211+
raise RuntimeError(
212+
"No issue-related tools found from the GitHub MCP server. "
213+
"Ensure your PAT scopes allow issue access and the server is reachable."
214+
)
215+
await self._run_agent(messages, settings, event_emitter, issue_tools)
216+
else:
217+
await self._run_agent(messages, settings,
218+
event_emitter,
219+
None)
220+
221+
except Exception as e:
222+
traceback.print_exc()
223+
await event_emitter.emit_event(f"I'm sorry I was unable to fulfill your request. I encountered the following exception: {str(e)}", True)
224+
225+
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
226+
"""
227+
Not implemented
228+
"""
229+
raise Exception("cancel not supported")
230+
231+
232+
def run():
233+
"""
234+
Runs the A2A Agent application.
235+
"""
236+
agent_card = get_agent_card(host="0.0.0.0", port=settings.SERVICE_PORT)
237+
238+
request_handler = DefaultRequestHandler(
239+
agent_executor=GithubExecutor(),
240+
task_store=InMemoryTaskStore(),
241+
)
242+
243+
server = A2AStarletteApplication(
244+
agent_card=agent_card,
245+
http_handler=request_handler,
246+
)
247+
248+
app = server.build() # this returns a Starlette app
249+
if not settings.JWKS_URI is None:
250+
logging.info("JWKS_URI is set - using JWT Validation middleware")
251+
app.add_middleware(AuthenticationMiddleware, backend=BearerAuthBackend(), on_error=on_auth_error)
252+
253+
uvicorn.run(app, host="0.0.0.0", port=settings.SERVICE_PORT)

a2a/git_issue_agent/git_issue_agent/__init__.py

Whitespace-only changes.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from crewai import Agent, Crew, Process, Task
2+
from git_issue_agent.config import Settings
3+
from git_issue_agent.data_types import IssueSearchInfo
4+
from git_issue_agent.llm import CrewLLM
5+
from git_issue_agent.prompts import TOOL_CALL_PROMPT, INFO_PARSER_PROMPT
6+
class GitAgents():
7+
8+
def __init__(self, config: Settings, issue_tools):
9+
self.llm = CrewLLM(config)
10+
11+
###################
12+
# Pre-requisite validator
13+
# ##################
14+
self.prereq_identifier = Agent(
15+
role="Pre-requisite Extractor",
16+
goal="To extract the information about github artifacts that a user is looking for",
17+
backstory=INFO_PARSER_PROMPT,
18+
verbose=True,
19+
llm=self.llm.llm
20+
)
21+
22+
self.prereq_identifier_task = Task(
23+
description=(
24+
"User query: {request}"
25+
),
26+
agent=self.prereq_identifier,
27+
output_pydantic=IssueSearchInfo,
28+
expected_output=(
29+
"A pydantic object representing the extracted relevant information."
30+
),
31+
)
32+
33+
self.prereq_id_crew = Crew(
34+
agents=[self.prereq_identifier],
35+
tasks=[self.prereq_identifier_task],
36+
process=Process.sequential,
37+
verbose=True,
38+
)
39+
40+
###################
41+
# Issue Researcher
42+
# ##################
43+
self.issue_researcher = Agent(
44+
role="GitHub Issue Analyst",
45+
goal=(
46+
"Answer the user's query, using tools provided from MCP server. "
47+
"Prefer read-only operations. When querying, be explicit about repo owner/name and filters."
48+
),
49+
backstory=TOOL_CALL_PROMPT,
50+
tools=issue_tools,
51+
verbose=True,
52+
llm=self.llm.llm,
53+
inject_date=True,
54+
max_iter=6
55+
)
56+
57+
# --- A generic task template -------------------------------------------------
58+
# The agent will use MCP tools to fulfill natural-language queries.
59+
self.issue_query_task = Task(
60+
description=(
61+
"Retrieve Github issues using tool calls in order to answer the user's query.\n"
62+
"User query: {request}\n"
63+
"Identified repository: {repo} \n"
64+
"Identified owner: {owner} \n"
65+
"Identified issue numbers: {issues}"
66+
),
67+
agent=self.issue_researcher,
68+
expected_output=(
69+
"A well formatted, detailed report, directly answering the user's query, citing the output of the tool to support your answer."
70+
),
71+
)
72+
73+
self.crew = Crew(
74+
agents=[self.issue_researcher],
75+
tasks=[self.issue_query_task],
76+
process=Process.sequential,
77+
verbose=True,
78+
)

0 commit comments

Comments
 (0)