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
19 changes: 19 additions & 0 deletions copilotj/core/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,25 @@ async def _create(
tool_calls=tool_calls if len(tool_calls) else None,
finish_reason=finish_reason,
)

# In native mode, the model may output "Thought: ..." as regular
# content before calling a tool. Move that text to
# reasoning_content so the downstream logic (LeaderDriven.run)
# correctly distinguishes between a tool-call turn and a final
# answer turn.
if (
completion.tool_calls
and completion.content
and not completion.reasoning_content
and completion.content.strip().lower().startswith("thought")
):
completion = ModelResponse(
content=None,
reasoning_content=completion.content.strip(),
tool_calls=completion.tool_calls,
finish_reason=completion.finish_reason,
)

self._runtime.log_info(str(completion))
return completion

Expand Down
44 changes: 42 additions & 2 deletions copilotj/core/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@
#
# SPDX-License-Identifier: Apache-2.0

from typing import Literal
from typing import Any, Literal

import pydantic

__all__ = ["TextMessage", "ImageMessage", "HandoffMessage"]
__all__ = [
"TextMessage",
"ImageMessage",
"HandoffMessage",
"ToolCallRecord",
"ToolCallMessage",
"ToolResultMessage",
]


class TextMessage(pydantic.BaseModel):
Expand All @@ -22,3 +29,36 @@ class ImageMessage(pydantic.BaseModel):
class HandoffMessage(pydantic.BaseModel):
target: str
message: TextMessage | ImageMessage


class ToolCallRecord(pydantic.BaseModel):
"""Serialisable record of a single tool call for conversation history."""

id: str
name: str
arguments: dict[str, Any]


class ToolCallMessage(pydantic.BaseModel):
"""Assistant message containing one or more tool calls.

Used in conversation history for both native and ReAct modes. The client
layer converts this to the appropriate API format (native tool_calls or
reconstructed ReAct text).
"""

role: Literal["assistant"] = "assistant"
tool_calls: list[ToolCallRecord]
reasoning_content: str | None = None


class ToolResultMessage(pydantic.BaseModel):
"""Tool role message carrying the execution result.

In native mode this maps to ``{"role": "tool", ...}``. In ReAct mode the
client layer converts it to a user message with ``Observation:`` prefix.
"""

role: Literal["tool"] = "tool"
tool_call_id: str
content: str
128 changes: 113 additions & 15 deletions copilotj/core/model_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# SPDX-License-Identifier: Apache-2.0

import abc
import json
import logging
from typing import Any, AsyncGenerator, Literal, Sequence, cast, overload, override

Expand All @@ -19,7 +20,7 @@
_get_vlm_and_key,
_get_vlm_base_url,
)
from copilotj.core.message import ImageMessage, TextMessage
from copilotj.core.message import ImageMessage, TextMessage, ToolCallMessage, ToolResultMessage
from copilotj.core.tool import Tool

logger = logging.getLogger(__name__)
Expand All @@ -37,10 +38,12 @@
"OllamaChatCompletionClient",
"new_model_client",
"new_vlm_model_client",
"detect_tool_call_mode",
]


type FinishReasons = Literal["stop", "tool_calls", "unknown"]
type MessageInput = TextMessage | ImageMessage | ToolCallMessage | ToolResultMessage


