Skip to content
Open
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
2 changes: 1 addition & 1 deletion a2a/git_issue_agent/.env.ollama
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# ollama pull ibm/granite4:latest

# LLM configuration
TASK_MODEL_ID=ollama/ibm/granite4:latest
TASK_MODEL_ID=ollama_chat/ibm/granite4:latest
# Ollama API base URL. Required by litellm (used by crewai >=1.10).
# For Docker Desktop / Kind: http://host.docker.internal:11434
# For in-cluster Ollama: http://ollama.ollama.svc:11434
Expand Down
2 changes: 1 addition & 1 deletion a2a/git_issue_agent/.env.template
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
TASK_MODEL_ID = "ollama/ibm/granite4:latest"
TASK_MODEL_ID = "ollama_chat/ibm/granite4:latest"
LLM_API_BASE = "http://host.docker.internal:11434"
LLM_API_KEY = "ollama"
MODEL_TEMPERATURE = 0
Expand Down
9 changes: 6 additions & 3 deletions a2a/git_issue_agent/git_issue_agent/agents.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from crewai import Agent, Crew, Process, Task
from git_issue_agent.config import Settings
from git_issue_agent.data_types import IssueSearchInfo
from git_issue_agent.llm import CrewLLM
from git_issue_agent.prompts import TOOL_CALL_PROMPT, INFO_PARSER_PROMPT

Expand All @@ -23,8 +22,10 @@ def __init__(self, config: Settings, issue_tools):
self.prereq_identifier_task = Task(
description=("User query: {request}"),
agent=self.prereq_identifier,
output_pydantic=IssueSearchInfo,
expected_output=("A pydantic object representing the extracted relevant information."),
expected_output=(
'A JSON object with keys "owner", "repo", and "issue_numbers". '
"Example: {\"owner\": \"kagenti\", \"repo\": \"kagenti\", \"issue_numbers\": null}"
),
)

self.prereq_id_crew = Crew(
Expand All @@ -49,6 +50,8 @@ def __init__(self, config: Settings, issue_tools):
llm=self.llm.llm,
inject_date=True,
max_iter=6,
max_retry_limit=3,
respect_context_window=True,
)

