Skip to content

Commit 933a3a9

Browse files
authored
fix: isolate parallel function tool failures (#2584)
1 parent 93da933 commit 933a3a9

File tree

16 files changed

+4069
-364
lines changed

16 files changed

+4069
-364
lines changed

src/agents/agent.py

Lines changed: 25 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import asyncio
44
import dataclasses
55
import inspect
6-
import json
76
from collections.abc import Awaitable
87
from dataclasses import dataclass, field
98
from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, cast
@@ -12,7 +11,6 @@
1211
from pydantic import BaseModel, TypeAdapter, ValidationError
1312
from typing_extensions import NotRequired, TypeAlias, TypedDict
1413

15-
from . import _debug
1614
from .agent_output import AgentOutputSchemaBase
1715
from .agent_tool_input import (
1816
AgentAsToolInput,
@@ -47,12 +45,14 @@
4745
FunctionToolResult,
4846
Tool,
4947
ToolErrorFunction,
50-
_extract_tool_argument_json_error,
48+
_build_handled_function_tool_error_handler,
49+
_build_wrapped_function_tool,
50+
_log_function_tool_invocation,
51+
_parse_function_tool_json_input,
5152
default_tool_error_function,
5253
)
5354
from .tool_context import ToolContext
54-
from .tracing import SpanError
55-
from .util import _error_tracing, _transforms
55+
from .util import _transforms
5656
from .util._types import MaybeAwaitable
5757

5858
if TYPE_CHECKING:
@@ -547,43 +547,34 @@ def _is_supported_parameters(value: Any) -> bool:
547547
include_json_schema=include_schema,
548548
)
549549

550-
def _normalize_tool_input(parsed: Any) -> Any:
550+
def _normalize_tool_input(parsed: Any, tool_name: str) -> Any:
551551
# Prefer JSON mode so structured params (datetime/UUID/Decimal, etc.) serialize cleanly.
552552
try:
553553
return params_adapter.dump_python(parsed, mode="json")
554554
except Exception as exc:
555555
raise ModelBehaviorError(
556-
f"Failed to serialize structured tool input for {tool_name_resolved}: {exc}"
556+
f"Failed to serialize structured tool input for {tool_name}: {exc}"
557557
) from exc
558558

559559
async def _run_agent_impl(context: ToolContext, input_json: str) -> Any:
560560
from .run import DEFAULT_MAX_TURNS, Runner
561561
from .tool_context import ToolContext
562562

563-
try:
564-
json_data = json.loads(input_json) if input_json else {}
565-
except Exception as exc:
566-
if _debug.DONT_LOG_TOOL_DATA:
567-
logger.debug(f"Invalid JSON input for tool {tool_name_resolved}")
568-
else:
569-
logger.debug(f"Invalid JSON input for tool {tool_name_resolved}: {input_json}")
570-
raise ModelBehaviorError(
571-
f"Invalid JSON input for tool {tool_name_resolved}: {input_json}"
572-
) from exc
573-
574-
if _debug.DONT_LOG_TOOL_DATA:
575-
logger.debug(f"Invoking tool {tool_name_resolved}")
576-
else:
577-
logger.debug(f"Invoking tool {tool_name_resolved} with input {input_json}")
563+
tool_name = (
564+
context.tool_name if isinstance(context, ToolContext) else tool_name_resolved
565+
)
566+
json_data = _parse_function_tool_json_input(
567+
tool_name=tool_name,
568+
input_json=input_json,
569+
)
570+
_log_function_tool_invocation(tool_name=tool_name, input_json=input_json)
578571

579572
try:
580573
parsed_params = params_adapter.validate_python(json_data)
581574
except ValidationError as exc:
582-
raise ModelBehaviorError(
583-
f"Invalid JSON input for tool {tool_name_resolved}: {exc}"
584-
) from exc
575+
raise ModelBehaviorError(f"Invalid JSON input for tool {tool_name}: {exc}") from exc
585576