class ToolCall(pydantic.BaseModel):
Expand Down Expand Up @@ -117,7 +120,7 @@ def get_api_key(self) -> str | None:
@abc.abstractmethod
async def create(
self,
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
*,
tools: list[Tool] | None = None,
extra_args: dict[str, Any] | None = None,
Expand All @@ -126,7 +129,7 @@ async def create(
@abc.abstractmethod
def create_stream(
self,
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
*,
tools: list[Tool] | None = None,
extra_args: dict[str, Any] | None = None,
Expand Down Expand Up @@ -226,7 +229,7 @@ def get_api_key(self) -> str | None:
@override
async def create(
self,
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
*,
tools: list[Tool] | None = None,
extra_args: dict[str, Any] | None = None,
Expand Down Expand Up @@ -261,7 +264,7 @@ async def create(
@override
async def create_stream(
self,
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
*,
tools: list[Tool] | None = None,
extra_args: dict[str, Any] | None = None,
Expand Down Expand Up @@ -347,7 +350,7 @@ async def _create(
) -> openai.AsyncStream[openai.types.chat.ChatCompletionChunk]: ...
async def _create(
self,
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
*,
tools: list[Tool] | None,
extra_args: dict[str, Any] | None,
Expand Down Expand Up @@ -382,10 +385,19 @@ async def _create(
raise ModelProviderError(f"OpenAI error: {str(e)}", "openai") from e

@classmethod
def _format_messages(cls, messages: Sequence[TextMessage | ImageMessage]):
def _format_messages(cls, messages: Sequence[TextMessage | ImageMessage | ToolCallMessage | ToolResultMessage]):
openai_messages: list[openai.types.chat.ChatCompletionMessageParam] = []
group: list[TextMessage | ImageMessage] = []
for message in messages:
# ToolCallMessage and ToolResultMessage are standalone — flush any
# pending group first, then emit them directly.
if isinstance(message, (ToolCallMessage, ToolResultMessage)):
if group:
openai_messages.append(cls._merge_messages(group))
group.clear()
openai_messages.append(cls._format_tool_message(message))
continue

if len(group) > 0 and group[0].role != message.role:
openai_messages.append(cls._merge_messages(group))
group.clear()
Expand All @@ -397,9 +409,35 @@ def _format_messages(cls, messages: Sequence[TextMessage | ImageMessage]):

return openai_messages

@staticmethod
def _format_tool_message(
msg: ToolCallMessage | ToolResultMessage,
) -> openai.types.chat.ChatCompletionMessageParam:
"""Convert a ToolCallMessage or ToolResultMessage to OpenAI API format."""
if isinstance(msg, ToolCallMessage):
return {
"role": "assistant",
"content": msg.reasoning_content,
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.name, "arguments": json.dumps(tc.arguments, ensure_ascii=False)},
}
for tc in msg.tool_calls
],
} # type: ignore[return-value]

# ToolResultMessage
return {
"role": "tool",
"tool_call_id": msg.tool_call_id,
"content": msg.content,
} # type: ignore[return-value]

@staticmethod
def _merge_messages(
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
) -> openai.types.chat.ChatCompletionMessageParam:
"""Format a sequence of messages into OpenAI's chat completion format."""
content = []
Expand Down Expand Up @@ -435,7 +473,7 @@ def get_api_key(self) -> str | None:
@override
async def create(
self,
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
*,
tools: list[Tool] | None = None,
extra_args: dict[str, Any] | None = None,
Expand Down Expand Up @@ -490,7 +528,7 @@ async def create(
@override
async def create_stream(
self,
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
*,
tools: list[Tool] | None = None,
extra_args: dict[str, Any] | None = None,
Expand Down Expand Up @@ -599,7 +637,7 @@ async def _create(
) -> openai.AsyncStream[openai.types.responses.ResponseStreamEvent]: ...
async def _create(
self,
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
*,
tools: list[Tool] | None,
extra_args: dict[str, Any] | None,
Expand All @@ -609,6 +647,13 @@ async def _create(
inputs: list[openai.types.responses.ResponseInputItemParam] = []
group: list[TextMessage | ImageMessage] = []
for message in messages:
if isinstance(message, (ToolCallMessage, ToolResultMessage)):
if group:
inputs.append(self._merge_messages(group))
group.clear()
inputs.append(self._format_tool_input(message))
continue

if len(group) > 0 and group[0].role != message.role:
inputs.append(self._merge_messages(group))
group.clear()
Expand Down Expand Up @@ -644,9 +689,31 @@ async def _create(
except openai.OpenAIError as e:
raise ModelProviderError(f"OpenAI error: {str(e)}", "openai") from e

@staticmethod
def _format_tool_input(
msg: ToolCallMessage | ToolResultMessage,
) -> openai.types.responses.ResponseInputItemParam:
"""Convert a ToolCallMessage or ToolResultMessage to Responses API input format."""
if isinstance(msg, ToolCallMessage):
# Responses API uses function_call_output items for assistant tool calls
return {
"type": "function_call",
"id": msg.tool_calls[0].id if msg.tool_calls else "unknown",
"call_id": msg.tool_calls[0].id if msg.tool_calls else "unknown",
"name": msg.tool_calls[0].name if msg.tool_calls else "unknown",
"arguments": json.dumps(msg.tool_calls[0].arguments, ensure_ascii=False) if msg.tool_calls else "{}",
} # type: ignore[return-value]

# ToolResultMessage → function_call_output
return {
"type": "function_call_output",
"call_id": msg.tool_call_id,
"output": msg.content,
} # type: ignore[return-value]

@staticmethod
def _merge_messages(
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
) -> openai.types.responses.ResponseInputItemParam:
"""Format a sequence of messages into the Responses API input format."""
role = _openai_convert_role(messages[0].role)
Expand Down Expand Up @@ -746,6 +813,25 @@ def _openai_parse_finish_reason(finish_reason: str | None) -> FinishReasons | No
return "unknown"


#############################
# Tool Call Detection #
#############################


def detect_tool_call_mode(model_client: ModelClient) -> Literal["native", "react"]:
"""Detect whether the model supports native tool calling.

Queries the LiteLLM model capability database via
:func:`copilotj.core.model_info.get_model_capabilities`. Models with
``supports_function_calling=True`` use the native path; everything else
falls back to ReAct text parsing.
"""
from copilotj.core.model_info import get_model_capabilities

caps = get_model_capabilities(model_client.get_model())
return "native" if caps.supports_function_calling else "react"


#############################
# Gemini #
#############################
Expand Down Expand Up @@ -791,22 +877,34 @@ def get_model(self) -> str:
def get_api_key(self) -> str | None:
return None

def _format_messages(self, messages: Sequence[TextMessage | ImageMessage]) -> list[dict]:
def _format_messages(self, messages: Sequence[MessageInput]) -> list[dict]:
"""Formats messages for the Ollama API."""
ollama_messages = []
for msg in messages:
if isinstance(msg, TextMessage):
ollama_messages.append({"role": msg.role, "content": msg.text})
elif isinstance(msg, ImageMessage):
logger.warning("Image messages not fully supported by Ollama client yet. Skipping image.")
elif isinstance(msg, ToolCallMessage):
ollama_messages.append(
{
"role": "assistant",
"content": msg.reasoning_content or None,
"tool_calls": [
{"function": {"name": tc.name, "arguments": tc.arguments}} for tc in msg.tool_calls
],
}
)
elif isinstance(msg, ToolResultMessage):
ollama_messages.append({"role": "tool", "content": msg.content})
else:
raise ValueError(f"Unsupported message type: {msg}")
return ollama_messages

@override
async def create(
self,
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
*,
tools: list[Tool] | None = None,
extra_args: dict[str, Any] | None = None,
Expand Down Expand Up @@ -846,7 +944,7 @@ async def create(
@override
async def create_stream(
self,
messages: Sequence[TextMessage | ImageMessage],
messages: Sequence[MessageInput],
*,
tools: list[Tool] | None = None,
extra_args: dict[str, Any] | None = None,
Expand Down
Loading
Loading