mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-02 05:38:12 +00:00
ensure result_as_answer, hooks, and cache parodity
This commit is contained in:
@@ -744,192 +744,297 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
|
||||
for tool in self.original_tools or []:
|
||||
original_tools_by_name[sanitize_tool_name(tool.name)] = tool
|
||||
|
||||
# Reserve max-usage slots deterministically in call order.
|
||||
# This prevents race conditions when multiple parallel calls target the same tool.
|
||||
reserved_usage_by_tool: dict[str, int] = {}
|
||||
execution_plan: list[tuple[str, str, str | dict[str, Any], Any | None, bool]] = []
|
||||
for call_id, func_name, func_args in parsed_calls:
|
||||
original_tool = original_tools_by_name.get(func_name)
|
||||
should_execute = True
|
||||
if (
|
||||
original_tool
|
||||
and getattr(original_tool, "max_usage_count", None) is not None
|
||||
):
|
||||
current_usage = getattr(original_tool, "current_usage_count", 0)
|
||||
reserved = reserved_usage_by_tool.get(func_name, 0)
|
||||
if current_usage + reserved >= original_tool.max_usage_count:
|
||||
should_execute = False
|
||||
else:
|
||||
reserved_usage_by_tool[func_name] = reserved + 1
|
||||
execution_plan.append(
|
||||
(call_id, func_name, func_args, original_tool, should_execute)
|
||||
)
|
||||
|
||||
assistant_message: LLMMessage = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": func_name,
|
||||
"arguments": func_args
|
||||
if isinstance(func_args, str)
|
||||
else json.dumps(func_args),
|
||||
},
|
||||
}
|
||||
for call_id, func_name, func_args, _, _ in execution_plan
|
||||
],
|
||||
}
|
||||
self.messages.append(assistant_message)
|
||||
|
||||
def _execute_one(
|
||||
idx: int,
|
||||
call_id: str,
|
||||
func_name: str,
|
||||
func_args: str | dict[str, Any],
|
||||
original_tool: Any | None,
|
||||
should_execute: bool,
|
||||
) -> tuple[int, str, str, str, Any | None]:
|
||||
if isinstance(func_args, str):
|
||||
try:
|
||||
args_dict = json.loads(func_args)
|
||||
except json.JSONDecodeError:
|
||||
args_dict = {}
|
||||
else:
|
||||
args_dict = func_args
|
||||
|
||||
agent_key = (
|
||||
getattr(self.agent, "key", "unknown") if self.agent else "unknown"
|
||||
)
|
||||
started_at = datetime.now()
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
event=ToolUsageStartedEvent(
|
||||
tool_name=func_name,
|
||||
tool_args=args_dict,
|
||||
from_agent=self.agent,
|
||||
from_task=self.task,
|
||||
agent_key=agent_key,
|
||||
),
|
||||
)
|
||||
|
||||
track_delegation_if_needed(func_name, args_dict, self.task)
|
||||
|
||||
error_event_emitted = False
|
||||
result: str = "Tool not found"
|
||||
if not should_execute and original_tool:
|
||||
result = (
|
||||
f"Tool '{func_name}' has reached its usage limit of "
|
||||
f"{original_tool.max_usage_count} times and cannot be used anymore."
|
||||
has_result_as_answer_in_batch = any(
|
||||
bool(
|
||||
original_tools_by_name.get(func_name)
|
||||
and getattr(
|
||||
original_tools_by_name.get(func_name), "result_as_answer", False
|
||||
)
|
||||
)
|
||||
for _, func_name, _ in parsed_calls
|
||||
)
|
||||
# Preserve sequential short-circuit behavior for result_as_answer tools.
|
||||
if has_result_as_answer_in_batch:
|
||||
logger.debug(
|
||||
"Skipping parallel native execution because batch includes result_as_answer tool"
|
||||
)
|
||||
else:
|
||||
# Reserve max-usage slots deterministically in call order.
|
||||
# This prevents race conditions when multiple parallel calls target the same tool.
|
||||
reserved_usage_by_tool: dict[str, int] = {}
|
||||
execution_plan: list[
|
||||
tuple[str, str, str | dict[str, Any], Any | None, bool]
|
||||
] = []
|
||||
for call_id, func_name, func_args in parsed_calls:
|
||||
original_tool = original_tools_by_name.get(func_name)
|
||||
should_execute = True
|
||||
if (
|
||||
original_tool
|
||||
and getattr(original_tool, "max_usage_count", None) is not None
|
||||
):
|
||||
current_usage = getattr(original_tool, "current_usage_count", 0)
|
||||
reserved = reserved_usage_by_tool.get(func_name, 0)
|
||||
if current_usage + reserved >= original_tool.max_usage_count:
|
||||
should_execute = False
|
||||
else:
|
||||
reserved_usage_by_tool[func_name] = reserved + 1
|
||||
execution_plan.append(
|
||||
(call_id, func_name, func_args, original_tool, should_execute)
|
||||
)
|
||||
elif func_name in available_functions:
|
||||
try:
|
||||
raw_result = available_functions[func_name](**args_dict)
|
||||
result = (
|
||||
str(raw_result)
|
||||
if not isinstance(raw_result, str)
|
||||
else raw_result
|
||||
)
|
||||
except Exception as e:
|
||||
result = f"Error executing tool: {e}"
|
||||
if self.task:
|
||||
self.task.increment_tools_errors()
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
event=ToolUsageErrorEvent(
|
||||
tool_name=func_name,
|
||||
tool_args=args_dict,
|
||||
from_agent=self.agent,
|
||||
from_task=self.task,
|
||||
agent_key=agent_key,
|
||||
error=e,
|
||||
),
|
||||
)
|
||||
error_event_emitted = True
|
||||
|
||||
if not error_event_emitted:
|
||||
assistant_message: LLMMessage = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": func_name,
|
||||
"arguments": func_args
|
||||
if isinstance(func_args, str)
|
||||
else json.dumps(func_args),
|
||||
},
|
||||
}
|
||||
for call_id, func_name, func_args, _, _ in execution_plan
|
||||
],
|
||||
}
|
||||
self.messages.append(assistant_message)
|
||||
|
||||
def _execute_one(
|
||||
idx: int,
|
||||
call_id: str,
|
||||
func_name: str,
|
||||
func_args: str | dict[str, Any],
|
||||
original_tool: Any | None,
|
||||
should_execute: bool,
|
||||
) -> tuple[int, str, str, str, Any | None]:
|
||||
if isinstance(func_args, str):
|
||||
try:
|
||||
args_dict = json.loads(func_args)
|
||||
except json.JSONDecodeError:
|
||||
args_dict = {}
|
||||
else:
|
||||
args_dict = func_args
|
||||
|
||||
agent_key = (
|
||||
getattr(self.agent, "key", "unknown")
|
||||
if self.agent
|
||||
else "unknown"
|
||||
)
|
||||
started_at = datetime.now()
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
event=ToolUsageFinishedEvent(
|
||||
output=result,
|
||||
event=ToolUsageStartedEvent(
|
||||
tool_name=func_name,
|
||||
tool_args=args_dict,
|
||||
from_agent=self.agent,
|
||||
from_task=self.task,
|
||||
agent_key=agent_key,
|
||||
started_at=started_at,
|
||||
finished_at=datetime.now(),
|
||||
),
|
||||
)
|
||||
|
||||
return idx, call_id, func_name, result, original_tool
|
||||
track_delegation_if_needed(func_name, args_dict, self.task)
|
||||
|
||||
max_workers = min(8, len(parsed_calls))
|
||||
ordered_results: list[tuple[int, str, str, str, Any | None] | None] = [
|
||||
None
|
||||
] * len(parsed_calls)
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {
|
||||
pool.submit(
|
||||
_execute_one,
|
||||
idx,
|
||||
call_id,
|
||||
func_name,
|
||||
func_args,
|
||||
original_tool,
|
||||
should_execute,
|
||||
): idx
|
||||
for idx, (
|
||||
call_id,
|
||||
func_name,
|
||||
func_args,
|
||||
original_tool,
|
||||
should_execute,
|
||||
) in enumerate(execution_plan)
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
idx = futures[future]
|
||||
ordered_results[idx] = future.result()
|
||||
structured_tool: CrewStructuredTool | None = None
|
||||
for structured in self.tools or []:
|
||||
if sanitize_tool_name(structured.name) == func_name:
|
||||
structured_tool = structured
|
||||
break
|
||||
|
||||
for record in ordered_results:
|
||||
if record is None:
|
||||
continue
|
||||
_, call_id, func_name, result, original_tool = record
|
||||
|
||||
tool_message: LLMMessage = {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": func_name,
|
||||
"content": result,
|
||||
}
|
||||
self.messages.append(tool_message)
|
||||
|
||||
if self.agent and self.agent.verbose:
|
||||
self._printer.print(
|
||||
content=f"Tool {func_name} executed with result: {result[:200]}...",
|
||||
color="green",
|
||||
hook_blocked = False
|
||||
before_hook_context = ToolCallHookContext(
|
||||
tool_name=func_name,
|
||||
tool_input=args_dict,
|
||||
tool=structured_tool, # type: ignore[arg-type]
|
||||
agent=self.agent,
|
||||
task=self.task,
|
||||
crew=self.crew,
|
||||
)
|
||||
before_hooks = get_before_tool_call_hooks()
|
||||
try:
|
||||
for hook in before_hooks:
|
||||
hook_result = hook(before_hook_context)
|
||||
if hook_result is False:
|
||||
hook_blocked = True
|
||||
break
|
||||
except Exception as hook_error:
|
||||
if self.agent.verbose:
|
||||
self._printer.print(
|
||||
content=f"Error in before_tool_call hook: {hook_error}",
|
||||
color="red",
|
||||
)
|
||||
|
||||
if (
|
||||
original_tool
|
||||
and hasattr(original_tool, "result_as_answer")
|
||||
and original_tool.result_as_answer
|
||||
):
|
||||
return AgentFinish(
|
||||
thought="Tool result is the final answer",
|
||||
output=result,
|
||||
text=result,
|
||||
from_cache = False
|
||||
input_str = json.dumps(args_dict) if args_dict else ""
|
||||
if self.tools_handler and self.tools_handler.cache:
|
||||
cached_result = self.tools_handler.cache.read(
|
||||
tool=func_name, input=input_str
|
||||
)
|
||||
if cached_result is not None:
|
||||
result = (
|
||||
str(cached_result)
|
||||
if not isinstance(cached_result, str)
|
||||
else cached_result
|
||||
)
|
||||
from_cache = True
|
||||
|
||||
error_event_emitted = False
|
||||
result: str = "Tool not found"
|
||||
if not should_execute and original_tool:
|
||||
result = (
|
||||
f"Tool '{func_name}' has reached its usage limit of "
|
||||
f"{original_tool.max_usage_count} times and cannot be used anymore."
|
||||
)
|
||||
elif hook_blocked:
|
||||
result = f"Tool execution blocked by hook. Tool: {func_name}"
|
||||
elif from_cache:
|
||||
pass
|
||||
elif func_name in available_functions:
|
||||
try:
|
||||
raw_result = available_functions[func_name](**args_dict)
|
||||
if self.tools_handler and self.tools_handler.cache:
|
||||
should_cache = True
|
||||
if (
|
||||
original_tool
|
||||
and hasattr(original_tool, "cache_function")
|
||||
and callable(original_tool.cache_function)
|
||||
):
|
||||
should_cache = original_tool.cache_function(
|
||||
args_dict, raw_result
|
||||
)
|
||||
if should_cache:
|
||||
self.tools_handler.cache.add(
|
||||
tool=func_name,
|
||||
input=input_str,
|
||||
output=raw_result,
|
||||
)
|
||||
result = (
|
||||
str(raw_result)
|
||||
if not isinstance(raw_result, str)
|
||||
else raw_result
|
||||
)
|
||||
except Exception as e:
|
||||
result = f"Error executing tool: {e}"
|
||||
if self.task:
|
||||
self.task.increment_tools_errors()
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
event=ToolUsageErrorEvent(
|
||||
tool_name=func_name,
|
||||
tool_args=args_dict,
|
||||
from_agent=self.agent,
|
||||
from_task=self.task,
|
||||
agent_key=agent_key,
|
||||
error=e,
|
||||
),
|
||||
)
|
||||
error_event_emitted = True
|
||||
|
||||
after_hook_context = ToolCallHookContext(
|
||||
tool_name=func_name,
|
||||
tool_input=args_dict,
|
||||
tool=structured_tool, # type: ignore[arg-type]
|
||||
agent=self.agent,
|
||||
task=self.task,
|
||||
crew=self.crew,
|
||||
tool_result=result,
|
||||
)
|
||||
after_hooks = get_after_tool_call_hooks()
|
||||
try:
|
||||
for after_hook in after_hooks:
|
||||
after_hook_result = after_hook(after_hook_context)
|
||||
if after_hook_result is not None:
|
||||
result = after_hook_result
|
||||
after_hook_context.tool_result = result
|
||||
except Exception as hook_error:
|
||||
if self.agent.verbose:
|
||||
self._printer.print(
|
||||
content=f"Error in after_tool_call hook: {hook_error}",
|
||||
color="red",
|
||||
)
|
||||
|
||||
reasoning_prompt = self._i18n.slice("post_tool_reasoning")
|
||||
reasoning_message: LLMMessage = {
|
||||
"role": "user",
|
||||
"content": reasoning_prompt,
|
||||
}
|
||||
self.messages.append(reasoning_message)
|
||||
return None
|
||||
if not error_event_emitted:
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
event=ToolUsageFinishedEvent(
|
||||
output=result,
|
||||
tool_name=func_name,
|
||||
tool_args=args_dict,
|
||||
from_agent=self.agent,
|
||||
from_task=self.task,
|
||||
agent_key=agent_key,
|
||||
started_at=started_at,
|
||||
finished_at=datetime.now(),
|
||||
),
|
||||
)
|
||||
|
||||
return idx, call_id, func_name, result, original_tool
|
||||
|
||||
max_workers = min(8, len(parsed_calls))
|
||||
ordered_results: list[tuple[int, str, str, str, Any | None] | None] = [
|
||||
None
|
||||
] * len(parsed_calls)
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {
|
||||
pool.submit(
|
||||
_execute_one,
|
||||
idx,
|
||||
call_id,
|
||||
func_name,
|
||||
func_args,
|
||||
original_tool,
|
||||
should_execute,
|
||||
): idx
|
||||
for idx, (
|
||||
call_id,
|
||||
func_name,
|
||||
func_args,
|
||||
original_tool,
|
||||
should_execute,
|
||||
) in enumerate(execution_plan)
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
idx = futures[future]
|
||||
ordered_results[idx] = future.result()
|
||||
|
||||
for record in ordered_results:
|
||||
if record is None:
|
||||
continue
|
||||
_, call_id, func_name, result, original_tool = record
|
||||
|
||||
tool_message: LLMMessage = {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": func_name,
|
||||
"content": result,
|
||||
}
|
||||
self.messages.append(tool_message)
|
||||
|
||||
if self.agent and self.agent.verbose:
|
||||
self._printer.print(
|
||||
content=f"Tool {func_name} executed with result: {result[:200]}...",
|
||||
color="green",
|
||||
)
|
||||
|
||||
if (
|
||||
original_tool
|
||||
and hasattr(original_tool, "result_as_answer")
|
||||
and original_tool.result_as_answer
|
||||
):
|
||||
return AgentFinish(
|
||||
thought="Tool result is the final answer",
|
||||
output=result,
|
||||
text=result,
|
||||
)
|
||||
|
||||
reasoning_prompt = self._i18n.slice("post_tool_reasoning")
|
||||
reasoning_message: LLMMessage = {
|
||||
"role": "user",
|
||||
"content": reasoning_prompt,
|
||||
}
|
||||
self.messages.append(reasoning_message)
|
||||
return None
|
||||
|
||||
# Only process the FIRST tool call for sequential execution with reflection
|
||||
tool_call = tool_calls[0]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from collections.abc import Callable, Coroutine
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
import json
|
||||
import threading
|
||||
@@ -699,9 +699,7 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
|
||||
"content": None,
|
||||
"tool_calls": tool_calls_to_report,
|
||||
}
|
||||
if all(
|
||||
type(tc).__qualname__ == "Part" for tc in pending_tool_calls
|
||||
):
|
||||
if all(type(tc).__qualname__ == "Part" for tc in pending_tool_calls):
|
||||
assistant_message["raw_tool_call_parts"] = list(pending_tool_calls)
|
||||
self.state.messages.append(assistant_message)
|
||||
|
||||
@@ -722,9 +720,9 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
|
||||
pool.submit(self._execute_single_native_tool_call, tool_call): idx
|
||||
for idx, tool_call in enumerate(runnable_tool_calls)
|
||||
}
|
||||
ordered_results: list[dict[str, Any] | None] = [
|
||||
None
|
||||
] * len(runnable_tool_calls)
|
||||
ordered_results: list[dict[str, Any] | None] = [None] * len(
|
||||
runnable_tool_calls
|
||||
)
|
||||
for future in as_completed(future_to_idx):
|
||||
idx = future_to_idx[future]
|
||||
ordered_results[idx] = future.result()
|
||||
@@ -732,10 +730,46 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
|
||||
result for result in ordered_results if result is not None
|
||||
]
|
||||
else:
|
||||
execution_results = [
|
||||
self._execute_single_native_tool_call(tool_call)
|
||||
for tool_call in runnable_tool_calls
|
||||
]
|
||||
# Execute sequentially so result_as_answer tools can short-circuit
|
||||
# immediately without running remaining calls.
|
||||
for tool_call in runnable_tool_calls:
|
||||
execution_result = self._execute_single_native_tool_call(tool_call)
|
||||
call_id = cast(str, execution_result["call_id"])
|
||||
func_name = cast(str, execution_result["func_name"])
|
||||
result = cast(str, execution_result["result"])
|
||||
from_cache = cast(bool, execution_result["from_cache"])
|
||||
original_tool = execution_result["original_tool"]
|
||||
|
||||
tool_message: LLMMessage = {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": func_name,
|
||||
"content": result,
|
||||
}
|
||||
self.state.messages.append(tool_message)
|
||||
|
||||
# Log the tool execution
|
||||
if self.agent and self.agent.verbose:
|
||||
cache_info = " (from cache)" if from_cache else ""
|
||||
self._printer.print(
|
||||
content=f"Tool {func_name} executed with result{cache_info}: {result[:200]}...",
|
||||
color="green",
|
||||
)
|
||||
|
||||
if (
|
||||
original_tool
|
||||
and hasattr(original_tool, "result_as_answer")
|
||||
and original_tool.result_as_answer
|
||||
):
|
||||
self.state.current_answer = AgentFinish(
|
||||
thought="Tool result is the final answer",
|
||||
output=result,
|
||||
text=result,
|
||||
)
|
||||
self.state.is_finished = True
|
||||
return "tool_result_is_final"
|
||||
|
||||
return "native_tool_completed"
|
||||
|
||||
for execution_result in execution_results:
|
||||
call_id = cast(str, execution_result["call_id"])
|
||||
@@ -843,7 +877,9 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
|
||||
from_cache = False
|
||||
input_str = json.dumps(args_dict) if args_dict else ""
|
||||
if self.tools_handler and self.tools_handler.cache:
|
||||
cached_result = self.tools_handler.cache.read(tool=func_name, input=input_str)
|
||||
cached_result = self.tools_handler.cache.read(
|
||||
tool=func_name, input=input_str
|
||||
)
|
||||
if cached_result is not None:
|
||||
result = (
|
||||
str(cached_result)
|
||||
@@ -920,7 +956,9 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
|
||||
|
||||
# Convert to string for message
|
||||
result = (
|
||||
str(raw_result) if not isinstance(raw_result, str) else raw_result
|
||||
str(raw_result)
|
||||
if not isinstance(raw_result, str)
|
||||
else raw_result
|
||||
)
|
||||
except Exception as e:
|
||||
result = f"Error executing tool: {e}"
|
||||
|
||||
@@ -586,3 +586,53 @@ class TestNativeToolExecution:
|
||||
assert elapsed < 0.8
|
||||
assert isinstance(executor.state.current_answer, AgentFinish)
|
||||
assert executor.state.current_answer.output == "one"
|
||||
|
||||
def test_execute_native_tool_result_as_answer_short_circuits_remaining_calls(
|
||||
self, mock_dependencies
|
||||
):
|
||||
executor = AgentExecutor(**mock_dependencies)
|
||||
call_counts = {"slow_one": 0, "slow_two": 0}
|
||||
|
||||
def slow_one() -> str:
|
||||
call_counts["slow_one"] += 1
|
||||
time.sleep(0.2)
|
||||
return "one"
|
||||
|
||||
def slow_two() -> str:
|
||||
call_counts["slow_two"] += 1
|
||||
time.sleep(0.2)
|
||||
return "two"
|
||||
|
||||
result_tool = Mock()
|
||||
result_tool.name = "slow_one"
|
||||
result_tool.result_as_answer = True
|
||||
result_tool.max_usage_count = None
|
||||
result_tool.current_usage_count = 0
|
||||
|
||||
executor.original_tools = [result_tool]
|
||||
executor._available_functions = {"slow_one": slow_one, "slow_two": slow_two}
|
||||
executor.state.pending_tool_calls = [
|
||||
{
|
||||
"id": "call_1",
|
||||
"function": {"name": "slow_one", "arguments": "{}"},
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"function": {"name": "slow_two", "arguments": "{}"},
|
||||
},
|
||||
]
|
||||
|
||||
started = time.perf_counter()
|
||||
result = executor.execute_native_tool()
|
||||
elapsed = time.perf_counter() - started
|
||||
|
||||
assert result == "tool_result_is_final"
|
||||
assert isinstance(executor.state.current_answer, AgentFinish)
|
||||
assert executor.state.current_answer.output == "one"
|
||||
assert call_counts["slow_one"] == 1
|
||||
assert call_counts["slow_two"] == 0
|
||||
assert elapsed < 0.35
|
||||
|
||||
tool_messages = [m for m in executor.state.messages if m.get("role") == "tool"]
|
||||
assert len(tool_messages) == 1
|
||||
assert tool_messages[0]["tool_call_id"] == "call_1"
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -17,6 +18,8 @@ from pydantic import BaseModel, Field
|
||||
from crewai import Agent, Crew, Task
|
||||
from crewai.events import crewai_event_bus
|
||||
from crewai.events.types.tool_usage_events import ToolUsageFinishedEvent
|
||||
from crewai.hooks import register_after_tool_call_hook, register_before_tool_call_hook
|
||||
from crewai.hooks.tool_hooks import ToolCallHookContext
|
||||
from crewai.llm import LLM
|
||||
from crewai.tools.base_tool import BaseTool
|
||||
|
||||
@@ -322,6 +325,136 @@ class TestOpenAINativeToolCalling:
|
||||
assert result is not None
|
||||
_assert_tools_overlapped()
|
||||
|
||||
@pytest.mark.vcr()
|
||||
@pytest.mark.timeout(180)
|
||||
def test_openai_parallel_native_tool_calling_tool_hook_parity_crew(
|
||||
self, parallel_tools: list[BaseTool]
|
||||
) -> None:
|
||||
ParallelProbe.reset()
|
||||
_attach_parallel_probe_handler()
|
||||
hook_calls: dict[str, list[dict[str, str]]] = {"before": [], "after": []}
|
||||
|
||||
def before_hook(context: ToolCallHookContext) -> bool | None:
|
||||
if context.tool_name.startswith("parallel_local_search_"):
|
||||
hook_calls["before"].append(
|
||||
{
|
||||
"tool_name": context.tool_name,
|
||||
"query": str(context.tool_input.get("query", "")),
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
def after_hook(context: ToolCallHookContext) -> str | None:
|
||||
if context.tool_name.startswith("parallel_local_search_"):
|
||||
hook_calls["after"].append(
|
||||
{
|
||||
"tool_name": context.tool_name,
|
||||
"query": str(context.tool_input.get("query", "")),
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
register_before_tool_call_hook(before_hook)
|
||||
register_after_tool_call_hook(after_hook)
|
||||
|
||||
try:
|
||||
agent = Agent(
|
||||
role="Parallel Tool Agent",
|
||||
goal="Use both tools exactly as instructed",
|
||||
backstory="You follow tool instructions precisely.",
|
||||
tools=parallel_tools,
|
||||
llm=LLM(model="gpt-5-nano", temperature=1),
|
||||
verbose=False,
|
||||
max_iter=3,
|
||||
)
|
||||
task = Task(
|
||||
description=_parallel_prompt(),
|
||||
expected_output="A one sentence summary of both tool outputs",
|
||||
agent=agent,
|
||||
)
|
||||
crew = Crew(agents=[agent], tasks=[task])
|
||||
result = crew.kickoff()
|
||||
|
||||
assert result is not None
|
||||
_assert_tools_overlapped()
|
||||
|
||||
before_names = [call["tool_name"] for call in hook_calls["before"]]
|
||||
after_names = [call["tool_name"] for call in hook_calls["after"]]
|
||||
assert len(before_names) >= 3, "Expected before hooks for all parallel calls"
|
||||
assert Counter(before_names) == Counter(after_names)
|
||||
assert all(call["query"] for call in hook_calls["before"])
|
||||
assert all(call["query"] for call in hook_calls["after"])
|
||||
finally:
|
||||
from crewai.hooks import (
|
||||
unregister_after_tool_call_hook,
|
||||
unregister_before_tool_call_hook,
|
||||
)
|
||||
|
||||
unregister_before_tool_call_hook(before_hook)
|
||||
unregister_after_tool_call_hook(after_hook)
|
||||
|
||||
@pytest.mark.vcr()
|
||||
@pytest.mark.timeout(180)
|
||||
def test_openai_parallel_native_tool_calling_tool_hook_parity_agent_kickoff(
|
||||
self, parallel_tools: list[BaseTool]
|
||||
) -> None:
|
||||
ParallelProbe.reset()
|
||||
_attach_parallel_probe_handler()
|
||||
hook_calls: dict[str, list[dict[str, str]]] = {"before": [], "after": []}
|
||||
|
||||
def before_hook(context: ToolCallHookContext) -> bool | None:
|
||||
if context.tool_name.startswith("parallel_local_search_"):
|
||||
hook_calls["before"].append(
|
||||
{
|
||||
"tool_name": context.tool_name,
|
||||
"query": str(context.tool_input.get("query", "")),
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
def after_hook(context: ToolCallHookContext) -> str | None:
|
||||
if context.tool_name.startswith("parallel_local_search_"):
|
||||
hook_calls["after"].append(
|
||||
{
|
||||
"tool_name": context.tool_name,
|
||||
"query": str(context.tool_input.get("query", "")),
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
register_before_tool_call_hook(before_hook)
|
||||
register_after_tool_call_hook(after_hook)
|
||||
|
||||
try:
|
||||
agent = Agent(
|
||||
role="Parallel Tool Agent",
|
||||
goal="Use both tools exactly as instructed",
|
||||
backstory="You follow tool instructions precisely.",
|
||||
tools=parallel_tools,
|
||||
llm=LLM(model="gpt-5-nano", temperature=1),
|
||||
verbose=False,
|
||||
max_iter=3,
|
||||
)
|
||||
result = agent.kickoff(_parallel_prompt())
|
||||
|
||||
assert result is not None
|
||||
_assert_tools_overlapped()
|
||||
|
||||
before_names = [call["tool_name"] for call in hook_calls["before"]]
|
||||
after_names = [call["tool_name"] for call in hook_calls["after"]]
|
||||
assert len(before_names) >= 3, "Expected before hooks for all parallel calls"
|
||||
assert Counter(before_names) == Counter(after_names)
|
||||
assert all(call["query"] for call in hook_calls["before"])
|
||||
assert all(call["query"] for call in hook_calls["after"])
|
||||
finally:
|
||||
from crewai.hooks import (
|
||||
unregister_after_tool_call_hook,
|
||||
unregister_before_tool_call_hook,
|
||||
)
|
||||
|
||||
unregister_before_tool_call_hook(before_hook)
|
||||
unregister_after_tool_call_hook(after_hook)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Anthropic Provider Tests
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
interactions:
|
||||
- request:
|
||||
body: '{"messages":[{"role":"system","content":"You are Parallel Tool Agent. You
|
||||
follow tool instructions precisely.\nYour personal goal is: Use both tools exactly
|
||||
as instructed"},{"role":"user","content":"\nCurrent Task: This is a tool-calling
|
||||
compliance test. In your next assistant turn, emit exactly 3 tool calls in the
|
||||
same response (parallel tool calls), in this order: 1) parallel_local_search_one(query=''latest
|
||||
OpenAI model release notes''), 2) parallel_local_search_two(query=''latest Anthropic
|
||||
model release notes''), 3) parallel_local_search_three(query=''latest Gemini
|
||||
model release notes''). Do not call any other tools and do not answer before
|
||||
those 3 tool calls are emitted. After the tool results return, provide a one
|
||||
paragraph summary."}],"model":"gpt-5-nano","temperature":1,"tool_choice":"auto","tools":[{"type":"function","function":{"name":"parallel_local_search_one","description":"Local
|
||||
search tool #1 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}},{"type":"function","function":{"name":"parallel_local_search_two","description":"Local
|
||||
search tool #2 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}},{"type":"function","function":{"name":"parallel_local_search_three","description":"Local
|
||||
search tool #3 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}}]}'
|
||||
headers:
|
||||
User-Agent:
|
||||
- X-USER-AGENT-XXX
|
||||
accept:
|
||||
- application/json
|
||||
accept-encoding:
|
||||
- ACCEPT-ENCODING-XXX
|
||||
authorization:
|
||||
- AUTHORIZATION-XXX
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '1748'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- api.openai.com
|
||||
x-stainless-arch:
|
||||
- X-STAINLESS-ARCH-XXX
|
||||
x-stainless-async:
|
||||
- 'false'
|
||||
x-stainless-lang:
|
||||
- python
|
||||
x-stainless-os:
|
||||
- X-STAINLESS-OS-XXX
|
||||
x-stainless-package-version:
|
||||
- 1.83.0
|
||||
x-stainless-read-timeout:
|
||||
- X-STAINLESS-READ-TIMEOUT-XXX
|
||||
x-stainless-retry-count:
|
||||
- '0'
|
||||
x-stainless-runtime:
|
||||
- CPython
|
||||
x-stainless-runtime-version:
|
||||
- 3.13.3
|
||||
method: POST
|
||||
uri: https://api.openai.com/v1/chat/completions
|
||||
response:
|
||||
body:
|
||||
string: "{\n \"id\": \"chatcmpl-DB244zBgA66fzl8TNcIPRWoE4lDIQ\",\n \"object\":
|
||||
\"chat.completion\",\n \"created\": 1771521916,\n \"model\": \"gpt-5-nano-2025-08-07\",\n
|
||||
\ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\":
|
||||
\"assistant\",\n \"content\": null,\n \"tool_calls\": [\n {\n
|
||||
\ \"id\": \"call_D2ojRWqkng6krQ51vWQEU8wR\",\n \"type\":
|
||||
\"function\",\n \"function\": {\n \"name\": \"parallel_local_search_one\",\n
|
||||
\ \"arguments\": \"{\\\"query\\\": \\\"latest OpenAI model release
|
||||
notes\\\"}\"\n }\n },\n {\n \"id\":
|
||||
\"call_v1tpTKw1sYcI75SWG1LCkAC3\",\n \"type\": \"function\",\n
|
||||
\ \"function\": {\n \"name\": \"parallel_local_search_two\",\n
|
||||
\ \"arguments\": \"{\\\"query\\\": \\\"latest Anthropic model
|
||||
release notes\\\"}\"\n }\n },\n {\n \"id\":
|
||||
\"call_RrbyZClymnngoNLhlkQLLpwM\",\n \"type\": \"function\",\n
|
||||
\ \"function\": {\n \"name\": \"parallel_local_search_three\",\n
|
||||
\ \"arguments\": \"{\\\"query\\\": \\\"latest Gemini model release
|
||||
notes\\\"}\"\n }\n }\n ],\n \"refusal\":
|
||||
null,\n \"annotations\": []\n },\n \"finish_reason\": \"tool_calls\"\n
|
||||
\ }\n ],\n \"usage\": {\n \"prompt_tokens\": 343,\n \"completion_tokens\":
|
||||
855,\n \"total_tokens\": 1198,\n \"prompt_tokens_details\": {\n \"cached_tokens\":
|
||||
0,\n \"audio_tokens\": 0\n },\n \"completion_tokens_details\":
|
||||
{\n \"reasoning_tokens\": 768,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\":
|
||||
0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\":
|
||||
\"default\",\n \"system_fingerprint\": null\n}\n"
|
||||
headers:
|
||||
CF-RAY:
|
||||
- CF-RAY-XXX
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 19 Feb 2026 17:25:23 GMT
|
||||
Server:
|
||||
- cloudflare
|
||||
Strict-Transport-Security:
|
||||
- STS-XXX
|
||||
Transfer-Encoding:
|
||||
- chunked
|
||||
X-Content-Type-Options:
|
||||
- X-CONTENT-TYPE-XXX
|
||||
access-control-expose-headers:
|
||||
- ACCESS-CONTROL-XXX
|
||||
alt-svc:
|
||||
- h3=":443"; ma=86400
|
||||
cf-cache-status:
|
||||
- DYNAMIC
|
||||
openai-organization:
|
||||
- OPENAI-ORG-XXX
|
||||
openai-processing-ms:
|
||||
- '6669'
|
||||
openai-project:
|
||||
- OPENAI-PROJECT-XXX
|
||||
openai-version:
|
||||
- '2020-10-01'
|
||||
set-cookie:
|
||||
- SET-COOKIE-XXX
|
||||
x-openai-proxy-wasm:
|
||||
- v0.1
|
||||
x-ratelimit-limit-requests:
|
||||
- X-RATELIMIT-LIMIT-REQUESTS-XXX
|
||||
x-ratelimit-limit-tokens:
|
||||
- X-RATELIMIT-LIMIT-TOKENS-XXX
|
||||
x-ratelimit-remaining-requests:
|
||||
- X-RATELIMIT-REMAINING-REQUESTS-XXX
|
||||
x-ratelimit-remaining-tokens:
|
||||
- X-RATELIMIT-REMAINING-TOKENS-XXX
|
||||
x-ratelimit-reset-requests:
|
||||
- X-RATELIMIT-RESET-REQUESTS-XXX
|
||||
x-ratelimit-reset-tokens:
|
||||
- X-RATELIMIT-RESET-TOKENS-XXX
|
||||
x-request-id:
|
||||
- X-REQUEST-ID-XXX
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
- request:
|
||||
body: '{"messages":[{"role":"system","content":"You are Parallel Tool Agent. You
|
||||
follow tool instructions precisely.\nYour personal goal is: Use both tools exactly
|
||||
as instructed"},{"role":"user","content":"\nCurrent Task: This is a tool-calling
|
||||
compliance test. In your next assistant turn, emit exactly 3 tool calls in the
|
||||
same response (parallel tool calls), in this order: 1) parallel_local_search_one(query=''latest
|
||||
OpenAI model release notes''), 2) parallel_local_search_two(query=''latest Anthropic
|
||||
model release notes''), 3) parallel_local_search_three(query=''latest Gemini
|
||||
model release notes''). Do not call any other tools and do not answer before
|
||||
those 3 tool calls are emitted. After the tool results return, provide a one
|
||||
paragraph summary."},{"role":"assistant","content":null,"tool_calls":[{"id":"call_D2ojRWqkng6krQ51vWQEU8wR","type":"function","function":{"name":"parallel_local_search_one","arguments":"{\"query\":
|
||||
\"latest OpenAI model release notes\"}"}},{"id":"call_v1tpTKw1sYcI75SWG1LCkAC3","type":"function","function":{"name":"parallel_local_search_two","arguments":"{\"query\":
|
||||
\"latest Anthropic model release notes\"}"}},{"id":"call_RrbyZClymnngoNLhlkQLLpwM","type":"function","function":{"name":"parallel_local_search_three","arguments":"{\"query\":
|
||||
\"latest Gemini model release notes\"}"}}]},{"role":"tool","tool_call_id":"call_D2ojRWqkng6krQ51vWQEU8wR","name":"parallel_local_search_one","content":"[one]
|
||||
latest OpenAI model release notes"},{"role":"tool","tool_call_id":"call_v1tpTKw1sYcI75SWG1LCkAC3","name":"parallel_local_search_two","content":"[two]
|
||||
latest Anthropic model release notes"},{"role":"tool","tool_call_id":"call_RrbyZClymnngoNLhlkQLLpwM","name":"parallel_local_search_three","content":"[three]
|
||||
latest Gemini model release notes"}],"model":"gpt-5-nano","temperature":1,"tool_choice":"auto","tools":[{"type":"function","function":{"name":"parallel_local_search_one","description":"Local
|
||||
search tool #1 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}},{"type":"function","function":{"name":"parallel_local_search_two","description":"Local
|
||||
search tool #2 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}},{"type":"function","function":{"name":"parallel_local_search_three","description":"Local
|
||||
search tool #3 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}}]}'
|
||||
headers:
|
||||
User-Agent:
|
||||
- X-USER-AGENT-XXX
|
||||
accept:
|
||||
- application/json
|
||||
accept-encoding:
|
||||
- ACCEPT-ENCODING-XXX
|
||||
authorization:
|
||||
- AUTHORIZATION-XXX
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '2771'
|
||||
content-type:
|
||||
- application/json
|
||||
cookie:
|
||||
- COOKIE-XXX
|
||||
host:
|
||||
- api.openai.com
|
||||
x-stainless-arch:
|
||||
- X-STAINLESS-ARCH-XXX
|
||||
x-stainless-async:
|
||||
- 'false'
|
||||
x-stainless-lang:
|
||||
- python
|
||||
x-stainless-os:
|
||||
- X-STAINLESS-OS-XXX
|
||||
x-stainless-package-version:
|
||||
- 1.83.0
|
||||
x-stainless-read-timeout:
|
||||
- X-STAINLESS-READ-TIMEOUT-XXX
|
||||
x-stainless-retry-count:
|
||||
- '0'
|
||||
x-stainless-runtime:
|
||||
- CPython
|
||||
x-stainless-runtime-version:
|
||||
- 3.13.3
|
||||
method: POST
|
||||
uri: https://api.openai.com/v1/chat/completions
|
||||
response:
|
||||
body:
|
||||
string: "{\n \"id\": \"chatcmpl-DB24DjyYsIHiQJ7hHXob8tQFfeXBs\",\n \"object\":
|
||||
\"chat.completion\",\n \"created\": 1771521925,\n \"model\": \"gpt-5-nano-2025-08-07\",\n
|
||||
\ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\":
|
||||
\"assistant\",\n \"content\": \"The three latest release-note references
|
||||
retrieved encompass OpenAI, Anthropic, and Gemini, indicating that all three
|
||||
major model families are actively updating their offerings. These notes typically
|
||||
cover improvements to capabilities, safety measures, performance enhancements,
|
||||
and any new APIs or features, suggesting a trend of ongoing refinement across
|
||||
providers. If you\u2019d like, I can pull the full release notes or extract
|
||||
and compare the key changes across the three sources.\",\n \"refusal\":
|
||||
null,\n \"annotations\": []\n },\n \"finish_reason\": \"stop\"\n
|
||||
\ }\n ],\n \"usage\": {\n \"prompt_tokens\": 467,\n \"completion_tokens\":
|
||||
1437,\n \"total_tokens\": 1904,\n \"prompt_tokens_details\": {\n \"cached_tokens\":
|
||||
0,\n \"audio_tokens\": 0\n },\n \"completion_tokens_details\":
|
||||
{\n \"reasoning_tokens\": 1344,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\":
|
||||
0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\":
|
||||
\"default\",\n \"system_fingerprint\": null\n}\n"
|
||||
headers:
|
||||
CF-RAY:
|
||||
- CF-RAY-XXX
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 19 Feb 2026 17:25:35 GMT
|
||||
Server:
|
||||
- cloudflare
|
||||
Strict-Transport-Security:
|
||||
- STS-XXX
|
||||
Transfer-Encoding:
|
||||
- chunked
|
||||
X-Content-Type-Options:
|
||||
- X-CONTENT-TYPE-XXX
|
||||
access-control-expose-headers:
|
||||
- ACCESS-CONTROL-XXX
|
||||
alt-svc:
|
||||
- h3=":443"; ma=86400
|
||||
cf-cache-status:
|
||||
- DYNAMIC
|
||||
openai-organization:
|
||||
- OPENAI-ORG-XXX
|
||||
openai-processing-ms:
|
||||
- '10369'
|
||||
openai-project:
|
||||
- OPENAI-PROJECT-XXX
|
||||
openai-version:
|
||||
- '2020-10-01'
|
||||
x-openai-proxy-wasm:
|
||||
- v0.1
|
||||
x-ratelimit-limit-requests:
|
||||
- X-RATELIMIT-LIMIT-REQUESTS-XXX
|
||||
x-ratelimit-limit-tokens:
|
||||
- X-RATELIMIT-LIMIT-TOKENS-XXX
|
||||
x-ratelimit-remaining-requests:
|
||||
- X-RATELIMIT-REMAINING-REQUESTS-XXX
|
||||
x-ratelimit-remaining-tokens:
|
||||
- X-RATELIMIT-REMAINING-TOKENS-XXX
|
||||
x-ratelimit-reset-requests:
|
||||
- X-RATELIMIT-RESET-REQUESTS-XXX
|
||||
x-ratelimit-reset-tokens:
|
||||
- X-RATELIMIT-RESET-TOKENS-XXX
|
||||
x-request-id:
|
||||
- X-REQUEST-ID-XXX
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
version: 1
|
||||
@@ -0,0 +1,339 @@
|
||||
interactions:
|
||||
- request:
|
||||
body: '{"trace_id": "e456cc10-ce7b-4e68-a2cc-ddb806a2e7b9", "execution_type":
|
||||
"crew", "user_identifier": null, "execution_context": {"crew_fingerprint": null,
|
||||
"crew_name": "crew", "flow_name": null, "crewai_version": "1.9.3", "privacy_level":
|
||||
"standard"}, "execution_metadata": {"expected_duration_estimate": 300, "agent_count":
|
||||
0, "task_count": 0, "flow_method_count": 0, "execution_started_at": "2026-02-19T17:24:41.723158+00:00"},
|
||||
"ephemeral_trace_id": "e456cc10-ce7b-4e68-a2cc-ddb806a2e7b9"}'
|
||||
headers:
|
||||
Accept:
|
||||
- '*/*'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '488'
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- X-USER-AGENT-XXX
|
||||
X-Crewai-Organization-Id:
|
||||
- 3433f0ee-8a94-4aa4-822b-2ac71aa38b18
|
||||
X-Crewai-Version:
|
||||
- 1.9.3
|
||||
accept-encoding:
|
||||
- ACCEPT-ENCODING-XXX
|
||||
authorization:
|
||||
- AUTHORIZATION-XXX
|
||||
method: POST
|
||||
uri: https://app.crewai.com/crewai_plus/api/v1/tracing/ephemeral/batches
|
||||
response:
|
||||
body:
|
||||
string: '{"id":"a78f2aca-0525-47c7-8f37-b3fca0ad6672","ephemeral_trace_id":"e456cc10-ce7b-4e68-a2cc-ddb806a2e7b9","execution_type":"crew","crew_name":"crew","flow_name":null,"status":"running","duration_ms":null,"crewai_version":"1.9.3","total_events":0,"execution_context":{"crew_fingerprint":null,"crew_name":"crew","flow_name":null,"crewai_version":"1.9.3","privacy_level":"standard"},"created_at":"2026-02-19T17:24:41.989Z","updated_at":"2026-02-19T17:24:41.989Z","access_code":"TRACE-bd80d6be74","user_identifier":null}'
|
||||
headers:
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Length:
|
||||
- '515'
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Date:
|
||||
- Thu, 19 Feb 2026 17:24:41 GMT
|
||||
cache-control:
|
||||
- no-store
|
||||
content-security-policy:
|
||||
- CSP-FILTERED
|
||||
etag:
|
||||
- ETAG-XXX
|
||||
expires:
|
||||
- '0'
|
||||
permissions-policy:
|
||||
- PERMISSIONS-POLICY-XXX
|
||||
pragma:
|
||||
- no-cache
|
||||
referrer-policy:
|
||||
- REFERRER-POLICY-XXX
|
||||
strict-transport-security:
|
||||
- STS-XXX
|
||||
vary:
|
||||
- Accept
|
||||
x-content-type-options:
|
||||
- X-CONTENT-TYPE-XXX
|
||||
x-frame-options:
|
||||
- X-FRAME-OPTIONS-XXX
|
||||
x-permitted-cross-domain-policies:
|
||||
- X-PERMITTED-XXX
|
||||
x-request-id:
|
||||
- X-REQUEST-ID-XXX
|
||||
x-runtime:
|
||||
- X-RUNTIME-XXX
|
||||
x-xss-protection:
|
||||
- X-XSS-PROTECTION-XXX
|
||||
status:
|
||||
code: 201
|
||||
message: Created
|
||||
- request:
|
||||
body: '{"messages":[{"role":"system","content":"You are Parallel Tool Agent. You
|
||||
follow tool instructions precisely.\nYour personal goal is: Use both tools exactly
|
||||
as instructed"},{"role":"user","content":"\nCurrent Task: This is a tool-calling
|
||||
compliance test. In your next assistant turn, emit exactly 3 tool calls in the
|
||||
same response (parallel tool calls), in this order: 1) parallel_local_search_one(query=''latest
|
||||
OpenAI model release notes''), 2) parallel_local_search_two(query=''latest Anthropic
|
||||
model release notes''), 3) parallel_local_search_three(query=''latest Gemini
|
||||
model release notes''). Do not call any other tools and do not answer before
|
||||
those 3 tool calls are emitted. After the tool results return, provide a one
|
||||
paragraph summary.\n\nThis is the expected criteria for your final answer: A
|
||||
one sentence summary of both tool outputs\nyou MUST return the actual complete
|
||||
content as the final answer, not a summary."}],"model":"gpt-5-nano","temperature":1,"tool_choice":"auto","tools":[{"type":"function","function":{"name":"parallel_local_search_one","description":"Local
|
||||
search tool #1 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}},{"type":"function","function":{"name":"parallel_local_search_two","description":"Local
|
||||
search tool #2 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}},{"type":"function","function":{"name":"parallel_local_search_three","description":"Local
|
||||
search tool #3 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}}]}'
|
||||
headers:
|
||||
User-Agent:
|
||||
- X-USER-AGENT-XXX
|
||||
accept:
|
||||
- application/json
|
||||
accept-encoding:
|
||||
- ACCEPT-ENCODING-XXX
|
||||
authorization:
|
||||
- AUTHORIZATION-XXX
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '1929'
|
||||
content-type:
|
||||
- application/json
|
||||
host:
|
||||
- api.openai.com
|
||||
x-stainless-arch:
|
||||
- X-STAINLESS-ARCH-XXX
|
||||
x-stainless-async:
|
||||
- 'false'
|
||||
x-stainless-lang:
|
||||
- python
|
||||
x-stainless-os:
|
||||
- X-STAINLESS-OS-XXX
|
||||
x-stainless-package-version:
|
||||
- 1.83.0
|
||||
x-stainless-read-timeout:
|
||||
- X-STAINLESS-READ-TIMEOUT-XXX
|
||||
x-stainless-retry-count:
|
||||
- '0'
|
||||
x-stainless-runtime:
|
||||
- CPython
|
||||
x-stainless-runtime-version:
|
||||
- 3.13.3
|
||||
method: POST
|
||||
uri: https://api.openai.com/v1/chat/completions
|
||||
response:
|
||||
body:
|
||||
string: "{\n \"id\": \"chatcmpl-DB23W8RBF6zlxweiHYGb6maVfyctt\",\n \"object\":
|
||||
\"chat.completion\",\n \"created\": 1771521882,\n \"model\": \"gpt-5-nano-2025-08-07\",\n
|
||||
\ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\":
|
||||
\"assistant\",\n \"content\": null,\n \"tool_calls\": [\n {\n
|
||||
\ \"id\": \"call_sge1FXUkpmPEDe8nTOgn0tQG\",\n \"type\":
|
||||
\"function\",\n \"function\": {\n \"name\": \"parallel_local_search_one\",\n
|
||||
\ \"arguments\": \"{\\\"query\\\": \\\"latest OpenAI model release
|
||||
notes\\\"}\"\n }\n },\n {\n \"id\":
|
||||
\"call_z5jRPH4DQ7Wp3HdDUlZe8gGh\",\n \"type\": \"function\",\n
|
||||
\ \"function\": {\n \"name\": \"parallel_local_search_two\",\n
|
||||
\ \"arguments\": \"{\\\"query\\\": \\\"latest Anthropic model
|
||||
release notes\\\"}\"\n }\n },\n {\n \"id\":
|
||||
\"call_DNlgqnadODDsyQkSuLcXZCX2\",\n \"type\": \"function\",\n
|
||||
\ \"function\": {\n \"name\": \"parallel_local_search_three\",\n
|
||||
\ \"arguments\": \"{\\\"query\\\": \\\"latest Gemini model release
|
||||
notes\\\"}\"\n }\n }\n ],\n \"refusal\":
|
||||
null,\n \"annotations\": []\n },\n \"finish_reason\": \"tool_calls\"\n
|
||||
\ }\n ],\n \"usage\": {\n \"prompt_tokens\": 378,\n \"completion_tokens\":
|
||||
2456,\n \"total_tokens\": 2834,\n \"prompt_tokens_details\": {\n \"cached_tokens\":
|
||||
0,\n \"audio_tokens\": 0\n },\n \"completion_tokens_details\":
|
||||
{\n \"reasoning_tokens\": 2368,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\":
|
||||
0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\":
|
||||
\"default\",\n \"system_fingerprint\": null\n}\n"
|
||||
headers:
|
||||
CF-RAY:
|
||||
- CF-RAY-XXX
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 19 Feb 2026 17:25:02 GMT
|
||||
Server:
|
||||
- cloudflare
|
||||
Strict-Transport-Security:
|
||||
- STS-XXX
|
||||
Transfer-Encoding:
|
||||
- chunked
|
||||
X-Content-Type-Options:
|
||||
- X-CONTENT-TYPE-XXX
|
||||
access-control-expose-headers:
|
||||
- ACCESS-CONTROL-XXX
|
||||
alt-svc:
|
||||
- h3=":443"; ma=86400
|
||||
cf-cache-status:
|
||||
- DYNAMIC
|
||||
openai-organization:
|
||||
- OPENAI-ORG-XXX
|
||||
openai-processing-ms:
|
||||
- '19582'
|
||||
openai-project:
|
||||
- OPENAI-PROJECT-XXX
|
||||
openai-version:
|
||||
- '2020-10-01'
|
||||
set-cookie:
|
||||
- SET-COOKIE-XXX
|
||||
x-openai-proxy-wasm:
|
||||
- v0.1
|
||||
x-ratelimit-limit-requests:
|
||||
- X-RATELIMIT-LIMIT-REQUESTS-XXX
|
||||
x-ratelimit-limit-tokens:
|
||||
- X-RATELIMIT-LIMIT-TOKENS-XXX
|
||||
x-ratelimit-remaining-requests:
|
||||
- X-RATELIMIT-REMAINING-REQUESTS-XXX
|
||||
x-ratelimit-remaining-tokens:
|
||||
- X-RATELIMIT-REMAINING-TOKENS-XXX
|
||||
x-ratelimit-reset-requests:
|
||||
- X-RATELIMIT-RESET-REQUESTS-XXX
|
||||
x-ratelimit-reset-tokens:
|
||||
- X-RATELIMIT-RESET-TOKENS-XXX
|
||||
x-request-id:
|
||||
- X-REQUEST-ID-XXX
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
- request:
|
||||
body: '{"messages":[{"role":"system","content":"You are Parallel Tool Agent. You
|
||||
follow tool instructions precisely.\nYour personal goal is: Use both tools exactly
|
||||
as instructed"},{"role":"user","content":"\nCurrent Task: This is a tool-calling
|
||||
compliance test. In your next assistant turn, emit exactly 3 tool calls in the
|
||||
same response (parallel tool calls), in this order: 1) parallel_local_search_one(query=''latest
|
||||
OpenAI model release notes''), 2) parallel_local_search_two(query=''latest Anthropic
|
||||
model release notes''), 3) parallel_local_search_three(query=''latest Gemini
|
||||
model release notes''). Do not call any other tools and do not answer before
|
||||
those 3 tool calls are emitted. After the tool results return, provide a one
|
||||
paragraph summary.\n\nThis is the expected criteria for your final answer: A
|
||||
one sentence summary of both tool outputs\nyou MUST return the actual complete
|
||||
content as the final answer, not a summary."},{"role":"assistant","content":null,"tool_calls":[{"id":"call_sge1FXUkpmPEDe8nTOgn0tQG","type":"function","function":{"name":"parallel_local_search_one","arguments":"{\"query\":
|
||||
\"latest OpenAI model release notes\"}"}},{"id":"call_z5jRPH4DQ7Wp3HdDUlZe8gGh","type":"function","function":{"name":"parallel_local_search_two","arguments":"{\"query\":
|
||||
\"latest Anthropic model release notes\"}"}},{"id":"call_DNlgqnadODDsyQkSuLcXZCX2","type":"function","function":{"name":"parallel_local_search_three","arguments":"{\"query\":
|
||||
\"latest Gemini model release notes\"}"}}]},{"role":"tool","tool_call_id":"call_sge1FXUkpmPEDe8nTOgn0tQG","name":"parallel_local_search_one","content":"[one]
|
||||
latest OpenAI model release notes"},{"role":"tool","tool_call_id":"call_z5jRPH4DQ7Wp3HdDUlZe8gGh","name":"parallel_local_search_two","content":"[two]
|
||||
latest Anthropic model release notes"},{"role":"tool","tool_call_id":"call_DNlgqnadODDsyQkSuLcXZCX2","name":"parallel_local_search_three","content":"[three]
|
||||
latest Gemini model release notes"},{"role":"user","content":"Analyze the tool
|
||||
result. If requirements are met, provide the Final Answer. Otherwise, call the
|
||||
next tool. Deliver only the answer without meta-commentary."}],"model":"gpt-5-nano","temperature":1,"tool_choice":"auto","tools":[{"type":"function","function":{"name":"parallel_local_search_one","description":"Local
|
||||
search tool #1 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}},{"type":"function","function":{"name":"parallel_local_search_two","description":"Local
|
||||
search tool #2 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}},{"type":"function","function":{"name":"parallel_local_search_three","description":"Local
|
||||
search tool #3 for concurrency testing.","strict":true,"parameters":{"properties":{"query":{"description":"Search
|
||||
query","title":"Query","type":"string"}},"required":["query"],"type":"object","additionalProperties":false}}}]}'
|
||||
headers:
|
||||
User-Agent:
|
||||
- X-USER-AGENT-XXX
|
||||
accept:
|
||||
- application/json
|
||||
accept-encoding:
|
||||
- ACCEPT-ENCODING-XXX
|
||||
authorization:
|
||||
- AUTHORIZATION-XXX
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '3136'
|
||||
content-type:
|
||||
- application/json
|
||||
cookie:
|
||||
- COOKIE-XXX
|
||||
host:
|
||||
- api.openai.com
|
||||
x-stainless-arch:
|
||||
- X-STAINLESS-ARCH-XXX
|
||||
x-stainless-async:
|
||||
- 'false'
|
||||
x-stainless-lang:
|
||||
- python
|
||||
x-stainless-os:
|
||||
- X-STAINLESS-OS-XXX
|
||||
x-stainless-package-version:
|
||||
- 1.83.0
|
||||
x-stainless-read-timeout:
|
||||
- X-STAINLESS-READ-TIMEOUT-XXX
|
||||
x-stainless-retry-count:
|
||||
- '0'
|
||||
x-stainless-runtime:
|
||||
- CPython
|
||||
x-stainless-runtime-version:
|
||||
- 3.13.3
|
||||
method: POST
|
||||
uri: https://api.openai.com/v1/chat/completions
|
||||
response:
|
||||
body:
|
||||
string: "{\n \"id\": \"chatcmpl-DB23sY0Ahpd1yAgLZ882KkA50Zljx\",\n \"object\":
|
||||
\"chat.completion\",\n \"created\": 1771521904,\n \"model\": \"gpt-5-nano-2025-08-07\",\n
|
||||
\ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\":
|
||||
\"assistant\",\n \"content\": \"Results returned three items: the latest
|
||||
OpenAI model release notes, the latest Anthropic model release notes, and
|
||||
the latest Gemini model release notes.\",\n \"refusal\": null,\n \"annotations\":
|
||||
[]\n },\n \"finish_reason\": \"stop\"\n }\n ],\n \"usage\":
|
||||
{\n \"prompt_tokens\": 537,\n \"completion_tokens\": 1383,\n \"total_tokens\":
|
||||
1920,\n \"prompt_tokens_details\": {\n \"cached_tokens\": 0,\n \"audio_tokens\":
|
||||
0\n },\n \"completion_tokens_details\": {\n \"reasoning_tokens\":
|
||||
1344,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": 0,\n
|
||||
\ \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\":
|
||||
\"default\",\n \"system_fingerprint\": null\n}\n"
|
||||
headers:
|
||||
CF-RAY:
|
||||
- CF-RAY-XXX
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 19 Feb 2026 17:25:16 GMT
|
||||
Server:
|
||||
- cloudflare
|
||||
Strict-Transport-Security:
|
||||
- STS-XXX
|
||||
Transfer-Encoding:
|
||||
- chunked
|
||||
X-Content-Type-Options:
|
||||
- X-CONTENT-TYPE-XXX
|
||||
access-control-expose-headers:
|
||||
- ACCESS-CONTROL-XXX
|
||||
alt-svc:
|
||||
- h3=":443"; ma=86400
|
||||
cf-cache-status:
|
||||
- DYNAMIC
|
||||
openai-organization:
|
||||
- OPENAI-ORG-XXX
|
||||
openai-processing-ms:
|
||||
- '12339'
|
||||
openai-project:
|
||||
- OPENAI-PROJECT-XXX
|
||||
openai-version:
|
||||
- '2020-10-01'
|
||||
x-openai-proxy-wasm:
|
||||
- v0.1
|
||||
x-ratelimit-limit-requests:
|
||||
- X-RATELIMIT-LIMIT-REQUESTS-XXX
|
||||
x-ratelimit-limit-tokens:
|
||||
- X-RATELIMIT-LIMIT-TOKENS-XXX
|
||||
x-ratelimit-remaining-requests:
|
||||
- X-RATELIMIT-REMAINING-REQUESTS-XXX
|
||||
x-ratelimit-remaining-tokens:
|
||||
- X-RATELIMIT-REMAINING-TOKENS-XXX
|
||||
x-ratelimit-reset-requests:
|
||||
- X-RATELIMIT-RESET-REQUESTS-XXX
|
||||
x-ratelimit-reset-tokens:
|
||||
- X-RATELIMIT-RESET-TOKENS-XXX
|
||||
x-request-id:
|
||||
- X-REQUEST-ID-XXX
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
version: 1
|
||||
Reference in New Issue
Block a user