mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
fix(llm): integrate capability-aware Ollama tools from PR 3275
Preserve contributor head and current main reasoning/errors. Reject malformed capability declarations and retry discovery; actual localhost HTTP ReAct round-trip verified.
This commit is contained in:
2 files changed
+267
-7
No files matched your search
@@ -2,7 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from llm.core.interface import (
|
||||
@@ -15,12 +19,36 @@ from llm.core.interface import (
|
||||
from llm.core.types import (
|
||||
LLMInput,
|
||||
LLMOutput,
|
||||
Message,
|
||||
ModelInfo,
|
||||
ProviderType,
|
||||
Role,
|
||||
ToolCall,
|
||||
)
|
||||
from llm.providers.reasoning import prefilled_reasoning_from_env, strip_reasoning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _messages_payload(messages: list[Message]) -> list[dict[str, Any]]:
|
||||
"""Serialize messages, naming the tool each tool result answers.
|
||||
|
||||
Ollama correlates a tool result with its call through ``tool_name``; ids
|
||||
are optional on its side, so the name is what keeps several results apart.
|
||||
"""
|
||||
names: dict[str, str] = {}
|
||||
payload = []
|
||||
for msg in messages:
|
||||
item = msg.to_dict()
|
||||
for tc in msg.tool_calls or []:
|
||||
names[tc.id] = tc.name
|
||||
if msg.role == Role.TOOL:
|
||||
tool_name = msg.name or names.get(msg.tool_call_id or "")
|
||||
if tool_name:
|
||||
item["tool_name"] = tool_name
|
||||
payload.append(item)
|
||||
return payload
|
||||
|
||||
|
||||
class OllamaProvider(LLMProvider):
|
||||
provider_type = ProviderType.OLLAMA
|
||||
@@ -44,7 +72,7 @@ class OllamaProvider(LLMProvider):
|
||||
ModelInfo(
|
||||
name="llama3.2",
|
||||
provider=ProviderType.OLLAMA,
|
||||
supports_tools=False,
|
||||
supports_tools=True,
|
||||
supports_vision=False,
|
||||
max_tokens=4096,
|
||||
context_window=128000,
|
||||
@@ -52,7 +80,7 @@ class OllamaProvider(LLMProvider):
|
||||
ModelInfo(
|
||||
name="mistral",
|
||||
provider=ProviderType.OLLAMA,
|
||||
supports_tools=False,
|
||||
supports_tools=True,
|
||||
supports_vision=False,
|
||||
max_tokens=4096,
|
||||
context_window=8192,
|
||||
@@ -66,18 +94,48 @@ class OllamaProvider(LLMProvider):
|
||||
context_window=16384,
|
||||
),
|
||||
]
|
||||
self._tool_support: dict[str, bool] = {}
|
||||
|
||||
def model_supports_tools(self, model: str) -> bool:
|
||||
"""Whether ``model`` declares tool support.
|
||||
|
||||
Catalogued models answer from :attr:`ModelInfo.supports_tools`. Any other
|
||||
model is looked up once through ``/api/show``, whose ``capabilities``
|
||||
list is what the installed model itself declares. Only an answered lookup
|
||||
is cached: a failed one counts as no tool support for that request and
|
||||
is retried on the next.
|
||||
"""
|
||||
for info in self._models:
|
||||
if info.name == model:
|
||||
return info.supports_tools
|
||||
if model in self._tool_support:
|
||||
return self._tool_support[model]
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{self.base_url}/api/show",
|
||||
data=json.dumps({"model": model}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
capabilities = json.loads(response.read().decode("utf-8")).get("capabilities") or []
|
||||
except Exception as e:
|
||||
logger.warning("Could not read capabilities for Ollama model '%s': %s", model, type(e).__name__)
|
||||
return False
|
||||
if not isinstance(capabilities, list) or not all(isinstance(item, str) for item in capabilities):
|
||||
logger.warning("Invalid capabilities for Ollama model '%s'; sending no tools", model)
|
||||
return False
|
||||
supports_tools = "tools" in capabilities
|
||||
self._tool_support = {**self._tool_support, model: supports_tools}
|
||||
return supports_tools
|
||||
|
||||
def generate(self, input: LLMInput) -> LLMOutput:
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/api/chat"
|
||||
model = input.model or self.default_model
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [msg.to_dict() for msg in input.messages],
|
||||
"messages": _messages_payload(input.messages),
|
||||
"stream": False,
|
||||
}
|
||||
options: dict[str, Any] = {}
|
||||
@@ -87,6 +145,11 @@ class OllamaProvider(LLMProvider):
|
||||
options["num_predict"] = input.max_tokens
|
||||
if options:
|
||||
payload["options"] = options
|
||||
if input.tools:
|
||||
if self.model_supports_tools(model):
|
||||
payload["tools"] = [tool.to_openai_tool() for tool in input.tools]
|
||||
else:
|
||||
logger.warning("Ollama model '%s' does not declare tool support; sending no tools", model)
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
@@ -102,7 +165,9 @@ class OllamaProvider(LLMProvider):
|
||||
if result.get("message", {}).get("tool_calls"):
|
||||
tool_calls = [
|
||||
ToolCall(
|
||||
id=tc.get("id", ""),
|
||||
# Native Ollama calls often carry no id; give each one a
|
||||
# unique id so its result can be matched to it.
|
||||
id=tc.get("id") or f"call_{uuid.uuid4().hex[:24]}",
|
||||
name=tc.get("function", {}).get("name", ""),
|
||||
arguments=tc.get("function", {}).get("arguments", {}),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
@@ -15,6 +16,7 @@ from llm.providers.claude import ClaudeProvider
|
||||
from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR
|
||||
from llm.providers.ollama import OllamaProvider
|
||||
from llm.providers.openai import OpenAIProvider
|
||||
from llm.tools import ReActAgent, ToolExecutor, ToolRegistry
|
||||
|
||||
|
||||
def _tool() -> ToolDefinition:
|
||||
@@ -261,6 +263,187 @@ def test_ollama_provider_serializes_generation_options(
|
||||
assert output.stop_reason == "stop"
|
||||
|
||||
|
||||
class _FakeOllama:
|
||||
"""Serves /api/show and replays queued /api/chat replies, recording requests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
replies: list[dict[str, Any]],
|
||||
capabilities: list[str] | None = None,
|
||||
) -> None:
|
||||
self.replies = list(replies)
|
||||
self.capabilities = capabilities
|
||||
self.chats: list[dict[str, Any]] = []
|
||||
self.shows: list[str] = []
|
||||
monkeypatch.setattr(urllib.request, "urlopen", self.urlopen)
|
||||
|
||||
def urlopen(self, request: urllib.request.Request, timeout: float) -> BytesIO:
|
||||
body = json.loads(request.data)
|
||||
if request.full_url.endswith("/api/show"):
|
||||
self.shows.append(body["model"])
|
||||
if self.capabilities is None:
|
||||
raise OSError("connection refused")
|
||||
return BytesIO(json.dumps({"capabilities": self.capabilities}).encode())
|
||||
self.chats.append(body)
|
||||
return BytesIO(json.dumps(self.replies.pop(0)).encode())
|
||||
|
||||
|
||||
def _ollama_tool_calls(*calls: tuple[str, dict[str, Any]]) -> dict[str, Any]:
|
||||
return {
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{"function": {"name": name, "arguments": args}} for name, args in calls],
|
||||
},
|
||||
"done_reason": "stop",
|
||||
}
|
||||
|
||||
|
||||
def _ollama_answer(text: str) -> dict[str, Any]:
|
||||
return {"message": {"content": text}, "done_reason": "stop"}
|
||||
|
||||
|
||||
def _lookup_tool() -> ToolDefinition:
|
||||
return ToolDefinition(
|
||||
name="lookup",
|
||||
description="Look up a city",
|
||||
parameters={"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
)
|
||||
|
||||
|
||||
def test_ollama_provider_serializes_tools_and_parses_tool_calls(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
server = _FakeOllama(
|
||||
monkeypatch, [_ollama_tool_calls(("search", {"query": "ollama"}))], capabilities=["completion", "tools"]
|
||||
)
|
||||
provider = OllamaProvider(base_url="http://localhost:11434", default_model="qwen3")
|
||||
|
||||
output = provider.generate(LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()]))
|
||||
|
||||
assert server.shows == ["qwen3"]
|
||||
assert server.chats[0]["tools"] == [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": "Search",
|
||||
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
]
|
||||
assert output.has_tool_calls
|
||||
assert output.tool_calls[0].name == "search"
|
||||
assert output.tool_calls[0].arguments == {"query": "ollama"}
|
||||
assert output.tool_calls[0].id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "capabilities", "expect_tools", "expect_shows"),
|
||||
[
|
||||
("llama3.2", None, True, []),
|
||||
("codellama", None, False, []),
|
||||
("qwen3", ["completion", "tools"], True, ["qwen3"]),
|
||||
("gemma3", ["completion", "vision"], False, ["gemma3"]),
|
||||
("unknown", None, False, ["unknown", "unknown"]),
|
||||
],
|
||||
)
|
||||
def test_ollama_provider_sends_tools_only_to_models_that_declare_them(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
model: str,
|
||||
capabilities: list[str] | None,
|
||||
expect_tools: bool,
|
||||
expect_shows: list[str],
|
||||
) -> None:
|
||||
server = _FakeOllama(monkeypatch, [_ollama_answer("ok"), _ollama_answer("ok")], capabilities=capabilities)
|
||||
provider = OllamaProvider(default_model=model)
|
||||
request = LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()])
|
||||
|
||||
provider.generate(request)
|
||||
provider.generate(request)
|
||||
|
||||
assert all(("tools" in chat) is expect_tools for chat in server.chats)
|
||||
# An answered capability lookup is cached per model; a failed one is retried.
|
||||
assert server.shows == expect_shows
|
||||
|
||||
|
||||
def test_ollama_provider_retries_capability_lookup_after_a_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
server = _FakeOllama(monkeypatch, [_ollama_answer("ok"), _ollama_answer("ok"), _ollama_answer("ok")])
|
||||
provider = OllamaProvider(default_model="qwen3")
|
||||
request = LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()])
|
||||
|
||||
provider.generate(request)
|
||||
server.capabilities = ["completion", "tools"]
|
||||
provider.generate(request)
|
||||
provider.generate(request)
|
||||
|
||||
assert ["tools" in chat for chat in server.chats] == [False, True, True]
|
||||
assert server.shows == ["qwen3", "qwen3"]
|
||||
|
||||
|
||||
def test_ollama_react_agent_correlates_multiple_calls_without_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
server = _FakeOllama(
|
||||
monkeypatch,
|
||||
[
|
||||
_ollama_tool_calls(("lookup", {"city": "Hefei"}), ("search", {"query": "weather"})),
|
||||
_ollama_answer("Hefei is sunny."),
|
||||
],
|
||||
capabilities=["completion", "tools"],
|
||||
)
|
||||
registry = ToolRegistry()
|
||||
registry.register(_lookup_tool(), lambda city: f"city={city}")
|
||||
registry.register(_tool(), lambda query: f"query={query}")
|
||||
agent = ReActAgent(OllamaProvider(default_model="qwen3"), ToolExecutor(registry))
|
||||
|
||||
output = asyncio.run(
|
||||
agent.run(
|
||||
LLMInput(
|
||||
messages=[Message(role=Role.USER, content="Weather in Hefei?")],
|
||||
tools=[_lookup_tool(), _tool()],
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert output.content == "Hefei is sunny."
|
||||
follow_up = server.chats[1]
|
||||
assert "tools" in follow_up
|
||||
assistant, first, second = follow_up["messages"][1:]
|
||||
ids = [call["id"] for call in assistant["tool_calls"]]
|
||||
assert all(ids) and len(set(ids)) == 2
|
||||
assert [call["function"]["name"] for call in assistant["tool_calls"]] == ["lookup", "search"]
|
||||
assert first == {"role": "tool", "content": "city=Hefei", "tool_call_id": ids[0], "tool_name": "lookup"}
|
||||
assert second == {"role": "tool", "content": "query=weather", "tool_call_id": ids[1], "tool_name": "search"}
|
||||
|
||||
|
||||
def test_ollama_react_agent_reports_unknown_tool_under_its_own_name(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
server = _FakeOllama(
|
||||
monkeypatch,
|
||||
[_ollama_tool_calls(("missing", {}), ("search", {"query": "x"})), _ollama_answer("done")],
|
||||
capabilities=["completion", "tools"],
|
||||
)
|
||||
registry = ToolRegistry()
|
||||
registry.register(_tool(), lambda query: f"query={query}")
|
||||
agent = ReActAgent(OllamaProvider(default_model="qwen3"), ToolExecutor(registry))
|
||||
|
||||
asyncio.run(agent.run(LLMInput(messages=[Message(role=Role.USER, content="go")], tools=[_tool()])))
|
||||
|
||||
results = server.chats[1]["messages"][2:]
|
||||
assert [(r["tool_name"], r["content"]) for r in results] == [
|
||||
("missing", "Error: Tool 'missing' not found"),
|
||||
("search", "query=x"),
|
||||
]
|
||||
|
||||
|
||||
def test_ollama_react_agent_without_tool_support_answers_directly(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
server = _FakeOllama(monkeypatch, [_ollama_answer("I cannot call tools.")])
|
||||
agent = ReActAgent(OllamaProvider(default_model="codellama"), ToolExecutor())
|
||||
|
||||
output = asyncio.run(agent.run(LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()])))
|
||||
|
||||
assert output.content == "I cannot call tools."
|
||||
assert "tools" not in server.chats[0]
|
||||
assert server.shows == []
|
||||
|
||||
|
||||
def test_claude_provider_serializes_tools_for_messages_api():
|
||||
provider = ClaudeProvider(api_key="test")
|
||||
client = _AnthropicClient()
|
||||
@@ -275,3 +458,15 @@ def test_claude_provider_serializes_tools_for_messages_api():
|
||||
"input_schema": {"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('capabilities', ['tools', {'tools': True}, ['tools', 1]])
|
||||
def test_ollama_malformed_capability_declaration_is_not_authority(monkeypatch, capabilities):
|
||||
server = _FakeOllama(monkeypatch, [_ollama_answer('ok'), _ollama_answer('ok')], capabilities=capabilities)
|
||||
provider = OllamaProvider(default_model='custom')
|
||||
request = LLMInput(messages=[Message(role=Role.USER, content='hi')], tools=[_tool()])
|
||||
provider.generate(request)
|
||||
server.capabilities = ['completion', 'tools']
|
||||
provider.generate(request)
|
||||
assert ['tools' in chat for chat in server.chats] == [False, True]
|
||||
assert server.shows == ['custom', 'custom']
|
||||
Reference in new issue
Block a user