diff --git a/application/agents/base.py b/application/agents/base.py index c791ec45..4fc53795 100644 --- a/application/agents/base.py +++ b/application/agents/base.py @@ -148,22 +148,26 @@ class BaseAgent(ABC): "comment": "No response provided", } - # Build the assistant tool-call message + # Build the assistant tool-call message in standard format args = pending["arguments"] - function_call_content: Dict[str, Any] = { - "function_call": { + args_str = ( + json.dumps(args) if isinstance(args, dict) else (args or "{}") + ) + tc_obj: Dict[str, Any] = { + "id": call_id, + "type": "function", + "function": { "name": pending["name"], - "args": args, - "call_id": call_id, - } + "arguments": args_str, + }, } if pending.get("thought_signature"): - function_call_content["thought_signature"] = pending[ - "thought_signature" - ] - messages.append( - {"role": "assistant", "content": [function_call_content]} - ) + tc_obj["thought_signature"] = pending["thought_signature"] + messages.append({ + "role": "assistant", + "content": None, + "tool_calls": [tc_obj], + }) if action.get("decision") == "approved": # Execute the tool server-side @@ -403,28 +407,35 @@ class BaseAgent(ABC): if "tool_calls" in i: for tool_call in i["tool_calls"]: call_id = tool_call.get("call_id") or str(uuid.uuid4()) - - function_call_dict = { - "function_call": { - "name": tool_call.get("action_name"), - "args": tool_call.get("arguments"), - "call_id": call_id, - } - } - function_response_dict = { - "function_response": { - "name": tool_call.get("action_name"), - "response": {"result": tool_call.get("result")}, - "call_id": call_id, - } - } - - messages.append( - {"role": "assistant", "content": [function_call_dict]} + args = tool_call.get("arguments") + args_str = ( + json.dumps(args) + if isinstance(args, dict) + else (args or "{}") ) - messages.append( - {"role": "tool", "content": [function_response_dict]} + messages.append({ + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": { + "name": tool_call.get("action_name", ""), + "arguments": args_str, + }, + }], + }) + result = tool_call.get("result") + result_str = ( + json.dumps(result) + if not isinstance(result, str) + else (result or "") ) + messages.append({ + "role": "tool", + "tool_call_id": call_id, + "content": result_str, + }) messages.append({"role": "user", "content": query}) return messages diff --git a/application/agents/research_agent.py b/application/agents/research_agent.py index 280fa2cd..9c7212b1 100644 --- a/application/agents/research_agent.py +++ b/application/agents/research_agent.py @@ -593,16 +593,22 @@ class ResearchAgent(BaseAgent): ) result = result_str - function_call_content = { - "function_call": { - "name": call.name, - "args": call.arguments, - "call_id": call_id, - } - } - messages.append( - {"role": "assistant", "content": [function_call_content]} + import json as _json + + args_str = ( + _json.dumps(call.arguments) + if isinstance(call.arguments, dict) + else call.arguments ) + messages.append({ + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": {"name": call.name, "arguments": args_str}, + }], + }) tool_message = self.llm_handler.create_tool_message(call, result) messages.append(tool_message) diff --git a/application/agents/tool_executor.py b/application/agents/tool_executor.py index f8fec61e..cce7d3b4 100644 --- a/application/agents/tool_executor.py +++ b/application/agents/tool_executor.py @@ -31,12 +31,21 @@ class ToolExecutor: self.tool_calls: List[Dict] = [] self._loaded_tools: Dict[str, object] = {} self.conversation_id: Optional[str] = None + self.client_tools: Optional[List[Dict]] = None def get_tools(self) -> Dict[str, Dict]: - """Load tool configs from DB based on user context.""" + """Load tool configs from DB based on user context. + + If *client_tools* have been set on this executor, they are + automatically merged into the returned dict. + """ if self.user_api_key: - return self._get_tools_by_api_key(self.user_api_key) - return self._get_user_tools(self.user or "local") + tools = self._get_tools_by_api_key(self.user_api_key) + else: + tools = self._get_user_tools(self.user or "local") + if self.client_tools: + self.merge_client_tools(tools, self.client_tools) + return tools def _get_tools_by_api_key(self, api_key: str) -> Dict[str, Dict]: mongo = MongoDB.get_client() @@ -65,29 +74,85 @@ class ToolExecutor: user_tools = list(user_tools) return {str(i): tool for i, tool in enumerate(user_tools)} + def merge_client_tools( + self, tools_dict: Dict, client_tools: List[Dict] + ) -> Dict: + """Merge client-provided tool definitions into tools_dict. + + Client tools use the standard function-calling format:: + + [{"type": "function", "function": {"name": "get_weather", + "description": "...", "parameters": {...}}}] + + They are stored in *tools_dict* with ``client_side: True`` so that + :meth:`check_pause` returns a pause signal instead of trying to + execute them server-side. + + Args: + tools_dict: The mutable server tools dict (will be modified in place). + client_tools: List of tool definitions in OpenAI function-calling format. + + Returns: + The updated *tools_dict* (same reference, for convenience). + """ + for i, ct in enumerate(client_tools): + func = ct.get("function", ct) # tolerate bare {"name":..} too + name = func.get("name", f"clienttool{i}") + # Tool IDs must NOT contain underscores — ToolActionParser splits + # on "_" and takes the last segment as the ID. + tool_id = f"ct{i}" + + tools_dict[tool_id] = { + "name": name, + "client_side": True, + "actions": [ + { + "name": name, + "description": func.get("description", ""), + "active": True, + "parameters": func.get("parameters", {}), + } + ], + } + return tools_dict + def prepare_tools_for_llm(self, tools_dict: Dict) -> List[Dict]: """Convert tool configs to LLM function schemas.""" - return [ - { - "type": "function", - "function": { - "name": f"{action['name']}_{tool_id}", - "description": action["description"], - "parameters": self._build_tool_parameters(action), - }, - } - for tool_id, tool in tools_dict.items() - if ( - (tool["name"] == "api_tool" and "actions" in tool.get("config", {})) - or (tool["name"] != "api_tool" and "actions" in tool) - ) - for action in ( + result = [] + for tool_id, tool in tools_dict.items(): + is_api = tool["name"] == "api_tool" + is_client = tool.get("client_side", False) + + if is_api and "actions" not in tool.get("config", {}): + continue + if not is_api and "actions" not in tool: + continue + + actions = ( tool["config"]["actions"].values() - if tool["name"] == "api_tool" + if is_api else tool["actions"] ) - if action.get("active", True) - ] + + for action in actions: + if not action.get("active", True): + continue + + # Client-side tools already have parameters in OpenAI format + if is_client: + params = action.get("parameters", {}) + else: + params = self._build_tool_parameters(action) + + result.append({ + "type": "function", + "function": { + "name": f"{action['name']}_{tool_id}", + "description": action.get("description", ""), + "parameters": params, + }, + }) + return result def _build_tool_parameters(self, action: Dict) -> Dict: params = {"type": "object", "properties": {}, "required": []} diff --git a/application/api/answer/routes/base.py b/application/api/answer/routes/base.py index 9abd0261..19627266 100644 --- a/application/api/answer/routes/base.py +++ b/application/api/answer/routes/base.py @@ -315,6 +315,9 @@ class BaseAnswerResource: "json_schema": getattr(agent, "json_schema", None), "retriever_config": getattr(agent, "retriever_config", None), }, + client_tools=getattr( + agent.tool_executor, "client_tools", None + ), ) except Exception as e: logger.error( diff --git a/application/api/answer/services/compression/message_builder.py b/application/api/answer/services/compression/message_builder.py index 93772fe5..0c54e4e4 100644 --- a/application/api/answer/services/compression/message_builder.py +++ b/application/api/answer/services/compression/message_builder.py @@ -1,5 +1,6 @@ """Message reconstruction utilities for compression.""" +import json import logging import uuid from typing import Dict, List, Optional @@ -49,28 +50,35 @@ class MessageBuilder: if include_tool_calls and "tool_calls" in query: for tool_call in query["tool_calls"]: call_id = tool_call.get("call_id") or str(uuid.uuid4()) - - function_call_dict = { - "function_call": { - "name": tool_call.get("action_name"), - "args": tool_call.get("arguments"), - "call_id": call_id, - } - } - function_response_dict = { - "function_response": { - "name": tool_call.get("action_name"), - "response": {"result": tool_call.get("result")}, - "call_id": call_id, - } - } - - messages.append( - {"role": "assistant", "content": [function_call_dict]} + args = tool_call.get("arguments") + args_str = ( + json.dumps(args) + if isinstance(args, dict) + else (args or "{}") ) - messages.append( - {"role": "tool", "content": [function_response_dict]} + messages.append({ + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": { + "name": tool_call.get("action_name", ""), + "arguments": args_str, + }, + }], + }) + result = tool_call.get("result") + result_str = ( + json.dumps(result) + if not isinstance(result, str) + else (result or "") ) + messages.append({ + "role": "tool", + "tool_call_id": call_id, + "content": result_str, + }) # If no recent queries (everything was compressed), add a continuation user message if len(recent_queries) == 0 and compressed_summary: @@ -180,28 +188,35 @@ class MessageBuilder: if include_tool_calls and "tool_calls" in query: for tool_call in query["tool_calls"]: call_id = tool_call.get("call_id") or str(uuid.uuid4()) - - function_call_dict = { - "function_call": { - "name": tool_call.get("action_name"), - "args": tool_call.get("arguments"), - "call_id": call_id, - } - } - function_response_dict = { - "function_response": { - "name": tool_call.get("action_name"), - "response": {"result": tool_call.get("result")}, - "call_id": call_id, - } - } - - rebuilt_messages.append( - {"role": "assistant", "content": [function_call_dict]} + args = tool_call.get("arguments") + args_str = ( + json.dumps(args) + if isinstance(args, dict) + else (args or "{}") ) - rebuilt_messages.append( - {"role": "tool", "content": [function_response_dict]} + rebuilt_messages.append({ + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": { + "name": tool_call.get("action_name", ""), + "arguments": args_str, + }, + }], + }) + result = tool_call.get("result") + result_str = ( + json.dumps(result) + if not isinstance(result, str) + else (result or "") ) + rebuilt_messages.append({ + "role": "tool", + "tool_call_id": call_id, + "content": result_str, + }) # If no recent queries (everything was compressed), add a continuation user message if len(recent_queries) == 0 and compressed_summary: diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index e663460f..c194ca3c 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -834,6 +834,12 @@ class StreamProcessor: decoded_token=self.decoded_token, ) tool_executor.conversation_id = conversation_id + # Restore client tools so they stay available for subsequent LLM calls + saved_client_tools = state.get("client_tools") + if saved_client_tools: + tool_executor.client_tools = saved_client_tools + # Re-merge into tools_dict (they may have been stripped during serialization) + tool_executor.merge_client_tools(tools_dict, saved_client_tools) agent_type = agent_config.get("agent_type", "ClassicAgent") # Map class names back to agent creator keys @@ -950,6 +956,10 @@ class StreamProcessor: decoded_token=self.decoded_token, ) tool_executor.conversation_id = self.conversation_id + # Pass client-side tools (Phase 2) so they get merged in get_tools() + client_tools = self.data.get("client_tools") + if client_tools: + tool_executor.client_tools = client_tools # Base agent kwargs agent_kwargs = { diff --git a/application/llm/google_ai.py b/application/llm/google_ai.py index ca984ba7..538abe67 100644 --- a/application/llm/google_ai.py +++ b/application/llm/google_ai.py @@ -167,6 +167,8 @@ class GoogleLLM(BaseLLM): return "\n".join(parts) return "" + import json as _json + for message in messages: role = message.get("role") content = message.get("content") @@ -180,9 +182,66 @@ class GoogleLLM(BaseLLM): if role == "assistant": role = "model" - elif role == "tool": - role = "model" + parts = [] + + # Standard format: assistant message with tool_calls array + msg_tool_calls = message.get("tool_calls") + if msg_tool_calls and role == "model": + for tc in msg_tool_calls: + func = tc.get("function", {}) + args = func.get("arguments", "{}") + if isinstance(args, str): + try: + args = _json.loads(args) + except (_json.JSONDecodeError, TypeError): + args = {} + cleaned_args = self._remove_null_values(args) + thought_sig = tc.get("thought_signature") + if thought_sig: + parts.append( + types.Part( + functionCall=types.FunctionCall( + name=func.get("name", ""), + args=cleaned_args, + ), + thoughtSignature=thought_sig, + ) + ) + else: + parts.append( + types.Part.from_function_call( + name=func.get("name", ""), + args=cleaned_args, + ) + ) + if parts: + cleaned_messages.append(types.Content(role=role, parts=parts)) + continue + + # Standard format: tool message with tool_call_id + tool_call_id = message.get("tool_call_id") + if role == "tool" and tool_call_id is not None: + result_content = content + if isinstance(result_content, str): + try: + result_content = _json.loads(result_content) + except (_json.JSONDecodeError, TypeError): + pass + # Google expects function_response name — extract from tool_call_id context + # We use a placeholder name since Google API doesn't require exact match + parts.append( + types.Part.from_function_response( + name="tool_result", + response={"result": result_content}, + ) + ) + cleaned_messages.append(types.Content(role="model", parts=parts)) + continue + + if role == "tool": + role = "model" + if role and content is not None: if isinstance(content, str): parts = [types.Part.from_text(text=content)] @@ -191,15 +250,11 @@ class GoogleLLM(BaseLLM): if "text" in item: parts.append(types.Part.from_text(text=item["text"])) elif "function_call" in item: - # Remove null values from args to avoid API errors - + # Legacy format support cleaned_args = self._remove_null_values( item["function_call"]["args"] ) - # Create function call part with thought_signature if present - # For Gemini 3 models, we need to include thought_signature if "thought_signature" in item: - # Use Part constructor with functionCall and thoughtSignature parts.append( types.Part( functionCall=types.FunctionCall( @@ -210,7 +265,6 @@ class GoogleLLM(BaseLLM): ) ) else: - # Use helper method when no thought_signature parts.append( types.Part.from_function_call( name=item["function_call"]["name"], diff --git a/application/llm/handlers/base.py b/application/llm/handlers/base.py index be8aff22..815ce179 100644 --- a/application/llm/handlers/base.py +++ b/application/llm/handlers/base.py @@ -1,3 +1,4 @@ +import json import logging import uuid from abc import ABC, abstractmethod @@ -315,10 +316,34 @@ class LLMHandler(ABC): current_prompt = self._extract_text_from_content(content) elif role in {"assistant", "model"}: - # If this assistant turn contains tool calls, collect them; otherwise commit a response. + # Standard format: tool_calls array on assistant message + msg_tool_calls = message.get("tool_calls") + if msg_tool_calls: + for tc in msg_tool_calls: + call_id = tc.get("id") or str(uuid.uuid4()) + func = tc.get("function", {}) + args = func.get("arguments") + if isinstance(args, str): + try: + args = json.loads(args) + except (json.JSONDecodeError, TypeError): + pass + current_tool_calls[call_id] = { + "tool_name": "unknown_tool", + "action_name": func.get("name"), + "arguments": args, + "result": None, + "status": "called", + "call_id": call_id, + } + continue + + # Legacy format: function_call/function_response in content list if isinstance(content, list): + has_fc = False for item in content: if "function_call" in item: + has_fc = True fc = item["function_call"] call_id = fc.get("call_id") or str(uuid.uuid4()) current_tool_calls[call_id] = { @@ -329,37 +354,30 @@ class LLMHandler(ABC): "status": "called", "call_id": call_id, } - elif "function_response" in item: - fr = item["function_response"] - call_id = fr.get("call_id") or str(uuid.uuid4()) - current_tool_calls[call_id] = { - "tool_name": "unknown_tool", - "action_name": fr.get("name"), - "arguments": None, - "result": fr.get("response", {}).get("result"), - "status": "completed", - "call_id": call_id, - } - # No direct assistant text here; continue to next message - continue + if has_fc: + continue response_text = self._extract_text_from_content(content) _commit_query(response_text) elif role == "tool": - # Attach tool outputs to the latest pending tool call if possible + # Standard format: tool_call_id on tool message + call_id = message.get("tool_call_id") tool_text = self._extract_text_from_content(content) - # Attempt to parse function_response style - call_id = None - if isinstance(content, list): - for item in content: - if "function_response" in item and item["function_response"].get("call_id"): - call_id = item["function_response"]["call_id"] - break + if call_id and call_id in current_tool_calls: current_tool_calls[call_id]["result"] = tool_text current_tool_calls[call_id]["status"] = "completed" - elif queries: + # Legacy: function_response in content list + elif isinstance(content, list): + for item in content: + if "function_response" in item: + legacy_id = item["function_response"].get("call_id") + if legacy_id and legacy_id in current_tool_calls: + current_tool_calls[legacy_id]["result"] = tool_text + current_tool_calls[legacy_id]["status"] = "completed" + break + elif call_id is None and queries: queries[-1].setdefault("tool_calls", []).append( { "tool_name": "unknown_tool", @@ -805,24 +823,29 @@ class LLMHandler(ABC): tool_response, call_id = e.value break - function_call_content = { - "function_call": { - "name": call.name, - "args": call.arguments, - "call_id": call_id, - } - } - # Include thought_signature for Google Gemini 3 models - # It should be at the same level as function_call, not inside it - if call.thought_signature: - function_call_content["thought_signature"] = call.thought_signature - updated_messages.append( - { - "role": "assistant", - "content": [function_call_content], - } + # Standard internal format: assistant message with tool_calls array + args_str = ( + json.dumps(call.arguments) + if isinstance(call.arguments, dict) + else call.arguments ) + tool_call_obj = { + "id": call_id, + "type": "function", + "function": { + "name": call.name, + "arguments": args_str, + }, + } + # Preserve thought_signature for Google Gemini 3 models + if call.thought_signature: + tool_call_obj["thought_signature"] = call.thought_signature + updated_messages.append({ + "role": "assistant", + "content": None, + "tool_calls": [tool_call_obj], + }) updated_messages.append(self.create_tool_message(call, tool_response)) except Exception as e: diff --git a/application/llm/handlers/google.py b/application/llm/handlers/google.py index 0142922a..5c202153 100644 --- a/application/llm/handlers/google.py +++ b/application/llm/handlers/google.py @@ -67,18 +67,18 @@ class GoogleLLMHandler(LLMHandler): ) def create_tool_message(self, tool_call: ToolCall, result: Any) -> Dict: - """Create Google-style tool message.""" + """Create a tool result message in the standard internal format.""" + import json as _json + content = ( + _json.dumps(result) + if not isinstance(result, str) + else result + ) return { - "role": "model", - "content": [ - { - "function_response": { - "name": tool_call.name, - "response": {"result": result}, - } - } - ], + "role": "tool", + "tool_call_id": tool_call.id, + "content": content, } def _iterate_stream(self, response: Any) -> Generator: diff --git a/application/llm/handlers/openai.py b/application/llm/handlers/openai.py index 99ddde4c..6c8431d7 100644 --- a/application/llm/handlers/openai.py +++ b/application/llm/handlers/openai.py @@ -37,18 +37,18 @@ class OpenAILLMHandler(LLMHandler): ) def create_tool_message(self, tool_call: ToolCall, result: Any) -> Dict: - """Create OpenAI-style tool message.""" + """Create a tool result message in the standard internal format.""" + import json as _json + + content = ( + _json.dumps(result) + if not isinstance(result, str) + else result + ) return { "role": "tool", - "content": [ - { - "function_response": { - "name": tool_call.name, - "response": {"result": result}, - "call_id": tool_call.id, - } - } - ], + "tool_call_id": tool_call.id, + "content": content, } def _iterate_stream(self, response: Any) -> Generator: diff --git a/application/llm/openai.py b/application/llm/openai.py index 8bfc3cef..ee9438de 100644 --- a/application/llm/openai.py +++ b/application/llm/openai.py @@ -91,16 +91,52 @@ class OpenAILLM(BaseLLM): if role == "model": role = "assistant" + + # Standard format: assistant message with tool_calls (passthrough) + tool_calls = message.get("tool_calls") + if tool_calls and role == "assistant": + cleaned_tcs = [] + for tc in tool_calls: + func = tc.get("function", {}) + args = func.get("arguments", "{}") + if isinstance(args, dict): + args = json.dumps(self._remove_null_values(args)) + elif isinstance(args, str): + try: + parsed = json.loads(args) + args = json.dumps(self._remove_null_values(parsed)) + except (json.JSONDecodeError, TypeError): + pass + cleaned_tcs.append({ + "id": tc.get("id", ""), + "type": "function", + "function": {"name": func.get("name", ""), "arguments": args}, + }) + cleaned_messages.append({ + "role": "assistant", + "content": None, + "tool_calls": cleaned_tcs, + }) + continue + + # Standard format: tool message with tool_call_id (passthrough) + tool_call_id = message.get("tool_call_id") + if role == "tool" and tool_call_id is not None: + cleaned_messages.append({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": content if isinstance(content, str) else json.dumps(content), + }) + continue + if role and content is not None: if isinstance(content, str): cleaned_messages.append({"role": role, "content": content}) elif isinstance(content, list): - # Collect all content parts into a single message content_parts = [] - for item in content: + # Legacy format support: function_call / function_response if "function_call" in item: - # Function calls need their own message args = item["function_call"]["args"] if isinstance(args, str): try: @@ -116,28 +152,20 @@ class OpenAILLM(BaseLLM): "arguments": json.dumps(cleaned_args), }, } - cleaned_messages.append( - { - "role": "assistant", - "content": None, - "tool_calls": [tool_call], - } - ) + cleaned_messages.append({ + "role": "assistant", + "content": None, + "tool_calls": [tool_call], + }) elif "function_response" in item: - # Function responses need their own message - cleaned_messages.append( - { - "role": "tool", - "tool_call_id": item["function_response"][ - "call_id" - ], - "content": json.dumps( - item["function_response"]["response"]["result"] - ), - } - ) + cleaned_messages.append({ + "role": "tool", + "tool_call_id": item["function_response"]["call_id"], + "content": json.dumps( + item["function_response"]["response"]["result"] + ), + }) elif isinstance(item, dict): - # Collect content parts (text, images, files) into a single message if "type" in item and item["type"] == "text" and "text" in item: content_parts.append(item) elif "type" in item and item["type"] == "file" and "file" in item: @@ -145,10 +173,7 @@ class OpenAILLM(BaseLLM): elif "type" in item and item["type"] == "image_url" and "image_url" in item: content_parts.append(item) elif "text" in item and "type" not in item: - # Legacy format: {"text": "..."} without type content_parts.append({"type": "text", "text": item["text"]}) - - # Add the collected content parts as a single message if content_parts: cleaned_messages.append({"role": role, "content": content_parts}) else: diff --git a/tests/api/answer/services/compression/test_message_builder.py b/tests/api/answer/services/compression/test_message_builder.py index 9cb681d4..99a0e1c2 100644 --- a/tests/api/answer/services/compression/test_message_builder.py +++ b/tests/api/answer/services/compression/test_message_builder.py @@ -79,9 +79,10 @@ class TestBuildFromCompressedContext: # system + user + assistant + tool_call_assistant + tool_response = 5 assert len(messages) == 5 assert messages[3]["role"] == "assistant" - assert "function_call" in messages[3]["content"][0] + assert messages[3].get("tool_calls") is not None + assert messages[3]["tool_calls"][0]["function"]["name"] == "search" assert messages[4]["role"] == "tool" - assert "function_response" in messages[4]["content"][0] + assert messages[4].get("tool_call_id") == "call-1" def test_tool_calls_not_included_by_default(self): queries = [ @@ -127,8 +128,8 @@ class TestBuildFromCompressedContext: recent_queries=queries, include_tool_calls=True, ) - tool_msg = messages[3]["content"][0] - call_id = tool_msg["function_call"]["call_id"] + assistant_msg = messages[3] + call_id = assistant_msg["tool_calls"][0]["id"] assert call_id is not None assert len(call_id) > 0 diff --git a/tests/llm/handlers/test_google.py b/tests/llm/handlers/test_google.py index 900b2d5f..1a4558fc 100644 --- a/tests/llm/handlers/test_google.py +++ b/tests/llm/handlers/test_google.py @@ -196,9 +196,9 @@ class TestGoogleLLMHandler: assert result.finish_reason == "tool_calls" def test_create_tool_message(self): - """Test creating tool message.""" + """Test creating tool message in standard format.""" handler = GoogleLLMHandler() - + tool_call = ToolCall( id="call_123", name="get_weather", @@ -206,35 +206,26 @@ class TestGoogleLLMHandler: index=0 ) result = {"temperature": "25C", "condition": "cloudy"} - + message = handler.create_tool_message(tool_call, result) - - expected = { - "role": "model", - "content": [ - { - "function_response": { - "name": "get_weather", - "response": {"result": result}, - } - } - ], - } - - assert message == expected + + assert message["role"] == "tool" + assert message["tool_call_id"] == "call_123" + import json + assert json.loads(message["content"]) == result def test_create_tool_message_string_result(self): """Test creating tool message with string result.""" handler = GoogleLLMHandler() - + tool_call = ToolCall(id="call_456", name="get_time", arguments={}) result = "2023-12-01 15:30:00 JST" - + message = handler.create_tool_message(tool_call, result) - - assert message["role"] == "model" - assert message["content"][0]["function_response"]["response"]["result"] == result - assert message["content"][0]["function_response"]["name"] == "get_time" + + assert message["role"] == "tool" + assert message["tool_call_id"] == "call_456" + assert message["content"] == result def test_iterate_stream(self): """Test stream iteration.""" diff --git a/tests/llm/handlers/test_llm_handlers.py b/tests/llm/handlers/test_llm_handlers.py index b302970d..da693ec2 100644 --- a/tests/llm/handlers/test_llm_handlers.py +++ b/tests/llm/handlers/test_llm_handlers.py @@ -709,16 +709,15 @@ class TestHandleToolCalls: except StopIteration as e: messages, _pending = e.value + # Standard format: thought_signature is on tool_calls items assistant_msgs = [ m for m in messages - if m.get("role") == "assistant" - and isinstance(m.get("content"), list) + if m.get("role") == "assistant" and m.get("tool_calls") ] assert any( - "thought_signature" in item + tc.get("thought_signature") == "sig" for m in assistant_msgs - for item in m["content"] - if isinstance(item, dict) + for tc in m["tool_calls"] ) diff --git a/tests/llm/handlers/test_openai.py b/tests/llm/handlers/test_openai.py index 64c89f6c..03494573 100644 --- a/tests/llm/handlers/test_openai.py +++ b/tests/llm/handlers/test_openai.py @@ -128,9 +128,9 @@ class TestOpenAILLMHandler: assert result.finish_reason == "" def test_create_tool_message(self): - """Test creating tool message.""" + """Test creating tool message in standard format.""" handler = OpenAILLMHandler() - + tool_call = ToolCall( id="call_123", name="get_weather", @@ -138,36 +138,26 @@ class TestOpenAILLMHandler: index=0 ) result = {"temperature": "72F", "condition": "sunny"} - + message = handler.create_tool_message(tool_call, result) - - expected = { - "role": "tool", - "content": [ - { - "function_response": { - "name": "get_weather", - "response": {"result": result}, - "call_id": "call_123", - } - } - ], - } - - assert message == expected + + assert message["role"] == "tool" + assert message["tool_call_id"] == "call_123" + import json + assert json.loads(message["content"]) == result def test_create_tool_message_string_result(self): """Test creating tool message with string result.""" handler = OpenAILLMHandler() - + tool_call = ToolCall(id="call_456", name="get_time", arguments={}) result = "2023-12-01 10:30:00" - + message = handler.create_tool_message(tool_call, result) - + assert message["role"] == "tool" - assert message["content"][0]["function_response"]["response"]["result"] == result - assert message["content"][0]["function_response"]["call_id"] == "call_456" + assert message["tool_call_id"] == "call_456" + assert message["content"] == result def test_iterate_stream(self): """Test stream iteration.""" diff --git a/tests/test_client_tools.py b/tests/test_client_tools.py new file mode 100644 index 00000000..fa8d434b --- /dev/null +++ b/tests/test_client_tools.py @@ -0,0 +1,421 @@ +"""Tests for client-side tools (Phase 2). + +Covers merge_client_tools, prepare_tools_for_llm with client tools, +check_pause for client-side tools, and the full flow through the handler. +""" + +from unittest.mock import Mock + +import pytest + +from application.agents.tool_executor import ToolExecutor +from application.llm.handlers.base import LLMHandler, LLMResponse, ToolCall + + +# --------------------------------------------------------------------------- +# ToolExecutor.merge_client_tools +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestMergeClientTools: + + def test_merge_single_tool(self): + executor = ToolExecutor() + tools_dict = {} + client_tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string", "description": "City name"} + }, + "required": ["city"], + }, + }, + } + ] + + result = executor.merge_client_tools(tools_dict, client_tools) + + assert "ct0" in result + tool = result["ct0"] + assert tool["name"] == "get_weather" + assert tool["client_side"] is True + assert len(tool["actions"]) == 1 + assert tool["actions"][0]["name"] == "get_weather" + assert tool["actions"][0]["active"] is True + assert "city" in tool["actions"][0]["parameters"]["properties"] + + def test_merge_multiple_tools(self): + executor = ToolExecutor() + tools_dict = {"0": {"name": "existing_tool", "actions": []}} + client_tools = [ + {"type": "function", "function": {"name": "tool_a", "description": "A"}}, + {"type": "function", "function": {"name": "tool_b", "description": "B"}}, + ] + + result = executor.merge_client_tools(tools_dict, client_tools) + + # Original tool still present + assert "0" in result + # Client tools added + assert "ct0" in result + assert "ct1" in result + assert result["ct0"]["name"] == "tool_a" + assert result["ct1"]["name"] == "tool_b" + + def test_merge_bare_format(self): + """Accept simplified format without the outer 'function' wrapper.""" + executor = ToolExecutor() + tools_dict = {} + client_tools = [ + {"name": "simple_tool", "description": "Simple", "parameters": {}}, + ] + + result = executor.merge_client_tools(tools_dict, client_tools) + + assert "ct0" in result + assert result["ct0"]["name"] == "simple_tool" + + def test_merge_preserves_existing_tools(self): + executor = ToolExecutor() + tools_dict = { + "abc123": { + "name": "brave", + "actions": [{"name": "search", "active": True}], + } + } + client_tools = [ + {"type": "function", "function": {"name": "my_tool", "description": "D"}}, + ] + + executor.merge_client_tools(tools_dict, client_tools) + + assert "abc123" in tools_dict + assert tools_dict["abc123"]["name"] == "brave" + assert "ct0" in tools_dict + + def test_merge_empty_list(self): + executor = ToolExecutor() + tools_dict = {"0": {"name": "existing"}} + + executor.merge_client_tools(tools_dict, []) + + assert len(tools_dict) == 1 + + +# --------------------------------------------------------------------------- +# prepare_tools_for_llm with client tools +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestPrepareClientToolsForLlm: + + def test_client_tools_included_in_llm_schema(self): + executor = ToolExecutor() + tools_dict = {} + client_tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"], + }, + }, + } + ] + executor.merge_client_tools(tools_dict, client_tools) + + schemas = executor.prepare_tools_for_llm(tools_dict) + + assert len(schemas) == 1 + assert schemas[0]["type"] == "function" + assert schemas[0]["function"]["name"] == "get_weather_ct0" + assert schemas[0]["function"]["description"] == "Get weather" + # Parameters passed through directly (not filtered by _build_tool_parameters) + assert "city" in schemas[0]["function"]["parameters"]["properties"] + assert schemas[0]["function"]["parameters"]["required"] == ["city"] + + def test_mixed_server_and_client_tools(self): + executor = ToolExecutor() + tools_dict = { + "t1": { + "name": "test_tool", + "actions": [ + { + "name": "do_thing", + "description": "Does a thing", + "active": True, + "parameters": { + "properties": { + "query": { + "type": "string", + "filled_by_llm": True, + "required": True, + } + } + }, + } + ], + } + } + client_tools = [ + { + "type": "function", + "function": { + "name": "local_fn", + "description": "Local function", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + executor.merge_client_tools(tools_dict, client_tools) + + schemas = executor.prepare_tools_for_llm(tools_dict) + + assert len(schemas) == 2 + names = {s["function"]["name"] for s in schemas} + assert "do_thing_t1" in names + assert "local_fn_ct0" in names + + +# --------------------------------------------------------------------------- +# get_tools auto-merges client_tools +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetToolsAutoMerge: + + def test_get_tools_merges_client_tools(self, mock_mongo_db): + executor = ToolExecutor(user="alice") + executor.client_tools = [ + { + "type": "function", + "function": {"name": "my_fn", "description": "test"}, + } + ] + + tools = executor.get_tools() + + assert any( + t.get("client_side") is True for t in tools.values() + ), "Client tools should be merged into tools_dict" + + def test_get_tools_no_client_tools(self, mock_mongo_db): + executor = ToolExecutor(user="alice") + + tools = executor.get_tools() + + assert not any( + t.get("client_side") for t in tools.values() + ) + + +# --------------------------------------------------------------------------- +# check_pause for client-side tools +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCheckPauseClientTools: + + def _make_call(self, name="action_0", call_id="c1"): + call = Mock() + call.name = name + call.id = call_id + call.arguments = "{}" + call.thought_signature = None + return call + + def test_client_tool_triggers_pause(self): + executor = ToolExecutor() + tools_dict = { + "ct0": { + "name": "get_weather", + "client_side": True, + "actions": [ + {"name": "get_weather", "active": True, "parameters": {}}, + ], + } + } + call = self._make_call(name="get_weather_ct0") + result = executor.check_pause(tools_dict, call, "OpenAILLM") + + assert result is not None + assert result["pause_type"] == "requires_client_execution" + assert result["tool_name"] == "get_weather" + assert result["tool_id"] == "ct0" + + def test_server_tool_no_pause(self): + executor = ToolExecutor() + tools_dict = { + "0": { + "name": "brave", + "actions": [ + {"name": "search", "active": True, "parameters": {}}, + ], + } + } + call = self._make_call(name="search_0") + result = executor.check_pause(tools_dict, call, "OpenAILLM") + + assert result is None + + +# --------------------------------------------------------------------------- +# Handler flow: client tool causes pause +# --------------------------------------------------------------------------- + + +class ConcreteHandler(LLMHandler): + """Minimal concrete handler for testing.""" + + def parse_response(self, response): + return LLMResponse( + content=str(response), tool_calls=[], finish_reason="stop", + raw_response=response, + ) + + def create_tool_message(self, tool_call, result): + return {"role": "tool", "content": str(result)} + + def _iterate_stream(self, response): + for chunk in response: + yield chunk + + +@pytest.mark.unit +class TestHandlerClientToolPause: + + def test_client_tool_pauses_stream(self): + """When LLM calls a client-side tool, handler yields tool_calls_pending.""" + handler = ConcreteHandler() + + agent = Mock() + agent.llm = Mock() + agent.model_id = "test" + agent.tools = [] + agent._check_context_limit = Mock(return_value=False) + agent.context_limit_reached = False + agent.llm.__class__.__name__ = "MockLLM" + + # check_pause returns pause info for client tool + agent.tool_executor.check_pause = Mock(return_value={ + "call_id": "c1", + "name": "get_weather_ct0", + "tool_name": "get_weather", + "tool_id": "ct0", + "action_name": "get_weather", + "arguments": {"city": "SF"}, + "pause_type": "requires_client_execution", + "thought_signature": None, + }) + + # Simulate streaming: one chunk with tool_calls finish_reason + chunk = LLMResponse( + content="", + tool_calls=[ToolCall(id="c1", name="get_weather_ct0", arguments='{"city": "SF"}', index=0)], + finish_reason="tool_calls", + raw_response={}, + ) + handler.parse_response = lambda c: c + handler._iterate_stream = lambda r: iter(r) + + gen = handler.handle_streaming( + agent, [chunk], {"ct0": {"name": "get_weather", "client_side": True}}, [] + ) + events = list(gen) + + # Should have a requires_client_execution event + client_events = [ + e for e in events + if isinstance(e, dict) + and e.get("type") == "tool_call" + and e.get("data", {}).get("status") == "requires_client_execution" + ] + assert len(client_events) == 1 + + # Should have a tool_calls_pending event + pending_events = [ + e for e in events + if isinstance(e, dict) and e.get("type") == "tool_calls_pending" + ] + assert len(pending_events) == 1 + + def test_mixed_server_and_client_tools_in_batch(self): + """Server tool executes, client tool pauses.""" + handler = ConcreteHandler() + + agent = Mock() + agent._check_context_limit = Mock(return_value=False) + agent.context_limit_reached = False + agent.llm.__class__.__name__ = "MockLLM" + + call_count = {"n": 0} + + def check_pause_fn(tools_dict, call, llm_class): + call_count["n"] += 1 + if call_count["n"] == 2: # Second tool is client-side + return { + "call_id": "c2", + "name": "get_weather_ct0", + "tool_name": "get_weather", + "tool_id": "ct0", + "action_name": "get_weather", + "arguments": {}, + "pause_type": "requires_client_execution", + "thought_signature": None, + } + return None + + agent.tool_executor.check_pause = Mock(side_effect=check_pause_fn) + + def fake_execute(tools_dict, call): + yield {"type": "tool_call", "data": {"status": "pending"}} + return ("server result", call.id) + + agent._execute_tool_action = Mock(side_effect=fake_execute) + + calls = [ + ToolCall(id="c1", name="search_0", arguments="{}"), + ToolCall(id="c2", name="get_weather_ct0", arguments="{}"), + ] + + gen = handler.handle_tool_calls( + agent, + calls, + { + "0": {"name": "search"}, + "ct0": {"name": "get_weather", "client_side": True}, + }, + [], + ) + + events = [] + messages = None + pending = None + try: + while True: + events.append(next(gen)) + except StopIteration as e: + messages, pending = e.value + + # Server tool executed + assert agent._execute_tool_action.call_count == 1 + # Client tool pending + assert pending is not None + assert len(pending) == 1 + assert pending[0]["pause_type"] == "requires_client_execution"