mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 10:15:21 +00:00
feat: client side tools
This commit is contained in:
1 parent
d609efca49
commit
73256389cf
16 files changed
+864
-250
No files matched your search
+43
-32
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user