feat: client side tools

This commit is contained in:
Alex committed 2026-03-31 22:20:55 +01:00
1 parent d609efca49
commit 73256389cf
16 files changed
+864 -250

No files matched your search

+43 -32
View File
@@ -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
+15 -9
View File
@@ -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)
+86 -21
View File
@@ -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": []}
+3
View File
@@ -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(
@@ -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:
@@ -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 = {
+62 -8
View File
@@ -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"],
+62 -39
View File
@@ -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:
+10 -10
View File
@@ -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:
+10 -10
View File
@@ -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:
+51 -26
View File
@@ -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:
@@ -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
+14 -23
View File
@@ -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."""
+4 -5
View File
@@ -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"]
)
+13 -23
View File
@@ -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."""
+421
View File
@@ -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"