586-
params_data = _normalize_tool_input(parsed_params)
577+
params_data = _normalize_tool_input(parsed_params, tool_name)
587578
resolved_input = await resolve_agent_tool_input(
588579
params=params_data,
589580
schema_info=schema_info if should_capture_tool_input else None,
@@ -804,48 +795,17 @@ async def dispatch_stream_events() -> None:
804795

805796
return run_result.final_output
806797

807-
async def _run_agent_tool(context: ToolContext, input_json: str) -> Any:
808-
try:
809-
return await _run_agent_impl(context, input_json)
810-
except Exception as exc:
811-
if failure_error_function is None:
812-
raise
813-
814-
result = failure_error_function(context, exc)
815-
if inspect.isawaitable(result):
816-
result = await result
817-
818-
json_decode_error = _extract_tool_argument_json_error(exc)
819-
if json_decode_error is not None:
820-
span_error_message = "Error running tool"
821-
span_error_detail = str(json_decode_error)
822-
else:
823-
span_error_message = "Error running tool (non-fatal)"
824-
span_error_detail = str(exc)
825-
826-
_error_tracing.attach_error_to_current_span(
827-
SpanError(
828-
message=span_error_message,
829-
data={
830-
"tool_name": tool_name_resolved,
831-
"error": span_error_detail,
832-
},
833-
)
834-
)
835-
if _debug.DONT_LOG_TOOL_DATA:
836-
logger.debug(f"Tool {tool_name_resolved} failed")
837-
else:
838-
logger.error(
839-
f"Tool {tool_name_resolved} failed: {input_json} {exc}",
840-
exc_info=exc,
841-
)
842-
return result
843-
844-
run_agent_tool = FunctionTool(
798+
run_agent_tool = _build_wrapped_function_tool(
845799
name=tool_name_resolved,
846800
description=tool_description_resolved,
847801
params_json_schema=params_schema,
848-
on_invoke_tool=_run_agent_tool,
802+
invoke_tool_impl=_run_agent_impl,
803+
on_handled_error=_build_handled_function_tool_error_handler(
804+
span_message="Error running tool (non-fatal)",
805+
span_message_for_json_decode_error="Error running tool",
806+
log_label="Tool",
807+
),
808+
failure_error_function=failure_error_function,
849809
strict_json_schema=True,
850810
is_enabled=is_enabled,
851811
needs_approval=needs_approval,

src/agents/extensions/experimental/codex/codex_tool.py

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,16 @@
2020
from agents.models import _openai_shared
2121
from agents.run_context import RunContextWrapper
2222
from agents.strict_schema import ensure_strict_json_schema
23-
from agents.tool import FunctionTool, ToolErrorFunction, default_tool_error_function
23+
from agents.tool import (
24+
FunctionTool,
25+
ToolErrorFunction,
26+
_build_handled_function_tool_error_handler,
27+
_build_wrapped_function_tool,
28+
default_tool_error_function,
29+
)
2430
from agents.tool_context import ToolContext
2531
from agents.tracing import SpanError, custom_span
2632
from agents.usage import Usage as AgentsUsage
27-
from agents.util import _error_tracing
2833
from agents.util._types import MaybeAwaitable
2934

3035
from .codex import Codex
@@ -379,7 +384,7 @@ async def _on_invoke_tool(ctx: ToolContext[Any], input_json: str) -> Any:
379384
resolved_options.span_data_max_chars,
380385
resolved_thread_id_holder=resolved_thread_id_holder,
381386
)
382-
except Exception:
387+
except BaseException:
383388
resolved_thread_id = resolved_thread_id_holder["thread_id"]
384389
raise
385390

@@ -394,38 +399,27 @@ async def _on_invoke_tool(ctx: ToolContext[Any], input_json: str) -> Any:
394399
)
395400

396401
return CodexToolResult(thread_id=resolved_thread_id, response=response, usage=usage)
397-
except Exception as exc: # noqa: BLE001
402+
except BaseException:
398403
_try_store_thread_id_in_run_context_after_error(
399404
ctx=ctx,
400405
key=resolved_run_context_thread_id_key,
401406
thread_id=resolved_thread_id,
402407
enabled=resolved_options.use_run_context_thread_id,
403408
)
409+
raise
404410

405-
if resolved_options.failure_error_function is None:
406-
raise
407-
408-
result = resolved_options.failure_error_function(ctx, exc)
409-
if inspect.isawaitable(result):
410-
result = await result
411-
412-
_error_tracing.attach_error_to_current_span(
413-
SpanError(
414-
message="Error running Codex tool (non-fatal)",
415-
data={"tool_name": name, "error": str(exc)},
416-
)
417-
)
418-
if _debug.DONT_LOG_TOOL_DATA:
419-
logger.debug("Codex tool failed")
420-
else:
421-
logger.error("Codex tool failed: %s", exc, exc_info=exc)
422-
return result
423-
424-
function_tool = FunctionTool(
411+
function_tool = _build_wrapped_function_tool(
425412
name=name,
426413
description=description,
427414
params_json_schema=params_schema,
428-
on_invoke_tool=_on_invoke_tool,
415+
invoke_tool_impl=_on_invoke_tool,
416+
on_handled_error=_build_handled_function_tool_error_handler(
417+
span_message="Error running Codex tool (non-fatal)",
418+
log_label="Codex tool",
419+
include_input_json_in_logs=False,
420+
include_tool_name_in_log_messages=False,
421+
),
422+
failure_error_function=resolved_options.failure_error_function,
429423
strict_json_schema=True,
430424
is_enabled=resolved_options.is_enabled,
431425
)

src/agents/mcp/util.py

Lines changed: 11 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@
2222
ToolErrorFunction,
2323
ToolOutputImageDict,
2424
ToolOutputTextDict,
25+
_build_handled_function_tool_error_handler,
26+
_build_wrapped_function_tool,
2527
default_tool_error_function,
2628
)
27-
from ..tool_context import ToolContext
28-
from ..tracing import FunctionSpanData, SpanError, get_current_span, mcp_tools_span
29-
from ..util import _error_tracing
29+
from ..tracing import FunctionSpanData, get_current_span, mcp_tools_span
3030
from ..util._types import MaybeAwaitable
3131

3232
if TYPE_CHECKING:
@@ -261,55 +261,24 @@ def to_function_tool(
261261
except Exception as e:
262262
logger.info(f"Error converting MCP schema to strict mode: {e}")
263263

264-
# Wrap the invoke function with error handling, similar to regular function tools.
265-
# This ensures that MCP tool errors (like timeouts) are handled gracefully instead
266-
# of halting the entire agent flow.
267-
async def invoke_func(ctx: ToolContext[Any], input_json: str) -> ToolOutput:
268-
try:
269-
return await invoke_func_impl(ctx, input_json)
270-
except Exception as e:
271-
if effective_failure_error_function is None:
272-
raise
273-
274-
# Use configured error handling function to convert exception to error message.
275-
result = effective_failure_error_function(ctx, e)
276-
if inspect.isawaitable(result):
277-
result = await result
278-
279-
# Attach error to tracing span.
280-
_error_tracing.attach_error_to_current_span(
281-
SpanError(
282-
message="Error running tool (non-fatal)",
283-
data={
284-
"tool_name": tool.name,
285-
"error": str(e),
286-
},
287-
)
288-
)
289-
290-
# Log the error.
291-
if _debug.DONT_LOG_TOOL_DATA:
292-
logger.debug(f"MCP tool {tool.name} failed")
293-
else:
294-
logger.error(
295-
f"MCP tool {tool.name} failed: {input_json} {e}",
296-
exc_info=e,
297-
)
298-
299-
return result
300-
301264
needs_approval: (
302265
bool | Callable[[RunContextWrapper[Any], dict[str, Any], str], Awaitable[bool]]
303266
) = server._get_needs_approval_for_tool(tool, agent)
304267

305-
return FunctionTool(
268+
function_tool = _build_wrapped_function_tool(
306269
name=tool.name,
307270
description=tool.description or "",
308271
params_json_schema=schema,
309-
on_invoke_tool=invoke_func,
272+
invoke_tool_impl=invoke_func_impl,
273+
on_handled_error=_build_handled_function_tool_error_handler(
274+
span_message="Error running tool (non-fatal)",
275+
log_label="MCP tool",
276+
),
277+
failure_error_function=effective_failure_error_function,
310278
strict_json_schema=is_strict,
311279
needs_approval=needs_approval,
312280
)
281+
return function_tool
313282

314283
@staticmethod
315284
def _merge_mcp_meta(

0 commit comments

Comments
 (0)