Skip to content

Commit 8c41e7b

Browse files
authored
Merge pull request #185 from mrsabath/fix/ollama-crewai-function-calling
fix: resolve Ollama function calling failures with crewai 1.10.1
2 parents 6e62eec + e2a961d commit 8c41e7b

7 files changed

Lines changed: 82 additions & 18 deletions

File tree

a2a/git_issue_agent/.env.ollama

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# ollama pull ibm/granite4:latest
66

77
# LLM configuration
8-
TASK_MODEL_ID=ollama/ibm/granite4:latest
8+
TASK_MODEL_ID=ollama_chat/ibm/granite4:latest
99
# Ollama API base URL. Required by litellm (used by crewai >=1.10).
1010
# For Docker Desktop / Kind: http://host.docker.internal:11434
1111
# For in-cluster Ollama: http://ollama.ollama.svc:11434

a2a/git_issue_agent/.env.template

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
TASK_MODEL_ID = "ollama/ibm/granite4:latest"
1+
TASK_MODEL_ID = "ollama_chat/ibm/granite4:latest"
22
LLM_API_BASE = "http://host.docker.internal:11434"
33
LLM_API_KEY = "ollama"
44
MODEL_TEMPERATURE = 0

a2a/git_issue_agent/git_issue_agent/agents.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from crewai import Agent, Crew, Process, Task
22
from git_issue_agent.config import Settings
3-
from git_issue_agent.data_types import IssueSearchInfo
43
from git_issue_agent.llm import CrewLLM
54
from git_issue_agent.prompts import TOOL_CALL_PROMPT, INFO_PARSER_PROMPT
65

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

3031
self.prereq_id_crew = Crew(
@@ -49,6 +50,8 @@ def __init__(self, config: Settings, issue_tools):
4950
llm=self.llm.llm,
5051
inject_date=True,
5152
max_iter=6,
53+
max_retry_limit=3,
54+
respect_context_window=True,
5255
)
5356

5457
# --- A generic task template -------------------------------------------------