# --- A generic task template -------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion a2a/git_issue_agent/git_issue_agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Settings(BaseSettings):
description="Application log level",
)
TASK_MODEL_ID: str = Field(
os.getenv("TASK_MODEL_ID", "ollama/ibm/granite4:latest"),
os.getenv("TASK_MODEL_ID", "ollama_chat/ibm/granite4:latest"),
description="The ID of the task model",
)
LLM_API_BASE: str = Field(
Expand Down
17 changes: 16 additions & 1 deletion a2a/git_issue_agent/git_issue_agent/data_types.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from pydantic import BaseModel, Field
import json

from pydantic import BaseModel, Field, field_validator

############
# Pydantic types for LLM response formats
Expand All @@ -11,3 +13,16 @@ class IssueSearchInfo(BaseModel):
issue_numbers: list[int] | None = Field(
None, description="Specific issue number(s) mentioned by the user. If none mentioned leave blank."
)

@field_validator("issue_numbers", mode="before")
@classmethod
def coerce_string_to_list(cls, v):
"""Small LLMs often serialize arrays as strings in tool call args."""
if isinstance(v, str):
try:
parsed = json.loads(v)
if isinstance(parsed, list):
return parsed
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The field_validator for issue_numbers is a robust defense against small LLMs serializing arrays as strings. Good defensive coding.

except (json.JSONDecodeError, ValueError):
pass
return v
16 changes: 11 additions & 5 deletions a2a/git_issue_agent/git_issue_agent/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@

class CrewLLM:
def __init__(self, config: Settings):
kwargs = {}
if config.EXTRA_HEADERS is not None and None not in config.EXTRA_HEADERS:
kwargs["extra_headers"] = config.EXTRA_HEADERS

# For Ollama models, pass num_ctx to set the context window size.
# Ollama defaults to 2048 tokens which is too small for agent workflows.
if config.TASK_MODEL_ID.startswith(("ollama/", "ollama_chat/")):
kwargs["num_ctx"] = 8192

self.llm = LLM(
model=config.TASK_MODEL_ID,
base_url=config.LLM_API_BASE,
api_key=config.LLM_API_KEY,
**(
{"extra_headers": config.EXTRA_HEADERS}
if config.EXTRA_HEADERS is not None and None not in config.EXTRA_HEADERS
else {}
),
temperature=config.MODEL_TEMPERATURE,
**kwargs,
)
45 changes: 42 additions & 3 deletions a2a/git_issue_agent/git_issue_agent/main.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
from crewai_tools.adapters.tool_collection import ToolCollection

import json
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: import json and import re are stdlib imports but appear after the third-party crewai_tools import. Standard convention (and isort/ruff) expects stdlib before third-party. This is likely what's causing the ruff format CI failure.

import logging
import re
import sys

from git_issue_agent.config import Settings, settings
from git_issue_agent.data_types import IssueSearchInfo
from git_issue_agent.event import Event
from git_issue_agent.agents import GitAgents

logger = logging.getLogger(__name__)
logging.basicConfig(level=settings.LOG_LEVEL, stream=sys.stdout, format="%(levelname)s: %(message)s")


def _parse_prereq_from_raw(raw: str) -> IssueSearchInfo:
"""Parse IssueSearchInfo from raw LLM text when instructor/pydantic parsing fails.

Some Ollama models don't produce structured tool calls that crewai's instructor
integration expects. This fallback extracts JSON from the raw text output.
"""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The regex \{[^{}]*\} only matches flat JSON (no nested braces). If IssueSearchInfo ever gains nested fields, this will silently break. Fine for now since the schema is flat, but worth a comment noting the limitation.

# Try to find JSON in the raw output
json_match = re.search(r'\{[^{}]*\}', raw)
if json_match:
try:
data = json.loads(json_match.group())
return IssueSearchInfo(**data)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The silent fallback to IssueSearchInfo() means operators won't know when parsing degrades. Consider adding a self.logger.warning("Could not parse prereq JSON from raw output") (or similar) at the fallback in _get_prereq_output — the except block on line 64 logs the crew failure, but the JSON-parse fallback inside _parse_prereq_from_raw is silent.

except (json.JSONDecodeError, ValueError):
pass

# Fallback: return empty IssueSearchInfo (no pre-identified fields)
return IssueSearchInfo()


class GitIssueAgent:
def __init__(
self,
Expand All @@ -19,7 +41,7 @@ def __init__(
mcp_toolkit: ToolCollection = None,
logger=None,
):
self.agents = GitAgents(settings, mcp_toolkit)
self.agents = GitAgents(config, mcp_toolkit)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Good catch fixing the config parameter bug — settingsconfig. The passed config was being silently ignored before.

self.eventer = eventer
self.logger = logger or logging.getLogger(__name__)

Expand All @@ -45,11 +67,28 @@ def extract_user_input(self, body):

return latest_content

async def _get_prereq_output(self, query: str) -> IssueSearchInfo:
"""Run the prereq crew and extract IssueSearchInfo from raw text output.

We avoid using crewai's output_pydantic because it relies on instructor's
tool-call-based parsing, which fails with Ollama models that don't produce
structured tool calls. Instead, we ask the LLM for JSON and parse it ourselves.
"""
try:
await self.agents.prereq_id_crew.kickoff_async(
inputs={"request": query, "repo": "", "owner": "", "issues": []}
)
raw = self.agents.prereq_identifier_task.output.raw
self.logger.info(f"Prereq raw output: {raw}")
return _parse_prereq_from_raw(raw)
except Exception as e:
self.logger.warning(f"Prereq crew failed: {e}")
return IssueSearchInfo()

async def execute(self, user_input):
query = self.extract_user_input(user_input)
await self._send_event("🧐 Evaluating requirements...")
await self.agents.prereq_id_crew.kickoff_async(inputs={"request": query, "repo": "", "owner": "", "issues": []})
repo_id_task_output = self.agents.prereq_identifier_task.output.pydantic
repo_id_task_output = await self._get_prereq_output(query)

if repo_id_task_output.issue_numbers:
if not repo_id_task_output.owner or not repo_id_task_output.repo:
Expand Down
Loading