diff --git a/src/agents/agent.py b/src/agents/agent.py index 57005c0ff7..ba4c092b4b 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -991,11 +991,13 @@ async def get_system_prompt(self, run_context: RunContextWrapper[TContext]) -> s f"but got {len(params)}: {[p.name for p in params]}" ) - # Call the instructions function properly - if inspect.iscoroutinefunction(self.instructions): - return await cast(Awaitable[str], self.instructions(run_context, self)) - else: - return cast(str, self.instructions(run_context, self)) + # Call once, then await if needed. Callable instances with async + # ``__call__`` are not coroutine functions, so checking + # ``iscoroutinefunction(self.instructions)`` would skip the await. + result = self.instructions(run_context, self) + if inspect.isawaitable(result): + return await result + return result elif self.instructions is not None: logger.error( diff --git a/src/agents/logger.py b/src/agents/logger.py index 18b8670680..d534cec198 100644 --- a/src/agents/logger.py +++ b/src/agents/logger.py @@ -7,6 +7,7 @@ logger = logging.getLogger("openai.agents") _DiagnosticExtra = Callable[[], Mapping[str, object]] +_DiagnosticArgs = Callable[[], tuple[object, ...]] _DIAGNOSTIC_CONTEXT_FIELD = "openai_agents_diagnostic_context" @@ -242,3 +243,24 @@ def log_model_and_tool_action_warning( stacklevel=stacklevel, diagnostic_extra=diagnostic_extra, ) + + +def log_model_and_tool_data_warning( + target_logger: logging.Logger, + redacted_message: str, + *, + diagnostic_message: str, + diagnostic_args: _DiagnosticArgs | None = None, + stacklevel: int = 2, +) -> None: + """Log mixed model/tool data only when both data policies allow it.""" + if _debug.DONT_LOG_MODEL_DATA or _debug.DONT_LOG_TOOL_DATA: + target_logger.warning(redacted_message, stacklevel=stacklevel) + return + + try: + args = diagnostic_args() if diagnostic_args is not None else () + except Exception: + target_logger.warning(redacted_message, stacklevel=stacklevel) + return + target_logger.warning(diagnostic_message, *args, stacklevel=stacklevel) diff --git a/src/agents/realtime/agent.py b/src/agents/realtime/agent.py index fe2f9e7200..0fcead874b 100644 --- a/src/agents/realtime/agent.py +++ b/src/agents/realtime/agent.py @@ -2,9 +2,9 @@ import dataclasses import inspect -from collections.abc import Awaitable, Callable +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Generic, cast +from typing import Any, Generic from agents.prompts import Prompt @@ -121,10 +121,13 @@ async def get_system_prompt(self, run_context: RunContextWrapper[TContext]) -> s if isinstance(self.instructions, str): return self.instructions elif callable(self.instructions): - if inspect.iscoroutinefunction(self.instructions): - return await cast(Awaitable[str], self.instructions(run_context, self)) - else: - return cast(str, self.instructions(run_context, self)) + # Call once, then await if needed. Callable instances with async + # ``__call__`` are not coroutine functions, so checking + # ``iscoroutinefunction(self.instructions)`` would skip the await. + result = self.instructions(run_context, self) + if inspect.isawaitable(result): + return await result + return result elif self.instructions is not None: if _debug.DONT_LOG_MODEL_DATA: logger.error("Instructions must be a string or a function") diff --git a/src/agents/realtime/handoffs.py b/src/agents/realtime/handoffs.py index a26126df97..7cc150d631 100644 --- a/src/agents/realtime/handoffs.py +++ b/src/agents/realtime/handoffs.py @@ -166,16 +166,14 @@ async def _invoke_handoff( strict=True, ) input_func = cast(OnHandoffWithInput[THandoffInput], on_handoff) - if inspect.iscoroutinefunction(input_func): - await input_func(ctx, validated_input) - else: - input_func(ctx, validated_input) + result = input_func(ctx, validated_input) + if inspect.isawaitable(result): + await result elif on_handoff is not None: no_input_func = cast(OnHandoffWithoutInput, on_handoff) - if inspect.iscoroutinefunction(no_input_func): - await no_input_func(ctx) - else: - no_input_func(ctx) + result = no_input_func(ctx) + if inspect.isawaitable(result): + await result return agent diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index b0a4a63da6..50d2a21fb6 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -195,37 +195,19 @@ async def get_api_key(key: str | Callable[[], MaybeAwaitable[str]] | None) -> st ServerEventTypeAdapter: TypeAdapter[AllRealtimeServerEvents] | None = None -def _server_event_validation_summary(error: BaseException) -> str: - if isinstance(error, pydantic.ValidationError): - return f"{error.error_count()} validation error(s)" - - if not _debug.DONT_LOG_MODEL_DATA: - return type(error).__name__ - return "validation failed" - - -def _server_event_identity(event: Any) -> tuple[Any, Any]: +def _server_event_type(event: Any) -> Any: if not isinstance(event, dict): - return "unknown", None + return "unknown" - return event.get("type", "unknown"), event.get("event_id") + return event.get("type", "unknown") -def _log_server_event_validation_failure(event: Any, error: BaseException) -> str: - event_type, event_id = _server_event_identity(event) - +def _log_server_event_validation_failure(event: Any) -> None: if _debug.DONT_LOG_MODEL_DATA: - logger.error( - "Failed to validate server event type=%s event_id=%s: %s", - event_type, - event_id, - _server_event_validation_summary(error), - ) + logger.error("Failed to validate server event") else: logger.error("Failed to validate server event: %s", event, exc_info=True) - return str(event_type) - @dataclass(frozen=True) class _PendingResponseCreate: @@ -723,7 +705,7 @@ async def send_event(self, event: RealtimeModelSendEvent) -> None: else: await self._send_raw_message(converted) elif _debug.DONT_LOG_MODEL_DATA: - logger.error("Failed to convert raw message type=%s", event.message.get("type")) + logger.error("Failed to convert raw message") else: logger.error("Failed to convert raw message: %s", event) elif isinstance(event, RealtimeModelSendUserInput): @@ -1140,11 +1122,12 @@ async def _handle_ws_event(self, event: dict[str, Any]): validation_event ) except pydantic.ValidationError as e: - _log_server_event_validation_failure(event, e) + _log_server_event_validation_failure(event) await self._emit_event(RealtimeModelErrorEvent(error=e)) return except Exception as e: - event_type = _log_server_event_validation_failure(event, e) + _log_server_event_validation_failure(event) + event_type = str(_server_event_type(event)) exception_event = RealtimeModelExceptionEvent( exception=e, context=f"Failed to validate server event: {event_type}", diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py index d6d87f447a..fae507ae71 100644 --- a/src/agents/run_internal/turn_resolution.py +++ b/src/agents/run_internal/turn_resolution.py @@ -688,14 +688,10 @@ async def check_for_final_output_from_tools( ) return ToolsToFinalOutputResult(is_final_output=False, final_output=None) elif callable(agent.tool_use_behavior): - if inspect.iscoroutinefunction(agent.tool_use_behavior): - return await cast( - Awaitable[ToolsToFinalOutputResult], - agent.tool_use_behavior(context_wrapper, tool_results), - ) - return cast( - ToolsToFinalOutputResult, agent.tool_use_behavior(context_wrapper, tool_results) - ) + result = agent.tool_use_behavior(context_wrapper, tool_results) + if inspect.isawaitable(result): + return await result + return result logger.error("Invalid tool_use_behavior: %s", agent.tool_use_behavior) raise UserError(f"Invalid tool_use_behavior: {agent.tool_use_behavior}") diff --git a/src/agents/run_state.py b/src/agents/run_state.py index dc96ddf4ba..af586a9119 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -81,7 +81,11 @@ coerce_tool_search_call_raw_item, coerce_tool_search_output_raw_item, ) -from .logger import log_model_and_tool_action_warning, logger +from .logger import ( + log_model_and_tool_action_warning, + log_model_and_tool_data_warning, + logger, +) from .run_context import RunContextWrapper from .run_internal.items import ( NestedHistoryOwnedItemRef, @@ -3535,6 +3539,18 @@ def _deserialize_items( result: list[RunItem] = [] + def _capture_diagnostic_args(*values: object) -> Callable[[], tuple[object, ...]]: + def diagnostic_args() -> tuple[object, ...]: + return values + + return diagnostic_args + + def _capture_diagnostic_extra(**values: object) -> Callable[[], Mapping[str, object]]: + def diagnostic_extra() -> Mapping[str, object]: + return values + + return diagnostic_extra + def _resolve_agent_info( item_data: Mapping[str, Any], item_type: str ) -> tuple[Agent[Any] | None, str | None]: @@ -3570,9 +3586,19 @@ def _resolve_agent_info( agent, agent_name = _resolve_agent_info(item_data, item_type) if not agent: if agent_name: - logger.warning("Agent %s not found, skipping item", agent_name) + log_model_and_tool_data_warning( + logger, + "Agent not found, skipping item", + diagnostic_message="Agent %s not found, skipping item", + diagnostic_args=_capture_diagnostic_args(agent_name), + ) else: - logger.warning("Item missing agent field, skipping: %s", item_type) + log_model_and_tool_data_warning( + logger, + "Item missing agent field, skipping", + diagnostic_message="Item missing agent field, skipping: %s", + diagnostic_args=_capture_diagnostic_args(item_type), + ) continue raw_item_data = item_data["raw_item"] @@ -3661,11 +3687,14 @@ def _resolve_agent_info( if not source_agent or not target_agent: source_name = item_data.get("source_agent") target_name = item_data.get("target_agent") - logger.warning( - "Skipping handoff_output_item: could not resolve agents " - "(source=%s, target=%s).", - source_name, - target_name, + log_model_and_tool_data_warning( + logger, + "Skipping handoff output item: could not resolve agents", + diagnostic_message=( + "Skipping handoff_output_item: could not resolve agents " + "(source=%s, target=%s)." + ), + diagnostic_args=_capture_diagnostic_args(source_name, target_name), ) continue @@ -3732,7 +3761,10 @@ def _resolve_agent_info( raise except Exception as e: log_model_and_tool_action_warning( - logger, f"Failed to deserialize item of type {item_type}", e + logger, + "Failed to deserialize item", + e, + diagnostic_extra=_capture_diagnostic_extra(item_type=item_type), ) continue diff --git a/src/agents/sandbox/util/parse_utils.py b/src/agents/sandbox/util/parse_utils.py index e9c49e1cd4..eb216ad8ad 100644 --- a/src/agents/sandbox/util/parse_utils.py +++ b/src/agents/sandbox/util/parse_utils.py @@ -20,10 +20,26 @@ def parse_ls_la(output: str, *, base: str) -> list[FileEntry]: permissions_str = parts[0] owner = parts[2] group = parts[3] - try: - size = int(parts[4]) - except ValueError: - continue + # Character and block devices report a device identifier in place of the + # size column, in one of two formats. `stat` reports size 0 for them. + if permissions_str[:1] in {"c", "b"}: + size = 0 + if parts[4].endswith(","): + # GNU coreutils prints "major, minor", which occupies two + # fields and shifts every following field by one. + parts = line.split(maxsplit=9) + if len(parts) < 10: + continue + name = parts[9] + else: + # BSD ls prints a single hexadecimal identifier, e.g. 0x3000002. + name = parts[8] + else: + try: + size = int(parts[4]) + except ValueError: + continue + name = parts[8] kind_map: dict[str, EntryKind] = { "d": EntryKind.DIRECTORY, @@ -37,7 +53,6 @@ def parse_ls_la(output: str, *, base: str) -> list[FileEntry]: if permissions_str[:1] not in {"d", "-"} and len(permissions_str) >= 2: permissions_str = "-" + permissions_str[1:] - name = parts[8] if kind == EntryKind.SYMLINK and " -> " in name: name = name.split(" -> ", 1)[0] diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index 89ee27dc10..d3be57ca52 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -11,7 +11,7 @@ from openai import AsyncOpenAI from ... import _debug -from ...exceptions import AgentsException +from ...exceptions import AgentsException, UserError from ...logger import logger from ...tracing import Span, SpanError, TranscriptionSpanData, transcription_span from ...util._error_tracing import get_trace_error @@ -49,6 +49,8 @@ def _audio_buffer_to_base64(buffer: npt.NDArray[np.int16 | np.float32]) -> str: # Convert to int16. buffer = np.clip(buffer, -1.0, 1.0) buffer = (buffer * 32767).astype(np.int16) + elif buffer.dtype != np.int16: + raise UserError("Buffer must be a numpy array of int16 or float32") return base64.b64encode(buffer.tobytes()).decode("utf-8") diff --git a/tests/conftest.py b/tests/conftest.py index c279b6c9ef..de07690f7e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ from __future__ import annotations +import os import sys +from collections.abc import MutableMapping import pytest @@ -13,6 +15,32 @@ from .testing_processor import SPAN_PROCESSOR_TESTING +_PROXY_ENVIRONMENT_VARIABLES = ( + "ALL_PROXY", + "HTTP_PROXY", + "HTTPS_PROXY", + "all_proxy", + "http_proxy", + "https_proxy", +) +_PROXY_OPT_IN_ENVIRONMENT_VARIABLE = "OPENAI_AGENTS_TEST_USE_PROXY" + + +def _remove_ambient_proxy_environment(environment: MutableMapping[str, str]) -> None: + """Keep unit tests independent from host proxy configuration.""" + if environment.get(_PROXY_OPT_IN_ENVIRONMENT_VARIABLE, "").lower() in { + "1", + "true", + "yes", + }: + return + + for variable in _PROXY_ENVIRONMENT_VARIABLES: + environment.pop(variable, None) + + +_remove_ambient_proxy_environment(os.environ) + collect_ignore: list[str] = [] if sys.platform == "win32": @@ -50,8 +78,6 @@ def setup_span_processor(): # monkeypatch.delenv("OPENAI_API_KEY", ...) to remove it locally. @pytest.fixture(scope="session", autouse=True) def ensure_openai_api_key(): - import os - if not os.environ.get("OPENAI_API_KEY"): os.environ["OPENAI_API_KEY"] = "test_key" diff --git a/tests/realtime/test_agent.py b/tests/realtime/test_agent.py index bc2a4c408c..8f58de19db 100644 --- a/tests/realtime/test_agent.py +++ b/tests/realtime/test_agent.py @@ -30,6 +30,30 @@ def _instructions(ctx, agt) -> str: assert instructions == "Dynamic" +@pytest.mark.asyncio +async def test_async_callable_object_instructions_are_awaited(): + """Callable instances whose ``__call__`` is async must be awaited. + + ``inspect.iscoroutinefunction`` returns ``False`` for the instance itself, so the + previous implementation returned the unawaited coroutine as the system prompt. + """ + + class AsyncInstructions: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, ctx, agt) -> str: + self.calls += 1 + assert ctx.context is None + return "Dynamic async callable" + + instructions = AsyncInstructions() + agent = RealtimeAgent(name="test", instructions=instructions) + prompt = await agent.get_system_prompt(RunContextWrapper(context=None)) + assert prompt == "Dynamic async callable" + assert instructions.calls == 1 + + @pytest.mark.asyncio @pytest.mark.parametrize("redacted", [True, False]) async def test_mutated_invalid_instructions_respect_model_data_policy( diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index aeeb58081b..9e8618d8b9 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -1,5 +1,6 @@ import asyncio import json +import logging import time from types import SimpleNamespace from typing import Any, cast @@ -448,59 +449,145 @@ async def test_handle_invalid_event_schema_logs_error(self, model): assert error_event.type == "error" @pytest.mark.asyncio - async def test_handle_invalid_event_schema_redacts_payload_from_logs(self, model, monkeypatch): - """Test that invalid event logs omit payload data when model data logging is disabled.""" + async def test_handle_invalid_event_schema_redacts_event_from_logs( + self, model, monkeypatch, caplog + ): + """Invalid event logs omit all event data when model data logging is disabled.""" mock_listener = AsyncMock() model.add_listener(mock_listener) monkeypatch.setattr( "agents.realtime.openai_realtime._debug.DONT_LOG_MODEL_DATA", True, ) + caplog.set_level(logging.ERROR, logger="openai.agents") invalid_event = { - "type": "response.output_audio.delta", - "event_id": "evt_123", - "delta": "secret transcript", + "type": "SECRET_EVENT_TYPE", + "event_id": "SECRET_EVENT_ID", + "delta": "SECRET_EVENT_PAYLOAD", } - with patch("agents.realtime.openai_realtime.logger") as mock_logger: - await model._handle_ws_event(invalid_event) + await model._handle_ws_event(invalid_event) - mock_logger.error.assert_called_once() - logged_call = str(mock_logger.error.call_args) - assert "secret transcript" not in logged_call - assert "response.output_audio.delta" in logged_call - assert "evt_123" in logged_call - assert mock_logger.error.call_args.kwargs.get("exc_info") is not True + records = [ + record for record in caplog.records if record.msg == "Failed to validate server event" + ] + assert len(records) == 1 + record = records[0] + assert record.args == () + assert record.exc_info is None + assert record.exc_text is None + assert invalid_event not in record.__dict__.values() + rendered = logging.Formatter().format(record) + assert rendered == "Failed to validate server event" + assert "SECRET_EVENT_TYPE" not in rendered + assert "SECRET_EVENT_ID" not in rendered + assert "SECRET_EVENT_PAYLOAD" not in rendered assert mock_listener.on_event.call_count == 2 error_event = mock_listener.on_event.call_args_list[1][0][0] assert error_event.type == "error" @pytest.mark.asyncio - async def test_send_raw_message_conversion_failure_redacts_payload_from_logs( - self, model, monkeypatch + async def test_handle_invalid_event_schema_preserves_diagnostics_when_enabled( + self, model, monkeypatch, caplog + ): + """Invalid event logs retain event data when model data logging is enabled.""" + mock_listener = AsyncMock() + model.add_listener(mock_listener) + monkeypatch.setattr( + "agents.realtime.openai_realtime._debug.DONT_LOG_MODEL_DATA", + False, + ) + caplog.set_level(logging.ERROR, logger="openai.agents") + + invalid_event = { + "type": "diagnostic.event", + "event_id": "diagnostic_event_id", + "delta": "diagnostic payload", + } + + await model._handle_ws_event(invalid_event) + + records = [ + record + for record in caplog.records + if record.msg == "Failed to validate server event: %s" + ] + assert len(records) == 1 + record = records[0] + assert record.args == invalid_event + assert record.exc_info is not None + rendered = logging.Formatter().format(record) + assert "diagnostic.event" in rendered + assert "diagnostic_event_id" in rendered + assert "diagnostic payload" in rendered + + assert mock_listener.on_event.call_count == 2 + error_event = mock_listener.on_event.call_args_list[1][0][0] + assert error_event.type == "error" + + @pytest.mark.asyncio + async def test_send_raw_message_conversion_failure_redacts_event_from_logs( + self, model, monkeypatch, caplog ): - """A raw client message that fails to convert must not leak its payload to the logs - when model-data logging is disabled.""" + """A raw client message that fails to convert must not leak event data to logs.""" monkeypatch.setattr( "agents.realtime.openai_realtime._debug.DONT_LOG_MODEL_DATA", True, ) + caplog.set_level(logging.ERROR, logger="openai.agents") raw = RealtimeModelSendRawMessage( message={ - "type": "invalid.event.type", - "other_data": {"transcript": "secret transcript"}, + "type": "SECRET_RAW_EVENT_TYPE", + "other_data": {"transcript": "SECRET_RAW_EVENT_PAYLOAD"}, } ) - with patch("agents.realtime.openai_realtime.logger") as mock_logger: - await model.send_event(raw) + await model.send_event(raw) - mock_logger.error.assert_called_once() - logged_call = str(mock_logger.error.call_args) - assert "secret transcript" not in logged_call - assert "invalid.event.type" in logged_call + records = [ + record for record in caplog.records if record.msg == "Failed to convert raw message" + ] + assert len(records) == 1 + record = records[0] + assert record.args == () + assert record.exc_info is None + assert record.exc_text is None + assert raw not in record.__dict__.values() + rendered = logging.Formatter().format(record) + assert rendered == "Failed to convert raw message" + assert "SECRET_RAW_EVENT_TYPE" not in rendered + assert "SECRET_RAW_EVENT_PAYLOAD" not in rendered + + @pytest.mark.asyncio + async def test_send_raw_message_conversion_failure_preserves_diagnostics_when_enabled( + self, model, monkeypatch, caplog + ): + """A raw conversion failure retains event data when model data logging is enabled.""" + monkeypatch.setattr( + "agents.realtime.openai_realtime._debug.DONT_LOG_MODEL_DATA", + False, + ) + caplog.set_level(logging.ERROR, logger="openai.agents") + raw = RealtimeModelSendRawMessage( + message={ + "type": "diagnostic.raw.event", + "other_data": {"transcript": "diagnostic transcript"}, + } + ) + + await model.send_event(raw) + + records = [ + record for record in caplog.records if record.msg == "Failed to convert raw message: %s" + ] + assert len(records) == 1 + record = records[0] + assert record.args == (raw,) + rendered = logging.Formatter().format(record) + assert "diagnostic.raw.event" in rendered + assert "diagnostic transcript" in rendered @pytest.mark.asyncio async def test_custom_voice_response_events_update_response_sequencer(self, model, monkeypatch): diff --git a/tests/realtime/test_realtime_handoffs.py b/tests/realtime/test_realtime_handoffs.py index 952f79c7f2..4c5fc6e800 100644 --- a/tests/realtime/test_realtime_handoffs.py +++ b/tests/realtime/test_realtime_handoffs.py @@ -263,6 +263,34 @@ async def on_handoff(ctx: RunContextWrapper[Any]) -> None: assert called == [True] +@pytest.mark.asyncio +async def test_realtime_handoff_async_callable_objects_are_awaited() -> None: + class WithInput: + def __init__(self) -> None: + self.calls: list[int] = [] + + async def __call__(self, ctx: RunContextWrapper[Any], value: int) -> None: + self.calls.append(value) + + class NoInput: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, ctx: RunContextWrapper[Any]) -> None: + self.calls += 1 + + rt = RealtimeAgent(name="async_callable") + with_input = WithInput() + with_input_handoff = realtime_handoff(rt, on_handoff=with_input, input_type=int) + assert await with_input_handoff.on_invoke_handoff(RunContextWrapper(None), "7") is rt + assert with_input.calls == [7] + + no_input = NoInput() + no_input_handoff = realtime_handoff(rt, on_handoff=no_input) + assert await no_input_handoff.on_invoke_handoff(RunContextWrapper(None), "") is rt + assert no_input.calls == 1 + + class StrictInput(BaseModel): name: str age: int diff --git a/tests/sandbox/test_parse_utils.py b/tests/sandbox/test_parse_utils.py index 549f830d1f..2cc29a8037 100644 --- a/tests/sandbox/test_parse_utils.py +++ b/tests/sandbox/test_parse_utils.py @@ -85,6 +85,50 @@ def test_parse_ls_la_strips_trailing_alternate_access_markers() -> None: assert entries[2].permissions.owner & FileMode.READ +def test_parse_ls_la_includes_gnu_device_nodes() -> None: + # GNU coreutils prints "major, minor" in place of the single size column, + # which shifts every following field by one. + output = ( + "-rw-r--r-- 1 root root 123 Jan 1 00:00 regular.txt\n" + "crw-rw-rw- 1 root root 1, 3 Jan 1 00:00 null\n" + "brw-rw---- 1 root disk 8, 0 Jan 1 00:00 sda\n" + ) + + entries = parse_ls_la(output, base="/dev") + + assert [entry.path for entry in entries] == [ + "/dev/regular.txt", + "/dev/null", + "/dev/sda", + ] + assert entries[1].kind == EntryKind.OTHER + assert entries[1].size == 0 + assert entries[2].owner == "root" + assert entries[2].group == "disk" + + +def test_parse_ls_la_includes_bsd_device_nodes() -> None: + # BSD ls prints a single hexadecimal device identifier instead of the + # "major, minor" pair, so the fields are not shifted. + output = ( + "-rw-r--r-- 1 root wheel 123 Jan 1 00:00 regular.txt\n" + "crw-rw-rw- 1 root wheel 0x3000002 Jan 1 00:00 null\n" + "brw-r----- 1 root operator 0x1000000 Jan 1 00:00 disk0\n" + ) + + entries = parse_ls_la(output, base="/dev") + + assert [entry.path for entry in entries] == [ + "/dev/regular.txt", + "/dev/null", + "/dev/disk0", + ] + assert entries[1].kind == EntryKind.OTHER + assert entries[1].size == 0 + assert entries[2].owner == "root" + assert entries[2].group == "operator" + + @pytest.mark.parametrize( "permissions", [ diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index f935cfd7a7..580a08d1f4 100644 --- a/tests/test_agent_config.py +++ b/tests/test_agent_config.py @@ -33,6 +33,13 @@ async def async_instructions(agent: Agent[None], context: RunContextWrapper[None agent = agent.clone(instructions=async_instructions) assert await agent.get_system_prompt(context) == "async_123" + class AsyncCallableInstructions: + async def __call__(self, context: RunContextWrapper[None], agent: Agent[None]) -> str: + return "async_callable_123" + + agent = agent.clone(instructions=AsyncCallableInstructions()) + assert await agent.get_system_prompt(context) == "async_callable_123" + @pytest.mark.asyncio async def test_handoff_with_agents(): diff --git a/tests/test_agent_instructions_signature.py b/tests/test_agent_instructions_signature.py index 79c56018f9..c4239ecf62 100644 --- a/tests/test_agent_instructions_signature.py +++ b/tests/test_agent_instructions_signature.py @@ -35,6 +35,40 @@ def valid_instructions(context, agent): result = await agent.get_system_prompt(mock_run_context) assert result == "Valid sync instructions" + @pytest.mark.asyncio + async def test_async_callable_object_is_awaited(self, mock_run_context): + """Callable instances whose ``__call__`` is async must be awaited. + + ``inspect.iscoroutinefunction`` returns ``False`` for the instance itself, so the + previous implementation returned the unawaited coroutine as the system prompt. + """ + + class AsyncInstructions: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, context, agent) -> str: + self.calls += 1 + return "Valid async callable instructions" + + instructions = AsyncInstructions() + agent = Agent(name="test_agent", instructions=instructions) + result = await agent.get_system_prompt(mock_run_context) + assert result == "Valid async callable instructions" + assert instructions.calls == 1 + + @pytest.mark.asyncio + async def test_sync_callable_object_still_works(self, mock_run_context): + """Sync callable instances should continue to work as dynamic instructions.""" + + class SyncInstructions: + def __call__(self, context, agent) -> str: + return "Valid sync callable instructions" + + agent = Agent(name="test_agent", instructions=SyncInstructions()) + result = await agent.get_system_prompt(mock_run_context) + assert result == "Valid sync callable instructions" + @pytest.mark.asyncio async def test_one_parameter_raises_error(self, mock_run_context): """Test that function with only one parameter raises TypeError""" diff --git a/tests/test_error_logging_redaction.py b/tests/test_error_logging_redaction.py index 4f08056915..85ebd221b3 100644 --- a/tests/test_error_logging_redaction.py +++ b/tests/test_error_logging_redaction.py @@ -23,6 +23,7 @@ import agents._debug as _debug from agents import ( + Agent, ModelSettings, ModelTracing, OpenAIResponsesModel, @@ -37,6 +38,7 @@ log_model_and_tool_action_debug, log_model_and_tool_action_error, log_model_and_tool_action_warning, + log_model_and_tool_data_warning, log_tool_action_debug, log_tool_action_error as log_shared_tool_action_error, log_tool_action_warning, @@ -45,6 +47,7 @@ log_tool_action_error, resolve_approval_rejection_message, ) +from agents.run_state import _deserialize_items from agents.tracing.processor_interface import TracingProcessor from agents.tracing.provider import SynchronousMultiTracingProcessor from agents.tracing.spans import Span @@ -88,6 +91,14 @@ def __bool__(self) -> bool: return False +class _HostileValue: + def __str__(self) -> str: + raise AssertionError("redacted logging inspected __str__") + + def __repr__(self) -> str: + raise AssertionError("redacted logging inspected __repr__") + + class _FailingTracingProcessor(TracingProcessor): def __init__(self) -> None: self.str_calls = 0 @@ -127,6 +138,15 @@ def _emit_tool_execution_error_for_location() -> None: log_tool_action_error("Fixed operational message", ValueError("failure")) +def _emit_data_warning_for_location(test_logger) -> None: + log_model_and_tool_data_warning( + test_logger, + "Fixed operational warning", + diagnostic_message="Warning for %s", + diagnostic_args=lambda: ("diagnostic-value",), + ) + + def _responses_model() -> OpenAIResponsesModel: return OpenAIResponsesModel( model="test-model", @@ -382,6 +402,176 @@ def diagnostic_extra() -> dict[str, object]: assert "original failure" in logging.Formatter().format(record) +def test_shared_data_warning_does_not_inspect_or_attach_redacted_arguments(monkeypatch) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + test_logger = logging.Logger("sensitive-logging-data-warning") + handler = _RecordingHandler() + test_logger.addHandler(handler) + hostile = _HostileValue() + + log_model_and_tool_data_warning( + test_logger, + "Fixed operational warning", + diagnostic_message="Warning for %s", + diagnostic_args=lambda: (hostile,), + ) + + assert len(handler.records) == 1 + record = handler.records[0] + assert record.msg == "Fixed operational warning" + assert record.args == () + assert record.exc_info is None + assert record.exc_text is None + assert hostile not in record.__dict__.values() + assert logging.Formatter().format(record) == "Fixed operational warning" + + +def test_shared_data_warning_preserves_diagnostics_when_enabled(monkeypatch) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + test_logger = logging.Logger("sensitive-logging-data-warning") + handler = _RecordingHandler() + test_logger.addHandler(handler) + diagnostic_value = "diagnostic-agent" + + log_model_and_tool_data_warning( + test_logger, + "Fixed operational warning", + diagnostic_message="Warning for %s", + diagnostic_args=lambda: (diagnostic_value,), + ) + + assert len(handler.records) == 1 + record = handler.records[0] + assert record.msg == "Warning for %s" + assert record.args == (diagnostic_value,) + assert logging.Formatter().format(record) == "Warning for diagnostic-agent" + + +def test_shared_data_warning_falls_back_when_diagnostic_arguments_fail(monkeypatch) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False) + test_logger = logging.Logger("sensitive-logging-data-warning") + handler = _RecordingHandler() + test_logger.addHandler(handler) + + def diagnostic_args() -> tuple[object, ...]: + raise RuntimeError("SECRET_DIAGNOSTIC_ARGUMENT_FAILURE") + + log_model_and_tool_data_warning( + test_logger, + "Fixed operational warning", + diagnostic_message="Warning for %s", + diagnostic_args=diagnostic_args, + ) + + assert len(handler.records) == 1 + record = handler.records[0] + assert record.msg == "Fixed operational warning" + assert record.args == () + assert record.exc_info is None + assert "SECRET_DIAGNOSTIC_ARGUMENT_FAILURE" not in logging.Formatter().format(record) + + +def test_shared_data_warning_preserves_direct_caller_location(monkeypatch) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True) + test_logger = logging.Logger("sensitive-logging-data-warning-location") + handler = _RecordingHandler() + test_logger.addHandler(handler) + + _emit_data_warning_for_location(test_logger) + + record = handler.records[0] + assert Path(record.pathname).resolve() == Path(__file__).resolve() + assert record.funcName == "_emit_data_warning_for_location" + + +@pytest.mark.parametrize( + ("model_redacted", "tool_redacted"), + [ + (True, False), + (False, True), + (True, True), + (False, False), + ], +) +@pytest.mark.parametrize( + ("scenario", "secrets", "redacted_message"), + [ + ( + "missing_agent", + ("SECRET_AGENT_NAME",), + "Agent not found, skipping item", + ), + ( + "missing_agent_field", + ("SECRET_ITEM_TYPE",), + "Item missing agent field, skipping", + ), + ( + "missing_handoff_agents", + ("SECRET_SOURCE_AGENT", "SECRET_TARGET_AGENT"), + "Skipping handoff output item: could not resolve agents", + ), + ], +) +def test_run_state_deserialization_warnings_follow_both_data_policies( + monkeypatch, + model_redacted: bool, + tool_redacted: bool, + scenario: str, + secrets: tuple[str, ...], + redacted_message: str, +) -> None: + monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted) + monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted) + known_agent = Agent(name="KnownAgent") + if scenario == "missing_agent": + item_data = { + "type": "message_output_item", + "agent": secrets[0], + "raw_item": {}, + } + elif scenario == "missing_agent_field": + item_data = { + "type": secrets[0], + "raw_item": {}, + } + else: + item_data = { + "type": "handoff_output_item", + "agent": "KnownAgent", + "source_agent": secrets[0], + "target_agent": secrets[1], + "raw_item": {}, + } + + test_logger = logging.Logger("sensitive-logging-run-state") + handler = _RecordingHandler() + test_logger.addHandler(handler) + with patch("agents.run_state.logger", test_logger): + result = _deserialize_items([item_data], {"KnownAgent": known_agent}) + + assert result == [] + assert len(handler.records) == 1 + record = handler.records[0] + rendered = logging.Formatter().format(record) + redacted = model_redacted or tool_redacted + if redacted: + assert record.msg == redacted_message + assert record.args == () + assert record.exc_info is None + assert record.exc_text is None + assert rendered == redacted_message + for secret in secrets: + assert secret not in rendered + assert secret not in record.__dict__.values() + else: + for secret in secrets: + assert secret in rendered + + @pytest.mark.parametrize( "operation", [ diff --git a/tests/test_test_environment.py b/tests/test_test_environment.py new file mode 100644 index 0000000000..dac226337f --- /dev/null +++ b/tests/test_test_environment.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import pytest + +from .conftest import ( + _PROXY_ENVIRONMENT_VARIABLES, + _PROXY_OPT_IN_ENVIRONMENT_VARIABLE, + _remove_ambient_proxy_environment, +) + + +def test_remove_ambient_proxy_environment_clears_proxy_variables() -> None: + environment = { + variable: "socks5h://127.0.0.1:1234" for variable in _PROXY_ENVIRONMENT_VARIABLES + } + environment["UNRELATED"] = "preserved" + + _remove_ambient_proxy_environment(environment) + + assert all(variable not in environment for variable in _PROXY_ENVIRONMENT_VARIABLES) + assert environment["UNRELATED"] == "preserved" + + +@pytest.mark.parametrize("opt_in", ["1", "true", "TRUE", "yes", "YES"]) +def test_remove_ambient_proxy_environment_preserves_proxy_variables_when_opted_in( + opt_in: str, +) -> None: + environment = { + _PROXY_OPT_IN_ENVIRONMENT_VARIABLE: opt_in, + "ALL_PROXY": "socks5h://127.0.0.1:1234", + } + + _remove_ambient_proxy_environment(environment) + + assert environment["ALL_PROXY"] == "socks5h://127.0.0.1:1234" diff --git a/tests/test_tool_use_behavior.py b/tests/test_tool_use_behavior.py index de7f98b40f..2162baafd4 100644 --- a/tests/test_tool_use_behavior.py +++ b/tests/test_tool_use_behavior.py @@ -138,6 +138,43 @@ async def behavior( assert result.final_output == "async_custom" +@pytest.mark.asyncio +async def test_custom_tool_use_behavior_async_callable_object() -> None: + """Async callable objects should be awaited and invoked exactly once.""" + + class Behavior: + def __init__(self) -> None: + self.calls = 0 + + async def __call__( + self, + context: RunContextWrapper, + results: list[FunctionToolResult], + ) -> ToolsToFinalOutputResult: + self.calls += 1 + assert len(results) == 2 + return ToolsToFinalOutputResult( + is_final_output=True, + final_output="async_callable", + ) + + behavior = Behavior() + agent = Agent(name="test", tool_use_behavior=behavior) + tool_results = [ + _make_function_tool_result(agent, "ignored1"), + _make_function_tool_result(agent, "ignored2"), + ] + result = await run_loop.check_for_final_output_from_tools( + agent=agent, + tool_results=tool_results, + context_wrapper=RunContextWrapper(context=None), + ) + + assert result.is_final_output is True + assert result.final_output == "async_callable" + assert behavior.calls == 1 + + @pytest.mark.asyncio async def test_invalid_tool_use_behavior_raises() -> None: """If tool_use_behavior is invalid, we should raise a UserError.""" diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index e13532a769..090afa5806 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -11,6 +11,7 @@ import pytest from agents import trace +from agents.exceptions import UserError from tests.testing_processor import fetch_span_errors try: @@ -22,7 +23,10 @@ STTModelSettings, ) from agents.voice.exceptions import STTWebsocketConnectionError - from agents.voice.models.openai_stt import EVENT_INACTIVITY_TIMEOUT + from agents.voice.models.openai_stt import ( + EVENT_INACTIVITY_TIMEOUT, + _audio_buffer_to_base64, + ) from .fake_models import FakeStreamedAudioInput except ImportError: @@ -221,6 +225,14 @@ async def test_stream_audio_sends_pcm16( await session.close() +@pytest.mark.parametrize("dtype", [np.int32, np.float64], ids=["int32", "float64"]) +def test_stream_audio_rejects_unsupported_dtype(dtype: npt.DTypeLike) -> None: + buffer = np.array([1, 2], dtype=dtype) + + with pytest.raises(UserError, match="Buffer must be a numpy array of int16 or float32"): + _audio_buffer_to_base64(buffer) + + @pytest.mark.asyncio @pytest.mark.parametrize( "created,updated,completed",