a2a/git_issue_agent/git_issue_agent/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class Settings(BaseSettings):
1212
description="Application log level",
1313
)
1414
TASK_MODEL_ID: str = Field(
15-
os.getenv("TASK_MODEL_ID", "ollama/ibm/granite4:latest"),
15+
os.getenv("TASK_MODEL_ID", "ollama_chat/ibm/granite4:latest"),
1616
description="The ID of the task model",
1717
)
1818
LLM_API_BASE: str = Field(

a2a/git_issue_agent/git_issue_agent/data_types.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from pydantic import BaseModel, Field
1+
import json
2+
3+
from pydantic import BaseModel, Field, field_validator
24

35
############
46
# Pydantic types for LLM response formats
@@ -11,3 +13,16 @@ class IssueSearchInfo(BaseModel):
1113
issue_numbers: list[int] | None = Field(
1214
None, description="Specific issue number(s) mentioned by the user. If none mentioned leave blank."
1315
)
16+
17+
@field_validator("issue_numbers", mode="before")
18+
@classmethod
19+
def coerce_string_to_list(cls, v):
20+
"""Small LLMs often serialize arrays as strings in tool call args."""
21+
if isinstance(v, str):
22+
try:
23+
parsed = json.loads(v)
24+
if isinstance(parsed, list):
25+
return parsed
26+
except (json.JSONDecodeError, ValueError):
27+
pass
28+
return v

a2a/git_issue_agent/git_issue_agent/llm.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@
44

55
class CrewLLM:
66
def __init__(self, config: Settings):
7+
kwargs = {}
8+
if config.EXTRA_HEADERS is not None and None not in config.EXTRA_HEADERS:
9+
kwargs["extra_headers"] = config.EXTRA_HEADERS
10+
11+
# For Ollama models, pass num_ctx to set the context window size.
12+
# Ollama defaults to 2048 tokens which is too small for agent workflows.
13+
if config.TASK_MODEL_ID.startswith(("ollama/", "ollama_chat/")):
14+
kwargs["num_ctx"] = 8192
15+
716
self.llm = LLM(
817
model=config.TASK_MODEL_ID,
918
base_url=config.LLM_API_BASE,
1019
api_key=config.LLM_API_KEY,
11-
**(
12-
{"extra_headers": config.EXTRA_HEADERS}
13-
if config.EXTRA_HEADERS is not None and None not in config.EXTRA_HEADERS
14-
else {}
15-
),
20+
temperature=config.MODEL_TEMPERATURE,
21+
**kwargs,
1622
)

a2a/git_issue_agent/git_issue_agent/main.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,39 @@
1-
from crewai_tools.adapters.tool_collection import ToolCollection
2-
1+
import json
32
import logging
3+
import re
44
import sys
55

6+
from crewai_tools.adapters.tool_collection import ToolCollection
7+
8+
from git_issue_agent.agents import GitAgents
69
from git_issue_agent.config import Settings, settings
10+
from git_issue_agent.data_types import IssueSearchInfo
711
from git_issue_agent.event import Event
8-
from git_issue_agent.agents import GitAgents
912

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

1316

17+
def _parse_prereq_from_raw(raw: str) -> IssueSearchInfo:
18+
"""Parse IssueSearchInfo from raw LLM text when instructor/pydantic parsing fails.
19+
20+
Some Ollama models don't produce structured tool calls that crewai's instructor
21+
integration expects. This fallback extracts JSON from the raw text output.
22+
"""
23+
# Only matches flat JSON (no nested braces). Sufficient for the current
24+
# IssueSearchInfo schema; revisit if the model gains nested fields.
25+
json_match = re.search(r"\{[^{}]*\}", raw)
26+
if json_match:
27+
try:
28+
data = json.loads(json_match.group())
29+
return IssueSearchInfo(**data)
30+
except (json.JSONDecodeError, ValueError):
31+
pass
32+
33+
logger.warning("Could not parse prereq JSON from raw output: %s", raw)
34+
return IssueSearchInfo()
35+
36+
1437
class GitIssueAgent:
1538
def __init__(
1639
self,
@@ -19,7 +42,7 @@ def __init__(
1942
mcp_toolkit: ToolCollection = None,
2043
logger=None,
2144
):
22-
self.agents = GitAgents(settings, mcp_toolkit)
45+
self.agents = GitAgents(config, mcp_toolkit)
2346
self.eventer = eventer
2447
self.logger = logger or logging.getLogger(__name__)
2548

@@ -45,11 +68,28 @@ def extract_user_input(self, body):
4568

4669
return latest_content
4770

71+
async def _get_prereq_output(self, query: str) -> IssueSearchInfo:
72+
"""Run the prereq crew and extract IssueSearchInfo from raw text output.
73+
74+
We avoid using crewai's output_pydantic because it relies on instructor's
75+
tool-call-based parsing, which fails with Ollama models that don't produce
76+
structured tool calls. Instead, we ask the LLM for JSON and parse it ourselves.
77+
"""
78+
try:
79+
await self.agents.prereq_id_crew.kickoff_async(
80+
inputs={"request": query, "repo": "", "owner": "", "issues": []}
81+
)
82+
raw = self.agents.prereq_identifier_task.output.raw
83+
self.logger.info(f"Prereq raw output: {raw}")
84+
return _parse_prereq_from_raw(raw)
85+
except Exception as e:
86+
self.logger.warning(f"Prereq crew failed: {e}")
87+
return IssueSearchInfo()
88+
4889
async def execute(self, user_input):
4990
query = self.extract_user_input(user_input)
5091
await self._send_event("🧐 Evaluating requirements...")
51-
await self.agents.prereq_id_crew.kickoff_async(inputs={"request": query, "repo": "", "owner": "", "issues": []})
52-
repo_id_task_output = self.agents.prereq_identifier_task.output.pydantic
92+
repo_id_task_output = await self._get_prereq_output(query)
5393

5494
if repo_id_task_output.issue_numbers:
5595
if not repo_id_task_output.owner or not repo_id_task_output.repo:

0 commit comments

Comments
 (0)